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); - assert.match(ui, /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/); + assert.match(setContextCollapsed.toString(), /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/); }); test('Cockpit Contact navigation omits the redundant Focus camera action', () => { @@ -746,7 +761,9 @@ test('Global Context names its mixed contact cycle without changing the stable m test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => { assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/); - assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/); + const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8'); + assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/); + assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/); assert.match(html, /id="global-context-panel"/); assert.match(html, /id="global-context-flights-btn"/); assert.match(html, /id="global-context-missions-btn"/); @@ -771,14 +788,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', () assert.match(match[0], />CYCLE OFF<\/button>/); assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./); - const update = ui.match( - /setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/, - ); + const update = setBriefAutoRotate.toString(); assert.ok(update, 'cockpit briefing cycle state updater is missing'); - assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); - assert.match(update[1], /setAttribute\('aria-label', label\)/); - assert.match(update[1], /\.title = help;/); - assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/); + assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); + assert.match(update, /setAttribute\('aria-label', label\)/); + assert.match(update, /\.title = help;/); + assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/); }); test('voice Cockpit entry honours a requested contact layer before it enters', () => { @@ -845,7 +860,7 @@ test('cockpit state cannot report entryAllowed while already active', () => { ); assert.match( state, - /const entryAllowed = !active && Boolean\(/, + /const entryAllowed =\s*!active &&\s*Boolean\(/, 'entry is impossible while already inside, unconditionally', ); assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained'); 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 element rather than an . - * @param {string} feedType - * @returns {boolean} - */ -function isVideoFeedType(feedType) { - return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm'; -} - -/** - * Coerces a value to a finite number or returns the fallback. - * @param {*} value - * @param {number} [fallback=NaN] - * @returns {number} - */ -function safeNumber(value, fallback = NaN) { - const n = Number(value); - return Number.isFinite(n) ? n : fallback; -} - -/** - * Derives a deterministic heading from a camera ID string via a simple hash. - * Produces one of 16 evenly-spaced headings (0, 22.5, 45, ..., 337.5). - * @param {string} id - * @returns {number} Heading in degrees [0, 360). - */ -function headingFromId(id) { - const text = String(id || ''); - let acc = 0; - for (let i = 0; i < text.length; i++) { - acc = (acc * 33 + text.charCodeAt(i)) >>> 0; - } - return normalizeHeading((acc % 16) * 22.5); -} - -/** - * Rounds a value to the nearest multiple of `step`. - * @param {number} value - * @param {number} [step=0.1] - * @returns {number} - */ -function quantize(value, step = 0.1) { - return Math.round(value / step) * step; -} - -/** - * Sanitizes and clamps a calibration object to valid ranges. - * Missing or non-finite fields fall back to defaults. - * @param {Object} [value={}] - Raw calibration values. - * @returns {{ offsetNorthM: number, offsetEastM: number, headingDeg: number, pitchDeg: number, fovDeg: number, rangeScale: number, heightM: number }} - */ -function normalizeCalibration(value = {}) { - const raw = value && typeof value === 'object' ? value : {}; - return { - offsetNorthM: quantize(clamp(safeNumber(raw.offsetNorthM, 0), -900, 900), 0.1), - offsetEastM: quantize(clamp(safeNumber(raw.offsetEastM, 0), -900, 900), 0.1), - headingDeg: quantize(clamp(safeNumber(raw.headingDeg, 0), -180, 180), 0.1), - pitchDeg: quantize(clamp(safeNumber(raw.pitchDeg, 0), -45, 45), 0.1), - fovDeg: quantize(clamp(safeNumber(raw.fovDeg, 0), -50, 50), 0.1), - rangeScale: quantize(clamp(safeNumber(raw.rangeScale, 1), 0.35, 3.0), 0.01), - heightM: quantize(clamp(safeNumber(raw.heightM, 0), -120, 240), 0.1), - }; -} - -/** - * Returns true if the given calibration is effectively the default (all offsets near zero). - * @param {Object} calibration - * @returns {boolean} - */ -function isDefaultCalibration(calibration) { - const probe = normalizeCalibration(calibration); - return Object.keys(DEFAULT_CAMERA_CALIBRATION).every((key) => Math.abs(probe[key] - DEFAULT_CAMERA_CALIBRATION[key]) < 0.0001); -} - -/** - * Normalizes a coverage-mode request (viewshed design §3b). Accepts the three - * mode strings plus booleans for `setParams({showCoverage})` back-compat - * (true → 'on', false → 'off'); anything else keeps the current mode. - * @param {*} value - Requested mode ('off'|'on'|'viewshed') or boolean. - * @param {'off'|'on'|'viewshed'} current - Mode to keep when the request is invalid. - * @returns {'off'|'on'|'viewshed'} - */ -export function normalizeCoverageMode(value, current) { - if (value === true) return 'on'; - if (value === false) return 'off'; - if (value === 'off' || value === 'on' || value === 'viewshed') return value; - return current; -} - -/** - * Converts north/east metre offsets to lat/lon degree deltas at a given latitude. - * Uses the equirectangular approximation (111320 m/deg). - * @param {number} latDeg - Reference latitude (degrees). - * @param {number} northMeters - Offset northward (metres). - * @param {number} eastMeters - Offset eastward (metres). - * @returns {{ latOffset: number, lonOffset: number }} Degree deltas. - */ -function offsetDegrees(latDeg, northMeters, eastMeters) { - const latOffset = northMeters / 111320; - const lonDivisor = Math.max(0.15, Math.cos(toRad(latDeg))); - const lonOffset = eastMeters / (111320 * lonDivisor); - return { latOffset, lonOffset }; -} - -/** - * Returns `window.localStorage` when it is safely accessible, else null. - * Split out so store IO can be exercised under plain node:test with an - * injected storage-like object (getItem/setItem/removeItem) instead. - * @returns {Storage|null} - */ -function safeWindowLocalStorage() { - if (typeof window === 'undefined') return null; - try { - // NB: the window.localStorage property ACCESS itself throws SecurityError - // under "block all cookies", so it has to live inside the try (M11). - return window.localStorage || null; - } catch { - return null; - } -} - -/** - * Loads all persisted per-camera calibration overrides from the v2 store. - * - * v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual', - * savedAt: }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is - * NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import. - * - * @param {{getItem:function}|null} [storage] - Injectable storage (defaults - * to `window.localStorage`); lets the unit suite test this pure of a DOM. - * @returns {Map} - */ -export function readCalibrationStoreV2(storage = safeWindowLocalStorage()) { - const map = new Map(); - if (!storage) return map; - try { - const raw = storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2); - if (!raw) return map; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return map; - for (const [cameraId, entry] of Object.entries(parsed)) { - if (!cameraId || !entry || typeof entry !== 'object') continue; - if (!entry.values || typeof entry.values !== 'object') continue; - // 'manual' is the only provenance v2 knows (§9.3 killed 'legacy'); a - // malformed/foreign source string still normalizes to 'manual' rather - // than surfacing an unrecognized value into the badge logic. - map.set(cameraId, { - values: normalizeCalibration(entry.values), - source: 'manual', - savedAt: safeNumber(entry.savedAt, 0), - }); - } - return map; - } catch { - return map; - } -} - -/** - * Persists a calibration map to the v2 store. - * @param {Map} map - * @param {{setItem:function}|null} [storage] - Injectable storage (defaults - * to `window.localStorage`). - */ -export function writeCalibrationStoreV2(map, storage = safeWindowLocalStorage()) { - if (!storage) return; - try { - const payload = {}; - for (const [cameraId, entry] of map.entries()) { - payload[cameraId] = { - values: normalizeCalibration(entry.values), - source: 'manual', - savedAt: safeNumber(entry.savedAt, Date.now()), - }; - } - storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload)); - } catch { - // storage unavailable - } -} - -/** - * Loads the v2 calibration store. `_calibrationById` holds these entries - * directly (`{values, source:'manual', savedAt}`) — never bare offset values - * — so it round-trips straight back through `writeCalibrationStoreV2`. - * @returns {Map} - */ -function loadCalibrationStore() { - return readCalibrationStoreV2(); -} - -/** Persists the in-memory calibration entries (values + provenance) to the v2 store. */ -function saveCalibrationStore() { - writeCalibrationStoreV2(_calibrationById); -} - -/** - * Derives the panel CAL badge state for a camera (design §3b, as amended by - * the LOCKED §9.2 — panel-only, no in-world tint). - * - * Three states: - * - 'calibrated' — a human explicitly saved a v2 calibration (`source:'manual'`). - * - 'curated' — no manual save, but the catalog entry was hand-authored - * (`poseSource:'curated'`, file/env sources only). - * - 'raw-prior' — everything else (all Austin Open Data today). - * - * Pure — no scoring math, no raycasts. `confidenceFromScore` and score-based - * quality seeding are retired; this replaces them. - * @param {{calSource?: string|null, poseSource?: string|null}} camera - * @returns {'calibrated'|'curated'|'raw-prior'} - */ -export function deriveCalBadge(camera) { - if (camera?.calSource === 'manual') return 'calibrated'; - if (camera?.poseSource === 'curated') return 'curated'; - return 'raw-prior'; -} - -/** - * Initializes or recomputes a camera's derived pose fields from its base pose - * and calibration offsets. Also sets intrinsics, extrinsics, and anchor. - * - * On first call for a camera, captures the raw values as `basePose`. - * Subsequent calls re-derive lat/lon/heading/pitch/fov/range by applying - * calibration deltas to the frozen base pose. - * - * Note: the old score-based quality system (`confidenceFromScore`, seeded - * `camera.quality.score`) is retired — panel trust signal is now the 3-state - * CAL badge (`deriveCalBadge`, driven by `calSource`/`poseSource`), not a - * fabricated confidence score. - * - * @param {Object} camera - Mutable camera record. - */ -function ensureCameraPose(camera) { - if (!camera) return; - if (!camera.basePose) { - camera.basePose = { - lat: safeNumber(camera.lat, 0), - lon: safeNumber(camera.lon, 0), - headingDeg: normalizeHeading(safeNumber(camera.headingDeg, 0)), - pitchDeg: clamp(safeNumber(camera.pitchDeg, -17), -70, 10), - fovDeg: clamp(safeNumber(camera.fovDeg, 74), 20, 130), - rangeM: clamp(safeNumber(camera.rangeM, 700), 120, 5000), - mountHeightM: clamp(safeNumber(camera.mountHeightM, 24), 2, 240), - }; - } - - const nextCalibration = normalizeCalibration(camera.calibration || DEFAULT_CAMERA_CALIBRATION); - camera.calibration = nextCalibration; - - const base = camera.basePose; - const offsets = offsetDegrees(base.lat, nextCalibration.offsetNorthM, nextCalibration.offsetEastM); - camera.lat = base.lat + offsets.latOffset; - camera.lon = base.lon + offsets.lonOffset; - camera.headingDeg = normalizeHeading(base.headingDeg + nextCalibration.headingDeg); - camera.pitchDeg = clamp(base.pitchDeg + nextCalibration.pitchDeg, -70, 10); - camera.fovDeg = clamp(base.fovDeg + nextCalibration.fovDeg, 20, 130); - camera.rangeM = clamp(base.rangeM * nextCalibration.rangeScale, 120, 5000); - camera.mountHeightM = clamp(base.mountHeightM + nextCalibration.heightM, 2, 240); - - camera.intrinsics = { - fovDeg: camera.fovDeg, - principalPoint: [0.5, 0.5], - }; - camera.extrinsics = { - headingDeg: camera.headingDeg, - pitchDeg: camera.pitchDeg, - rollDeg: 0, - heightM: camera.mountHeightM, - }; - camera.anchor = { - lat: camera.lat, - lon: camera.lon, - elevM: safeNumber(camera.groundElevationM, 0), - targetLatLon: camera.anchor?.targetLatLon || null, - }; -} - -/** - * Projects a point along a bearing from a given lat/lon by a distance. - * Uses the spherical-earth direct geodesic formula (R = 6371 km). - * @param {number} latDeg - Origin latitude (degrees). - * @param {number} lonDeg - Origin longitude (degrees). - * @param {number} bearingDeg - Azimuth from north (degrees). - * @param {number} distanceM - Distance in metres. - * @returns {{ lat: number, lon: number }} Destination in degrees. - */ -function projectPoint(latDeg, lonDeg, bearingDeg, distanceM) { - const angular = distanceM / 6371000; - const bearing = toRad(bearingDeg); - const lat1 = toRad(latDeg); - const lon1 = toRad(lonDeg); - - const sinLat2 = Math.sin(lat1) * Math.cos(angular) - + Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing); - const lat2 = Math.asin(sinLat2); - - const y = Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1); - const x = Math.cos(angular) - Math.sin(lat1) * sinLat2; - const lon2 = lon1 + Math.atan2(y, x); - - return { - lat: Cesium.Math.toDegrees(lat2), - lon: Cesium.Math.toDegrees(lon2), - }; -} - -/** - * V2 core geometry (design §2a): computes the pitched frustum pyramid — mount - * point, far-cap (monitor plane) center, and the 4 far-plane corners — purely - * from the calibrated pose + a caller-supplied ground altitude. ZERO scene - * queries: ground sampling happens in the caller (one-shot snap), and the - * obstruction probe passes its clamp in as `rangeOverrideM`. - * - * Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges): - * mountAlt = groundAltM + mountHeightM - * capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch) - * halfW = R·tan(hFov/2) - * vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2) - * upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading - * corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset - * The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated - * pitch must never bury the plane's anchor); corners derive rigidly from the - * clamped center so the wireframe rays always terminate on the plane's - * corners — the bottom pair may dip below ground (tiles occlude it). - * - * @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg, - * rangeM, mountHeightM. - * @param {number} groundAltM - Ground altitude at the mount (metres). - * @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the - * effective range (never lengthens it). - * @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number, - * mount: {lat:number,lon:number,alt:number}, - * capCenter: {lat:number,lon:number,alt:number}, - * corners: { tl: Object, tr: Object, br: Object, bl: Object }, - * topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }} - */ -export function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) { - const ground = safeNumber(groundAltM, 0); - const poseRange = Math.max(1, safeNumber(camera.rangeM, 700)); - const override = safeNumber(rangeOverrideM, NaN); - const R = Number.isFinite(override) && override > 0 ? Math.min(poseRange, override) : poseRange; - const pitch = toRad(clamp(safeNumber(camera.pitchDeg, -17), -89, 89)); - const hFov = toRad(clamp(safeNumber(camera.fovDeg, 74), 8, 160)); - const heading = safeNumber(camera.headingDeg, 0); - const mountAlt = ground + safeNumber(camera.mountHeightM, 24); - - const horiz = R * Math.cos(pitch); - const vert = R * Math.sin(pitch); - const capLL = projectPoint(camera.lat, camera.lon, heading, horiz); - const capAlt = mountAlt + vert; - - const halfW = R * Math.tan(hFov / 2); - const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT); - const halfH = R * Math.tan(vFovRad / 2); - - // In-plane "up" of the pitched cap, decomposed into a vertical part and a - // horizontal part along the heading (pitch < 0 tilts the cap's top forward). - const upVert = Math.cos(pitch) * halfH; - const upHoriz = -Math.sin(pitch) * halfH; - - const capL = projectPoint(capLL.lat, capLL.lon, heading - 90, halfW); - const capR = projectPoint(capLL.lat, capLL.lon, heading + 90, halfW); - // Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch - // never buries the plane's anchor — then derive the corners RIGIDLY from the - // lifted center. Clamping each corner independently flattened the wireframe - // into a ground-hugging fan while the rigid plane kept its height (owner - // field test 2026-07-04): the corner rays must always terminate exactly on - // the monitor plane's corners. The bottom pair may dip below ground; the 3D - // tiles occlude that portion, exactly as they do for the plane itself. - const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M; - const capAltClamped = Math.max(minAlt, capAlt); - const corner = (base, sign) => { - const ll = projectPoint(base.lat, base.lon, heading, sign * upHoriz); - return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert }; - }; - - const topCenter = corner(capLL, 1); - return { - rangeM: R, - vFovDeg: Cesium.Math.toDegrees(vFovRad), - halfW, - halfH, - mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt }, - capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped }, - corners: { - tl: corner(capL, 1), - tr: corner(capR, 1), - br: corner(capR, -1), - bl: corner(capL, -1), - }, - topCenter, - groundAltM: ground, - }; -} - -/** - * Resolves the obstruction probe's effective-range clamp just short of a hit, - * with the field-derived 12 m floor used by the original H6 monitor. - * @param {number} rangeM Nominal camera range. - * @param {number} hitDistanceM Distance to the first obstruction. - * @returns {number|null} Clamp range, or null when the hit does not shorten it. - */ -export function activationProbeClampRange(rangeM, hitDistanceM) { - const nominalRange = Number(rangeM); - const hitDistance = Number(hitDistanceM); - if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null; - if (!Number.isFinite(hitDistance) || hitDistance <= 0 || hitDistance >= nominalRange) return null; - return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M); -} - -/** - * Computes the great-circle distance between two points using the haversine formula. - * @param {number} lat1 - Latitude of point 1 (degrees). - * @param {number} lon1 - Longitude of point 1 (degrees). - * @param {number} lat2 - Latitude of point 2 (degrees). - * @param {number} lon2 - Longitude of point 2 (degrees). - * @returns {number} Distance in kilometres. - */ -function haversineKm(lat1, lon1, lat2, lon2) { - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 - + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -/** - * Computes the area of a circular sector (camera FOV wedge). - * @param {number} rangeM - Radius in metres. - * @param {number} fovDeg - Field of view in degrees. - * @returns {number} Area in km^2. - */ -function sectorAreaKm2(rangeM, fovDeg) { - const theta = toRad(clamp(fovDeg, 12, 170)); - const areaM2 = 0.5 * rangeM * rangeM * theta; - return areaM2 / 1_000_000; -} - -/** - * Returns a coarse grid key describing the viewer's current position and zoom - * level. Used to detect meaningful view changes for auto-hop camera switching. - * @returns {string} Grid key in the form "zoomBucket:latGrid:lonGrid". - */ -function currentViewContext() { - const carto = _viewer?.camera?.positionCartographic; - if (!carto) return 'none'; - const lat = Cesium.Math.toDegrees(carto.latitude); - const lon = Cesium.Math.toDegrees(carto.longitude); - const alt = carto.height || 0; - const zoomBucket = alt < 1500 ? 'street' - : alt < 12000 ? 'city' - : alt < 75000 ? 'regional' - : 'global'; - const grid = zoomBucket === 'street' ? 0.045 - : zoomBucket === 'city' ? 0.24 - : zoomBucket === 'regional' ? 1.0 - : 4.5; - return `${zoomBucket}:${Math.floor(lat / grid)}:${Math.floor(lon / grid)}`; -} - -/** - * Builds the initial camera catalog from CAMERA_SEEDS definitions. - * Each seed is resolved against its city's POI coordinates, offset, and - * passed through ensureCameraPose to populate derived fields. - * @returns {Object[]} Array of fully-initialized camera objects. - */ -function seedCatalog() { - const catalog = []; - for (const seed of CAMERA_SEEDS) { - const city = CITY_POIS[seed.cityId]; - const poi = city?.pois?.[seed.poiIndex]; - if (!city || !poi) continue; - const { latOffset, lonOffset } = offsetDegrees( - poi.lat, - seed.offsetNorthM || 0, - seed.offsetEastM || 0 - ); - const camera = { - id: seed.id, - name: seed.label, - cityId: seed.cityId, - city: city.name, - provider: 'OSM Camera Grid', - sourceKind: 'seed', - feedType: 'image', - feedConfigured: false, - headingConfidence: 'medium', - lat: poi.lat + latOffset, - lon: poi.lon + lonOffset, - headingDeg: normalizeHeading(seed.headingDeg ?? poi.heading ?? 0), - fovDeg: clamp(seed.fovDeg ?? 70, 20, 120), - rangeM: clamp(seed.rangeM ?? 700, 260, 1800), - mountHeightM: clamp(seed.elevationM ?? 22, 8, 80), - groundElevationM: Number(city.groundElevation) || 0, - absoluteHeightM: (Number(city.groundElevation) || 0) + clamp(seed.elevationM ?? 22, 8, 80), - pitchDeg: clamp(seed.pitchDeg ?? -17, -40, -4), - }; - ensureCameraPose(camera); - catalog.push(camera); - } - return catalog; -} - -/** - * Looks up a city ID from CITY_POIS by exact or partial name match. - * @param {string} cityName - * @returns {string|null} Matching city ID or null. - */ -function cityIdByName(cityName) { - const probe = String(cityName || '').trim().toLowerCase(); - if (!probe) return null; - for (const [cityId, city] of Object.entries(CITY_POIS)) { - if (city.name.toLowerCase() === probe) return cityId; - } - for (const [cityId, city] of Object.entries(CITY_POIS)) { - if (city.name.toLowerCase().includes(probe) || probe.includes(city.name.toLowerCase())) return cityId; - } - return null; -} - -/** - * Fetches configured camera sources from the backend. - * @returns {Promise} Array of raw source objects, or empty on failure. - */ -async function loadCameraSources() { - try { - const resp = await fetch(SOURCE_ENDPOINT, { cache: 'no-store' }); - if (!resp.ok) return []; - const data = await resp.json(); - if (!Array.isArray(data?.sources)) return []; - return data.sources; - } catch { - return []; - } -} - -/** - * Merges raw backend sources with seed data to produce the final camera catalog. - * Seeds provide fallback values for heading, FOV, range, etc. when not specified - * by the source. Each camera is passed through ensureCameraPose. - * @param {Object[]} rawSources - Raw source objects from the backend. - * @returns {Object[]} Array of fully-initialized camera objects. - */ -function buildCatalogFromSources(rawSources) { - const sources = Array.isArray(rawSources) ? rawSources : []; - if (!sources.length) return []; - - const seeded = seedCatalog(); - const seedById = new Map(seeded.map((camera) => [camera.id, camera])); - - const catalog = []; - for (const source of sources) { - if (!source || typeof source !== 'object') continue; - const id = String(source.id || '').trim(); - if (!id) continue; - const seed = seedById.get(id); - const cityId = String(source.cityId || '').trim() || cityIdByName(source.city) || seed?.cityId || ''; - const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null; - - const lat = safeNumber(source.lat, seed?.lat ?? NaN); - const lon = safeNumber(source.lon, seed?.lon ?? NaN); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - - const sourceHeading = safeNumber(source.headingDeg, NaN); - const headingDeg = normalizeHeading( - Number.isFinite(sourceHeading) - ? sourceHeading - : (seed?.headingDeg ?? headingFromId(id)) - ); - const fovDeg = clamp(safeNumber(source.fovDeg, seed?.fovDeg ?? 74), 20, 125); - const rangeM = clamp(safeNumber(source.rangeM, seed?.rangeM ?? 700), 220, 2200); - const mountHeightM = clamp(safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24), 6, 120); - const pitchDeg = clamp(safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17), -55, -2); - const groundElevationM = safeNumber(source.groundElevationM, city?.groundElevation ?? seed?.groundElevationM ?? 0); - const feedType = normalizeFeedType(source.feedType || source.type || 'image'); - const headingConfidence = String(source.headingConfidence || (seed ? 'high' : 'low')).toLowerCase(); - // CAL badge input (design §3b passthrough): hand-authored file/env source - // entries may carry poseSource:'curated'. Austin Open Data rows never set - // this — they stay RAW PRIOR until a human manually calibrates them. - const poseSource = source.poseSource === 'curated' ? 'curated' : (seed?.poseSource || null); - - const camera = { - id, - name: String(source.name || seed?.name || id), - cityId, - city: String(source.city || city?.name || seed?.city || 'Global'), - provider: String(source.provider || seed?.provider || 'Configured CCTV Source'), - sourceKind: String(source.sourceKind || source.kind || (source.url ? 'configured' : 'seed')).toLowerCase(), - feedType, - feedConfigured: typeof source.url === 'string' && !!source.url.trim(), - lat, - lon, - headingDeg, - headingConfidence, - fovDeg, - rangeM, - mountHeightM, - groundElevationM, - absoluteHeightM: groundElevationM + mountHeightM, - pitchDeg, - license: String(source.license || source.licenseNote || ''), - poseSource, - }; - ensureCameraPose(camera); - catalog.push(camera); - } - - return catalog; -} - -/** - * Reports whether the active Google Photorealistic 3D Tileset (if any) has - * finished loading the tiles in view. Shared mesh-floor sampling is gated on - * this so a one-shot cell never bakes in a miss from still-streaming tiles. - * Discovers + caches the tileset lazily from scene - * primitives (the CCTV module holds only a `_viewer` reference). When no - * tileset is present (OSM fallback) this returns true so ground sampling is - * not permanently blocked. - * - * Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`, - * i.e. a globe stack is active) must NOT report ready — Cesium 1.138's - * the shared sampler can only inspect *visible* 3D tilesets, so a sample taken - * against the hidden Google tileset would silently miss. - * - * @returns {boolean} True when tiles are loaded AND visible (or no tileset - * exists to wait on). - */ -function projectionTilesReady() { - if (!_activeTileset || _activeTileset.isDestroyed?.()) { - _activeTileset = null; - const primitives = _viewer?.scene?.primitives; - if (primitives && typeof primitives.get === 'function') { - for (let i = 0; i < primitives.length; i++) { - const p = primitives.get(i); - if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) { - _activeTileset = p; - break; - } - } - } - } - if (!_activeTileset) return true; - if (_activeTileset.show === false) return false; - return _activeTileset.tilesLoaded === true; -} - -/** - * Converts a computeFrustumGeometry result into the Cartesian3 positions the - * entities consume. Fresh objects per call (geometry updates are rare — - * slider/save/activation — and entities must never share scratch objects). - * @param {Object} geometry - Result of computeFrustumGeometry. - * @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3, - * tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3, - * bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }} - */ -function frustumCartesians(geometry) { - const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt); - return { - mount: at(geometry.mount), - capCenter: at(geometry.capCenter), - tl: at(geometry.corners.tl), - tr: at(geometry.corners.tr), - br: at(geometry.corners.br), - bl: at(geometry.corners.bl), - label: Cesium.Cartesian3.fromDegrees( - geometry.topCenter.lon, - geometry.topCenter.lat, - geometry.topCenter.alt + 1.2 - ), - }; -} - -/** - * Unit ECEF direction of the frustum view axis (heading/pitch) at a position. - * Used by the plane orientation and the activation obstruction probe — both - * need the UNCLAMPED axis, so it comes from the pose, not from clamped points. - * @param {Object} camera - Camera pose (headingDeg, pitchDeg). - * @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame. - * @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the - * frame's in-plane "up" (both unit, mutually perpendicular). - */ -function frustumFrameEcef(camera, atPos) { - const h = toRad(camera.headingDeg); - const p = toRad(camera.pitchDeg); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos); - const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3()); - // ENU components: view axis + the perpendicular "up" of the pitched cap. - const dirEnu = new Cesium.Cartesian3( - Math.sin(h) * Math.cos(p), - Math.cos(h) * Math.cos(p), - Math.sin(p) - ); - const upEnu = new Cesium.Cartesian3( - -Math.sin(p) * Math.sin(h), - -Math.sin(p) * Math.cos(h), - Math.cos(p) - ); - return { - dir: Cesium.Matrix3.multiplyByVector(rot, dirEnu, new Cesium.Cartesian3()), - up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()), - }; -} - -/** - * Orientation quaternion for the monitor plane entity: local +Z is the plane - * normal, pointing BACK along the view axis toward the mount so the textured - * front face reads correctly from the natural viewpoint (focusCamera flies the - * viewer to look along the camera heading). Local +X = viewer-right, +Y = - * frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry — - * computed only on slider/save/activation, never per frame (§2b). - * @param {Object} camera - Camera pose. - * @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF. - * @returns {Cesium.Quaternion} - */ -function planeOrientationFor(camera, capCenterPos) { - const frame = frustumFrameEcef(camera, capCenterPos); - const right = Cesium.Cartesian3.cross(frame.dir, frame.up, new Cesium.Cartesian3()); - const normal = Cesium.Cartesian3.negate(frame.dir, new Cesium.Cartesian3()); - const m = new Cesium.Matrix3( - right.x, frame.up.x, normal.x, - right.y, frame.up.y, normal.y, - right.z, frame.up.z, normal.z - ); - return Cesium.Quaternion.fromRotationMatrix(m); -} - -/** - * Task 5: the surface regime the scene is CURRENTLY rendering, derived live - * from `globe.show` (mapStackController's `_activatePhotoreal` / - * `_activateGlobeStack` flip exactly this flag). Reading scene state directly - * — rather than caching the map-stack id — means the regime is correct even - * for stack changes this module never got an event for. - * @returns {'google-3d'|'terrain-globe'} - */ -function currentSurfaceRegime() { - return surfaceRegimeKey(_viewer?.scene?.globe?.show); -} - -/** - * Task 5: the record's ellipsoidal ground PRIOR (Re:Earth point-height batch, - * `record.groundPrior.ellipsoid`), falling back to the catalog's fabricated - * orthometric `groundElevationM` only when the prior batch hasn't landed yet. - * This is the value that replaces every previous - * `Number(camera.groundElevationM) || 0` ground fallback — it alone lifts - * London's cameras from the fabricated 15 m to ~52.7 m ellipsoidal in every - * regime, on first paint. - * @param {Object} record - Camera record. - * @returns {number} Ellipsoidal ground altitude in metres. - */ -function groundPriorAltFor(record) { - const prior = record?.groundPrior?.ellipsoid; - return Number.isFinite(prior) ? prior : (Number(record?.camera?.groundElevationM) || 0); -} - -/** - * Task 5: whether the record's one-shot ground resolution has completed for - * the given regime (per-regime latch — replaces the old boolean - * `groundResolved`). - * @param {Object} record - Camera record. - * @param {string} [regime] - Defaults to the current surface regime. - * @returns {boolean} - */ -function isGroundResolved(record, regime = currentSurfaceRegime()) { - return record?.groundResolved?.[regime] === true; -} - -/** - * Ground altitude used for pure geometry recomputes: the given regime's - * cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor - * in google-3d, the DEM/prior in terrain-globe) when it exists, else the prior - * itself. Never queries the scene. - * @param {Object} record - Camera record. - * @param {string} [regime] - Defaults to the current surface regime. - * @returns {number} Ground altitude in metres. - */ -function groundAltFor(record, regime = currentSurfaceRegime()) { - const cached = record?.groundSamples?.[regime]; - return Number.isFinite(cached) ? cached : groundPriorAltFor(record); -} - -/** - * FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw - * pixel buffer, no DOM) so it is unit-testable. - * - * Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would - * cost a third more work to mix in a constant. - * - * @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel. - * @returns {number|null} Unsigned 32-bit signature, or null for empty input. - */ -export function frameSignatureFromPixels(data) { - if (!data || typeof data.length !== 'number' || data.length < 4) return null; - let hash = 0x811c9dc5; - for (let i = 0; i < data.length; i += 4) { - hash = Math.imul(hash ^ data[i], 0x01000193); - hash = Math.imul(hash ^ data[i + 1], 0x01000193); - hash = Math.imul(hash ^ data[i + 2], 0x01000193); - } - return hash >>> 0; -} - -/** - * Signature of the runtime's freshly decoded frame, via a reused 64x36 - * scratch canvas. - * - * @param {Object} runtime - Projection runtime holding the decoded `.image`. - * @returns {number|null} Signature, or null when it cannot be computed (the - * caller then treats the frame as changed — the pre-2026-07-30 behavior). - */ -function projectionFrameSignature(runtime) { - const image = runtime?.image; - if (!image) return null; - if (!runtime.signatureCtx) { - const canvas = document.createElement('canvas'); - canvas.width = FRAME_SIGNATURE_W; - canvas.height = FRAME_SIGNATURE_H; - runtime.signatureCanvas = canvas; - runtime.signatureCtx = canvas.getContext('2d', { willReadFrequently: true }); - } - const ctx = runtime.signatureCtx; - if (!ctx) return null; - try { - ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); - ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); - return frameSignatureFromPixels( - ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data - ); - } catch { - // Tainted canvas (a cross-origin source served without CORS) or a decode - // race. Returning null means "assume changed", so behavior degrades to - // the unconditional redraw this optimization replaced. - return null; - } -} - -/** - * Blits the latest projection canvas into the next of two alternating - * offscreen buffer canvases and returns it (H5). - * - * Two buffers are required because Cesium's Material image path re-uploads a - * canvas texture only when the uniform receives a NEW object reference — - * redrawing the same canvas in place is invisible to the GPU. Alternating - * references forces a texture recreate, which at <=1Hz and 1080p is cheap. - * - * @param {Object} runtime - Projection runtime with `.canvas`. - * @returns {HTMLCanvasElement|null} The freshly painted buffer, or null. - */ -function paintNextProjectionBuffer(runtime) { - if (!runtime?.canvas) return null; - if (!runtime.buffers) { - runtime.buffers = [0, 1].map(() => { - const buffer = document.createElement('canvas'); - buffer.width = PROJECTION_CANVAS_WIDTH; - buffer.height = PROJECTION_CANVAS_HEIGHT; - return buffer; - }); - runtime.bufferIndex = 0; - } - runtime.bufferIndex = (runtime.bufferIndex + 1) % 2; - const buffer = runtime.buffers[runtime.bufferIndex]; - const ctx = buffer.getContext('2d'); - if (!ctx) return null; - ctx.clearRect(0, 0, buffer.width, buffer.height); - ctx.drawImage(runtime.canvas, 0, 0); - return buffer; -} - -/** - * Pushes fresh pixels into the monitor plane material. Called every - * projection tick. - * - * Video feeds are skipped entirely — their HTMLVideoElement uniform is - * updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap - * the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS. - * - * @param {Object} record - Camera record with an initialized projection runtime. - */ -function refreshProjectionTextures(record) { - const runtime = record?.projection; - if (!runtime || runtime.mode === 'video') return; - const now = Date.now(); - if (now - safeNumber(runtime.lastTextureSwapAt, 0) < PROJECTION_TEXTURE_SWAP_MS) return; - - const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial); - if (!planeShowing) return; - - // Only swap when the canvas content actually changed since the last swap. - // Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED - // canvas re-uploads the texture for nothing, and each material image - // reassignment is a flash opportunity on the live plane (owner field test - // 2026-07-04: intermittent white flashes on the monitor plane). - if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return; - - const buffer = paintNextProjectionBuffer(runtime); - if (!buffer) return; - runtime.lastTextureSwapAt = now; - runtime.lastSwappedCanvasStamp = runtime.canvasStamp; - runtime.planeMaterial.image = buffer; -} - -/** - * Builds the URL for fetching a camera frame image from the backend. - * Includes a tick parameter to control cache invalidation cadence. - * @param {Object} camera - Camera object. - * @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing. - * @returns {string} Frame URL. - */ -function frameUrlFor(camera, refreshMs = ACTIVE_FRAME_REFRESH_MS) { - const cadenceMs = Math.max(1000, safeNumber(refreshMs, ACTIVE_FRAME_REFRESH_MS)); - const tick = Math.floor(Date.now() / cadenceMs); - const params = new URLSearchParams({ - label: camera.name, - city: camera.city, - lat: camera.lat.toFixed(6), - lon: camera.lon.toFixed(6), - heading: String(Math.round(camera.headingDeg)), - fov: String(Math.round(camera.fovDeg)), - pitch: String(Math.round(camera.pitchDeg || -10)), - ts: String(tick), - }); - return `${FRAME_ENDPOINT}/${encodeURIComponent(camera.id)}?${params.toString()}`; -} - -/** - * Builds the URL for fetching a camera's video/media stream. - * @param {Object} camera - Camera object. - * @returns {string} Media URL. - */ -function mediaUrlFor(camera) { - return `${MEDIA_ENDPOINT}/${encodeURIComponent(camera.id)}?ts=${Math.floor(Date.now() / 15000)}`; -} - -/** - * Paints a placeholder frame onto the projection canvas when no live feed - * image or video is available. Shows camera name, city, and status text - * over a dark gradient with tactical border lines. - * @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas. - * @param {Object} camera - Camera object for label info. - * @param {Object|null} [health=null] - Health state for status message. - */ -function paintProjectionPlaceholder(ctx, camera, health = null) { - if (!ctx) return; - const w = PROJECTION_CANVAS_WIDTH; - const h = PROJECTION_CANVAS_HEIGHT; - ctx.clearRect(0, 0, w, h); - const g = ctx.createLinearGradient(0, 0, w, h); - g.addColorStop(0, '#05111a'); - g.addColorStop(1, '#01070c'); - ctx.fillStyle = g; - ctx.fillRect(0, 0, w, h); - - const label = String(camera?.name || 'CCTV'); - const city = String(camera?.city || 'GLOBAL'); - const status = String(health?.message || health?.status || camera?.feedType || 'NO FEED').toUpperCase(); - - ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)'; - ctx.lineWidth = 2; - ctx.strokeRect(18, 18, w - 36, h - 36); - ctx.strokeRect(36, 36, w - 72, h - 72); - - ctx.fillStyle = 'rgba(170, 242, 255, 0.95)'; - ctx.font = '600 32px "JetBrains Mono", monospace'; - ctx.fillText(label.slice(0, 42), 46, 74); - ctx.fillStyle = 'rgba(127, 216, 231, 0.8)'; - ctx.font = '500 24px "JetBrains Mono", monospace'; - ctx.fillText(city.toUpperCase(), 46, 112); - ctx.font = '500 21px "JetBrains Mono", monospace'; - ctx.fillText(status.slice(0, 58), 46, h - 42); -} - -/** - * Re-derives the monitor plane entity's placement (position, orientation, - * dimensions) + label from the record's current frustum geometry, so the plane - * always caps the wireframe exactly (corner rays terminate on its corners). - * No-op when the record has no plane runtime (idle neighbors have no plane). - * @param {Object} record - Camera record. - */ -function updatePlanePlacement(record) { - const runtime = record?.projection; - if (!runtime?.planeEntity) return; - const geometry = record.frustumGeometry - || computeFrustumGeometry(record.camera, groundAltFor(record), record.probeClampRangeM); - const positions = record.frustumPositions || frustumCartesians(geometry); - runtime.planeEntity.position = positions.capCenter; - runtime.planeEntity.orientation = planeOrientationFor(record.camera, positions.capCenter); - if (runtime.planeEntity.plane) { - runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2( - geometry.halfW * 2, - geometry.halfH * 2 - ); - } - if (runtime.labelPosition) { - Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); - } -} - -/** - * Clear the active monitor-plane label source and ownership marker. - */ -function clearProjectionOverlay() { - _cctvOverlayHost.clearSource(CCTV_PROJECTION_OVERLAY_SOURCE_ID); - _cctvOverlayHost.setVisible(CCTV_PROJECTION_OVERLAY_SOURCE_ID, false); - _projectionOverlayOwnerId = null; -} - -/** - * Shows/hides the monitor plane and its associated shared-host label. - * @param {Object} runtime - Projection runtime. - * @param {boolean} visible - */ -function setPlaneVisible(runtime, visible) { - if (!runtime) return; - if (runtime.planeEntity) runtime.planeEntity.show = !!visible; - if (visible && runtime.overlayEntry && runtime.cameraId) { - if (_projectionOverlayOwnerId !== runtime.cameraId) { - _cctvOverlayHost.setEntries( - CCTV_PROJECTION_OVERLAY_SOURCE_ID, - [runtime.overlayEntry], - CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, - ); - _cctvOverlayHost.setVisible(CCTV_PROJECTION_OVERLAY_SOURCE_ID, true); - _projectionOverlayOwnerId = runtime.cameraId; - } - } else if (_projectionOverlayOwnerId === runtime.cameraId) { - clearProjectionOverlay(); - } -} - -/** Create the native monitor plane plus its cached host-label presentation. */ -function createProjectionPlane(record, runtime, geometry, positions) { - runtime.labelPosition ||= new Cesium.Cartesian3(); - Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); - runtime.cameraId = String(record.camera.id); - runtime.overlayEntry = createCctvProjectionOverlayEntry({ - cameraId: runtime.cameraId, - name: record.camera.name, - position: () => runtime.labelPosition, - }); - runtime.planeEntity = _viewer.entities.add({ - id: `cctv-${record.camera.id}-plane`, - properties: { cctvCameraId: record.camera.id }, - show: false, - position: positions.capCenter, - orientation: planeOrientationFor(record.camera, positions.capCenter), - plane: { - plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0), - dimensions: new Cesium.Cartesian2(geometry.halfW * 2, geometry.halfH * 2), - material: runtime.planeMaterial, - outline: true, - outlineColor: PLANE_OUTLINE_COLOR, - }, - }); - return runtime.planeEntity; -} - -/** - * Create the production monitor-plane/host-label pair without media setup. - * @param {Object} viewer Cesium viewer seam. - * @param {Object} record CCTV runtime record. - * @returns {Object} Projection runtime. - */ -export function _createCctvProjectionPlaneForTest(viewer, record) { - _viewer = viewer; - const geometry = record.frustumGeometry - || computeFrustumGeometry(record.camera, groundAltFor(record), record.probeClampRangeM); - const positions = record.frustumPositions || frustumCartesians(geometry); - record.frustumGeometry = geometry; - record.frustumPositions = positions; - const runtime = { - cameraId: String(record.camera.id), - planeEntity: null, - labelPosition: new Cesium.Cartesian3(), - overlayEntry: null, - planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE), - }; - createProjectionPlane(record, runtime, geometry, positions); - record.projection = runtime; - return runtime; -} - -/** - * Exercise the production geometry-to-plane-and-label cache update. - * @param {Object} record CCTV runtime record. - */ -export function _updateCctvProjectionPlaneForTest(record) { - updatePlanePlacement(record); -} - -/** - * Creates the projection runtime for a camera record: an offscreen canvas, - * the monitor plane plus associated host label, and either an - * or element depending on the feed type. - * - * The plane is the only projection representation (v2): the frustum's far cap, - * perpendicular to the view axis (§2b — never billboarded; a wall primitive - * can't pitch, the plane can). It is textured with the live frame: video - * element direct, canvas double-buffer otherwise. - * - * @param {Object} record - Camera record. - * @returns {Object|null} Projection runtime, or null if no viewer. - */ -function createProjectionRuntime(record) { - if (!_viewer) return null; - const canvas = document.createElement('canvas'); - canvas.width = PROJECTION_CANVAS_WIDTH; - canvas.height = PROJECTION_CANVAS_HEIGHT; - const ctx = canvas.getContext('2d', { alpha: true }); - - const feedType = normalizeFeedType(record.camera.feedType); - const mode = isVideoFeedType(feedType) ? 'video' : 'image'; - const runtime = { - mode, - canvas, - ctx, - image: null, - video: null, - planeEntity: null, - cameraId: String(record.camera.id), - labelPosition: new Cesium.Cartesian3(), - overlayEntry: null, - planeMaterial: null, - buffers: null, - bufferIndex: 0, - lastTextureSwapAt: 0, - lastImageRefreshAt: 0, - imageReady: false, - imageLoading: false, - imageStamp: 0, - drawnImageStamp: -1, - // Signature of the pixels currently ON the canvas, plus the reused 64x36 - // scratch used to compute it. null = "nothing known", which always redraws. - lastFrameSignature: null, - signatureCanvas: null, - signatureCtx: null, - lastPlaceholderPaintAt: 0, - // canvasStamp increments on every canvas write (frame blit / placeholder - // paint); lastSwappedCanvasStamp trails it so refreshProjectionTextures - // only re-uploads the plane texture when there is genuinely new content. - canvasStamp: 1, - lastSwappedCanvasStamp: 0, - }; - - paintProjectionPlaceholder(ctx, record.camera); - - if (mode === 'video') { - const video = document.createElement('video'); - video.muted = true; - video.loop = true; - video.autoplay = true; - video.playsInline = true; - video.crossOrigin = 'anonymous'; - video.preload = 'auto'; - video.src = mediaUrlFor(record.camera); - video.addEventListener('canplay', () => { - video.play().catch(() => {}); - }); - runtime.video = video; - } else { - const img = new Image(); - img.decoding = 'async'; - img.crossOrigin = 'anonymous'; - img.onload = () => { - runtime.imageLoading = false; - runtime.imageReady = true; - runtime.imageStamp = Date.now(); - }; - img.onerror = () => { - runtime.imageLoading = false; - runtime.imageReady = false; - }; - runtime.image = img; - } - - // Monitor plane = the frustum's far cap: video feeds bind the video element - // directly (Cesium updates video-backed entity materials per frame); image - // feeds start on the placeholder canvas and switch to double-buffer swaps - // at <=1Hz. - const geometry = record.frustumGeometry - || computeFrustumGeometry(record.camera, groundAltFor(record), record.probeClampRangeM); - const positions = record.frustumPositions || frustumCartesians(geometry); - runtime.planeMaterial = new Cesium.ImageMaterialProperty({ - image: (mode === 'video' && runtime.video) ? runtime.video : canvas, - transparent: true, - color: Cesium.Color.WHITE.withAlpha(0.95), - }); - createProjectionPlane(record, runtime, geometry, positions); - - return runtime; -} - -/** - * Lazily initializes the projection runtime for a record if it doesn't exist yet. - * @param {Object} record - Camera record. - * @returns {Object|null} The record's projection runtime. - */ -function ensureProjectionRuntime(record) { - if (!record) return null; - if (record.projection) return record.projection; - const runtime = createProjectionRuntime(record); - record.projection = runtime; - if (runtime) { - _projectionEntities.push(runtime); - } - return runtime; -} - -/** - * Tears down a projection runtime: stops video playback, removes the monitor - * plane, and clears its host label if it owns the active source. - * @param {Object} runtime - Projection runtime to destroy. - */ -function destroyProjectionRuntime(runtime) { - if (!runtime) return; - if (runtime.video) { - runtime.video.pause(); - runtime.video.removeAttribute('src'); - runtime.video.load(); - } - if (runtime.planeEntity && _viewer) { - _viewer.entities.remove(runtime.planeEntity); - runtime.planeEntity = null; - } - if (_projectionOverlayOwnerId === runtime.cameraId) clearProjectionOverlay(); - runtime.overlayEntry = null; - runtime.labelPosition = null; - runtime.planeMaterial = null; -} - -/** - * Triggers a new frame fetch for an image-mode projection if the refresh - * interval has elapsed. Active cameras refresh more frequently than idle ones. - * @param {Object} record - Camera record. - * @param {boolean} [force=false] - Bypass the interval check. - */ -function refreshProjectionImage(record, force = false) { - const runtime = record?.projection; - if (!runtime || runtime.mode !== 'image' || !runtime.image) return; - // Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas - // nobody can see. The refresh interval re-fills naturally on return. - if (typeof document !== 'undefined' && document.hidden && !force) return; - // Do not replace an in-flight URL on the 10-second refresh boundary. Slow - // providers otherwise leave cancelled server requests behind and the plane - // can remain permanently pending. The proxy bounds each attempt; load/error - // clears this latch so the next normal tick can refresh. - if (runtime.imageLoading) return; - const now = Date.now(); - const refreshMs = record.camera.id === _activeCameraId - ? PROJECTION_ACTIVE_REFRESH_MS - : PROJECTION_IDLE_REFRESH_MS; - if (!force && now - runtime.lastImageRefreshAt < refreshMs) return; - runtime.lastImageRefreshAt = now; - - const frameUrl = frameUrlFor(record.camera, refreshMs); - const sep = frameUrl.includes('?') ? '&' : '?'; - runtime.imageLoading = true; - runtime.imageReady = false; - runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`; -} - -/** - * Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS. - * The projection loop runs at RAF cadence — unthrottled, a pending feed would - * re-fill the 1080p canvas (gradient + text) on every single frame. - * @param {Object} record - Camera record. - * @param {Object} runtime - Projection runtime. - * @param {Object|null} health - Health state for status text. - */ -function paintPlaceholderThrottled(record, runtime, health) { - const now = Date.now(); - if (now - safeNumber(runtime.lastPlaceholderPaintAt, 0) < PLACEHOLDER_REPAINT_MS) return; - runtime.lastPlaceholderPaintAt = now; - runtime.drawnImageStamp = -1; - // The placeholder overwrites the canvas, so the last real frame is no longer - // on it. Drop the signature or an identical frame returning after an outage - // would be skipped as "unchanged" and leave the placeholder on the plane. - runtime.lastFrameSignature = null; - runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; - paintProjectionPlaceholder(runtime.ctx, record.camera, health); -} - -/** - * Draws the current frame (video or image) onto the projection canvas. - * Falls back to the placeholder if the media source is not yet ready. - * Image feeds only repaint when a NEW image finished loading (stamp check): - * re-blitting an unchanged 1080p image every RAF tick is pure waste since - * texture uploads are already throttled to 1Hz buffer swaps. - * @param {Object} record - Camera record with an initialized projection runtime. - */ -function drawProjectionFrame(record) { - const runtime = record?.projection; - if (!runtime || !runtime.ctx) return; - - const health = _healthById.get(record.camera.id) || null; - - if (runtime.mode === 'video' && runtime.video) { - const video = runtime.video; - if (video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0) { - runtime.ctx.clearRect(0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT); - runtime.ctx.drawImage(video, 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT); - runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; - return; - } - paintPlaceholderThrottled(record, runtime, health); - return; - } - - refreshProjectionImage(record); - if (runtime.image && runtime.imageReady) { - if (runtime.drawnImageStamp !== runtime.imageStamp) { - // The frame URL carries a 10s cache-buster tick, so a fresh Image - // DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider - // actually published a new picture — measured 2026-07-30: a London - // camera republished once in 5 minutes, an Austin one not at all. - // Redrawing regardless bumped canvasStamp, which forced a buffer swap - // and a fresh 1920x1080 texture upload; the plane renders its white - // base color (planeMaterial color = WHITE, alpha .95) for the frame or - // two Cesium needs to rebind, which IS the periodic white flash from the - // owner field tests (2026-07-04 and 2026-07-30). - const signature = projectionFrameSignature(runtime); - runtime.drawnImageStamp = runtime.imageStamp; - if (signature !== null && signature === runtime.lastFrameSignature) { - // Identical pixels — leave the canvas, and therefore the bound - // texture, completely alone. No canvasStamp bump, no swap, no flash. - return; - } - runtime.lastFrameSignature = signature; - runtime.ctx.clearRect(0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT); - runtime.ctx.drawImage(runtime.image, 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT); - runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; - runtime.lastPlaceholderPaintAt = 0; - } - return; - } - // A refresh is in flight (imageReady=false): keep the last good frame on - // the canvas instead of flashing the placeholder. Placeholder only paints - // when nothing has ever been drawn for this camera. - if (runtime.drawnImageStamp === -1) { - paintPlaceholderThrottled(record, runtime, health); - } -} - -/** - * Starts the requestAnimationFrame loop that drives projection canvas updates - * (frame draw + texture swap) for the active camera. - */ -/** - * The projection rAF has real work only while a camera is actively projected - * or a focus fade is in flight — otherwise it burned a wakeup + style poll - * every rendered frame for the whole enabled lifetime of the layer. The tick - * self-stops when idle; every state edge that creates work re-arms it - * (enable, setActiveCamera, showProjection, focus-target appearance). - * (perf wave 1) - * @returns {boolean} Whether the loop currently has work. - */ -function projectionLoopIsNeeded() { - if (!_enabled || !_viewer) return false; - if (_showProjection && getActiveRecord()) return true; - return focusPassIsNeeded(getFocusTarget(), _activeFocusStyleCount); -} - -function startProjectionLoop() { - if (_projectionRaf) return; - if (!projectionLoopIsNeeded()) return; - // The armed projection loop uploads video textures / runs focus fades per - // frame — the scene must render continuously while it runs. Released when - // the tick self-stops. (perf wave 2) - holdContinuousRender('cctv-projection'); - - const tick = () => { - if (!_viewer || !projectionLoopIsNeeded()) { - _projectionRaf = 0; - releaseContinuousRender('cctv-projection'); - return; - } - - refreshCctvFocusStyles(performance.now()); - - const active = getActiveRecord(); - if (_enabled && _showProjection && active) { - ensureProjectionRuntime(active); - if (active.projection?.video) { - active.projection.video.play().catch(() => {}); - } - if (active.projection) { - drawProjectionFrame(active); - refreshProjectionTextures(active); - } - } - - _projectionRaf = requestAnimationFrame(tick); - }; - - _projectionRaf = requestAnimationFrame(tick); -} - -/** - * Focus modulation rides the layer's existing animation loop; no additional - * scene listener is installed. Only camera-icon alpha changes here — coverage - * geometry and monitor-plane styling retain their established cadence. - */ -function refreshCctvFocusStyles(nowMs) { - nowMs = focusNowMs(nowMs); - const target = getFocusTarget(); - if (!_enabled || !_viewer || !focusPassIsNeeded(target, _activeFocusStyleCount)) return; - if (nowMs - _lastFocusStyleAt < 80) return; - _lastFocusStyleAt = nowMs; - const scene = _viewer.scene; - const camera = _viewer.camera; - const result = applyCctvFocusDeemphasis({ - records: _records, - target, - previousActiveCount: _activeFocusStyleCount, - nowMs, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchFocusScreen) - ), - cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - baseColorFor: (record) => ( - record.camera.id === _activeCameraId ? ACTIVE_CAMERA_COLOR : IDLE_CAMERA_COLOR - ), - }); - _activeFocusStyleCount = result.activeCount; -} - -/** - * Apply the gated CCTV focus pass through the production color path. - * @param {object} input - * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} - */ -export function applyCctvFocusDeemphasis({ - records, - target, - previousActiveCount = 0, - nowMs, - screenPositionFor, - cameraDistanceFor, - baseColorFor, - 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; - if (!bb) continue; - const position = bb.position; - // CCTV never publishes a tracked focus target, so every icon is ambient. - const focus = advanceSpriteFocus(bb, { - // Keep hidden icons in the state/release pass so the active count cannot - // drop while a stale dim alpha remains waiting to reappear. - screenPosition: bb.show === false || !position ? null : screenPositionFor(position), - cameraDistance: position ? cameraDistanceFor(position) : Number.NaN, - nowMs, - target, - params, - spriteHalfWidthPx: (bb.width || 24) * (bb.scale || 1) * 0.5, - spriteHalfHeightPx: (bb.height || 24) * (bb.scale || 1) * 0.5, - }); - transitioning ||= focus.transitioning; - if (focus.active) activeCount += 1; - const base = baseColorFor(record); - const alpha = base.alpha * focus.factor; - if (focusAlphaNeedsWrite(bb.color?.alpha, alpha, params)) { - // Order-independent narrow amendment to always-visible icons: CCTV - // contacts retain a non-zero floor while yielding near the tracked target. - bb.color = base.withAlpha(alpha); - writes += 1; - } - } - return { writes, transitioning, activeCount, ran: true }; -} - -/** Cancels the projection animation loop. */ -function stopProjectionLoop() { - if (_projectionRaf) { - cancelAnimationFrame(_projectionRaf); - _projectionRaf = 0; - } - releaseContinuousRender('cctv-projection'); -} - -/** - * Recomputes the pure frustum geometry from the camera pose + the given ground - * altitude and writes it into the scene: billboard position, the 5 wireframe - * polylines (4 corner rays + closed far-plane rectangle), and the monitor - * plane placement. This is the ONLY place v2 geometry is written — called on - * slider input / save / activation / the one-shot ground snap, never per frame. - * @param {Object} record - Camera record. - * @param {number} groundAltM - Ground altitude at the mount (metres). - */ -function applyFrustumGeometry(record, groundAltM) { - const geometry = computeFrustumGeometry(record.camera, groundAltM, record.probeClampRangeM); - const positions = frustumCartesians(geometry); - record.frustumGeometry = geometry; - record.frustumPositions = positions; - record.position = positions.mount; - record.camera.absoluteHeightM = geometry.mount.alt; - if (record.billboard) { - record.billboard.position = positions.mount; - } - if (record.coverageEntities?.length >= 5) { - record.coverageEntities[0].polyline.positions = [positions.mount, positions.tl]; - record.coverageEntities[1].polyline.positions = [positions.mount, positions.tr]; - record.coverageEntities[2].polyline.positions = [positions.mount, positions.br]; - record.coverageEntities[3].polyline.positions = [positions.mount, positions.bl]; - record.coverageEntities[4].polyline.positions = [ - positions.tl, positions.tr, positions.br, positions.bl, positions.tl, - ]; - } - // A live viewshed volume tracks its wireframe: rebuild from the SAME fresh - // positions (weld invariant). Only records currently showing a volume pay - // this (6 triangles, synchronous — trivial even during slider/gizmo drags). - // Tint derives from the live active id, not the cached viewshedActiveTint — - // during an activation switch this runs BEFORE refreshCoverageStyles, and - // the cache is stale for exactly that window. - if (record.viewshedPrimitive) { - rebuildViewshedVolume(record, record.camera.id === _activeCameraId); - } - updatePlanePlacement(record); - // Gizmo handles track the pose they manipulate: refresh when the ACTIVE - // camera's geometry rewrites (incl. during its own drag). - if (_gizmo?.isEnabled() && record.camera.id === _activeCameraId) { - _gizmo.refresh(); - } -} - -/** - * Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground - * resolution (Task 5, spec §2). Per regime: - * - * - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM - * floor because mesh floors are regime-disabled. The exact Re:Earth prior - * remains the immediate fallback while that coarse cell warms. - * - `google-3d` (photoreal): the shared mesh-floor sampler may refine the - * DEM cell once, subject to its existing tiles-ready, distance, - * camera-height, and acceptance gates. Geometry reads only - * `cachedGroundFloor`, never a CCTV-owned point sample. - * - * v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No - * timer, no deadband: this function is called only from the staggered - * geometry queue (the enable-time drain + update()'s one-shot tiles-ready - * completion re-enqueue), from explicit pose-edit call sites, and from the - * map-stack regime-change handler. - * @param {Object} record - Camera record. - * @param {Object} [options={}] - * @param {boolean} [options.sampleGround=true] - When false, skip shared - * mesh-floor refinement and use the cached/prior ground instead. - */ -function updateRecordGeometry(record, options = {}) { - const sampleGround = options.sampleGround !== false; - const regime = currentSurfaceRegime(); - const point = { lat: record.camera.lat, lon: record.camera.lon }; - warmGroundFloor([point]); - - if (regime === 'terrain-globe') { - const cachedFloor = cachedGroundFloor(point.lat, point.lon); - const ground = Number.isFinite(cachedFloor) ? cachedFloor : groundPriorAltFor(record); - record.groundSamples['terrain-globe'] = ground; - record.groundResolved['terrain-globe'] = true; - applyFrustumGeometry(record, ground); - return; - } - - // Photoreal regime. Sampling is delegated to the shared coarse-cell - // sampler. It remains event-driven, one-shot per cell, and keeps its - // existing acceptance window; CCTV adds no rooftop rejection policy. - if (sampleGround && projectionTilesReady()) { - record.groundMeshSampleRequestCount = (record.groundMeshSampleRequestCount || 0) + 1; - const viewerCarto = _viewer?.camera?.positionCartographic; - const excludeObjects = [...(record.coverageEntities || [])]; - if (record.billboard) excludeObjects.push(record.billboard); - if (record.projection?.planeEntity) excludeObjects.push(record.projection.planeEntity); - sampleMeshFloorCells(_viewer?.scene, [point], { - excludeObjects: excludeObjects.filter(Boolean), - viewerLat: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) : undefined, - viewerLon: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : undefined, - }); - } - - const cachedFloor = cachedGroundFloor(point.lat, point.lon); - const ground = Number.isFinite(cachedFloor) - ? cachedFloor - : groundAltFor(record, 'google-3d'); - applyFrustumGeometry(record, ground); - - record.groundResolved['google-3d'] = Number.isFinite(cachedFloor); - if (Number.isFinite(cachedFloor)) { - record.groundSamples['google-3d'] = ground; - } -} - -/** - * Re-arms a record for a fresh one-shot ground resolution after a GENUINE - * pose change (an explicit user select/move or manual calibration edit). - * Clears the CURRENT regime's resolved latch so the next real pass in - * updateRecordGeometry always applies, then the record re-freezes. Never - * called on a timer. - * - * The cached `groundSamples` entries are deliberately KEPT (only the latch is - * cleared). They are the record's "has ever resolved" memory: the B9c - * fallback guard in updateRecordGeometry reads the google-3d entry so a - * rearmed camera whose tiles are mid-stream (e.g. select → flyTo → - * tilesLoaded false) is not yanked back to prior/catalog heights before its - * fresh shared floor lands. In the terrain-globe regime the re-arm is - * effectively free: the next pass re-latches from the DEM/prior with zero - * scene queries. - * @param {Object} record - Camera record. - */ -function rearmGroundResolution(record) { - if (!record) return; - record.groundResolved[currentSurfaceRegime()] = false; -} - -/** - * Resolves a user-moved ground anchor exactly once at commit. The synchronous - * pass uses a warm shared floor immediately, or preserves the pre-drag floor - * while the new cell is cold. A revision and coordinate check prevent an - * older asynchronous release from rewriting a newer edit. - * @param {Object} record - Camera record whose lat/lon just committed. - */ -function resolveCommittedGroundAnchor(record) { - if (!record?.camera) return; - record.calibrationGroundResolveCount = (record.calibrationGroundResolveCount || 0) + 1; - const revision = (record.calibrationGroundRevision || 0) + 1; - record.calibrationGroundRevision = revision; - const point = { lat: record.camera.lat, lon: record.camera.lon }; - - rearmGroundResolution(record); - updateRecordGeometry(record); - if (isGroundResolved(record)) return; - - resolveGroundFloorCells([point]).then(() => { - if (_recordById.get(record.camera.id) !== record) return; - if (record.calibrationGroundRevision !== revision) return; - if (record.camera.lat !== point.lat || record.camera.lon !== point.lon) return; - rearmGroundResolution(record); - updateRecordGeometry(record); - refreshCoverageStyles(); - notifyListeners(); - }); -} - -/** - * Task 5: batches every catalog camera's coords through the Re:Earth - * ellipsoidal ground resolver (`/api/terrain/heights` proxy — network-cached, - * chunked, geoid fallback; NOT a scene query). The catalog's orthometric - * `groundElevationM` rides along as `sourceOrthometricM` so the geoid - * fallback chain is meaningful where the catalog value is real (Caltrans). - * Never rejects — a total failure resolves null and geometry stays on - * catalog fallbacks (no worse than pre-Task-5). - * @param {Object[]} catalog - Camera objects (post-ensureCameraPose). - * @returns {Promise|null>} - */ -async function resolveGroundPriors(catalog) { - try { - const coords = catalog.map((camera) => { - const ortho = Number(camera.groundElevationM); - return { - lat: camera.lat, - lon: camera.lon, - ...(Number.isFinite(ortho) ? { sourceOrthometricM: ortho } : {}), - }; - }); - return await resolveEllipsoidalGround(coords); - } catch (error) { - console.warn('[Data:CCTV] ground-prior batch failed (keeping catalog fallbacks):', error?.message || error); - return null; - } -} - -/** - * Task 5: applies a LATE-arriving ground-prior batch (init's bounded race - * lost — cold proxy cache / slow upstream). Pure recomputes only, no scene - * queries: - * - terrain-globe regime: the prior IS the resolution → re-run the - * resolution (updateRecordGeometry latches it) for every record. - * - google-3d regime: records still awaiting a shared floor move from the - * catalog fallback onto the exact prior; records already holding a shared - * mesh/DEM floor keep it untouched. - * Guarded per record against a torn-down/re-inited layer (records are only - * touched while they are still the live catalog entries). - * @param {Object[]} records - The record array captured at init time. - * @param {Array<{ellipsoid:number, source:string}>} priors - Aligned by index. - */ -function applyLateGroundPriors(records, priors) { - if (!Array.isArray(records) || !Array.isArray(priors)) return; - let applied = 0; - for (let i = 0; i < records.length && i < priors.length; i++) { - const record = records[i]; - const prior = priors[i]; - if (!record || !prior || !Number.isFinite(prior.ellipsoid)) continue; - // Stale-record guard: init() may have re-run (destroy/init cycle) while - // the batch was in flight — only touch records still live in the map. - if (_recordById.get(record.camera.id) !== record) continue; - record.groundPrior = prior; - // Keep the cheap pre-enable altitude consistent for records whose - // geometry hasn't been applied yet (applyFrustumGeometry overwrites it). - if (!record.frustumPositions) { - record.camera.absoluteHeightM = prior.ellipsoid + record.camera.mountHeightM; - } - const regime = currentSurfaceRegime(); - if (regime === 'terrain-globe') { - // Prior IS the resolution — re-latch onto the fresh value. - updateRecordGeometry(record, { sampleGround: false }); - applied += 1; - } else if (!Number.isFinite(record.groundSamples['google-3d'])) { - // Still awaiting the shared floor: snap interim geometry onto the exact - // prior (pure recompute; the shared cell may refine later). - applyFrustumGeometry(record, prior.ellipsoid); - applied += 1; - } - } - if (applied) notifyListeners(); -} - -/** - * Task 5: surface-regime change handler ('gev:map-stack-changed' - * CustomEvent, dispatched by main.js from MapStackController.onChange). The - * surface HEIGHT at a camera differs between regimes (a photogrammetric - * deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so - * on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and - * no-ops): - * 1. every record's geometry recomputes IMMEDIATELY from the new regime's - * resolution — cached sample if that regime has one, else the Re:Earth - * prior (never blank, zero scene queries); - * 2. entering google-3d re-arms the one-shot tiles-ready completion latch so - * update()'s existing event-driven machinery refines records that never - * took their sample, through the same staggered queue. - * Event-driven only — never called on a timer. - */ -function handleMapStackChanged() { - if (!_viewer || !_records.length) return; - const regime = currentSurfaceRegime(); - if (regime === _lastAppliedRegime) return; - _lastAppliedRegime = regime; - - for (const record of _records) { - if (regime === 'terrain-globe') { - // Prior IS the resolution — latch it (zero scene queries). - record.groundSamples['terrain-globe'] = groundPriorAltFor(record); - record.groundResolved['terrain-globe'] = true; - } - const ground = groundAltFor(record, regime); - // Skip the entity rewrite when the applied ground already matches (e.g. - // entering google-3d before any sample: prior → prior is a no-op). - if (record.frustumGeometry && Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001) { - continue; - } - applyFrustumGeometry(record, ground); - } - - if (regime === 'google-3d') { - // Fresh google-3d session: let update()'s ONE-SHOT completion pass - // re-enqueue records without an accepted sample once the (re-shown) - // tileset reports tilesLoaded. Records already sampled in a previous - // google-3d session keep their cached resolution — 0 new samples, well - // under the ≤1-per-(camera, session) ceiling. - _tilesReadyReenqueued = false; - } - notifyListeners(); -} - -/** - * Stops the staggered geometry-load queue and optionally clears progress - * counters (kept when pausing mid-flight is not needed — we always clear). - * @param {boolean} [clearProgress=true] - */ -function stopGeometryLoadQueue(clearProgress = true) { - if (_geoQueueTimer) { - clearTimeout(_geoQueueTimer); - _geoQueueTimer = 0; - } - _geoQueue = []; - _geoProgressNotifier = null; - if (clearProgress) { - _geoLoading = false; - _geoLoadTotal = 0; - _geoLoadDone = 0; - } -} - -/** - * Creates the notification coalescer used by a staggered geometry drain. - * Progress emits after roughly 300 ms or ten batches, whichever comes first; - * finish always emits once even when the last progress tick just fired. - * - * @param {Function} notify Notification callback. - * @param {Object} [options={}] Testable timing options. - * @param {() => number} [options.now] Monotonic clock returning milliseconds. - * @param {number} [options.intervalMs] Maximum progress-notification cadence. - * @param {number} [options.batchLimit] Maximum batches between progress ticks. - * @returns {{ progress: () => boolean, finish: () => void }} Drain notifier. - */ -export function createGeometryProgressNotifier(notify, options = {}) { - const now = typeof options.now === 'function' ? options.now : Date.now; - const intervalMs = Number.isFinite(options.intervalMs) - ? Math.max(0, options.intervalMs) - : GEO_PROGRESS_NOTIFY_INTERVAL_MS; - const batchLimit = Number.isFinite(options.batchLimit) - ? Math.max(1, Math.floor(options.batchLimit)) - : GEO_PROGRESS_NOTIFY_BATCH_LIMIT; - let lastNotifyAt = now(); - let batchesSinceNotify = 0; - - return { - progress() { - batchesSinceNotify += 1; - const current = now(); - if (current - lastNotifyAt < intervalMs && batchesSinceNotify < batchLimit) { - return false; - } - batchesSinceNotify = 0; - lastNotifyAt = current; - notify?.(); - return true; - }, - finish() { - batchesSinceNotify = 0; - lastNotifyAt = now(); - notify?.(); - }, - }; -} - -/** - * Processes one geometry-queue batch and routes progress/completion through - * the callbacks shared by production and the unit drain harness. - * - * @param {Object} options Batch inputs. - * @param {Object[]} options.queue Mutable record queue. - * @param {number} options.batchSize Maximum records to visit. - * @param {(record: Object) => void} options.visit Per-record geometry work. - * @param {() => void} options.progress Coalesced progress publication. - * @param {() => void} options.complete Unconditional completion publication. - * @returns {boolean} True when more records remain. - */ -export function processCctvGeometryQueueBatch({ - queue, - batchSize, - visit, - progress, - complete, -}) { - const safeQueue = Array.isArray(queue) ? queue : []; - const take = Number.isFinite(batchSize) ? Math.max(1, Math.floor(batchSize)) : 1; - const batch = safeQueue.splice(0, take); - for (const record of batch) visit?.(record); - if (safeQueue.length) { - progress?.(); - return true; - } - complete?.(); - return false; -} - -/** - * Selects per-batch geometry-drain pacing from current camera ownership. - * Called for every batch so releasing tracking immediately restores normal - * throughput without restarting the queue. - * - * @param {Object} [ownership={}] Current camera-ownership state. - * @param {*} [ownership.trackedEntity] Cesium tracked entity, if any. - * @param {boolean} [ownership.cockpitActive] Whether cockpit owns the camera. - * @returns {{ batchSize: number, delayMs: number }} Drain pacing. - */ -export function cctvGeometryDrainPacing({ trackedEntity = null, cockpitActive = false } = {}) { - if (trackedEntity || cockpitActive) { - return { batchSize: GEO_TRACKING_BATCH_SIZE, delayMs: GEO_TRACKING_BATCH_DELAY_MS }; - } - return { batchSize: GEO_LOAD_BATCH_SIZE, delayMs: GEO_LOAD_BATCH_DELAY_MS }; -} - -/** - * Processes one tracking-aware geometry-drain batch. Ownership is read inside - * every call so a mid-drain tracking/cockpit transition changes the very next - * batch's size and delay. - * - * @param {Object} options Batch inputs. - * @param {Object[]} options.queue Mutable record queue. - * @param {() => Object} [options.readOwnership] Current camera ownership. - * @param {(record: Object) => void} options.visit Per-record geometry work. - * @param {() => void} options.progress Coalesced progress publication. - * @param {() => void} options.complete Unconditional completion publication. - * @returns {{ hasMore: boolean, batchSize: number, delayMs: number }} Batch result and pacing. - */ -export function processCctvGeometryDrainBatch({ - queue, - readOwnership, - visit, - progress, - complete, -}) { - const pacing = cctvGeometryDrainPacing(readOwnership?.() || {}); - const hasMore = processCctvGeometryQueueBatch({ - queue, - batchSize: pacing.batchSize, - visit, - progress, - complete, - }); - return { hasMore, ...pacing }; -} - -/** - * Moves the current active record to the front of a live drain queue. - * @param {Object[]} queue Mutable geometry queue. - * @param {Object|null} activeRecord Current active CCTV record. - * @returns {boolean} Whether the queue order changed. - */ -export function prioritizeActiveCctvGeometryRecord(queue, activeRecord) { - if (!Array.isArray(queue) || !activeRecord) return false; - const index = queue.indexOf(activeRecord); - if (index <= 0) return false; - queue.splice(index, 1); - queue.unshift(activeRecord); - return true; -} - -/** - * Processes one batch (GEO_LOAD_BATCH_SIZE records) of the geometry queue: - * full ground-sampled coverage geometry per record, then yields back to the - * event loop before the next batch so tile rendering never stalls. When the - * initial-load pass completes it clears the loading flag and refreshes styles. - */ -export function processGeometryBatch() { - _geoQueueTimer = 0; - if (!_viewer) { - stopGeometryLoadQueue(); - return; - } - // Active-camera-first is re-established every batch because the operator - // can select a new camera while a long catalog drain is in flight. - prioritizeActiveCctvGeometryRecord(_geoQueue, getActiveRecord()); - const batchResult = processCctvGeometryDrainBatch({ - queue: _geoQueue, - readOwnership: () => ({ - trackedEntity: _viewer.trackedEntity, - cockpitActive: typeof document !== 'undefined' - && document.body?.classList.contains('cockpit-mode'), - }), - visit: (record) => { - try { - updateRecordGeometry(record); - } catch (err) { - console.warn('[Data:CCTV] geometry refresh error:', err?.message || err); - } - if (_geoLoading && _geoLoadDone < _geoLoadTotal) { - _geoLoadDone += 1; - } - }, - progress: () => _geoProgressNotifier?.progress(), - complete: () => { - const wasInitialLoad = _geoLoading; - _geoLoading = false; - if (wasInitialLoad) { - _geoLoadDone = _geoLoadTotal; - if (_enabled) { - refreshCoverageStyles(); - // Geometry refinement may have replaced record.position objects — the - // one-shot drain completion re-anchors the card entries (event-driven, - // not a per-frame or timer pass). - refreshAmbientCards(); - } - } - // Completion is never coalesced: subscribers must observe the final - // loading state even if the last progress tick just happened. - _geoProgressNotifier?.finish(); - _geoProgressNotifier = null; - }, - }); - if (batchResult.hasMore) { - _geoQueueTimer = setTimeout(processGeometryBatch, batchResult.delayMs); - return; - } -} - -/** - * Appends records to the geometry queue (no progress tracking) and starts - * the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion - * pass (records left `!groundResolved` by an enable-time drain that ran while - * tiles were still streaming) so it shares the same stagger machinery as the - * initial load. Fires at most once per enable — never on a recurring timer. - * @param {Object[]} records - Camera records needing geometry refresh. - */ -function enqueueGeometryRefresh(records) { - for (const record of records) { - if (!_geoQueue.includes(record)) { - _geoQueue.push(record); - } - } - if (!_geoQueueTimer && _geoQueue.length) { - _geoProgressNotifier = createGeometryProgressNotifier(notifyListeners); - _geoQueueTimer = setTimeout(processGeometryBatch, 0); - } -} - -/** - * Starts the initial staggered load: orders all records active-camera-first, - * then by distance from the current viewer position (nearest first, so - * cameras likely in view refine before off-screen ones), and exposes - * loaded/total progress through uiState()/getStats() while running. - */ -function startGeometryLoadQueue() { - stopGeometryLoadQueue(); - // Fresh drain → fresh one-shot completion pass: re-arm the tiles-ready - // latch so update() can complete any records this drain leaves unresolved. - _tilesReadyReenqueued = false; - if (!_records.length) return; - const active = getActiveRecord(); - const carto = _viewer?.camera?.positionCartographic; - const refLat = carto ? Cesium.Math.toDegrees(carto.latitude) : (active?.camera.lat ?? 0); - const refLon = carto ? Cesium.Math.toDegrees(carto.longitude) : (active?.camera.lon ?? 0); - const pending = _records - .filter((record) => record !== active) - .map((record) => ({ - record, - distKm: haversineKm(refLat, refLon, record.camera.lat, record.camera.lon), - })) - .sort((a, b) => a.distKm - b.distKm) - .map((entry) => entry.record); - _geoQueue = active ? [active, ...pending] : pending; - _geoLoadTotal = _geoQueue.length; - _geoLoadDone = 0; - _geoLoading = true; - _geoProgressNotifier = createGeometryProgressNotifier(notifyListeners); - _geoQueueTimer = setTimeout(processGeometryBatch, 0); -} - -/** - * Returns the camera record for the currently active camera. A stale ID falls - * back to the first record, but an intentional null remains an honest - * deselected state. - * @returns {Object|null} Active camera record, or null when none is active. - */ -function getActiveRecord() { - if (!_activeCameraId) return null; - if (_recordById.has(_activeCameraId)) { - return _recordById.get(_activeCameraId); - } - // Sync _activeCameraId when falling back to first record to prevent ID mismatch - const fallback = _records[0] || null; - if (fallback && fallback.camera?.id) { - _activeCameraId = fallback.camera.id; - } - return fallback; -} - -/** - * Pauses video playback on all non-active camera projections and resumes - * the active one (if projection is enabled). - * @param {string|null} activeId - ID of the currently active camera. - */ -function pauseInactiveProjectionFeeds(activeId) { - for (const record of _records) { - if (!record.projection?.video) continue; - if (record.camera.id === activeId && _enabled && _showProjection) { - record.projection.video.play().catch(() => {}); - } else { - record.projection.video.pause(); - } - } -} - -/** - * Determines which camera coverage overlays should be visible based on - * proximity to the active camera. Limits visibility to - * COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM. - * @param {Object|null} activeRecord - The active camera record. - * @returns {Set} Set of visible camera IDs. - */ -function buildCoverageVisibleSet(activeRecord) { - if (!_records.length) return new Set(); - // Coverage emphasis is relative to a selected camera. Without one, do not - // invent an arbitrary catalog-order cohort. - if (!activeRecord) return new Set(); - - const ranked = _records.map((record) => { - if (record === activeRecord) { - return { record, distKm: -1 }; - } - return { - record, - distKm: haversineKm( - activeRecord.camera.lat, - activeRecord.camera.lon, - record.camera.lat, - record.camera.lon - ), - }; - }); - - ranked.sort((a, b) => a.distKm - b.distKm); - - const primary = ranked.filter((entry) => entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1); - const fallback = ranked; - const chosen = (primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback) - .slice(0, COVERAGE_NEIGHBOR_LIMIT) - .map((entry) => entry.record.camera.id); - return new Set(chosen); -} - -/** - * Removes (and destroys) a record's viewshed volume primitive, if any. - * @param {Object} record - Camera record. - */ -function destroyViewshedVolume(record) { - if (!record) return; - if (record.viewshedPrimitive && _viewer) { - _viewer.scene.primitives.remove(record.viewshedPrimitive); - } - record.viewshedPrimitive = null; -} - -/** - * (Re)builds a record's translucent viewshed volume from its CURRENT - * frustumPositions — the same 5 Cartesians the wireframe draws, so the volume - * is welded to the cone by construction. Called only where the wireframe - * already rewrites (style refresh on mode/visible-set/active changes, - * applyFrustumGeometry on pose edits) — no new update cadence, zero scene - * queries (viewshed design §3b). - * @param {Object} record - Camera record. - * @param {boolean} isActive - Active camera gets the brighter fill. - */ -function rebuildViewshedVolume(record, isActive) { - destroyViewshedVolume(record); - if (!_viewer || !record?.frustumPositions || !record.viewshedColors) return; - const color = isActive ? record.viewshedColors.fillActive : record.viewshedColors.fill; - const primitive = createFrustumVolumePrimitive(record.frustumPositions, color); - // QA tag: the harness counts viewshed volumes by this marker. - primitive._gevViewshed = record.camera.id; - record.viewshedPrimitive = _viewer.scene.primitives.add(primitive); - record.viewshedActiveTint = !!isActive; -} - -/** - * Updates visual styles (colors, widths, visibility) for all camera billboards, - * coverage polylines, viewshed volumes, and projection entities based on which - * camera is active, whether the layer is enabled, and the current - * coverage-mode/projection toggle states. - */ -/** - * Field-test fix (2026-07-06): horizon-culls camera billboards, mirroring the - * flights layer's EllipsoidalOccluder pass. With the Cesium globe hidden - * (Google-3D regime) nothing writes far-side depth, and the billboards are now - * always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge - * fix), so without this pass London's cluster would shine through the planet - * from a US viewpoint. Pure math over ≤ catalog-size points; runs on - * camera.moveEnd + init only (event-driven — no steady-state work). - */ -function refreshHorizonCulling() { - if (!_viewer || _viewer.isDestroyed() || !_records.length) return; - const occluder = horizonOccluder(_viewer.camera); - for (const record of _records) { - const bb = record.billboard; - if (!bb) continue; - const visible = occluder.isPointVisible(bb.position); - if (bb.show !== visible) bb.show = visible; - } -} - -// --------------------------------------------------------------------------- -// Ambient card tier (2026-07-29 design — spec: -// docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md) -// --------------------------------------------------------------------------- - -/** Returns (creating on demand) the stable frame slot for a camera id. */ -function ensureCardFrameSlot(cameraId) { - let slot = _cardFrameSlots.get(cameraId); - if (!slot) { - slot = createFrameSlot(); - _cardFrameSlots.set(cameraId, slot); - } - return slot; -} - -/** - * Rebuilds the ambient card selection: horizon + in-view projection of the - * catalog (pure math — no scene queries), the zoom-budgeted nearest-first - * LOD pick, greedy screen-space declutter, and the eviction-grace pass that - * keeps budget-edge cards alive across small camera moves (zero-flicker). - * Runs on camera.moveEnd, enable, activation change, and the one-shot - * geometry-drain completion — NEVER per frame. The active camera is excluded - * from the ambient selection/quota. By default its monitor plane is the sole - * active representation; the optional protected-card path is applied only by - * `pushAmbientCardEntries`. Camera icons are never touched here (cards - * annotate markers, they don't replace them). - */ -function refreshAmbientCards() { - if (!_enabled || !_viewer || _viewer.isDestroyed() || !_records.length) { - _cctvOverlayHost.setEntries(CCTV_OVERLAY_SOURCE_ID, [], CCTV_OVERLAY_SOURCE_OPTIONS); - return; - } - const scene = _viewer.scene; - const carto = _viewer.camera.positionCartographic; - const viewerLat = carto ? Cesium.Math.toDegrees(carto.latitude) : 0; - const viewerLon = carto ? Cesium.Math.toDegrees(carto.longitude) : 0; - // The active camera is excluded from ambient selection ALWAYS (not just - // after its async activation settles). It is published separately in the - // protected lane below, and grace never applies to it. - const activeId = _activeCameraId; - const occluder = horizonOccluder(_viewer.camera); - const width = scene.canvas.clientWidth || scene.canvas.width || 0; - const height = scene.canvas.clientHeight || scene.canvas.height || 0; - const marginX = width * CARD_VIEW_MARGIN; - const marginY = height * CARD_VIEW_MARGIN; - - const candidates = []; - const screenById = new Map(); - for (const record of _records) { - const id = record.camera.id; - if (id === activeId || !record.position) continue; - let inView = false; - let sx = NaN; - let sy = NaN; - if (occluder.isPointVisible(record.position)) { - const screen = scene.cartesianToCanvasCoordinates(record.position); - if (screen && Number.isFinite(screen.x) && Number.isFinite(screen.y) - && screen.x >= -marginX && screen.x <= width + marginX - && screen.y >= -marginY && screen.y <= height + marginY) { - inView = true; - sx = screen.x; - sy = screen.y; - screenById.set(id, { sx, sy }); - } - } - candidates.push({ - id, - distanceKm: haversineKm(viewerLat, viewerLon, record.camera.lat, record.camera.lon), - inView, - isVideo: isVideoFeedType(normalizeFeedType(record.camera.feedType)), - sx, - sy, - }); - } - - // Owner finding 4: current card holders rank with the 20% incumbency - // distance discount, so a small camera move never batch-swaps the ring. - // Item C: passing the viewport dims + per-candidate screen anchors routes - // the budget fill through the screen-distribution grid, so periphery - // cells hold cards instead of everything clustering at screen center. - const { cardIds, budgets } = selectCctvLod(candidates, { - cameraHeightM: carto?.height, - incumbentIds: _cardIds, - viewW: width, - viewH: height, - }); - // Like the cold-fill burst, card density yields to the staggered geometry - // drain: painting the raised 20/28/40 budget per frame starves the - // frame-paced mesh-floor queue on weak GPUs (qa-cctv-v2 N=800 drain-budget - // regression). During the initial load the budget holds at the low tier; - // full density arrives the moment the drain completes (which triggers its - // own refreshAmbientCards pass). - const cardLimit = _geoLoading - ? Math.min(budgets.cardLimit, CCTV_AMBIENT_CARD_DRAIN_CAP) - : budgets.cardLimit; - const decluttered = declutterCctvCards( - cardIds - .filter((id) => screenById.has(id)) - .slice(0, cardLimit) - .map((id, index) => ({ - id, - ...screenById.get(id), - // Priority carrier, not kilometers: declutter sorts ascending on - // this field, and the selection's order (distribution + incumbency) - // must survive — a periphery cell-winner must not be re-outranked - // by central proximity when two anchors contest the min separation. - distanceKm: index, - })), - { limit: cardLimit } - ); - // Owner finding 2: grace must never apply to the active camera — drop any - // lingering grace entry and keep it out of the retained-card baseline. - if (activeId) { - _cardIds.delete(activeId); - _cardGraceState.delete(activeId); - } - const retention = applyEvictionGrace({ - selectedIds: decluttered, - builtIds: [..._cardIds], - graceState: _cardGraceState, - nowMs: Date.now(), - cardLimit: budgets.cardLimit, - }); - _cardIds = new Set(retention.keepIds); - _cardGraceState = retention.graceState; - - // Bounded thumbnail LRU: live cards (grace included) never lose their - // persisted frame — that persistence IS the no-flicker guarantee. The - // hover card's slot is protected too while the gesture lasts. - const keepFrames = new Set(_cardIds); - if (_hoverCardId) keepFrames.add(_hoverCardId); - if (_activeCameraCardEnabled && _activeCameraId) keepFrames.add(_activeCameraId); - const drops = planFrameCachePrune( - [..._cardFrameSlots].map(([id, slot]) => ({ id, stamp: slot.stamp })), - keepFrames - ); - for (const id of drops) _cardFrameSlots.delete(id); - - pushAmbientCardEntries(); -} - -/** - * Builds and publishes the card entry list from the current kept set and the - * hover-summoned pin. The hover entry is budget-exempt and high-priority. The - * active camera is absent by default; `activeCameraCardEnabled` retains the - * migrated protected-publication path as an explicit product option. If the - * LOD selection adopts the hovered camera it remains a normal budgeted entry - * that keeps the pin while the gesture lasts. - */ -function pushAmbientCardEntries() { - const entries = []; - let rank = 0; - const push = (id, { pinned = false, active = false } = {}) => { - const record = _recordById.get(id); - if (!record?.position) return; - entries.push(createCctvThumbnailOverlayEntry({ - id, - position: record.position, - gapPx: CARD_GAP_PX, - title: record.camera.name, - frameSlot: ensureCardFrameSlot(id), - rank: rank++, - pinned, - active, - })); - }; - for (const id of _cardIds) push(id, { pinned: id === _hoverCardId }); - if (_hoverCardId && !_cardIds.has(_hoverCardId) && _hoverCardId !== _activeCameraId) { - push(_hoverCardId, { pinned: true }); - } - if (_activeCameraCardEnabled && _activeCameraId) { - push(_activeCameraId, { active: true }); - } - _cctvOverlayHost.setEntries( - CCTV_OVERLAY_SOURCE_ID, - entries, - CCTV_OVERLAY_SOURCE_OPTIONS, - ); -} - -/** - * Throttled MOUSE_MOVE hover pass (owner round 2, item B): pointing at a - * camera icon that has no card summons its card immediately. This is - * EVENT-DRIVEN picking on a user gesture, not steady-state work — the - * ≥120 ms throttle caps it at ~8 scene.pick calls/s while the pointer is - * actually moving (a still pointer costs nothing), so it can never approach - * per-frame cost. Skipped while the camera is in motion, while ADJUST mode - * owns the pointer (gizmo drags), and while the layer is disabled. - * @param {Cesium.Cartesian2} position - Pointer position (CSS px). - */ -function handleHoverMove(position) { - if (!_enabled || _cameraMoving || _calibrationMode || !position) return; - if (!_viewer || _viewer.isDestroyed()) return; - const now = Date.now(); - if (now - _hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return; - _hoverLastPickAt = now; - let picked = null; - try { - picked = _viewer.scene.pick(position); - } catch { - picked = null; - } - const cameraId = extractPickedCameraId(picked); - if (cameraId && cameraId === _hoverCardId) { - // Still on the hovered camera — keep the card alive. - cancelHoverRelease(); - return; - } - const record = cameraId ? _recordById.get(cameraId) : null; - // Hovering the active camera or a camera that already has a card is a - // no-op; video feeds stay icon-only until activated (ambient tier is - // stills-only — same rule as the LOD selection). - const eligible = !!record - && cameraId !== _activeCameraId - && !_cardIds.has(cameraId) - && !isVideoFeedType(normalizeFeedType(record.camera.feedType)); - if (eligible) { - cancelHoverRelease(); - _hoverCardId = cameraId; - // Immediacy: the pinned entry publishes now — chrome may paint before - // the first frame arrives (documented exception in cctvCards.js). - pushAmbientCardEntries(); - hoverFetchCardFrame(record); - } else if (_hoverCardId) { - // Pointer left the hovered icon: linger ~1 s, then release. - scheduleHoverRelease(); - } -} - -/** Cancels a pending hover-card release. */ -function cancelHoverRelease() { - if (_hoverReleaseTimer) { - clearTimeout(_hoverReleaseTimer); - _hoverReleaseTimer = 0; - } -} - -/** - * Schedules the hover card's release ~1 s after unhover. On release the pin - * drops; the card stays only if the LOD selection has adopted the camera - * (it is then a normal budgeted entry in `_cardIds`). - */ -function scheduleHoverRelease() { - if (_hoverReleaseTimer) return; - _hoverReleaseTimer = setTimeout(() => { - _hoverReleaseTimer = 0; - _hoverCardId = null; - pushAmbientCardEntries(); - }, HOVER_RELEASE_MS); -} - -/** Clears the hover-card state (teardown / activation of the hovered camera). */ -function clearHoverCard() { - cancelHoverRelease(); - _hoverCardId = null; - _hoverLastPickAt = 0; -} - -/** - * Fast-tracked frame fetch for the hover-summoned card: launches - * immediately, bypassing the pacer's launch-spacing gate (a single - * user-gesture fetch is fine even during the geometry drain), but still - * respecting the per-camera failure backoff / freshness check - * (frameFetchDue), the no-double-fetch pending set, and the burst in-flight - * cap of 4. - * @param {Object} record - The hovered camera's record. - */ -function hoverFetchCardFrame(record) { - const cameraId = record.camera.id; - if (_cardFetchPendingIds.has(cameraId)) return; - if (_cardFetchInFlightCount >= CCTV_CARD_FETCH_BURST_LIMIT) return; - const slot = ensureCardFrameSlot(cameraId); - const refreshMs = staticFrameRefreshMs(record.camera); - if (!frameFetchDue(slot, refreshMs, Date.now())) return; - fetchCardFrame(record, slot, refreshMs, { userGesture: true }); -} - -/** - * Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per - * tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill - * — any selected card still missing its FIRST frame — bursts up to 4 - * in-flight fetches at 250 ms spacing so arriving in a new area populates - * in a few seconds instead of 16-32 s; once every selected card has a first - * frame the layer drops back to the salvaged steady-state gate (single - * flight, one request per second). Priority: frameless cards first in ring - * order (nearest-first — they're what makes a card appear at all), then the - * stalest refresh-overdue card by its source cadence (staticFrameRefreshMs). - * Failures back off per camera (frameFetchDue) instead of hammering a dead - * source, so a dead upstream never eats the burst slots. - */ -function cardFrameTick() { - if (!_enabled || (!_cardIds.size && !(_activeCameraCardEnabled && _activeCameraId))) return; - const now = Date.now(); - let frameless = null; - let stalest = null; - let coldFill = false; - const consider = (id) => { - const record = _recordById.get(id); - if (!record) return; - const slot = ensureCardFrameSlot(id); - if (_cardFetchPendingIds.has(id)) { - // An in-flight first-frame fetch keeps cold-fill mode active without - // being re-launchable. - if (!(slot.stamp > 0)) coldFill = true; - return; - } - const refreshMs = staticFrameRefreshMs(record.camera); - if (!frameFetchDue(slot, refreshMs, now)) return; - if (!(slot.stamp > 0)) { - coldFill = true; - if (!frameless) frameless = { record, slot, refreshMs }; - return; - } - if (!stalest || slot.stamp < stalest.slot.stamp) { - stalest = { record, slot, refreshMs }; - } - }; - // The optional protected active card stays outside the 40-card ambient - // quota and uses the same source-owned pacing/retry/cache lifecycle. - if (_activeCameraCardEnabled && _activeCameraId) consider(_activeCameraId); - for (const id of _cardIds) consider(id); - const policy = cardFetchPolicy({ - // The cold-fill burst yields to the staggered geometry drain: 4 concurrent - // image fetch+decodes mid-drain starve the mesh-floor queue on weak GPUs - // (qa-cctv-v2 drain-budget regression). Steady 1/s trickle still runs; - // the burst fires the moment the drain completes. - coldFill: coldFill && !_geoLoading, - inFlight: _cardFetchInFlightCount, - sinceLastLaunchMs: _cardLastFetchAt > 0 ? now - _cardLastFetchAt : Infinity, - }); - _cardFetchMode = policy.mode; - if (!policy.launch) return; - const pick = frameless || stalest; - if (pick) fetchCardFrame(pick.record, pick.slot, pick.refreshMs); -} - -/** - * Fetches one paced static frame and settles it into the stable slot via the - * pure persistence rule (applyFrameResult): success replaces the thumbnail, - * failure leaves the drawn frame untouched. The frame is downscaled once - * into a 2x-thumb offscreen canvas; the renderer reads the slot live. - * @param {Object} record - Camera record. - * @param {Object} slot - The camera's stable frame slot. - * @param {number} refreshMs - Source cadence (also keys the frame-URL tick). - * @param {Object} [options] - * @param {boolean} [options.userGesture] - Hover fast-track (item B): the - * launch bypasses the pacer gate, so its spacing sample would pollute the - * pacing telemetry — skip the min-spacing sample only. The launch still - * stamps `_cardLastFetchAt`, so the pacer waits a full interval after it. - */ -function fetchCardFrame(record, slot, refreshMs, { userGesture = false } = {}) { - if (typeof document !== 'undefined' && document.hidden && !userGesture) return; - const now = Date.now(); - const cameraId = record.camera.id; - _cardFetchInFlightCount += 1; - _cardFetchPendingIds.add(cameraId); - if (_cardLastFetchAt > 0 && !userGesture) { - const spacing = now - _cardLastFetchAt; - // NOTE: cold-fill bursts legitimately push this to ~250 ms — read it - // together with the ambientCards.fetchMode telemetry. - _cardMinFetchSpacingMs = _cardMinFetchSpacingMs == null - ? spacing - : Math.min(_cardMinFetchSpacingMs, spacing); - } - _cardLastFetchAt = now; - _cardFetchCount += 1; - - const image = new Image(); - _cardFetchImages.add(image); - const settle = (ok) => { - image.onload = null; - image.onerror = null; - if (_cardFetchImages.delete(image)) { - _cardFetchInFlightCount = Math.max(0, _cardFetchInFlightCount - 1); - _cardFetchPendingIds.delete(cameraId); - } - let frame = null; - if (ok) { - try { - const canvas = document.createElement('canvas'); - canvas.width = CCTV_FRAME_CANVAS_W; - canvas.height = CCTV_FRAME_CANVAS_H; - canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height); - frame = canvas; - } catch { - frame = null; - } - } - Object.assign(slot, applyFrameResult(slot, { ok: !!frame, frame }, Date.now())); - _viewer?.scene?.requestRender?.(); - }; - image.onload = () => settle(true); - image.onerror = () => settle(false); - image.src = frameUrlFor(record.camera, refreshMs); -} - -/** Starts the card-frame pacer (idempotent; policy-gated per tick). */ -function startCardFrameLoop() { - if (_cardFetchTimer) return; - _cardFetchTimer = setInterval(cardFrameTick, CARD_FETCH_TICK_MS); -} - -/** - * Hidden-state gate (perf wave 2): detach in-flight card frame decodes when - * the document hides — a hidden canvas has no reader, and image decode is - * the expensive half. New fetches are gated at fetchCardFrame; the steady - * pacer refills naturally on return. Installed once at module scope. - */ -if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (!document.hidden) return; - for (const image of _cardFetchImages) { - image.onload = null; - image.onerror = null; - image.removeAttribute('src'); - } - _cardFetchImages.clear(); - _cardFetchPendingIds.clear(); - _cardFetchInFlightCount = 0; - }); -} - -/** Stops the pacer and detaches all in-flight fetch handlers. */ -function stopCardFrameLoop() { - if (_cardFetchTimer) { - clearInterval(_cardFetchTimer); - _cardFetchTimer = 0; - } - for (const image of _cardFetchImages) { - image.onload = null; - image.onerror = null; - image.removeAttribute('src'); - } - _cardFetchImages.clear(); - _cardFetchPendingIds.clear(); - _cardFetchInFlightCount = 0; - _cardFetchMode = 'steady'; -} - -/** - * Complete ambient-tier teardown (layer disable/destroy): pacer + in-flight - * handlers, shared-host source entries, card set, grace state, thumbnail - * cache, and fetch telemetry. Nothing leaks across a toggle. - */ -function teardownAmbientCards() { - stopCardFrameLoop(); - _cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID); - _cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false); - clearHoverCard(); - _cameraMoving = false; - _cardIds = new Set(); - _cardGraceState = new Map(); - _cardFrameSlots = new Map(); - _cardFetchCount = 0; - _cardLastFetchAt = 0; - _cardMinFetchSpacingMs = null; -} - -/** - * Hides every per-record CCTV visual without recomputing coverage membership or - * styles. Disabling makes every visibility branch false, so a direct sweep is - * sufficient; live viewshed primitives still require explicit destruction. - * - * @param {Object[]} records CCTV runtime records. - * @param {(record: Object) => void} destroyVolume Viewshed teardown callback. - * @param {string|null} [activeCameraId=null] Active camera whose activation probe must be re-armed. - */ -export function hideCctvRecordVisuals(records, destroyVolume, activeCameraId = null) { - for (const record of Array.isArray(records) ? records : []) { - if (record) { - record.probeClampRangeM = null; - if (record.camera?.id === activeCameraId) record.activationDone = false; - } - for (const entity of record?.coverageEntities || []) entity.show = false; - if (record?.viewshedPrimitive) destroyVolume?.(record); - if (record?.projection?.planeEntity) record.projection.planeEntity.show = false; - } -} - -function hideCctvVisuals() { - hideCctvRecordVisuals(_records, destroyViewshedVolume, _activeCameraId); - clearProjectionOverlay(); - if (_billboards) _billboards.show = false; - pauseInactiveProjectionFeeds(null); -} - -/** Applies coverage visibility/style state and lazily builds eligible sets. */ -export function refreshCoverageStyles() { - const activeRecord = getActiveRecord(); - ensureActiveCoverageEntities(activeRecord); - const activeId = activeRecord?.camera.id || null; - const coverageVisible = buildCoverageVisibleSet(activeRecord); - const coverageOn = _coverageMode !== 'off'; - const viewshedOn = _coverageMode === 'viewshed'; - if (coverageOn) { - ensureVisibleCoverageEntities(_records, coverageVisible); - } - for (const record of _records) { - const isActive = record.camera.id === activeId; - if (record.billboard) { - record.billboard.color = isActive ? ACTIVE_CAMERA_COLOR : IDLE_CAMERA_COLOR; - record.billboard.scale = isActive ? 1.25 : 1.0; - // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard - // (set at creation) — see the field-test far-zoom submerge fix there. - } - - if (_enabled && _showProjection && isActive) { - ensureProjectionRuntime(record); - } - // One live plane in the world at a time (§2c): only the active camera's - // far cap carries the monitor plane; idle neighbors get the faint - // wireframe only. - const planeShowing = !!(_enabled && _showProjection && isActive); - - const inVisibleSet = coverageVisible.has(record.camera.id); - for (const entity of record.coverageEntities || []) { - // The frustum wireframe is part of the projection representation — - // force it on for the active camera and let it read through geometry - // via depthFailMaterial (polylines have no disableDepthTestDistance). - entity.show = !!(_enabled && ((coverageOn && inVisibleSet) || planeShowing)); - if (!entity.polyline) continue; - // Viewshed mode swaps the cyan/green scheme for the camera's own hue so - // adjacent cones read as distinct coverage claims (design §3b); the - // active camera keeps its width/alpha emphasis in both schemes. - const hue = viewshedOn ? record.viewshedColors : null; - if (entity._coverageRole === 'cap') { - entity.polyline.material = hue - ? (isActive ? hue.lineActive : hue.line) - : (isActive ? ACTIVE_COVERAGE_CENTER : IDLE_COVERAGE_CENTER_MUTED); - entity.polyline.width = isActive ? 2.2 : 1.0; - entity.polyline.depthFailMaterial = planeShowing - ? (hue ? hue.line.withAlpha(0.26) : ACTIVE_COVERAGE_CENTER_DEPTHFAIL) - : undefined; - } else { - entity.polyline.material = hue - ? (isActive ? hue.lineActive : hue.line.withAlpha(0.6)) - : (isActive ? ACTIVE_COVERAGE_EDGE : IDLE_COVERAGE_EDGE_MUTED); - entity.polyline.width = isActive ? 1.8 : 0.9; - entity.polyline.depthFailMaterial = planeShowing - ? (hue ? hue.line.withAlpha(0.18) : ACTIVE_COVERAGE_EDGE_DEPTHFAIL) - : undefined; - } - } - - // Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the - // visible set. Rebuild on active-tint flips (rare); otherwise leave the - // primitive alone so idle refreshes never churn geometry. - const wantVolume = !!(_enabled && viewshedOn && inVisibleSet && record.frustumPositions); - if (wantVolume) { - if (!record.viewshedPrimitive || record.viewshedActiveTint !== isActive) { - rebuildViewshedVolume(record, isActive); - } - } else if (record.viewshedPrimitive) { - destroyViewshedVolume(record); - } - - if (record.projection) { - setPlaneVisible(record.projection, planeShowing); - } - } - - if (_billboards) _billboards.show = !!_enabled; - pauseInactiveProjectionFeeds(activeId); -} - -/** - * Finds the camera closest to the Cesium viewer's current position. - * @returns {string|null} Camera ID of the nearest camera, or null. - */ -function nearestCameraIdToViewer() { - const carto = _viewer?.camera?.positionCartographic; - if (!carto || !_records.length) return null; - const lat = Cesium.Math.toDegrees(carto.latitude); - const lon = Cesium.Math.toDegrees(carto.longitude); - - let best = null; - for (const record of _records) { - const distKm = haversineKm(lat, lon, record.camera.lat, record.camera.lon); - if (!best || distKm < best.distKm) { - best = { id: record.camera.id, distKm }; - } - } - return best?.id || null; -} - -/** - * Counts how many other cameras have overlapping coverage with the target. - * Overlap is approximated by comparing inter-camera distance against the - * combined range of both cameras (scaled by 0.92). - * @param {Object} targetRecord - Camera record to check. - * @returns {number} Number of overlapping neighbors. - */ -function coverageNeighborCount(targetRecord) { - if (!targetRecord) return 0; - let count = 0; - for (const record of _records) { - if (record === targetRecord) continue; - const dKm = haversineKm( - targetRecord.camera.lat, - targetRecord.camera.lon, - record.camera.lat, - record.camera.lon - ); - const overlapKm = (targetRecord.camera.rangeM + record.camera.rangeM) / 1000 * 0.92; - if (dKm <= overlapKm) count++; - } - return count; -} - -/** - * Builds a single-line summary string for the active camera, including city, - * heading, FOV, coverage area, overlap count, projection mode, alignment - * confidence, source type, and view context. - * @returns {string} Summary text separated by mid-dots. - */ -function buildSummaryText() { - const active = getActiveRecord(); - if (!active) { - return _records.length - ? `${_records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE` - : 'No cameras available in catalog.'; - } - - const area = sectorAreaKm2(active.camera.rangeM, active.camera.fovDeg); - const overlapCount = coverageNeighborCount(active); - const viewKey = currentViewContext(); - const viewBand = viewKey.split(':')[0] || 'global'; - const health = _healthById.get(active.camera.id) || null; - const calBadge = deriveCalBadge(active.camera); - - return [ - `${active.camera.city.toUpperCase()} CCTV`, - `${active.camera.name.toUpperCase()}`, - `HDG ${Math.round(active.camera.headingDeg)}°`, - `FOV ${Math.round(active.camera.fovDeg)}°`, - `COVERAGE ${area.toFixed(2)}km²`, - overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', - `PROJ ${_showProjection ? 'MONITOR' : 'OFF'}`, - _coverageMode === 'viewshed' ? 'VIEWSHED' : null, - `CAL ${calBadge.replace('-', ' ').toUpperCase()}`, - health?.sourceKind ? `SRC ${String(health.sourceKind).toUpperCase()}` : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, - `${viewBand.toUpperCase()} CONTEXT`, - ].filter(Boolean).join(' · '); -} - -/** - * Builds a public-facing camera state object for UI consumption. - * Includes all pose, calibration, CAL badge, projection, and feed metadata. - * @param {Object} record - Camera record. - * @param {string|null} [activeId=null] - Active camera ID for the `active` flag. - * @returns {Object} Public camera state. - */ -function getPublicCameraState(record, activeId = null) { - const resolvedActiveId = activeId || getActiveRecord()?.camera.id || null; - const camera = record.camera; - const health = _healthById.get(camera.id) || null; - const isActive = camera.id === resolvedActiveId; - const refreshMs = isActive ? ACTIVE_FRAME_REFRESH_MS : IDLE_FRAME_REFRESH_MS; - return { - id: camera.id, - name: camera.name, - city: camera.city, - provider: camera.provider, - lat: camera.lat, - lon: camera.lon, - headingDeg: camera.headingDeg, - pitchDeg: camera.pitchDeg, - fovDeg: camera.fovDeg, - rangeM: camera.rangeM, - elevationM: camera.absoluteHeightM, - mountHeightM: camera.mountHeightM, - active: isActive, - feedType: camera.feedType, - sourceKind: health?.sourceKind || camera.sourceKind || (camera.feedConfigured ? 'configured' : 'seed'), - sourceStatus: health?.status || 'unknown', - sourceMessage: health?.message || '', - sourceLabel: health?.label || camera.provider || '', - calibration: { ...normalizeCalibration(camera.calibration) }, - // Save-gated persistence (design §3e): true while the live pose carries - // edits that have not been SAVEd (or RESET). Drives the CAL · EDITED chip. - calDirty: !!record.calDirty, - // Deterministic QA seam: counts commit-grade anchor resolutions (E/N drag - // release, numeric E/N edit, or reset), never transient gizmo moves. - groundResolveCount: record.calibrationGroundResolveCount || 0, - // Per-record QA seam for proving transient gizmo moves never enter the - // shared mesh-floor sampler while unrelated catalog cells finish. - groundMeshSampleRequestCount: record.groundMeshSampleRequestCount || 0, - // Datum QA seam: expose the immutable Re:Earth ellipsoidal prior - // separately from the currently applied frustum ground. Google-3D may - // legitimately refine the latter to the rendered mesh, so callers must - // not infer the prior by subtracting mount height from live geometry. - groundPriorM: Number.isFinite(record.groundPrior?.ellipsoid) - ? record.groundPrior.ellipsoid - : null, - intrinsics: camera.intrinsics ? { ...camera.intrinsics } : null, - extrinsics: camera.extrinsics ? { ...camera.extrinsics } : null, - anchor: camera.anchor ? { ...camera.anchor } : null, - // Panel-only trust signal (design §3b, amended by LOCKED §9.2/§9.3): no - // in-world rendering reads this, no score-based quality math backs it. - calBadge: deriveCalBadge(camera), - poseSource: camera.poseSource || null, - basePose: camera.basePose ? { ...camera.basePose } : null, - frameUrl: frameUrlFor(camera, refreshMs), - mediaUrl: mediaUrlFor(camera), - }; -} - -/** - * Assembles the full UI state payload containing layer toggles, camera list, - * active camera details, summary text, and error state. - * @returns {Object} Complete UI state for subscribers. - */ -function uiState() { - const active = getActiveRecord(); - const activeId = active?.camera.id || null; - const payload = { - enabled: _enabled, - // Compat boolean + the full tri-state (viewshed design §3b). - showCoverage: _coverageMode !== 'off', - coverageMode: _coverageMode, - showProjection: _showProjection, - calibrationMode: _calibrationMode, - autoHop: _autoHop, - autoHopSuspended: _autoHopSuspended, - autoHopSec: _autoHopSec, - count: _count, - lastUpdate: _lastUpdate, - error: _lastError, - loading: { - active: _geoLoading, - loaded: Math.min(_geoLoadDone, _geoLoadTotal), - total: _geoLoadTotal, - }, - // Ambient card tier telemetry (QA harnesses assert the fetch pacing — - // minFrameFetchSpacingMs reads together with fetchMode: cold-fill bursts - // legitimately reach ~250 ms, steady state stays >=1000 ms). - ambientCards: { - count: _cardIds.size, - limit: CCTV_AMBIENT_CARD_MAX, - frameFetches: _cardFetchCount, - minFrameFetchSpacingMs: _cardMinFetchSpacingMs, - fetchMode: _cardFetchMode, - fetchesInFlight: _cardFetchInFlightCount, - // Item B QA seam: the hover-summoned pinned card, if any. - hoverId: _hoverCardId, - }, - activeCameraId: activeId, - activeCamera: active ? getPublicCameraState(active, activeId) : null, - cameras: _records.map((record) => getPublicCameraState(record, activeId)), - summary: buildSummaryText(), - }; - return payload; -} - -/** Dispatches the current UI state to all registered subscriber callbacks. */ -function notifyListeners() { - const payload = uiState(); - for (const callback of _listeners) { - try { - callback(payload); - } catch (error) { - console.warn('[Data:CCTV] listener error:', error); - } - } -} - -/** - * Throttled notifyListeners for transient (mid-drag) calibration patches — - * the panel re-render is DOM-heavy, so live gizmo drags publish state at - * ≤10 Hz while the in-world geometry still tracks every processed move. - */ -function notifyListenersThrottled() { - const now = Date.now(); - if (now - _lastTransientNotifyAt < 100) return; - _lastTransientNotifyAt = now; - notifyListeners(); -} - -/** - * Applies a calibration patch to a record's IN-MEMORY pose (save-gated - * persistence, design §3e: no localStorage write here — only the explicit - * `calibration.save` action persists). - * - * Transient grade (gizmo mid-drag): recompute pose + frustum geometry only - * (the cheap v2 path) with throttled notify — no ground re-arm, no frame - * re-fetch, no store touch. - * Commit grade (drag end, numeric entry, voice): only an E/N anchor move - * resolves a new shared floor; all other edits keep the frozen reference. - * - * @param {Object} record - Camera record. - * @param {Object} patch - Partial 7-field calibration (absolute offset values). - * @param {{transient?: boolean}} [options] - * @returns {boolean} True when the patch applied. - */ -function applyCalibrationPatch(record, patch, options = {}) { - if (!record || !patch || typeof patch !== 'object') return false; - record.camera.calibration = normalizeCalibration({ - ...record.camera.calibration, - ...patch, - }); - ensureCameraPose(record.camera); - // §9.1: touching range takes manual control — clear the activation clamp. - if ('rangeScale' in patch) { - record.probeClampRangeM = null; - } - const anchorMoved = calibrationPatchMovesAnchor(patch); - if (options.transient === true && anchorMoved) { - record.calibrationAnchorDirty = true; - } - record.calDirty = true; - if (options.transient === true) { - applyFrustumGeometry(record, groundAltFor(record)); - notifyListenersThrottled(); - return true; - } - if (anchorMoved) { - resolveCommittedGroundAnchor(record); - } else { - applyFrustumGeometry(record, groundAltFor(record)); - } - refreshProjectionImage(record, true); - return true; -} - -/** - * Lazily creates the calibration gizmo controller. The gizmo sees the layer - * only through these callbacks: it attaches to the active record while the - * layer is enabled AND ADJUST mode is on, funnels drags through - * applyCalibrationPatch (transient), and runs the commit tail on release. - */ -function ensureGizmo() { - if (_gizmo || !_viewer) return; - // Both patch callbacks receive the gizmo's PINNED drag record — never - // re-resolve the active camera here: a mid-drag voice select or auto-hop - // would route the captured offsets onto a camera with a different basePose. - const liveRecord = (record) => ( - record && _recordById.get(record.camera?.id) === record ? record : null - ); - _gizmo = createCalibrationGizmo({ - viewer: _viewer, - getActiveRecord: () => (_enabled && _calibrationMode ? getActiveRecord() : null), - applyPatch: (patch, draggedRecord) => { - const record = _enabled && _calibrationMode ? liveRecord(draggedRecord) : null; - if (record) applyCalibrationPatch(record, patch, { transient: true }); - }, - endPatch: (draggedRecord) => { - const record = liveRecord(draggedRecord); - if (!record) return; - if (record.calibrationAnchorDirty) { - record.calibrationAnchorDirty = false; - resolveCommittedGroundAnchor(record); - } else { - applyFrustumGeometry(record, groundAltFor(record)); - } - refreshProjectionImage(record, true); - refreshCoverageStyles(); - notifyListeners(); - }, - }); -} - -/** - * §9.1 activation obstruction probe (LOCKED owner decision): on camera - * ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis - * (mount → cap-center direction). If it hits the tiles closer than the pose - * range, clamp the plane's effective range just short of the first hit so the - * "big and dramatic" true end cap never clips into downtown buildings. - * - * This is the ONLY raycast in the whole CCTV subsystem — once per activation, - * never per-frame (the zero-raycast invariant applies to steady state). The - * per-camera range slider overrides the clamp: a user-set rangeScale skips the - * probe entirely. Probe failure/miss keeps the unclamped range. - * @param {Object} record - Camera record being activated. - */ -function runActivationObstructionProbe(record) { - record.probeClampRangeM = null; - const scene = _viewer?.scene; - if (!scene || typeof scene.pickFromRay !== 'function') return; - const camera = record.camera; - const rangeScale = normalizeCalibration(camera.calibration).rangeScale; - if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp - try { - const mountAlt = groundAltFor(record) + camera.mountHeightM; - const mountPos = Cesium.Cartesian3.fromDegrees(camera.lon, camera.lat, mountAlt); - const { dir } = frustumFrameEcef(camera, mountPos); - // Exclude everything the layer itself draws so the probe can only hit the - // world (3D tiles), not our own billboards/polylines/planes. - const exclude = [_billboards, ..._coverageEntities]; - for (const runtime of _projectionEntities) { - if (runtime?.planeEntity) exclude.push(runtime.planeEntity); - } - const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); - if (!hit?.position) return; - const dist = Cesium.Cartesian3.distance(mountPos, hit.position); - record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); - } catch { - // probe failure → keep the unclamped range - } -} - -/** - * Clears a deactivated camera's temporary obstruction clamp and rewrites its - * geometry through the normal single-range path. - * @param {Object|null} record Camera runtime record being deactivated. - * @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite. - * @returns {boolean} Whether a clamp was cleared. - */ -export function clearProbeClampOnDeactivation(record, rewriteGeometry) { - if (!record || !Number.isFinite(record.probeClampRangeM)) return false; - record.probeClampRangeM = null; - rewriteGeometry?.(record); - return true; -} - -/** - * Reports whether selecting a record must run the full activation path. - * Disable re-arms the still-active record so its obstruction probe runs again - * on its next real activation after the temporary clamp is cleared. - * @param {string} cameraId Requested camera ID. - * @param {string|null} activeCameraId Current active camera ID. - * @param {Object|null} record Requested camera runtime record. - * @returns {boolean} Whether activation work must run. - */ -export function cctvRecordNeedsActivation(cameraId, activeCameraId, record) { - return cameraId !== activeCameraId || record?.activationDone !== true; -} - -/** - * Bind CCTV activation to clean taps while preserving the layer's hover-move - * callback on the shared Cesium handler. Drag-like and long-press gestures do - * not reach camera activation or focus dispatch. - * @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler. - * @param {(click: Object) => void} onClick - Accepted CCTV click callback. - * @param {Object} [options] - Gesture test seams and optional onMouseMove hook. - * @returns {void} - */ -export function bindCctvWorldClickGesture(handler, onClick, options = {}) { - bindTrackingClickGesture(handler, (click, gesture) => { - if (!isTrackingClickGesture(gesture)) return; - onClick(click); - }, options); -} - -/** - * Sets the active camera by ID, initializes its projection runtime, refreshes - * its frame, and updates styles. - * @param {string} cameraId - ID of the camera to activate. - * @returns {'activated'|'unchanged'|'not-found'} Discriminated activation result. - */ -export function setActiveCamera(cameraId) { - if (!cameraId || !_recordById.has(cameraId)) return CCTV_ACTIVATION_RESULT.NOT_FOUND; - const record = _recordById.get(cameraId); - const previousActiveRecord = getActiveRecord(); - // Re-selecting the already-active camera is a no-op: re-running the - // activation path re-probes and rewrites the plane entity's geometry, and - // that async primitive rebuild visibly flashes the monitor plane (owner - // field test 2026-07-04 — every click ON the plane picks its own camera). - // `activationDone` distinguishes a real activation from the enable()-time - // default `_activeCameraId` assignment, which never ran this path. - if (!cctvRecordNeedsActivation(cameraId, _activeCameraId, record)) { - return CCTV_ACTIVATION_RESULT.UNCHANGED; - } - _activeCameraId = cameraId; - _autoHopSuspended = false; - // A real activation creates projection work — wake the self-stopping loop. - startProjectionLoop(); - if (previousActiveRecord && previousActiveRecord !== record) { - clearProbeClampOnDeactivation(previousActiveRecord, (previous) => { - applyFrustumGeometry(previous, groundAltFor(previous)); - }); - } - // The clicked camera leaves the ambient quota immediately (never graced). - // Shipped behavior publishes no card for it because the monitor plane is - // now the active representation; the opt-in protected-card path republishes - // it synchronously through refreshAmbientCards() below. - _cardIds.delete(cameraId); - _cardGraceState.delete(cameraId); - // Activating the hovered camera consumes the transient pin; the active - // monitor plane replaces it from this moment. - if (_hoverCardId === cameraId) clearHoverCard(); - // If the record's geometry refinement is still queued, jump it to the front - // so the newly active camera resolves before idle neighbors. - const queueIdx = _geoQueue.indexOf(record); - if (queueIdx > 0) { - _geoQueue.splice(queueIdx, 1); - _geoQueue.unshift(record); - } - // §9.1: one obstruction probe per activation, BEFORE the geometry pass so - // the range clamp lands in the same rewrite. A FIRST-EVER activation (no - // real ground sample yet) probes from the catalog-prior altitude — if that - // prior is off, the clamp is measured from a shifted origin, but a - // re-activation after the one-shot snap lands re-probes from real ground, - // so it self-corrects. - runActivationObstructionProbe(record); - // Coverage is activation-lazy even while the mode is OFF: the active - // camera's projection representation must be ready without materializing - // any idle neighbor. The following geometry rewrite welds these entities to - // the current sampled positions. - ensureActiveCoverageEntities(record); - ensureProjectionRuntime(record); - refreshProjectionImage(record, true); - // FIX B9b: an explicit user (re)select re-arms one real ground sample for the - // newly-active camera, then freezes. Idle neighbors keep their resolved state. - // FIX B9c: if tiles are ready this real pass applies + re-resolves - // immediately; if tiles are still streaming, updateRecordGeometry's fallback - // guard recomputes purely from the cached real ground (the pose + the probe - // clamp above still land — no scene queries) and the record stays unresolved - // until update()'s one-shot tiles-ready completion pass re-grounds it. - rearmGroundResolution(record); - updateRecordGeometry(record); - record.activationDone = true; - refreshCoverageStyles(); - // The newly active camera leaves the ambient ring (its monitor plane takes - // over); the freed slot re-fills on this same pass. - refreshAmbientCards(); - // ADJUST mode follows the active camera. - _gizmo?.refresh(); - notifyListeners(); - return CCTV_ACTIVATION_RESULT.ACTIVATED; -} - -/** - * Clears the active CCTV camera in place without moving the viewer or - * disabling the layer. The normal selection-refresh path releases the active - * projection, emphasis, and probe state while ambient cards remain available. - * @returns {boolean} True when a camera was deactivated. - */ -export function deactivateActiveCamera() { - const record = _activeCameraId ? _recordById.get(_activeCameraId) : null; - if (!record) return false; - _activeCameraId = null; - _autoHopSuspended = true; - record.activationDone = false; - clearProbeClampOnDeactivation(record, (previous) => { - applyFrustumGeometry(previous, groundAltFor(previous)); - }); - refreshCoverageStyles(); - refreshAmbientCards(); - _gizmo?.refresh(); - notifyListeners(); - return true; -} - -/** - * True only for a clean click that is empty from CCTV's perspective: an - * active camera exists, ADJUST does not own the pointer, and the scene pick - * carries no canonical object ID. Any identified scene object is non-empty, - * including selectable siblings that do not participate in the pick registry. - * @param {Object|null} picked - `scene.pick()` result. - * @param {Object} [context] - * @param {string|null} [context.activeCameraId] - * @param {boolean} [context.calibrationMode] - * @returns {boolean} - */ -export function cctvEmptyClickDeselects(picked, { - activeCameraId = null, - calibrationMode = false, -} = {}) { - if (!activeCameraId || calibrationMode) return false; - return resolvePickId(picked) === null; -} - -/** - * Creates the five Cesium polyline entities that visualize a camera's pitched - * frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane - * rectangle. Entity ids stay in the `cctv--` scheme (pick-owner - * regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap. - * @param {Object} record - Camera record. - * @returns {Cesium.Entity[]} Array of five coverage entities. - */ -function buildCoverageEntities(record) { - const { camera } = record; - // Prefer the record's already-refined geometry. Lazy creation commonly - // happens after the staggered ground pass; recomputing from the catalog - // prior here would regress the camera to its pre-sampled datum. - let geometry = record.frustumGeometry; - let positions = record.frustumPositions; - if (!geometry || !positions) { - geometry = computeFrustumGeometry( - camera, - groundPriorAltFor(record), - record.probeClampRangeM - ); - positions = frustumCartesians(geometry); - record.frustumGeometry = geometry; - record.frustumPositions = positions; - record.position = positions.mount; - } - - const addPolyline = (role, linePositions) => _viewer.entities.add({ - id: `cctv-${camera.id}-${role}`, - properties: { cctvCameraId: camera.id }, - polyline: { - positions: linePositions, - width: 1.2, - material: IDLE_COVERAGE_COLOR, - }, - }); - - const entities = [ - addPolyline('ray-tl', [positions.mount, positions.tl]), - addPolyline('ray-tr', [positions.mount, positions.tr]), - addPolyline('ray-br', [positions.mount, positions.br]), - addPolyline('ray-bl', [positions.mount, positions.bl]), - addPolyline('cap', [positions.tl, positions.tr, positions.br, positions.bl, positions.tl]), - ]; - - entities[0]._coverageRole = 'edge'; - entities[1]._coverageRole = 'edge'; - entities[2]._coverageRole = 'edge'; - entities[3]._coverageRole = 'edge'; - entities[4]._coverageRole = 'cap'; - return entities; -} - -/** - * Materializes coverage entities for eligible records exactly once. - * The helper is dependency-injected so unit tests can prove the enable policy - * without constructing Cesium entities. - * - * @param {Object[]} records CCTV runtime records. - * @param {(record: Object) => boolean} [isEligible] Eligibility predicate. - * @param {(record: Object) => Object[]} buildEntities Coverage builder. - * @returns {Object[]} Newly created entities across all eligible records. - */ -export function materializeCctvCoverageEntities( - records, - isEligible = () => true, - buildEntities, -) { - const created = []; - if (typeof buildEntities !== 'function') return created; - for (const record of Array.isArray(records) ? records : []) { - if (!record || record.coverageEntities?.length || !isEligible(record)) continue; - const entities = buildEntities(record); - record.coverageEntities = Array.isArray(entities) ? entities.filter(Boolean) : []; - created.push(...record.coverageEntities); - } - return created; -} - -/** Materializes one active camera's coverage set. */ -export function materializeCctvActiveCoverageEntities(record, buildEntities) { - return materializeCctvCoverageEntities([record], () => true, buildEntities); -} - -/** Materializes only records in the current coverage-visible ID set. */ -export function materializeCctvVisibleCoverageEntities(records, visibleIds, buildEntities) { - const eligibleIds = visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []); - return materializeCctvCoverageEntities( - records, - (record) => eligibleIds.has(record.camera?.id), - buildEntities, - ); -} - -/** Registers newly built coverage entities with the layer-global collection. */ -function registerCoverageEntities(created) { - _coverageEntities.push(...created); - return created; -} - -function ensureActiveCoverageEntities(record) { - return registerCoverageEntities( - materializeCctvActiveCoverageEntities(record, buildCoverageEntities), - ); -} - -function ensureVisibleCoverageEntities(records, visibleIds) { - return registerCoverageEntities( - materializeCctvVisibleCoverageEntities(records, visibleIds, buildCoverageEntities), - ); -} - -/** - * Extracts a camera ID from a Cesium pick result by checking billboard IDs, - * primitive IDs, and entity cctvCameraId properties. - * @param {Object|null} picked - Result from scene.pick(). - * @returns {string|null} Camera ID, or null if the pick is not a CCTV entity. - */ -function extractPickedCameraId(picked) { - if (!picked) return null; - - const entity = picked.id?.properties ? picked.id : picked.primitive?.id?.properties ? picked.primitive.id : null; - const maybeProp = entity?.properties?.cctvCameraId; - if (maybeProp) { - const value = typeof maybeProp.getValue === 'function' - ? maybeProp.getValue(Cesium.JulianDate.now()) - : maybeProp; - const record = typeof value === 'string' ? _recordById.get(value) : null; - const ownsCoverageEntity = Boolean(record?.coverageEntities?.includes(entity)); - const ownsProjectionEntity = record?.projection?.planeEntity === entity - || _projectionEntities.some((runtime) => ( - runtime?.cameraId === value && runtime.planeEntity === entity - )); - if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value; - } - - // Camera billboard IDs are intentionally the upstream camera ID, so a - // sibling may legitimately use the same string. A bare ID match is not - // ownership proof: require this layer's billboard collection or the exact - // billboard stored on the record. - const directId = typeof picked.id === 'string' - ? picked.id - : typeof picked.primitive?.id === 'string' ? picked.primitive.id : null; - const record = directId === null ? null : _recordById.get(directId); - if (!record) return null; - return picked.primitive === _billboards || picked.primitive === record.billboard - ? directId - : null; -} - -/** Test-only seam for the CCTV ownership proof used by the world-click route. */ -export function _extractPickedCameraIdForTest(picked) { - return extractPickedCameraId(picked); -} - -/** - * Removes all coverage entities and projection runtimes from the scene. - */ -function destroyCoverageEntities() { - if (!_viewer) return; - for (const entity of _coverageEntities) { - _viewer.entities.remove(entity); - } - _coverageEntities = []; - - for (const record of _records) { - destroyViewshedVolume(record); - } - - for (const runtime of _projectionEntities) { - destroyProjectionRuntime(runtime); - } - _projectionEntities = []; -} - -/** Resets all module-scoped runtime state to initial values. */ -function clearRuntimeState() { - stopGeometryLoadQueue(); - // Idempotent — also covers a re-init without a prior destroy(). - teardownAmbientCards(); - clearProjectionOverlay(); - _records = []; - _recordById = new Map(); - _healthById = new Map(); - _count = 0; - _lastUpdate = null; - _lastHealthSyncAt = 0; - _lastError = null; - _lastFocusStyleAt = 0; - _activeFocusStyleCount = 0; - // FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh - // init re-discovers against the current scene primitives. - _activeTileset = null; - // Task 5: the applied-regime tracker is record-set-scoped — a fresh init - // recomputes it against the then-current scene. - _lastAppliedRegime = null; -} - -/** - * Primes the minimum module state needed to exercise the production coverage - * refresh path in unit tests. - * @param {Object} [options={}] Test state values. - * @param {Object|null} [options.viewer] Viewer-like entity owner. - * @param {Object[]} [options.records] Seeded CCTV records. - * @param {string|null} [options.activeCameraId] Active record id. - * @param {boolean} [options.enabled=true] Layer enabled state. - * @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode. - * @param {boolean} [options.showProjection=false] Projection visibility. - * @returns {void} - */ -export function _setCctvCoverageStateForTest({ - viewer = null, - records = [], - activeCameraId = null, - enabled = true, - coverageMode = 'on', - showProjection = false, -} = {}) { - _viewer = viewer; - _records = Array.isArray(records) ? records : []; - _recordById = new Map( - _records - .filter((record) => record?.camera?.id) - .map((record) => [record.camera.id, record]), - ); - _coverageEntities = []; - _projectionEntities = []; - _billboards = null; - _activeCameraId = activeCameraId; - _autoHopSuspended = false; - _enabled = !!enabled; - _coverageMode = normalizeCoverageMode(coverageMode, 'on'); - _showProjection = !!showProjection; -} - -/** - * Flies the Cesium viewer camera to frame the specified CCTV camera, - * looking along its heading from above. - * @param {Cesium.Viewer|null} viewer Cesium viewer that owns the camera. - * @param {Object|null} record CCTV camera runtime record. - * @param {number} [duration=2.2] - Flight duration in seconds. - * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. - */ -export function focusCctvRecord(viewer, record, duration = 2.2) { - if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA; - if (typeof document !== 'undefined' - && document.body?.classList.contains('cockpit-mode')) { - console.debug('[Data:CCTV] focus ignored while cockpit owns the camera'); - return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE; - } - if (viewer.trackedEntity) { - console.debug('[Data:CCTV] focus ignored while a tracked entity owns the camera'); - return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; - } - const { camera } = record; - const range = Math.max(280, camera.rangeM * 1.18); - viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(record.position, Math.max(40, camera.rangeM * 0.36)), - { - offset: new Cesium.HeadingPitchRange( - toRad(camera.headingDeg), - toRad(-22), - range - ), - duration: Math.max(0.2, duration || 0), - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - } - ); - return CCTV_FOCUS_RESULT.FOCUSED; -} - -function focusCamera(cameraId, duration = 2.2) { - return focusCctvRecord(_viewer, _recordById.get(cameraId), duration); -} - -/** - * Advances to the next camera if auto-hop is enabled and the hop interval - * has elapsed. If the viewer has panned to a new region since the last hop, - * snaps to the nearest camera instead of cycling sequentially. - * @param {number} nowMs - Current timestamp in milliseconds. - */ -export function maybeAutoHop(nowMs) { - if (!_autoHop || _autoHopSuspended || !_enabled || _records.length < 2) return; - if (nowMs - _lastHopAt < _autoHopSec * 1000) return; - - const viewKey = currentViewContext(); - const viewChanged = viewKey !== _lastViewContext; - _lastViewContext = viewKey; - - if (viewChanged) { - const nearest = nearestCameraIdToViewer(); - if (nearest && nearest !== _activeCameraId) { - // Use setActiveCamera so the full activation path runs (obstruction - // probe, projection runtime, geometry rewrite) — previously bypassed - // with a bare assignment - setActiveCamera(nearest); - _lastHopAt = nowMs; - return; - } - } - - const nextIdx = cctvCycleIndex( - _records.findIndex((record) => record.camera.id === _activeCameraId), - 1, - _records.length, - ); - setActiveCamera(_records[nextIdx].camera.id); - _lastHopAt = nowMs; -} - -/** - * Resolves a catalog cycle target, including the explicit no-selection state. - * NEXT from null selects the first record; PREV selects the last. - * @param {number} currentIdx - * @param {number} step - * @param {number} count - * @returns {number} - */ -export function cctvCycleIndex(currentIdx, step, count) { - const total = Number.isFinite(count) ? Math.floor(count) : 0; - if (total <= 0) return -1; - const delta = Number.isFinite(step) ? Math.trunc(step) : 1; - if (!Number.isFinite(currentIdx) || currentIdx < 0) { - return delta < 0 ? total - 1 : 0; - } - return (((Math.floor(currentIdx) + delta) % total) + total) % total; -} - -/** - * Fetches per-camera health status from the backend and updates _healthById. - * Rate-limited to HEALTH_SYNC_INTERVAL_MS unless forced. - * @param {boolean} [force=false] - Bypass the interval check. - */ -async function syncHealthState(force = false) { - const now = Date.now(); - if (!force && now - _lastHealthSyncAt < HEALTH_SYNC_INTERVAL_MS) return; - _lastHealthSyncAt = now; - - try { - const resp = await fetch(HEALTH_ENDPOINT, { cache: 'no-store' }); - if (!resp.ok) return; - const data = await resp.json(); - const rows = Array.isArray(data?.cameras) ? data.cameras : []; - const next = new Map(); - for (const row of rows) { - const id = String(row?.id || '').trim(); - if (!id) continue; - next.set(id, { - status: String(row.status || '').toLowerCase() || 'unknown', - sourceKind: String(row.sourceKind || row.feedType || '').toLowerCase(), - label: String(row.label || row.provider || ''), - message: String(row.message || ''), - updatedAt: safeNumber(row.updatedAt, now), - }); - } - _healthById = next; - } catch { - // keep previous health map - } -} - -// --------------------------------------------------------------------------- -// Exported layer object — standard layer interface + CCTV-specific methods -// --------------------------------------------------------------------------- - -/** - * CCTV data layer implementing the standard layer interface. - * Manages camera catalog, coverage visualization, the far-cap projection - * plane, health sync, calibration, and auto-hop. - */ -const cctvLayer = { - id: 'cctv', - name: 'CCTV', - icon: '📹', - source: 'CCTV + Street View fallback', - updateInterval: DEFAULT_UPDATE_INTERVAL_MS, - - /** - * Initializes the CCTV layer: loads camera sources, builds the catalog, - * restores calibration from localStorage, creates billboards, sets up click - * handling, and performs initial health sync. Coverage entities stay lazy. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - async init(viewer) { - _viewer = viewer; - clearRuntimeState(); - _enabled = false; - _activeCameraId = null; - _autoHopSuspended = false; - _lastHopAt = 0; - _lastViewContext = ''; - _calibrationById = loadCalibrationStore(); - - _billboards = new Cesium.BillboardCollection(); - _viewer.scene.primitives.add(_billboards); - registerSpriteCollection('cctv', _billboards); - - const sources = await loadCameraSources(); - const catalogFromSources = buildCatalogFromSources(sources); - const catalog = catalogFromSources.length ? catalogFromSources : seedCatalog(); - - // Viewshed color identity (design §3a): golden-angle hue over the - // id-SORTED catalog index — deterministic across sessions for a stable - // catalog, maximally separated for neighboring cameras. - const hueIndexById = new Map( - catalog.map((camera) => camera.id).sort().map((id, index) => [id, index]) - ); - - for (const camera of catalog) { - const savedEntry = _calibrationById.get(camera.id); - if (savedEntry) { - camera.calibration = normalizeCalibration(savedEntry.values); - camera.calSource = savedEntry.source; - } - ensureCameraPose(camera); - } - - // Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth - // ellipsoidal ground-prior resolver (network-cached — NOT a scene query; - // the catalog's orthometric groundElevationM feeds the geoid fallback - // chain). Bounded wait: a warm proxy cache resolves in milliseconds, so - // records are normally built WITH their prior (correct first paint in - // every regime); a cold/slow upstream loses the race and the batch - // applies post-hoc via applyLateGroundPriors instead of hanging init. - const priorsPromise = resolveGroundPriors(catalog); - const priors = await Promise.race([ - priorsPromise, - new Promise((resolve) => setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS)), - ]); - - for (let i = 0; i < catalog.length; i++) { - const camera = catalog[i]; - // Ellipsoidal ground prior (or null while the batch is still in - // flight). Geometry falls back to the catalog value only until the - // batch lands. - const groundPrior = priors?.[i] || null; - // Cheap first-pass altitude from the ellipsoidal prior (catalog value - // only as the pre-prior fallback) — the staggered geometry queue - // refines with sampled tile heights after enable so the init path - // never raycasts the scene once per camera. - const priorGround = Number.isFinite(groundPrior?.ellipsoid) - ? groundPrior.ellipsoid - : (Number(camera.groundElevationM) || 0); - camera.absoluteHeightM = priorGround + camera.mountHeightM; - const position = Cesium.Cartesian3.fromDegrees(camera.lon, camera.lat, camera.absoluteHeightM); - const billboard = _billboards.add({ - id: camera.id, - image: CAMERA_ICON, - position, - color: IDLE_CAMERA_COLOR, - width: 24, - height: 24, - // Field-test fix (2026-07-06): always-on-top. The old finite value - // (1800 m) re-engaged the depth test at far zoom, where the COARSE - // far-LOD Google-3D mesh sits above the true ground and swallowed - // ground-anchored icons ("submerged" pills over SF). Far-side-of-globe - // icons are handled by refreshHorizonCulling() (the flights-layer - // EllipsoidalOccluder pattern), not by the depth test. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42), - }); - - const record = { - camera, - position, - billboard, - coverageEntities: [], - projection: null, - // Task 5 (height-datum fix): regime-aware ground resolution state. - // groundPrior — { ellipsoid, source } from the Re:Earth batch - // (null until a late batch lands). The prior applies in EVERY - // regime and is the terrain-globe resolution outright. - // groundResolved — PER-REGIME one-shot latch (regime key → - // boolean): true once this record's resolution completed for that - // regime; such records are excluded from the completion pass so - // their geometry freezes. Re-armed only on a genuine pose change, - // explicit user select/move, or a surface-regime change — never - // on the 10s timer. - // groundSamples — PER-REGIME resolved ground (regime key → - // metres): the accepted one-shot scene sample in google-3d, the - // mirrored prior in terrain-globe. Kept across re-arms as the - // "has ever resolved" memory for the B9c mid-stream guard. - // frustumPositions — cached Cartesians for pure recomputes (so - // plane placement never re-derives geometry it already has). - groundPrior, - groundResolved: {}, - groundSamples: {}, - frustumGeometry: null, - frustumPositions: null, - // §9.1 activation obstruction probe result: effective-range clamp so - // the far-cap plane never clips into the tiles. Null = unclamped. - // Reset + re-probed on every activation; cleared when the user takes - // the range slider (slider overrides the clamp). - probeClampRangeM: null, - // Viewshed (design §3a/§3b): per-camera color identity + the volume - // primitive handle (exists only in viewshed mode for the visible set). - viewshedColors: viewshedColors(cameraHue(hueIndexById.get(camera.id) ?? 0)), - viewshedPrimitive: null, - viewshedActiveTint: false, - }; - _records.push(record); - _recordById.set(camera.id, record); - } - - _count = _records.length; - if (_records.length > 0) { - // Projection runtime + first frame fetch are deferred to enable() so - // initializing the catalog stays render-cheap. - _activeCameraId = _records[0].camera.id; - } - - // Task 5: if the prior batch lost init's bounded race, apply it post-hoc - // when it lands (pure recomputes — applyLateGroundPriors guards against - // a torn-down/re-inited catalog). - if (!priors) { - const initRecords = _records.slice(); - priorsPromise.then((late) => { - if (late) applyLateGroundPriors(initRecords, late); - }).catch(() => {}); - } - - // Task 5: track the surface regime the initial geometry was computed for - // and listen for map-stack changes (main.js re-dispatches - // MapStackController.onChange as this CustomEvent). The handler compares - // regimes itself, so 'switching'/'error' emissions and same-regime stack - // swaps (bing→osm) no-op. - _lastAppliedRegime = currentSurfaceRegime(); - if (!_mapStackListener && typeof window !== 'undefined') { - _mapStackListener = () => handleMapStackChanged(); - window.addEventListener('gev:map-stack-changed', _mapStackListener); - } - - // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with - // the billboards' always-on-top depth setting) + one initial pass so the - // first paint is already culled. - if (!_horizonCullListener) { - // Ambient cards piggyback the same settle event: moveEnd-driven - // reselection only, never per frame (refreshAmbientCards no-ops while - // the layer is disabled). - _horizonCullListener = () => { - _cameraMoving = false; - refreshHorizonCulling(); - refreshAmbientCards(); - }; - _viewer.camera.moveEnd.addEventListener(_horizonCullListener); - } - if (!_moveStartListener) { - // Item B: hover picking pauses while the camera is in motion. - _moveStartListener = () => { - _cameraMoving = true; - }; - _viewer.camera.moveStart.addEventListener(_moveStartListener); - } - refreshHorizonCulling(); - - _clickHandler = new Cesium.ScreenSpaceEventHandler(_viewer.scene.canvas); - bindCctvWorldClickGesture(_clickHandler, (click) => { - if (!_enabled) return; - const picked = _viewer.scene.pick(click.position); - const cameraId = extractPickedCameraId(picked); - if (cameraId) { - activateCctvCameraFromWorldClick(cameraId, setActiveCamera); - return; - } - // Any identified scene object owns this click even if its layer does not - // register a shared pick predicate. This keeps selectable siblings ahead - // of an overlapping CCTV card while ID-less globe/terrain/tile surfaces - // remain eligible for true empty-space deselection. - const pickedId = resolvePickId(picked); - if (pickedId !== null) return; - // Item A (owner round 2): the scene pick found no camera — try the - // painted ambient cards. The cards canvas is pointer-events:none (this - // handler owns the events), so a click landing on a card's rect selects - // its camera exactly like a click on the icon. Cesium click positions - // and the recorded rects are both CSS px — direct comparison. - const cardId = _cctvOverlayHost.hitTest( - click.position.x, - click.position.y, - { sourceId: CCTV_OVERLAY_SOURCE_ID }, - )?.entryId; - if (cardId && _recordById.has(cardId)) { - activateCctvCameraFromWorldClick(cardId, setActiveCamera); - return; - } - if (cctvEmptyClickDeselects(picked, { - activeCameraId: _activeCameraId, - calibrationMode: _calibrationMode, - })) { - deactivateActiveCamera(); - } - }, { - // Item B: hover summons a card on a cardless camera icon. The gesture - // classifier owns MOUSE_MOVE too, so chain hover work through its seam - // instead of replacing the travel accumulator's handler. - onMouseMove: (movement) => handleHoverMove(movement?.endPosition), - }); - - await syncHealthState(true); - refreshCoverageStyles(); - notifyListeners(); - restoreSpriteOrder(_viewer); - console.log('[Data:CCTV] Initialized with', _count, 'cameras'); - }, - - /** - * Enables the layer: shows entities, starts the projection loop, and kicks - * the staggered geometry-load queue. Heavy work (per-camera ground - * sampling) is deferred/batched so the frame budget never collapses at - * enable time. - */ - enable() { - _enabled = true; - _lastUpdate = Date.now(); - // Pick-ownership (H2): camera billboards use the camera id directly; - // coverage polyline entities use `cctv--` entity ids. - registerPickOwner('cctv', (pickedId) => { - if (_recordById.has(pickedId)) return true; - if (typeof pickedId === 'string' && pickedId.startsWith(GIZMO_ID_PREFIX)) return true; - const coverage = /^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(pickedId); - return Boolean(coverage && _recordById.has(coverage[1])); - }); - if (!_activeCameraId && _records.length) { - _activeCameraId = _records[0].camera.id; - _autoHopSuspended = false; - } - const activeRecord = getActiveRecord(); - if (activeRecord) { - ensureProjectionRuntime(activeRecord); - refreshProjectionImage(activeRecord, true); - } - startGeometryLoadQueue(); - refreshCoverageStyles(); - startProjectionLoop(); - // The projection loop self-stops when idle; a focus target appearing - // (user starts tracking a contact) is the one edge it can't see while - // stopped, so re-arm on it. Removed on disable. - _removeFocusAppearListener?.(); - _removeFocusAppearListener = onFocusTargetAppear(() => startProjectionLoop()); - // Ambient card tier: shared host source + policy-gated frame pacer + the - // initial selection pass (moveEnd drives every later reselection). - _cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, true); - startCardFrameLoop(); - refreshAmbientCards(); - notifyListeners(); - restoreSpriteOrder(_viewer); - }, - - /** Disables the layer: hides entities, stops the projection loop and load queue. */ - disable() { - _enabled = false; - unregisterPickOwner('cctv'); - // ADJUST mode does not survive a layer toggle — predictable re-entry. - _calibrationMode = false; - releaseContinuousRender('cctv-adjust'); - _gizmo?.setEnabled(false); - _removeFocusAppearListener?.(); - _removeFocusAppearListener = null; - stopProjectionLoop(); - stopGeometryLoadQueue(); - // Ambient cards tear down COMPLETELY on disable (owner design point 6): - // source entries, pacer timer, in-flight handlers, and caches. - teardownAmbientCards(); - hideCctvVisuals(); - notifyListeners(); - }, - - /** - * Periodic update tick: syncs health state and runs auto-hop logic. Ground - * geometry is NOT resampled here — v2 grounds each camera once via the - * staggered load queue (see startGeometryLoadQueue/updateRecordGeometry) - * and never resamples on a timer. The ONE exception is a one-shot - * completion pass: the enable-time drain can run while 3D tiles are still - * streaming (each such pass keeps the fabricated catalog height and leaves - * the record `!groundResolved`), so the FIRST tick that sees - * projectionTilesReady() re-enqueues those records once — each then takes - * its single real sample and freezes (design §4). Guarded by a boolean - * latch (`_tilesReadyReenqueued`), NOT a timer loop: after it fires, no - * tick ever samples anything again. - */ - async update() { - if (!_enabled) return; - const now = Date.now(); - _lastUpdate = now; - if (!_tilesReadyReenqueued && projectionTilesReady()) { - _tilesReadyReenqueued = true; - // Per-regime resolution (Task 5): only records unresolved for the - // CURRENT surface regime need the completion pass. On globe stacks - // projectionTilesReady() is false while a (hidden) Google tileset - // exists, so this latch effectively fires for the google-3d regime — - // terrain-globe records resolve from the prior in their drain pass. - const unresolved = _records.filter((record) => !isGroundResolved(record)); - if (unresolved.length) enqueueGeometryRefresh(unresolved); - } - await syncHealthState(); - maybeAutoHop(now); - notifyListeners(); - }, - - /** - * Tears down the layer: destroys click handler, projection loop, coverage - * entities, billboards, and clears all runtime state and subscribers. - * @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref). - */ - destroy(viewer) { - unregisterPickOwner('cctv'); - if (_mapStackListener && typeof window !== 'undefined') { - window.removeEventListener('gev:map-stack-changed', _mapStackListener); - _mapStackListener = null; - } - const teardownViewer = viewer || _viewer; - if (_horizonCullListener && teardownViewer?.camera?.moveEnd) { - teardownViewer.camera.moveEnd.removeEventListener(_horizonCullListener); - _horizonCullListener = null; - } - if (_moveStartListener && teardownViewer?.camera?.moveStart) { - teardownViewer.camera.moveStart.removeEventListener(_moveStartListener); - _moveStartListener = null; - } - if (_gizmo) { - _gizmo.destroy(); - _gizmo = null; - } - _calibrationMode = false; - releaseContinuousRender('cctv-adjust'); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (teardownViewer?.scene?.screenSpaceCameraController) { - teardownViewer.scene.screenSpaceCameraController.enableInputs = true; - } - stopProjectionLoop(); - stopGeometryLoadQueue(); - teardownAmbientCards(); - destroyCoverageEntities(); - if (_billboards && teardownViewer) { - teardownViewer.scene.primitives.remove(_billboards); - _billboards = null; - } - clearRuntimeState(); - _viewer = null; - _enabled = false; - _activeCameraId = null; - _autoHopSuspended = false; - // Clear existing subscribers rather than replacing the Set — - // replacing would silently orphan any unsubscribe() closures - _listeners.clear(); - }, - - /** - * Applies runtime parameter changes: coverage/projection toggles, auto-hop - * settings, camera selection, and calibration patches/resets. - * @param {Object} [params={}] - Parameter object. - * @param {boolean} [params.showCoverage] - Back-compat coverage toggle (true→'on', false→'off'). - * @param {'off'|'on'|'viewshed'} [params.coverageMode] - Full coverage-mode API. - * @param {boolean} [params.showProjection] - Toggle projection overlay visibility. - * @param {boolean} [params.autoHop] - Enable/disable auto-hop. - * @param {number} [params.autoHopSec] - Auto-hop interval in seconds. - * @param {string} [params.selectedCameraId] - Camera ID to activate. - * @param {Object} [params.calibration] - Calibration config: `patch` edits - * the live pose (save-gated — no persistence), `save` persists the current - * calibration as manual, `reset` restores the base prior. - * @param {boolean} [params.calibrationMode] - Toggle the ADJUST gizmo. - * @param {boolean} [params.focusSelected] - Fly to the active camera. - * @param {number} [params.focusDurationSec] - Fly-to duration. - */ - setParams(params = {}) { - if (typeof params.showCoverage === 'boolean') { - _coverageMode = normalizeCoverageMode(params.showCoverage, _coverageMode); - } - if (typeof params.coverageMode === 'string') { - _coverageMode = normalizeCoverageMode(params.coverageMode, _coverageMode); - } - if (typeof params.showProjection === 'boolean') { - _showProjection = params.showProjection; - if (_showProjection) { - const active = getActiveRecord(); - if (active) ensureProjectionRuntime(active); - startProjectionLoop(); - } - } - if (typeof params.autoHop === 'boolean') { - _autoHop = params.autoHop; - if (params.autoHop) _autoHopSuspended = false; - } - if (typeof params.autoHopSec === 'number' && Number.isFinite(params.autoHopSec)) { - _autoHopSec = clamp(Math.round(params.autoHopSec), MIN_AUTO_HOP_SEC, MAX_AUTO_HOP_SEC); - } - if (typeof params.selectedCameraId === 'string' && _recordById.has(params.selectedCameraId)) { - setActiveCamera(params.selectedCameraId); - } - if (params.calibration && typeof params.calibration === 'object') { - const calibrationCfg = params.calibration; - const targetCameraId = typeof calibrationCfg.cameraId === 'string' && calibrationCfg.cameraId - ? calibrationCfg.cameraId - : _activeCameraId; - const targetRecord = targetCameraId ? _recordById.get(targetCameraId) : null; - if (targetRecord) { - if (calibrationCfg.reset) { - // RESET: back to the base prior, delete the persisted entry, clear - // the dirty flag (semantics unchanged from v2). - targetRecord.camera.calibration = normalizeCalibration(DEFAULT_CAMERA_CALIBRATION); - targetRecord.camera.calSource = null; - targetRecord.calDirty = false; - ensureCameraPose(targetRecord.camera); - _calibrationById.delete(targetCameraId); - saveCalibrationStore(); - // Reset returns to the base lat/lon, so resolve that anchor once. - resolveCommittedGroundAnchor(targetRecord); - refreshProjectionImage(targetRecord, true); - } - if (calibrationCfg.patch && typeof calibrationCfg.patch === 'object') { - // Save-gated persistence (design §3e): a patch edits the LIVE pose - // only. The store — and the CALIBRATED badge's `calSource` — move - // exclusively on the explicit `save` action below. (§9.1 range-slider - // clamp override + B9b re-ground live inside applyCalibrationPatch.) - applyCalibrationPatch(targetRecord, calibrationCfg.patch); - } - if (calibrationCfg.save) { - // SAVE CAL: persist the current in-memory calibration with manual - // provenance. Saving an all-default calibration clears the entry - // (a no-op calibration is not a calibration). - if (isDefaultCalibration(targetRecord.camera.calibration)) { - targetRecord.camera.calSource = null; - _calibrationById.delete(targetCameraId); - } else { - targetRecord.camera.calSource = 'manual'; - _calibrationById.set(targetCameraId, { - values: { ...targetRecord.camera.calibration }, - source: 'manual', - savedAt: Date.now(), - }); - } - targetRecord.calDirty = false; - saveCalibrationStore(); - } - } - } - if (typeof params.calibrationMode === 'boolean') { - _calibrationMode = params.calibrationMode; - if (_calibrationMode) { - ensureGizmo(); - _gizmo?.setEnabled(true); - // ADJUST mode: gizmo drags mutate entity geometry from pointer events, - // which don't trigger renders in requestRenderMode. (perf wave 2) - holdContinuousRender('cctv-adjust'); - } else { - releaseContinuousRender('cctv-adjust'); - _gizmo?.setEnabled(false); - } - } - if (params.focusSelected && _activeCameraId) { - focusCamera(_activeCameraId, Number(params.focusDurationSec) || 1.8); - } - refreshCoverageStyles(); - notifyListeners(); - }, - - /** - * Returns the current runtime parameters including toggle states, - * active camera, and calibration values. - * @returns {Object} - */ - getParams() { - const active = getActiveRecord(); - return { - showCoverage: _coverageMode !== 'off', - coverageMode: _coverageMode, - showProjection: _showProjection, - calibrationMode: _calibrationMode, - autoHop: _autoHop, - autoHopSec: _autoHopSec, - selectedCameraId: active?.camera.id || null, - calibration: active?.camera ? { - cameraId: active.camera.id, - values: { ...normalizeCalibration(active.camera.calibration) }, - } : null, - }; - }, - - /** - * Returns a sampled list of camera positions for the detection overlay system. - * @param {Object} [options={}] - * @param {number} [options.maxCount] - Maximum number of objects to return. - * @param {number} [options.seed] - Offset seed for deterministic stride sampling. - * @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]} - */ - getDetectableObjects(options = {}) { - if (!_enabled || _records.length === 0) 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; - const stride = Math.max(1, Math.ceil(_records.length / maxCount)); - const start = seed % stride; - - const objects = []; - for (let i = start; i < _records.length; i += stride) { - const camera = _records[i].camera; - objects.push({ - position: _records[i].position, - sourceId: camera.id, - id: `CAM-${camera.id}`, - type: 'CAM', - }); - if (objects.length >= maxCount) break; - } - return objects; - }, - - /** - * Returns basic layer statistics, including initial-load progress while - * the staggered geometry queue is draining. - * @returns {{ count: number, lastUpdate: number|null, error: string|null, loading: boolean, loadingLoaded: number, loadingTotal: number }} - */ - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - error: _lastError, - loading: _geoLoading, - loadingLoaded: Math.min(_geoLoadDone, _geoLoadTotal), - loadingTotal: _geoLoadTotal, - }; - }, - - /** - * Registers a callback that receives the full UI state on every change. - * The callback is invoked immediately with the current state. - * @param {Function} callback - Listener function receiving the UI state object. - * @returns {Function} Unsubscribe function. - */ - subscribe(callback) { - if (typeof callback !== 'function') return () => {}; - _listeners.add(callback); - callback(uiState()); - return () => { - _listeners.delete(callback); - }; - }, - - /** - * Returns the current UI state snapshot without subscribing. - * @returns {Object} - */ - getUIState() { - return uiState(); - }, - - /** - * Opts the active camera into or out of protected thumbnail publication. - * The default is false: the monitor plane remains the sole active-camera - * representation while ambient and hover-pinned cards continue unchanged. - * @param {Object} [options] - * @param {boolean} [options.activeCameraCardEnabled=false] - * @returns {{activeCameraCardEnabled:boolean}} - */ - setCardPresentationOptions(options = {}) { - return setCctvCardPresentationOptions(options); - }, - - /** - * Selects a camera by ID and optionally flies to it. - * @param {string} cameraId - Camera ID to select. - * @param {Object} [options={}] - * @param {boolean} [options.focus] - If true, fly the viewer to the camera. - * @param {number} [options.durationSec] - Fly-to duration in seconds. - * @returns {boolean} True if the camera was found and selected. - */ - selectCamera(cameraId, options = {}) { - const result = setActiveCamera(cameraId); - if (result === CCTV_ACTIVATION_RESULT.NOT_FOUND) return false; - if (options.focus) { - focusCamera(cameraId, options.durationSec || 1.8); - } - return true; - }, - - /** - * Flies the viewer to a specific camera. - * @param {string} cameraId - Camera ID to focus on. - * @param {number} [durationSec=2.2] - Flight duration in seconds. - * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. - */ - focusCamera(cameraId, durationSec = 2.2) { - return focusCamera(cameraId, durationSec); - }, - - /** - * Cycles the active camera forward or backward by `step` positions in the catalog. - * @param {number} [step=1] - Number of positions to advance (negative to go back). - * @param {Object} [options={}] - * @param {boolean} [options.focus] - If true, fly to the new camera. - * @param {number} [options.durationSec] - Fly-to duration in seconds. - * @returns {string|null} The newly active camera ID, or null if catalog is empty. - */ - cycleCamera(step = 1, options = {}) { - if (!_records.length) return null; - const current = getActiveRecord(); - const nextIdx = cctvCycleIndex( - _records.findIndex((record) => record === current), - step, - _records.length, - ); - const nextId = _records[nextIdx].camera.id; - setActiveCamera(nextId); - if (options.focus) { - focusCamera(nextId, options.durationSec || 1.8); - } - return nextId; - }, - - /** - * Selects and flies to the camera nearest the current viewer position. - * @param {Object} [options={}] - * @param {boolean} [options.focus=true] Whether to fly after selection. - * @param {number} [options.durationSec] - Fly-to duration in seconds. - * @returns {string|null} The nearest camera ID, or null if none found. - */ - focusNearest(options = {}) { - const nearest = nearestCameraIdToViewer(); - if (!nearest) return null; - setActiveCamera(nearest); - if (options.focus !== false) { - focusCamera(nearest, options.durationSec || 1.8); - } - return nearest; - }, -}; - -export default cctvLayer; +export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor; +export const _pushAmbientCardEntriesForTest = + layer._pushAmbientCardEntriesForTest; +export const _setCctvOverlayHostForTest = layer._setCctvOverlayHostForTest; +export const createCctvProjectionOverlayEntry = + layer.createCctvProjectionOverlayEntry; +export const setCctvCardPresentationOptions = + layer.setCctvCardPresentationOptions; +export const surfaceRegimeKey = layer.surfaceRegimeKey; +export const normalizeCoverageMode = layer.normalizeCoverageMode; +export const readCalibrationStoreV2 = layer.readCalibrationStoreV2; +export const writeCalibrationStoreV2 = layer.writeCalibrationStoreV2; +export const deriveCalBadge = layer.deriveCalBadge; +export const computeFrustumGeometry = layer.computeFrustumGeometry; +export const activationProbeClampRange = layer.activationProbeClampRange; +export const frameSignatureFromPixels = layer.frameSignatureFromPixels; +export const _createCctvProjectionPlaneForTest = + layer._createCctvProjectionPlaneForTest; +export const _updateCctvProjectionPlaneForTest = + layer._updateCctvProjectionPlaneForTest; +export const applyCctvFocusDeemphasis = layer.applyCctvFocusDeemphasis; +export const createGeometryProgressNotifier = + layer.createGeometryProgressNotifier; +export const processCctvGeometryQueueBatch = + layer.processCctvGeometryQueueBatch; +export const cctvGeometryDrainPacing = layer.cctvGeometryDrainPacing; +export const processCctvGeometryDrainBatch = + layer.processCctvGeometryDrainBatch; +export const prioritizeActiveCctvGeometryRecord = + layer.prioritizeActiveCctvGeometryRecord; +export const processGeometryBatch = layer.processGeometryBatch; +export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals; +export const refreshCoverageStyles = layer.refreshCoverageStyles; +export const clearProbeClampOnDeactivation = + layer.clearProbeClampOnDeactivation; +export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation; +export const bindCctvWorldClickGesture = layer.bindCctvWorldClickGesture; +export const setActiveCamera = layer.setActiveCamera; +export const deactivateActiveCamera = layer.deactivateActiveCamera; +export const cctvEmptyClickDeselects = layer.cctvEmptyClickDeselects; +export const materializeCctvCoverageEntities = + layer.materializeCctvCoverageEntities; +export const materializeCctvActiveCoverageEntities = + layer.materializeCctvActiveCoverageEntities; +export const materializeCctvVisibleCoverageEntities = + layer.materializeCctvVisibleCoverageEntities; +export const _extractPickedCameraIdForTest = + layer._extractPickedCameraIdForTest; +export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; +export const focusCctvRecord = layer.focusCctvRecord; +export const maybeAutoHop = layer.maybeAutoHop; +export const cctvCycleIndex = layer.cctvCycleIndex; +export { + CCTV_CALIBRATION_STORAGE_KEY_V1, + CCTV_CALIBRATION_STORAGE_KEY_V2, + FRUSTUM_GROUND_CLEARANCE_M, + CCTV_FOCUS_RESULT, + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, +} from '../layers/cctv/index.js'; +export default layer; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 2d5464430..55cb80c6e 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -14,6 +14,11 @@ // computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under // plain node:test. import { test } from 'node:test'; +// Preserve structural pins across component qualification and formatter wrapping. +function componentFunctionSource(fn) { + return fn.toString().replace(/\blayerState\./g, '').replace(/\bparts\.\w+\./g, '') + .replace(/,\s*(?=\))/g, '').replace(/\s+/g, ' ').replace(/\(\s+/g, '('); +} import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -67,8 +72,8 @@ import { activateCctvCameraFromWorldClick, } from '../cctvFocusRequest.js'; -const UI_SOURCE = fs.readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui.js'), +const CCTV_PRESENTATION_SOURCE = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'cctvPresentation.js'), 'utf8', ); @@ -208,7 +213,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig }); assert.equal(insertCount, 0, 'building a 100-camera catalog must insert zero coverage entities'); - assert.doesNotMatch(cctvLayer.init.toString(), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/); + assert.doesNotMatch(componentFunctionSource(cctvLayer.init), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/); const activated = materializeCctvActiveCoverageEntities(records[42], build); assert.equal(activated.length, 5); @@ -224,7 +229,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig assert.equal(coverageOn.length, 14 * 5); assert.equal(insertCount, 15 * 5, 'COVERAGE ON builds only remaining visible/eligible sets'); assert.equal(records.filter((record) => record.coverageEntities.length > 0).length, 15); - assert.match(refreshCoverageStyles.toString(), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/); + assert.match(componentFunctionSource(refreshCoverageStyles), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/); assert.equal( materializeCctvVisibleCoverageEntities(records, visibleIds, build).length, 0, @@ -614,7 +619,7 @@ test('CCTV disable→enable defers the active-camera re-probe until its next act assert.equal(record.activationDone, false); assert.equal(cctvRecordNeedsActivation('active', 'active', record), true); assert.doesNotMatch( - cctvLayer.enable.toString(), + componentFunctionSource(cctvLayer.enable), /setActiveCamera|runActivationObstructionProbe|pickFromRay/, 'enable must restore nominal visuals without entering the activation probe path', ); @@ -724,11 +729,11 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']); assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); - assert.match(cctvLayer.init.toString(), /bindCctvWorldClickGesture\(_clickHandler/); - assert.match(cctvLayer.init.toString(), /_cctvOverlayHost\.hitTest/); - assert.match(cctvLayer.init.toString(), /sourceId: CCTV_OVERLAY_SOURCE_ID/); - assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/); - assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/); + assert.match(componentFunctionSource(cctvLayer.init), /bindCctvWorldClickGesture\(_clickHandler/); + assert.match(componentFunctionSource(cctvLayer.init), /_cctvOverlayHost\.hitTest/); + assert.match(componentFunctionSource(cctvLayer.init), /sourceId: CCTV_OVERLAY_SOURCE_ID/); + assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/); + assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/); }); // ─── Empty-space deselection and stable null-active state ────────────────── @@ -795,7 +800,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and activeCameraId: 'cam-1', }), true, 'an ID-less surface pick remains true empty space'); - const initSource = cctvLayer.init.toString(); + const initSource = componentFunctionSource(cctvLayer.init); assert.ok( initSource.indexOf('if (pickedId !== null) return') < initSource.indexOf('_cctvOverlayHost.hitTest'), @@ -931,10 +936,10 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones assert.equal(cctvCycleIndex(2, 1, records.length), 0); assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1); - const renderer = UI_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n \}\n/); + const renderer = CCTV_PRESENTATION_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/); assert.ok(renderer, '_renderCctvState is missing'); assert.match(renderer[0], /else if \(!activeId\)[\s\S]*?selectedIndex = -1/); - assert.match(renderer[0], /_cctvFocusBtn\.disabled = !enabled \|\| cameras\.length === 0 \|\| !activeId/); + assert.match(renderer[0], /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/); } finally { cctvLayer.setParams({ autoHop: false }); _setCctvOverlayHostForTest(); diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index 5b43167ab..dfe6baee9 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -1,3 +1,5 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; +import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -14,9 +16,8 @@ import militaryFlightsLayer, { const SUBJECT = 'abc123'; const NEXT_SUBJECT = 'def456'; -const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); -const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8'); -const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'); +const FLIGHTS_SOURCE = readLayerSource(new URL('./flights.js', import.meta.url)); +const MILITARY_SOURCE = readLayerSource(new URL('./militaryFlights.js', import.meta.url)); const LAYERS = [ { @@ -84,27 +85,35 @@ function candidateIds(layer) { } test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => { - const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/ - .exec(UI_SOURCE)?.[0]; - assert.ok(dispatcher, 'Cockpit event dispatcher is defined'); - assert.match(dispatcher, /info\?\.icao24/); - assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/); - assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/); - assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/); - assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/, - 'entry and in-Cockpit handoff publish the active subject'); - assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/, + const previousWindow = globalThis.window; + const details = []; + globalThis.window = { dispatchEvent: event => details.push(event.detail) }; + try { + dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' }); + dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' }); + dispatchCockpitModeChanged(false); + assert.deepEqual(details, [ + { active: true, subjectId: 'abc123', layerId: 'military' }, + { active: true, subjectId: 'def456', layerId: null }, + { active: false, subjectId: null, layerId: null }, + ]); + } finally { globalThis.window = previousWindow; } + for (const action of [enter, _adoptTrackedEntity]) { + assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/, + 'entry and in-Cockpit handoff each publish the active subject'); + } + assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/, 'exit clears the active subject'); for (const [name, source] of [ ['commercial', FLIGHTS_SOURCE], ['military', MILITARY_SOURCE], ]) { - const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/ + const consumer = /function\s*(?:parts\.\w+\.)?_applyCockpitState\(\s*detail\s*=\s*\{\},?\s*\)\s*\{[\s\S]*?\n {0,2}\}/ .exec(source)?.[0]; assert.ok(consumer, `${name} Cockpit consumer is defined`); assert.match(consumer, /detail\?\.subjectId/); - assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/); + assert.match(consumer, /\.trim\(\s*,?\s*\)\s*\.toLowerCase\(\s*,?\s*\)/); assert.doesNotMatch(consumer, /layerId/, `${name} must also suppress a duplicate subject originating in the sibling AIR feed`); } diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 216122d8d..36f8b24d3 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -25,7 +26,7 @@ function numericConstant(source, name) { } for (const layer of LAYERS) { - const source = readFileSync(layer.path, 'utf8'); + const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)]; @@ -37,11 +38,11 @@ for (const layer of LAYERS) { }); test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => { - const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; + const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, '_modelRegimeActive is defined'); - assert.match(regime, /if \(!_models3dEnabled\) return false;/, + assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, 'OFF must keep Cockpit AIR contacts in 2D'); - assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/, + assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/, 'Cockpit must not bypass the owner-visible Display toggle'); }); @@ -51,13 +52,13 @@ for (const layer of LAYERS) { // owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so // it no longer routes through the toggle-gated `_modelRegimeActive` — the // suppression is now an explicit early return. - const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; + const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, '_trackedModelRegimeActive is defined'); - assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/, + assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/, '_trackedModelRegimeActive excludes cockpit'); - const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0]; + const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(tracked, '_updateTrackedModel is defined'); - assert.match(tracked, /_trackedModelRegimeActive\(\)/, + assert.match(tracked, /(?:parts\.\w+\.)?_trackedModelRegimeActive\(\s*,?\s*\)/, 'the tracked-model driver uses the cockpit-aware predicate'); }); @@ -68,15 +69,15 @@ for (const layer of LAYERS) { assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000); assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60); - const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; - const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; - assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/); - assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/); + const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/); + assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/); assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/); assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/); - const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; - assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/, + const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/, 'Cockpit keeps its 60-model performance ceiling'); }); @@ -88,19 +89,19 @@ for (const layer of LAYERS) { // `_iconKind` is identity for every unconverted contact (see // tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the // operator explicitly converted into a TR-3B. - assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/, + assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/, 'near contacts and model fallbacks retain the class-derived aircraft silhouette'); - assert.match(source, /bb\.rotation = 0;/, + assert.match(source, /bb\.rotation\s*=\s*0;/, 'far dots are reset to a rotation-free presentation'); - assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/, + assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/, 'near 2D silhouettes continue to receive projected course'); - assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/, + assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, 'the gap-proof billboard-to-model handoff remains intact'); }); test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => { - const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0]; - assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/); - assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/); + const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/); + assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/); }); } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index acacff5ce..e76f6a75f 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -671,7 +671,8 @@ test('pathological detection paint holds alternate frames without freezing share test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8') + + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); assert.doesNotMatch(source, /['"]detection-overlay['"]/); @@ -694,7 +695,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle // three times" — same guarantee, and the copies can no longer drift. assert.match( uiSource, - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical detection default is still Dense @ 75%', ); assert.equal( diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index 9241a56b2..56a1c7dcf 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/detectionRenderDemand.test.mjs // // Detection must not hold the render loop open — and must not drop work on the @@ -427,8 +428,8 @@ test('the render-governor gate covers the parked case, with teeth on the painter // promptness goes with them, silently. This test is where that shows up. test('aircraft brackets stay prompt because the aircraft layers hold the render loop', async () => { for (const file of ['./flights.js', './militaryFlights.js']) { - const source = await readFile(new URL(file, import.meta.url), 'utf8'); - const enable = /\n enable\([\s\S]*?\n \},/.exec(source)?.[0]; + const source = readLayerSource(new URL(file, import.meta.url)); + const enable = /\n([ \t]*)enable\([\s\S]*?\n\1\},/.exec(source)?.[0]; assert.ok(enable, `${file}: enable() is still identifiable`); assert.match( enable, diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index cb4d3ac13..6642f3b01 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -1,342 +1,22 @@ -import * as Cesium from 'cesium'; +import { + createEarthquakesLayer as createLayer, + createUsgsEarthquakeSource, +} from '../layers/earthquakes/index.js'; import { clearOverlaySource, setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; - -/** - * USGS earthquake discs — last 24 hours, M2.5+. - * - * Ellipse axes are STATIC (plain numbers), redefined only when a poll brings - * new data. They must never become a `CallbackProperty` again: every entity - * here is a `CLAMP_TO_GROUND` ellipse, and a per-frame axis re-tessellates its - * ground primitive on EVERY frame. Measured on the shipped 58-event feed - * (2026-08-20 QA hunt, parked camera over SF at 40 km): - * - * 58 discs, callback axes → 32.4 ms/frame, 30 fps - * 58 discs, static axes → 1.4 ms/frame, 60 fps - * - * The former ±15 % radius "pulse" was DROPPED to buy that back: an - * imperceptible breathing wobble on a translucent ground disc is not worth - * rebuilding 58 ground primitives 60 times a second. With no per-frame - * animator left, the layer also no longer holds the render governor - * continuous — the manager's `layer-tick` / `layer-visibility` requests - * already cover every discrete mutation this layer makes. - */ - -const API_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'; - -export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; -export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; -export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; - -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); - -/** - * Color by depth: - * - Shallow (<70km): Red - * - Intermediate (70-300km): Orange - * - Deep (>300km): Yellow - */ -function depthColor(depthKm) { - if (depthKm < 70) return Cesium.Color.RED; - if (depthKm < 300) return Cesium.Color.ORANGE; - return Cesium.Color.YELLOW; +export * from '../layers/earthquakes/index.js'; +/** Wire the standalone source and application overlay owner. */ +export function createEarthquakesLayer({ + source = createUsgsEarthquakeSource(), + overlayHost = { + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }, +} = {}) { + return createLayer({ source, overlayHost }); } - -/** - * Build the source-owned presentation for one ambient magnitude label. - * Magnitude formatting deliberately remains here instead of moving into the - * shared renderer. - * @param {object} input - * @param {string} input.id Stable USGS or deterministic fallback id. - * @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse. - * @param {number} input.magnitude USGS magnitude. - * @param {string} input.accent Source-owned depth-band color. - * @returns {object} - */ -export function createEarthquakeOverlayEntry({ id, position, magnitude, accent }) { - const mag = Number(magnitude); - return { - id: String(id), - position, - variant: 'label', - title: `M${mag.toFixed(1)}`, - accent, - priority: Math.round(mag * 1000), - collisionGroup: 'ambient-label', - paintLane: 'ambient-label', - interactive: false, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - gapPx: 15, - verticalOnly: true, - placement: 'above', - }; -} - -/** Keep the largest events, with stable identity as the tie-break. */ -export function selectEarthquakeOverlayCohort( - entries, - limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT, -) { - const cap = Math.max(0, Math.min( - EARTHQUAKE_OVERLAY_COHORT_LIMIT, - Math.floor(Number(limit) || 0), - )); - if (!Array.isArray(entries) || cap === 0) return []; - return entries.slice().sort((a, b) => ( - b.priority - a.priority || String(a.id).localeCompare(String(b.id)) - )).slice(0, cap); -} - -/** - * Map one earthquake's raw plain values to a JSON-safe analyst record - * (analyst query engine seam). Pure — no Cesium types. Missing/unknown - * fields are null, never NaN/undefined. Falls back to an index-based id - * when the USGS event id is absent. - * @param {Object|null|undefined} raw - Plain values pulled off the entity: - * {id, mag, place, time, depth, lat, lon}. - * @param {number} [index=0] - Position in the snapshot (fallback id only). - * @returns {{id: string, magnitude: number|null, depthKm: number|null, - * lat: number|null, lon: number|null, timeMs: number|null, place: string|null}} - */ -export function mapAnalystRecord(raw, index = 0) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - return { - id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`, - magnitude: num(raw?.mag), - depthKm: num(raw?.depth), - lat: num(raw?.lat), - lon: num(raw?.lon), - timeMs: num(raw?.time), // USGS epoch ms - place: text(raw?.place), - }; -} - -/** Validate a complete feed before replacing the last good earthquake snapshot. */ -export function normalizeEarthquakeSnapshot(geojson) { - if (!Array.isArray(geojson?.features)) return null; - const rows = []; - const ids = new Set(); - for (const [index, feature] of geojson.features.entries()) { - const coordinates = feature?.geometry?.coordinates; - const properties = feature?.properties; - if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties - || typeof properties !== 'object' || Array.isArray(properties) - || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null; - const [lon, lat, depthKm] = coordinates; - const mag = properties.mag; - if (!Number.isFinite(lon) || Math.abs(lon) > 180 - || !Number.isFinite(lat) || Math.abs(lat) > 90 - || (depthKm != null && !Number.isFinite(depthKm)) - || (mag != null && (!Number.isFinite(mag) || mag > 10))) return null; - // A missing magnitude cannot establish that this event meets M2.5+. - if (mag == null || mag < 2.5) continue; - const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id); - if (ids.has(stableId)) return null; - ids.add(stableId); - rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null, - mag, place: typeof properties.place === 'string' ? properties.place : null, - time: Number.isFinite(properties.time) ? properties.time : null }); - } - return rows; -} - -export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) { - let _dataSource = null; - let _count = 0; - let _lastUpdate = null; - let _lastError = null; - let _enabled = false; - - const layer = { - id: 'earthquakes', - name: 'Earthquakes (24h)', - icon: '🌋', - source: 'USGS', - updateInterval: 60000, - - init(viewer) { - _dataSource = new Cesium.CustomDataSource('earthquakes'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); - _count = 0; - _lastUpdate = null; - _lastError = null; - _enabled = false; - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - console.log('[Data:Earthquakes] Initialized'); - }, - - enable(viewer) { - _enabled = true; - // No continuous-render hold: the discs are static geometry now, so the - // layer has no per-frame animator to keep the render loop alive for. - if (_dataSource) _dataSource.show = true; - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); - }, - - disable(viewer) { - _enabled = false; - if (_dataSource) _dataSource.show = false; - overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - }, - - async update(viewer) { - try { - const response = await fetch(API_URL); - if (!response.ok) { - _lastError = `USGS HTTP ${response.status}`; - console.warn(`[Data:Earthquakes] API returned ${response.status}`); - return false; - } - - const geojson = await response.json(); - const rows = normalizeEarthquakeSnapshot(geojson); - if (!rows) { - _lastError = 'Malformed USGS response'; - return false; - } - - const nextEntities = []; - let count = 0; - const overlayEntries = []; - - for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) { - count++; - const baseRadius = Math.pow(2, mag) * 1000; - const color = depthColor(depthKm || 0); - const isSignificant = mag >= 5.0; - const fillAlpha = isSignificant ? 0.4 : 0.3; - const outlineAlpha = isSignificant ? 1.0 : 0.8; - - const position = Cesium.Cartesian3.fromDegrees(lon, lat); - nextEntities.push(new Cesium.Entity({ - id: `earthquake:${stableId}`, - position, - ellipse: { - // Static axes — see the module header. A CallbackProperty here - // re-tessellates the clamped ground geometry every frame. - semiMajorAxis: baseRadius, - semiMinorAxis: baseRadius, - material: new Cesium.ColorMaterialProperty( - color.withAlpha(fillAlpha) - ), - outline: true, - outlineColor: color.withAlpha(outlineAlpha), - outlineWidth: isSignificant ? 3 : 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - properties: { - // Analyst seam (additive): the USGS event id (e.g. "us7000abcd"). - usgsId, - mag, - place, - time, - depth: depthKm, - }, - })); - overlayEntries.push(createEarthquakeOverlayEntry({ - id: String(stableId), - position, - magnitude: mag, - accent: color.toCssColorString(), - })); - } - - _dataSource.entities.removeAll(); - for (const entity of nextEntities) _dataSource.entities.add(entity); - if (_enabled) { - overlayHost.setEntries( - EARTHQUAKE_OVERLAY_SOURCE_ID, - selectEarthquakeOverlayCohort(overlayEntries), - { - cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT, - collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, - moving: false, - }, - ); - } - - _count = count; - _lastUpdate = Date.now(); - _lastError = null; - console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`); - return true; - - } catch (e) { - console.warn('[Data:Earthquakes] Fetch error:', e); - _lastError = 'USGS network error'; - return false; - } - }, - - destroy(viewer) { - _enabled = false; - overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - if (_dataSource) { - viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - } - _count = 0; - _lastUpdate = null; - _lastError = null; - }, - - /** - * Snapshot the layer's in-memory earthquake records as plain JSON-safe - * objects for the analyst query engine. On-demand only (called at most - * once per spoken query) — zero per-frame cost, no listeners, no caching. - * Returns [] while the layer is disabled or empty. - * @param {number} [maxCount=2000] - Maximum records to return (truncation). - * @returns {Array} See mapAnalystRecord for the record shape. - */ - getAnalystRecords(maxCount = 2000) { - if (!_dataSource || !_dataSource.show) return []; - const entities = _dataSource.entities.values; - if (!entities.length) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = Cesium.JulianDate.now(); - const result = []; - for (const entity of entities) { - if (result.length >= limit) break; - const cartesian = entity.position ? entity.position.getValue(now) : null; - const carto = cartesian ? Cesium.Cartographic.fromCartesian(cartesian) : null; - const p = entity.properties; - result.push(mapAnalystRecord({ - id: p?.usgsId?.getValue(now) ?? null, - mag: p?.mag?.getValue(now), - place: p?.place?.getValue(now), - time: p?.time?.getValue(now), - depth: p?.depth?.getValue(now), - lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, - lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, - }, result.length)); - } - return result; - }, - - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - error: _lastError, - }; - }, - }; - return layer; -} - -const earthquakesLayer = createEarthquakesLayer(); - -export default earthquakesLayer; +export default createEarthquakesLayer(); diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 8119a6dc7..5a014b1f5 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -294,7 +294,8 @@ test('a quake poll still reaches the screen with the render loop idle', async () }); test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => { - const source = readFileSync(new URL('./earthquakes.js', import.meta.url), 'utf8'); + const source = ['index', 'model', 'source'].map(name => + readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n'); assert.doesNotMatch( source, /new Cesium\.CallbackProperty/, diff --git a/src/data/fireAnchors.js b/src/data/fireAnchors.js index 60736a3d6..7d0912b03 100644 --- a/src/data/fireAnchors.js +++ b/src/data/fireAnchors.js @@ -1,87 +1,9 @@ -// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections -// (owner field finding 2026-07-21: at close/oblique zoom over high country, -// fire dots anchored at ellipsoid height 0 read as buried inside the terrain -// ~1-2 km below the visible surface). -// -// The fix rides the height-datum ship's machinery end to end: anchors read -// the SAME shared ground floor every other ground-adjacent consumer uses -// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM -// cell), and cold cells warm through the same batched, chunked, cached -// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts); -// fires are static, and floor cells latch warm for the session, so each -// rendered detection costs at most one DEM lookup EVER — and detections -// sharing a ~111 m coarse cell share that lookup. -import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js'; - -/** @constant {number} Metres above the resolved floor for a fire anchor. - * The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel; - * a few metres of lift biases "slightly above the visible surface" (owner - * principle) without a visible hover on the screen-sized sprite. */ -export const FIRE_ANCHOR_LIFT_M = 5; - -/** - * Synchronous anchor height for one detection: shared ground floor + lift - * when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render - * exactly as before until their floor lands). Warm-cache read only; never - * triggers network or sampling. - * @param {number} lat - * @param {number} lon - * @returns {number} Ellipsoidal anchor height in metres. - */ -export function fireAnchorHeight(lat, lon) { - const floor = cachedGroundFloor(lat, lon); - return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0; -} - -/** @type {?Promise} Tail of the batch chain — batches run strictly - * sequentially so overlapping renders can't stack concurrent requests on - * the single dev-server proxy (same courtesy as terrainHeights' sequential - * chunking). Each queued batch re-filters against the warm cache when it - * actually runs, so cells resolved by an earlier batch are never refetched. */ -let _chain = null; - -/** - * Batched warm of the ground-floor cells behind a rendered detection set. - * Fire-and-forget safe (never rejects). Resolves `true` only when at least - * one requested point actually gained a warm floor — a failed resolve (proxy - * down caches only geoid fallbacks, which do NOT count as a floor) resolves - * `false`, so a render → warm → re-render chain terminates instead of - * looping; the next natural rebuild retries and the cache self-heals. - * @param {Array<{lat: number, lon: number}>} points - Rendered detections. - * @returns {Promise} Whether any requested point warmed. - */ -export function warmFireAnchorFloors(points) { - const cold = collectCold(points); - if (!cold.length) return Promise.resolve(false); - const prev = _chain; - const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold); - _chain = run.then(() => true, () => false); - return run; -} - -/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */ -function collectCold(points) { - if (!Array.isArray(points)) return []; - const cold = []; - for (const p of points) { - if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; - if (cachedGroundFloor(p.lat, p.lon) != null) continue; - cold.push({ lat: p.lat, lon: p.lon }); - } - return cold; -} - -/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */ -async function resolveBatch(points) { - const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null); - if (!cold.length) return false; - try { - await resolveGroundFloorCells(cold); - } catch { /* resolver is best-effort and never throws; belt and braces */ } - return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null); -} - -/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */ -export function _resetFireAnchorsForTest() { - _chain = null; -} +import { createFireAnchors } from '../layers/firms/anchors.js'; +import * as groundFloor from './groundFloor.js'; +export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js'; +const anchors = createFireAnchors(groundFloor); +export const { + fireAnchorHeight, + warmFireAnchorFloors, + _resetFireAnchorsForTest, +} = anchors; diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index f3990e6de..4b2428cf9 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,1607 +1,50 @@ import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; import { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, -} from './spriteOrder.js'; -import { - clearSelectedEntityContextForLayer, - getContextStore, - registerEntityContext, - selectEntityContext, -} from './contextStore.js'; -import { - isOwnedByOtherLayer, - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { adaptFirmsRecords } from './firmsAdapt.js'; -import { fireAnchorHeight, warmFireAnchorFloors } from './fireAnchors.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { - accentForSeverity, - fireDetectionKey, - FIRMS_AMBIENT_COHORT_LIMIT, - FIRMS_OVERLAY_SOURCE_ID, - satelliteShortName, -} from './firmsLabels.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { requestWorldFocus } from '../worldFocus.js'; + createFirmsHeatmapLayer as createLayer, + createFirmsHelpers, + createFirmsSource, +} from '../layers/firms/index.js'; +import * as render from '../renderGovernor.js'; +import * as sprites from './spriteOrder.js'; +import * as context from './contextStore.js'; +import * as picking from './pickRegistry.js'; +import * as anchors from './fireAnchors.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as focus from '../worldFocus.js'; +const services = { + render, + sprites, + context, + picking, + anchors, + overlays, + focus, +}; +const helpers = createFirmsHelpers({ services }); +export const mapAnalystRecord = helpers.mapAnalystRecord; +export const fireCullPosition = helpers.fireCullPosition; +export const applyHorizonCull = helpers.applyHorizonCull; +export const buildSelectedFireCard = helpers.buildSelectedFireCard; +export const buildFireCard = helpers.buildFireCard; +export const buildCellCard = helpers.buildCellCard; +export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy; -/** Same-origin live-fires proxy (vite.config.js firmsProxy — key stays server-side). */ -const FIRMS_API_URL = '/api/firms'; -/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ -const REFRESH_INTERVAL_MS = 600_000; - -/** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. - * Labels in every band go through the shared screen-space greedy declutter - * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. - */ -const LOD_LEVELS = [ - { id: 'global', minHeight: 9000000, mode: 'cells', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000 }, - { id: 'regional', minHeight: 3000000, mode: 'cells', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000 }, - { id: 'local', minHeight: 750000, mode: 'detections', maxDetections: 2500, labelDistance: 4500000 }, - { id: 'close', minHeight: 0, mode: 'detections', maxDetections: 3000, labelDistance: 1800000 }, -]; -const LOD_CHECK_MS = 650; -/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ -const LOD_HYSTERESIS = 0.1; -/** Padding fraction applied to the camera view rectangle before clipping. */ -const VIEW_PADDING = 0.3; -/** Number of top-FRP detections registered in the shared context store. */ -const CONTEXT_TOP_N = 50; -/** Hard cap on ambient (non-selected) labels, regardless of zoom/density. */ -const MAX_AMBIENT_LABELS = FIRMS_AMBIENT_COHORT_LIMIT; -/** Min screen-space separation between accepted labels, in CSS pixels. */ -const LABEL_MIN_SEP_PX = 150; -/** Candidates projecting outside the canvas by more than this are skipped. */ -const LABEL_VIEW_MARGIN_PX = 16; -/** - * Anchors below this height get a separate LIFTED point for occlusion tests - * (never for rendering). Mirrors the flights layer's `cullPosition` idiom - * (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor - * + lift, and in negative-geoid coastal regions that lands a few tens of - * metres BELOW the WGS84 ellipsoid — EllipsoidalOccluder then judges such a - * point "beyond the horizon" while its true-surface neighbours stay visible, - * so near-limb fires would blink out for a datum reason. - */ -const CULL_LIFT_THRESHOLD_M = 10; -/** Height of the lifted occlusion-test point (flights uses the same 12 m). */ -const CULL_LIFT_M = 12; - -/** Color stops shared by cell heat fills and detection glow sprites. */ -const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; - -/** Pre-baked radial-glow sprites keyed by `:`. */ -const glowSpriteCache = new Map(); - -const scratchViewRect = new Cesium.Rectangle(); -const scratchCenterA = new Cesium.Cartographic(); -const scratchCenterB = new Cesium.Cartographic(); -const scratchWindowCoord = new Cesium.Cartesian2(); - -/** - * Map one internal fire record (firmsAdapt.js shape) to a plain JSON-safe - * analyst record (analyst query engine seam). Pure — no Cesium types. - * Missing/unknown fields are null, never NaN/undefined. The id reuses the - * layer's FIRE-##### pick-id convention (index keys pick ids and - * context-store ids, so the two stay consistent). - * @param {Object|null|undefined} fire - Internal fire record. - * @returns {{id: string, lat: number|null, lon: number|null, frp: number|null, - * confidence: number|null, satellite: string|null, acqTime: number|null}} - */ -export function mapAnalystRecord(fire) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - return { - id: `FIRE-${String(fire?.index ?? 0).padStart(5, '0')}`, - lat: num(fire?.lat), - lon: num(fire?.lon), - frp: num(fire?.frp), - confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence) - satellite: text(fire?.satellite) || text(fire?.sensor), - acqTime: Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null - }; -} - -export function createFirmsHeatmapLayer({ - id, - name, - icon = '▲', - source = 'NASA FIRMS', - overlayHost = { - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, - }, - screenSpaceEventHandlerFactory = (viewer) => ( - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) - ), -}) { - let _viewer = null; - let _dataSource = null; - let _billboards = null; - let _enabled = false; - let _destroyed = false; - let _loading = false; - /** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */ - let _keyRequired = false; - /** True when the proxy served a cached payload past TTL (upstream failing). */ - let _stale = false; - /** Surfaced error string when the live fetch failed outright. */ - let _error = null; - let _fires = []; - let _firesByFrp = []; - let _count = 0; - let _cellCount = 0; - let _lastUpdate = null; - let _currentLodId = null; - let _currentLodIndex = -1; - let _lastViewRect = null; - let _preRenderRemover = null; - let _moveEndSettleRemover = null; - let _lastLodCheck = 0; - let _contextIds = new Set(); - let _clickHandler = null; - let _moveEndRemover = null; - let _selectedFire = null; - /** Priority-ordered label candidates from the last rebuild (detections or cells). */ - let _labelCandidates = []; - let _labelLodDistance = 0; - /** pick id string -> fire record, for the currently rendered sprites. */ - const _pickIndexById = new Map(); - /** Actionable card id -> current detection record painted for that id. */ - const _fireByCardId = new Map(); - /** - * Occlusion-test anchors index-aligned with the billboard collection (the - * collection is cleared and refilled in one ordered pass per rebuild, and - * nothing else adds to it). Lifted where the render anchor sits at/below - * the ellipsoid — see {@link fireCullPosition}. - * @type {Array} - */ - const _cullPositions = []; - /** - * gridDegrees -> heat-sorted full cell list. The full-grid aggregation is - * viewport-independent but walks every detection (~200k live), so it is - * computed once per grid size per data refresh; renders only clip + cap - * (field-test round 1: intermediate-zoom chug during LOD rebuilds). - * @type {Map>} - */ - const _cellCacheByGrid = new Map(); - /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ - let _camSnapValid = false; - const _camPos = new Cesium.Cartesian3(); - const _camDir = new Cesium.Cartesian3(); - - return { - id, - name, - icon, - source, - // Live layer: the manager calls update() every 10 minutes while enabled, - // which refetches through the /api/firms proxy (the proxy's 30 min TTL — - // not this interval — is what protects the upstream FIRMS quota). - updateInterval: REFRESH_INTERVAL_MS, - - init(viewer) { - if (_destroyed) return; - _viewer = viewer; - _dataSource = new Cesium.CustomDataSource(id); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); +/** Supply the local source and the application's scene service owners. */ +export function createFirmsHeatmapLayer(options) { + return createLayer({ + ...options, + icon: options.icon ?? '▲', + source: options.source ?? 'NASA FIRMS', + feed: options.feed ?? createFirmsSource(), + services, + overlayHost: options.overlayHost ?? { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, }, - - async enable(viewer) { - if (_destroyed) return; - _enabled = true; - _viewer = viewer; - if (!_dataSource) this.init(viewer); - if (_dataSource) _dataSource.show = true; - if (_billboards) { - // The camera can have moved anywhere while the layer was off: moveEnd - // was not being listened to, and the preRender watcher is inert while - // disabled AND while the (untimed) refetch below is in flight. So the - // retained per-sprite show flags describe the OLD viewpoint. Re-cull - // BEFORE the collection becomes visible — otherwise re-enabling at a - // new location flashes far-side fires through the planet (and keeps - // near-side ones hidden) until the fetch resolves. - refreshHorizonCulling(); - _billboards.show = true; - } - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - installLodWatcher(); - installMoveEndWatcher(); - installClickHandler(); - registerPickOwner(id, (pickedId) => _pickIndexById.has(pickedId)); - if (!_fires.length && !_loading) await loadHeatmap(); - restoreSpriteOrderOnEnable('firms', viewer); - }, - - disable() { - _enabled = false; - clearFireSelection(); - if (_dataSource) _dataSource.show = false; - if (_billboards) _billboards.show = false; - overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); - clearSelectedEntityContextForLayer(id); - removeClickHandler(); - removeMoveEndWatcher(); - unregisterPickOwner(id); - removeLodWatcher(); - }, - - async update() { - if (_destroyed || !_enabled || _loading) return; - // Scheduled 10-minute poll (and the manager's immediate first update): - // refetch live fires through the proxy and re-render. Viewport-driven - // re-renders between polls are handled by the LOD watcher. - await loadHeatmap(); - }, - - destroy(viewer) { - if (_destroyed) return; - _destroyed = true; - _enabled = false; - removeLodWatcher(); - removeMoveEndWatcher(); - removeClickHandler(); - unregisterPickOwner(id); - if (_dataSource && viewer) { - viewer.dataSources.remove(_dataSource, true); - } - _dataSource = null; - removeDetectionCollections(viewer); - clearContextRegistrations(); - clearSelectedEntityContextForLayer(id); - _fires = []; - _firesByFrp = []; - _cellCacheByGrid.clear(); - _count = 0; - _cellCount = 0; - _lastUpdate = null; - _keyRequired = false; - _stale = false; - _error = null; - _currentLodId = null; - overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); - _currentLodIndex = -1; - _lastViewRect = null; - _selectedFire = null; - _labelCandidates = []; - _labelLodDistance = 0; - _pickIndexById.clear(); - _fireByCardId.clear(); - _cullPositions.length = 0; - _camSnapValid = false; - }, - - /** - * Layer stats for the data panel. Degraded feed states surface through - * `error` (established qa-failstate pattern: a dead feed must never look - * like a healthy empty layer) with a matching human `loadingLabel`: - * 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy - * served past-TTL cache, 'KEY REQUIRED' keyless. - */ - getStats() { - const now = Date.now(); - const staleText = _lastUpdate ? `STALE · cached ${formatAge(now - _lastUpdate) || '<1h'}` : 'STALE'; - let loadingLabel = ''; - if (_loading) { - loadingLabel = _fires.length ? 'refreshing...' : 'loading...'; - } else if (_keyRequired) { - loadingLabel = 'KEY REQUIRED'; - } else if (_stale) { - loadingLabel = staleText; - } else if (_error) { - loadingLabel = _error; - } else if (_lastUpdate) { - loadingLabel = `LIVE · updated ${formatAgoMinutes(now - _lastUpdate)}`; - } - return { - count: _count, - cells: _cellCount, - lastUpdate: _lastUpdate, - loading: _loading, - stale: _stale, - error: _keyRequired ? 'KEY REQUIRED' : (_stale ? staleText : _error), - loadingLabel, - }; - }, - - /** - * Strongest currently-loaded detection (by FRP) for voice targeting - * ("take me to the biggest fire"). - * @returns {{latitude: number, longitude: number, frp: number, label: string}|null} - */ - getStrongestFire() { - const strongest = _firesByFrp.length ? _firesByFrp[0] : null; - if (!strongest) return null; - return { - latitude: strongest.lat, - longitude: strongest.lon, - frp: strongest.frp, - label: `Fire · FRP ${formatFrp(strongest.frp)} MW`, - }; - }, - - /** - * Detection-overlay seam, shaped like the other layers' detectable - * objects (traffic/flights). NOT yet registered in initDetection's layer - * list — wiring fires through the detection/label-arbiter pipeline is a - * deliberate post-PR#1 task; when that happens the arbiter replaces this - * layer's greedy declutter as the SELECTOR and the shared overlay remains - * the renderer. Walks the FRP-sorted index so the strongest fires come first. - * @param {{maxCount?: number}} [options] - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>} - */ - getDetectableObjects(options = {}) { - if (!_enabled || !_firesByFrp.length) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : 250; - const result = []; - for (const fire of _firesByFrp) { - result.push({ - position: firePosition(fire), - id: `FIRE-${String(fire.index).padStart(5, '0')}`, - type: 'FIRE', - }); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * Snapshot the layer's in-memory fire records as plain JSON-safe - * objects for the analyst query engine. Walks the FRP-sorted index so - * truncation keeps the STRONGEST fires (200k+ detections can be live — - * the cap is load-bearing, not cosmetic). 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 (!_enabled || !_firesByFrp.length) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const fire of _firesByFrp) { - result.push(mapAnalystRecord(fire)); - if (result.length >= limit) break; - } - return result; - }, - - /** Test seam that binds the production click path and indexes. */ - _bindInteractionForTest(viewer, fires = []) { - _viewer = viewer; - _enabled = true; - _fires = fires; - _firesByFrp = [...fires]; - _pickIndexById.clear(); - _labelLodDistance = 1e7; - _labelCandidates = fires.map((fire) => { - _pickIndexById.set(`firms-${fire.index}`, fire); - return { fire, position: firePosition(fire) }; - }); - rebuildAmbientLabels(); - installClickHandler(); - return _clickHandler; - }, - }; - - /** - * Fetch live fires from the /api/firms proxy and swap them in. Handles the - * three degraded states distinctly: keyless (503 {error:'no_key'} → KEY - * REQUIRED, no retry storm — the 10 min poll re-checks cheaply without - * touching upstream), stale (proxy served past-TTL cache → surfaced via - * getStats), and outright failure (existing fires are KEPT — old data - * beats a wiped map). A click-selected fire survives the swap when the - * same detection (lat/lon/acqMs) is still present. - */ - async function loadHeatmap() { - if (!_dataSource) return; - _loading = true; - - try { - const response = await fetch(FIRMS_API_URL, { cache: 'no-store' }); - if (!response.ok) { - let payload = null; - try { - payload = await response.json(); - } catch { /* non-JSON error body — fall through to the generic error */ } - if (response.status === 503 && payload?.error === 'no_key') { - _keyRequired = true; - _error = null; - _stale = false; - return; - } - throw new Error(`FIRMS HTTP ${response.status}`); - } - - const payload = await response.json(); - _keyRequired = false; - _error = null; - _stale = Boolean(payload?.stale); - const previousSelection = _selectedFire; - _selectedFire = null; - _fires = adaptFirmsRecords(payload?.fires); - _cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells - _firesByFrp = [..._fires].sort((a, b) => b.frp - a.frp); - _count = _fires.length; - // Data age, not response age: a stale proxy payload truthfully reads old. - _lastUpdate = Number.isFinite(payload?.fetchedAt) ? payload.fetchedAt : Date.now(); - // Settle the previous selection BEFORE the LOD rebuild. renderCurrentLod - // runs refreshContextRegistrations(), which deletes every context record - // not in the new top-N — including the one the store still points at. - // Clearing after that deletion fails the ownership guard inside - // clearSelectedEntityContextForLayer and emits nothing at all, so an - // eviction-aware readout never hears that its subject is gone. - const reselected = findMatchingFire(previousSelection); - if (!reselected && previousSelection) { - // The selected detection is not in the new payload: it left the feed - // rather than being deselected. Eviction-aware readouts hold their - // last-known values for this instead of tearing down. - clearSelectedEntityContextForLayer(id, { evicted: true }); - } - renderCurrentLod(true); - if (reselected) selectFire(reselected); - } catch (error) { - console.warn(`[Data:${id}] FIRMS live load failed:`, error); - _error = 'live feed unavailable'; - } finally { - _loading = false; - } - } - - /** - * Find the record in the freshly-loaded set matching a previous selection. - * Identity is the detection itself (lat/lon/acquisition time) — indices - * are regenerated every fetch, so object/index identity cannot be used. - * @param {?Object} previous - Previously selected fire record. - * @returns {?Object} Matching new record. - */ - function findMatchingFire(previous) { - if (!previous) return null; - const key = fireDetectionKey(previous); - return _fires.find((fire) => fireDetectionKey(fire) === key) || null; - } - - /** - * Rebuild the render for the current LOD band and viewport. Skips work - * when neither the (hysteresis-damped) LOD band nor the padded view - * rectangle changed meaningfully. - * @param {boolean} force - Rebuild even if LOD/view appear unchanged. - * @returns {boolean} True when a rebuild actually ran. Callers use this to - * avoid a second horizon walk in the same tick: a rebuild already culls - * its freshly-added sprites, an early-out does not. - */ - function renderCurrentLod(force = false) { - if (!_dataSource || !_fires.length || !_viewer) return false; - const lodIndex = selectLodIndex(cameraHeight()); - const lod = LOD_LEVELS[lodIndex]; - const viewRect = computeViewRect(); - if (!force && lodIndex === _currentLodIndex && !viewChangedEnough(viewRect)) return false; - _currentLodIndex = lodIndex; - _currentLodId = lod.id; - _lastViewRect = viewRect ? Cesium.Rectangle.clone(viewRect) : null; - const bounds = viewRect ? paddedDegreeBounds(viewRect) : null; - - if (lod.mode === 'detections') { - renderDetections(lod, bounds); - } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); - } - return true; - } - - /** - * Bin all detections into grid cells, then viewport-clip BEFORE the - * top-N cap so fires in view never lose their slot to higher-scoring - * cells on other continents. `bounds === null` (sky/horizon or - * near-global view) falls back to the global top-N behavior. - * @param {Object} lod - Active LOD descriptor. - * @param {?Object} bounds - Padded view bounds in degrees, or null. - * @returns {Array} Cells sorted by heat score, capped. - */ - function aggregateFires(lod, bounds) { - const gridDegrees = lod.gridDegrees; - - let sorted = _cellCacheByGrid.get(gridDegrees); - if (!sorted) { - const cells = new Map(); - for (const fire of _fires) { - const latCell = Math.floor(fire.lat / gridDegrees) * gridDegrees; - const lonCell = Math.floor(fire.lon / gridDegrees) * gridDegrees; - const key = `${latCell.toFixed(3)}:${lonCell.toFixed(3)}`; - // confidence is normalized 0..1 — weight ×4 preserves the old 0..100×0.04 scale. - const intensity = Math.max(1, fire.frp * 0.18 + fire.confidence * 4 + fire.brightness * 0.01); - const existing = cells.get(key) || { - latCell, - lonCell, - count: 0, - intensity: 0, - maxFrp: 0, - night: 0, - newestAcqMs: 0, - }; - existing.count += 1; - existing.intensity += intensity; - existing.maxFrp = Math.max(existing.maxFrp, fire.frp); - existing.newestAcqMs = Math.max(existing.newestAcqMs, fire.acqMs); - if (fire.night) existing.night += 1; - cells.set(key, existing); - } - // Heat-sorted once; per-render clipping below preserves the order. - sorted = [...cells.values()].sort((a, b) => heatScore(b) - heatScore(a)); - _cellCacheByGrid.set(gridDegrees, sorted); - } - - if (!bounds) return sorted.slice(0, lod.maxCells); - const clipped = []; - for (const cell of sorted) { - if (!cellIntersectsBounds(cell, gridDegrees, bounds)) continue; - clipped.push(cell); - if (clipped.length >= lod.maxCells) break; - } - return clipped; - } - - /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. - * @param {Object} lod - Active LOD descriptor. - * @param {?Object} bounds - Padded view bounds in degrees, or null. - */ - function renderCells(cells, lod, bounds) { - ensureDetectionCollections(); - _dataSource.entities.removeAll(); - if (_billboards) _billboards.removeAll(); - _pickIndexById.clear(); - _cullPositions.length = 0; - _cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(heatScore)); - - _labelCandidates = []; - _labelLodDistance = lod.labelDistance; - - for (const cell of cells) { - const score = heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = heatColor(normalized, alpha); - const centerLon = cell.lonCell + lod.gridDegrees / 2; - const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - _dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. - _labelCandidates.push({ - position, - cullPosition: cellCullPosition(centerLon, centerLat), - fire: null, - cell, - accent: cellAccent(normalized), - }); - } - - rebuildAmbientLabels(); - refreshContextRegistrations(topFiresWithinBounds(bounds)); - } - - /** - * Render individual detections (local/close bands) as pre-baked - * radial-glow sprite billboards sized by FRP, capped per viewport by - * highest FRP, depth test disabled. The 3D Tiles mesh is never sampled - * per point; instead, the CLOSE band (where terrain parallax is actually - * visible) batch-warms the shared cached DEM floor for its rendered - * subset and re-renders once floors land, so anchors sit on the terrain - * (firePosition). The local band keeps cold anchors at height 0 — at - * ≥750 km camera height the divergence is invisible and warming a - * continent-wide viewport would waste the DEM proxy. - * Labels are not built here; candidates feed {@link rebuildAmbientLabels}. - * @param {Object} lod - Active LOD descriptor. - * @param {?Object} bounds - Padded view bounds in degrees, or null. - */ - function renderDetections(lod, bounds) { - ensureDetectionCollections(); - _dataSource.entities.removeAll(); - if (!_billboards) return; - _billboards.removeAll(); - _pickIndexById.clear(); - _cullPositions.length = 0; - - let candidates; - if (bounds) { - // Walk the pre-sorted FRP index and early-exit at the cap — the old - // collect-then-sort touched and sorted every in-view detection per - // rebuild (tens of thousands mid-zoom; field-test round 1 chug). - candidates = []; - for (const fire of _firesByFrp) { - if (!boundsContainPoint(bounds, fire.lat, fire.lon)) continue; - candidates.push(fire); - if (candidates.length >= lod.maxDetections) break; - } - } else { - // Sky/horizon view: fall back to the globally strongest detections. - candidates = _firesByFrp.slice(0, lod.maxDetections); - } - - _cellCount = candidates.length; - _labelCandidates = []; - _labelLodDistance = lod.labelDistance; - - for (const fire of candidates) { - const coreSize = frpPixelSize(fire.frp); - const position = firePosition(fire); - const cullPosition = fireCullPosition(fire); - const pickId = `firms-${fire.index}`; - _pickIndexById.set(pickId, fire); - _cullPositions.push(cullPosition); - _billboards.add({ - id: pickId, - position, - image: glowSprite(detectionColorStop(fire), sizeBucket(coreSize)), - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - // FRP-sorted walk: candidate order doubles as label priority. - _labelCandidates.push({ position, cullPosition, fire, cell: null }); - } - - // Freshly-added sprites default to show=true; cull the far side now so a - // rebuild never flashes through-the-planet detections while the camera - // sits still (the moveEnd/preRender hooks only fire on camera motion). - refreshHorizonCulling(); - rebuildAmbientLabels(); - refreshContextRegistrations(candidates.slice(0, CONTEXT_TOP_N)); - - if (lod.id === 'close') { - // Batched (chunked ≤200, sequential, session-cached) DEM warm for the - // rendered subset; fires are static so each coarse cell resolves once - // ever. Re-render ONLY when a floor actually landed — a failed resolve - // reports false, so this chain terminates instead of looping against a - // down proxy (the next camera-driven rebuild retries). - warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !_enabled || !_viewer) return; - const currentLod = LOD_LEVELS[_currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } - } - - /** - * Rebuild the card entries for the shared world-overlay host: the - * selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS - * ambient cards picked by a greedy screen-space declutter — walk the - * priority-ranked candidates, project each with - * SceneTransforms.worldToWindowCoordinates, and accept only candidates - * ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose - * projection fails are dropped silently. SELECTION runs here, on LOD/ - * viewport rebuilds and camera moveEnd — never per frame; RENDERING is - * the host, which re-projects the accepted entries every frame so - * cards track the camera smoothly. - */ - function rebuildAmbientLabels() { - if (!_viewer) return; - const scene = _viewer.scene; - const now = Date.now(); - const entries = []; - /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ - const accepted = []; - // worldToWindowCoordinates happily projects points on the FAR side of the - // planet, so without this the ≤18 ambient slots get spent on fires the - // overlay host will then horizon-cull at paint time (`horizonCull: true` - // in applyFirmsOverlayPolicy) — near-side fires silently lose their cards. - // Shared with the sprite pass (fireHorizonOccluder) so cards and sprites - // never disagree about which hemisphere a detection is on. - const occluder = fireHorizonOccluder(); - const beyondHorizon = (position) => ( - occluder ? occluder.isPointVisible(position) !== true : false - ); - _fireByCardId.clear(); - - if (_selectedFire) { - const selectedCard = buildSelectedFireCard(_selectedFire, now); - _fireByCardId.set(selectedCard.id, _selectedFire); - entries.push(selectedCard); - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - firePosition(_selectedFire), - scratchWindowCoord - ); - // Seed the accepted list so ambient cards keep clear of the detail card. - // A selected fire behind the limb is not painted, so it must not reserve - // screen space either. - if (screen && !beyondHorizon(fireCullPosition(_selectedFire))) { - accepted.push({ x: screen.x, y: screen.y }); - } - } - - const width = scene.canvas.clientWidth; - const height = scene.canvas.clientHeight; - let ambientCount = 0; - - for (const candidate of _labelCandidates) { - if (ambientCount >= MAX_AMBIENT_LABELS) break; - if (candidate.fire && candidate.fire === _selectedFire) continue; - // Spend the bounded cohort only on the visible hemisphere. At global - // LOD, far-side cells can still project on-canvas; accepting those first - // starves front-side cards before the host applies its authoritative cull. - if (beyondHorizon(candidate.cullPosition || candidate.position)) continue; - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - candidate.position, - scratchWindowCoord - ); - if (!screen) continue; // projection failed (e.g. behind camera) — drop silently - if (screen.x < -LABEL_VIEW_MARGIN_PX || screen.x > width + LABEL_VIEW_MARGIN_PX - || screen.y < -LABEL_VIEW_MARGIN_PX || screen.y > height + LABEL_VIEW_MARGIN_PX) continue; - if (!screenSeparated(accepted, screen)) continue; - accepted.push({ x: screen.x, y: screen.y }); - ambientCount += 1; - const card = candidate.fire - ? buildFireCard(candidate, now) - : buildCellCard(candidate, now); - if (candidate.fire) _fireByCardId.set(card.id, candidate.fire); - entries.push(card); - } - - overlayHost.setEntries( - FIRMS_OVERLAY_SOURCE_ID, - entries.map((entry) => { - const card = applyFirmsOverlayPolicy(entry, _labelLodDistance); - if (!card.interactive) return card; - return { - ...card, - accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`, - activate: () => { - const fire = _fireByCardId.get(card.id); - if (!fire) return false; - selectAndFocusFire(fire); - return true; - }, - }; - }), - { - cohortLimit: FIRMS_AMBIENT_COHORT_LIMIT, - collisionCapacity: FIRMS_AMBIENT_COHORT_LIMIT, - moving: false, - }, - ); - } - - /** - * Install the LEFT_CLICK handler for fire selection (enable-time only, - * mirroring the flight layers). Clicking a fire sprite selects it and - * surfaces it in the shared context store; clicking empty space clears - * the selection. Picks owned by sibling layers are left alone. - */ - function installClickHandler() { - if (_clickHandler || !_viewer) return; - _clickHandler = screenSpaceEventHandlerFactory(_viewer); - _clickHandler.setInputAction((click) => { - const picked = _viewer.scene.pick(click.position); - const fire = pickedFire(picked); - if (fire) { - selectAndFocusFire(fire); - return; - } - // A pick that belongs to a sibling layer (e.g. an aircraft) is not - // "empty space" — leave the selection alone and let that layer handle it. - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer(id, pickedId)) return; - } - const cardHit = overlayHost.hitTest?.(click.position?.x, click.position?.y, { - sourceId: FIRMS_OVERLAY_SOURCE_ID, - }); - if (cardHit) { - const carded = _fireByCardId.get(cardHit.entryId); - if (carded) selectAndFocusFire(carded); - return; - } - clearFireSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - - function removeClickHandler() { - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - } - - /** Select one stable detection and request one UI-owned camera transfer. */ - function selectAndFocusFire(fire) { - selectFire(fire); - requestWorldFocus({ - kind: 'fire', - id: fireDetectionKey(fire), - label: 'FIRE', - position: firePosition(fire), - }); - } - - /** - * Resolve a scene pick to one of this layer's fire records, or null. - * BillboardCollection picks surface our id either on picked.primitive.id - * or directly on picked.id depending on the CesiumJS pick path. - * @param {*} picked - Result of scene.pick(). - * @returns {?Object} Fire record. - */ - function pickedFire(picked) { - if (!picked) return null; - const primitiveId = picked.primitive?.id; - if (typeof primitiveId === 'string' && _pickIndexById.has(primitiveId)) { - return _pickIndexById.get(primitiveId); - } - if (typeof picked.id === 'string' && _pickIndexById.has(picked.id)) { - return _pickIndexById.get(picked.id); - } - return null; - } - - /** - * Select a fire: show its detail label and mark it selected in the shared - * context store so voice "what's selected" resolves to it. - * @param {Object} fire - Detection record. - */ - function selectFire(fire) { - _selectedFire = fire; - try { - registerFireContext(fire); - _contextIds.add(fireDetectionKey(fire)); - selectEntityContext(fire.contextEntity); - } catch { - // context store unavailable — the selection label still works - } - rebuildAmbientLabels(); - } - - function clearFireSelection() { - if (!_selectedFire) return; - _selectedFire = null; - clearSelectedEntityContextForLayer(id); - rebuildAmbientLabels(); - } - - /** - * Ambient labels are placed via screen-space projection, so they need a - * re-declutter after the camera settles — on moveEnd, never per frame. - */ - function installMoveEndWatcher() { - if (_moveEndRemover || !_viewer) return; - _moveEndRemover = _viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - // Settled-camera exactness for the horizon pass (the throttled preRender - // watcher can last have run up to LOD_CHECK_MS before the camera - // stopped). Runs ahead of the `_loading` gate on purpose: the sprites on - // screen during a refresh are the ones that need culling. - refreshHorizonCulling(); - if (_loading) return; - rebuildAmbientLabels(); - }); - } - - function removeMoveEndWatcher() { - if (_moveEndRemover) { - _moveEndRemover(); - _moveEndRemover = null; - } - } - - /** - * Register the top detections of the current render set in the shared - * context store (voice "what's burning here") and drop stale entries - * from the previous rebuild. - * @param {Array} fires - FRP-ranked detections to register. - */ - function refreshContextRegistrations(fires) { - let store = null; - try { - store = getContextStore(); - } catch { - return; - } - - const nextIds = new Set(); - for (const fire of fires) { - nextIds.add(registerFireContext(fire)); - } - // The click-selected fire must stay queryable ("what's selected") even - // when it falls outside the top-N context slice. - if (_selectedFire) { - nextIds.add(registerFireContext(_selectedFire)); - } - - for (const staleId of _contextIds) { - if (!nextIds.has(staleId)) store.entities.delete(staleId); - } - _contextIds = nextIds; - } - - /** - * Register one fire in the shared context store (idempotent) and return - * its record id. - * @param {Object} fire - Detection record. - * @returns {string} Context record id. - */ - function registerFireContext(fire) { - const recordId = fireDetectionKey(fire); - if (!fire.contextEntity) { - fire.contextEntity = { - show: true, - }; - } - // Refreshed every registration: the anchor re-grounds onto the DEM when - // its floor cell warms, and voice targeting must project the same point - // the sprite renders at. - fire.contextEntity.__localBaseCartesian = firePosition(fire); - registerEntityContext(fire.contextEntity, { - id: recordId, - layerId: id, - layerName: name, - source: 'NASA FIRMS', - dataSource: _dataSource, - label: `Fire · FRP ${formatFrp(fire.frp)} MW`, - latitude: fire.lat, - longitude: fire.lon, - properties: { - frp: fire.frp, - confidence: confidenceBucket(fire.confidence), - age: fire.acqMs > 0 ? formatAge(Date.now() - fire.acqMs) : 'unknown', - sensor: fire.sensor || 'unknown', - }, - }); - return recordId; - } - - function clearContextRegistrations() { - if (!_contextIds.size) return; - try { - const store = getContextStore(); - for (const recordId of _contextIds) store.entities.delete(recordId); - } catch { - // store unavailable — nothing to clean - } - _contextIds = new Set(); - } - - /** - * Top-FRP detections within the given bounds (or globally when bounds is - * null). Walks the pre-sorted FRP index so it stays a single cheap pass. - * @param {?Object} bounds - Padded view bounds in degrees, or null. - * @param {number} limit - Max detections returned. - * @returns {Array} - */ - function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) { - if (!bounds) return _firesByFrp.slice(0, limit); - const top = []; - for (const fire of _firesByFrp) { - if (!boundsContainPoint(bounds, fire.lat, fire.lon)) continue; - top.push(fire); - if (top.length >= limit) break; - } - return top; - } - - function ensureDetectionCollections() { - if (!_viewer || _billboards) return; - _billboards = new Cesium.BillboardCollection({ - scene: _viewer.scene, - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - _billboards.show = _enabled; - _viewer.scene.primitives.add(_billboards); - registerSpriteCollection('firms', _billboards); - restoreSpriteOrder(_viewer); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, _enabled); - } - - function removeDetectionCollections(viewer) { - const scene = viewer?.scene || _viewer?.scene; - if (scene && !scene.isDestroyed?.()) { - try { - if (_billboards) scene.primitives.remove(_billboards); - } catch { /* already torn down */ } - } - _billboards = null; - overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); - } - - /** - * Shared horizon occluder positioned at the current camera, or null when - * there is no usable camera yet (init order / headless stubs). - * `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium; - * both are probed so a partial stub (either shape) still gets a real - * occluder rather than silently degrading to "nothing is ever occluded". - * @returns {?Cesium.EllipsoidalOccluder} - */ - function fireHorizonOccluder() { - const camera = _viewer?.camera?.positionWC ? _viewer.camera : _viewer?.scene?.camera; - if (!camera?.positionWC) return null; - return horizonOccluder(camera); - } - - /** - * Hide detection sprites that sit beyond the ellipsoid horizon (see - * {@link applyHorizonCull}). Runs on the cadences that already exist in this - * layer — the throttled LOD preRender watcher (camera-moved only), camera - * moveEnd, and each detection rebuild — never as a new per-frame pass. - * No-ops in the `cells` bands, where the collection is empty. - */ - function refreshHorizonCulling() { - if (!_billboards || !_billboards.length) return; - applyHorizonCull(_billboards, fireHorizonOccluder(), _cullPositions); - } - - function cameraHeight() { - return _viewer?.camera?.positionCartographic?.height ?? Number.POSITIVE_INFINITY; - } - - /** Raw LOD index for a camera height (0 = global ... 3 = close). */ - function rawLodIndex(height) { - const index = LOD_LEVELS.findIndex((level) => height >= level.minHeight); - return index === -1 ? LOD_LEVELS.length - 1 : index; - } - - /** - * LOD selection with ±10% hysteresis around band floors: a band switch - * only happens once the camera clears the boundary by the margin, so a - * slow zoom oscillating on an edge cannot trigger a rebuild every check. - * @param {number} height - Camera height in meters. - * @returns {number} LOD index to render. - */ - function selectLodIndex(height) { - const raw = rawLodIndex(height); - if (_currentLodIndex < 0 || raw === _currentLodIndex) return raw; - let index = _currentLodIndex; - // Zooming out: enter a coarser band only after clearing its floor by +10%. - while (index > raw && height >= LOD_LEVELS[index - 1].minHeight * (1 + LOD_HYSTERESIS)) index -= 1; - // Zooming in: leave the current band only after dropping 10% below its floor. - while (index < raw && height <= LOD_LEVELS[index].minHeight * (1 - LOD_HYSTERESIS)) index += 1; - return index; - } - - /** Current camera view rectangle, or null when looking at sky/horizon. */ - function computeViewRect() { - try { - const rect = _viewer?.camera?.computeViewRectangle( - _viewer?.scene?.globe?.ellipsoid, - scratchViewRect - ); - return rect || null; - } catch { - return null; - } - } - - /** - * True when the camera view rectangle drifted far enough from the last - * rendered one that the padding is at risk of being consumed (pan) or - * the footprint changed notably (zoom within a band). - * @param {?Cesium.Rectangle} viewRect - Current view rectangle. - * @returns {boolean} - */ - function viewChangedEnough(viewRect) { - if (!viewRect) return false; // sky/horizon — keep the current render - if (!_lastViewRect) return true; // previous render was global fallback - const lastWidth = Cesium.Rectangle.computeWidth(_lastViewRect); - const lastHeight = Cesium.Rectangle.computeHeight(_lastViewRect); - const width = Cesium.Rectangle.computeWidth(viewRect); - const height = Cesium.Rectangle.computeHeight(viewRect); - if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true; - if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true; - const lastCenter = Cesium.Rectangle.center(_lastViewRect, scratchCenterA); - const center = Cesium.Rectangle.center(viewRect, scratchCenterB); - const latShift = Math.abs(center.latitude - lastCenter.latitude); - let lonShift = Math.abs(center.longitude - lastCenter.longitude); - if (lonShift > Math.PI) lonShift = Cesium.Math.TWO_PI - lonShift; - return latShift > lastHeight * VIEW_PADDING * 0.6 || lonShift > lastWidth * VIEW_PADDING * 0.6; - } - - function installLodWatcher() { - if (_preRenderRemover || !_viewer) return; - // The LOD sweep is throttle-gated inside preRender. When the camera - // settles just inside the throttle window, the final rebuild would wait - // for a frame that idle mode never produces — schedule one. (perf wave 2) - if (!_moveEndSettleRemover) { - _moveEndSettleRemover = _viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - setTimeout(() => governorRequestRender('firms-lod-settle'), LOD_CHECK_MS + 40); - }); - } - _preRenderRemover = _viewer.scene.preRender.addEventListener(() => { - // NOTE: `_loading`/empty-data are deliberately NOT part of this guard. - // A refresh has no timeout, and sprites from the previous payload stay - // on screen throughout it — so the horizon pass below must keep running - // while a fetch is in flight. Only the rebuild is gated on fresh data. - if (!_enabled) return; - const now = performance.now(); - if (now - _lastLodCheck < LOD_CHECK_MS) return; - _lastLodCheck = now; - // Idle camera: two scratch compares and out — no view-rect math, no - // aggregation, no allocation while the user is parked. - const camera = _viewer.camera; - if (_camSnapValid - && Cesium.Cartesian3.equalsEpsilon(camera.positionWC, _camPos, 0, 0.5) - && Cesium.Cartesian3.equalsEpsilon(camera.directionWC, _camDir, 0, 1e-7)) { - return; - } - Cesium.Cartesian3.clone(camera.positionWC, _camPos); - Cesium.Cartesian3.clone(camera.directionWC, _camDir); - _camSnapValid = true; - // The camera moved, so the horizon moved. A rebuild culls its own fresh - // sprites (renderDetections); an early-out — same LOD band and view - // rect, or a refresh in flight — does not, so cull here instead. - // Exactly ONE occlusion walk per tick either way, bounded by the - // idle-gate + LOD_CHECK_MS throttle above: a parked camera costs - // nothing, a moving one pays one ≤maxDetections show-flip walk at - // ~1.5 Hz. - const rebuilt = (_fires.length && !_loading) ? renderCurrentLod(false) : false; - if (!rebuilt) refreshHorizonCulling(); - }); - } - - function removeLodWatcher() { - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (_moveEndSettleRemover) { - _moveEndSettleRemover(); - _moveEndSettleRemover = null; - } - } -} - -/** - * Expand a view rectangle by VIEW_PADDING per side and convert to degree - * bounds with an explicit anti-meridian wrap flag. Returns null for - * near-global views where clipping would be pointless. - * @param {Cesium.Rectangle} rect - Camera view rectangle (radians). - * @returns {?{west: number, south: number, east: number, north: number, wraps: boolean}} - */ -function paddedDegreeBounds(rect) { - const width = Cesium.Rectangle.computeWidth(rect); - const height = Cesium.Rectangle.computeHeight(rect); - if (width >= Math.PI * 1.85) return null; - const padLon = width * VIEW_PADDING; - const padLat = height * VIEW_PADDING; - const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat); - const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat); - let west = rect.west - padLon; - let east = rect.east + padLon; - let wraps; - if (width + padLon * 2 >= Cesium.Math.TWO_PI) { - west = -Math.PI; - east = Math.PI; - wraps = false; - } else { - west = Cesium.Math.negativePiToPi(west); - east = Cesium.Math.negativePiToPi(east); - wraps = west > east; - } - return { - west: Cesium.Math.toDegrees(west), - south: Cesium.Math.toDegrees(south), - east: Cesium.Math.toDegrees(east), - north: Cesium.Math.toDegrees(north), - wraps, - }; -} - -/** Point-in-bounds test in degrees, anti-meridian aware. */ -function boundsContainPoint(bounds, lat, lon) { - if (lat < bounds.south || lat > bounds.north) return false; - if (bounds.wraps) return lon >= bounds.west || lon <= bounds.east; - return lon >= bounds.west && lon <= bounds.east; -} - -/** Cell-rectangle/bounds intersection test in degrees, anti-meridian aware. */ -function cellIntersectsBounds(cell, gridDegrees, bounds) { - if (cell.latCell + gridDegrees < bounds.south || cell.latCell > bounds.north) return false; - const west = cell.lonCell; - const east = cell.lonCell + gridDegrees; - if (bounds.wraps) return east >= bounds.west || west <= bounds.east; - return east >= bounds.west && west <= bounds.east; -} - -function heatScore(cell) { - return cell.intensity + cell.count * 0.8 + cell.night * 0.6 + cell.maxFrp * 0.12; -} - -function heatColor(value, alpha) { - if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); - if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); - return Cesium.Color.YELLOW.withAlpha(alpha); -} - -/** - * Pick a sprite color stop from FRP + confidence, reusing the same - * yellow → orange → red thresholds as the aggregated heat cells. - * @param {Object} fire - Detection record. - * @returns {{name: string, color: Cesium.Color}} - */ -function detectionColorStop(fire) { - const heat = Math.min(1, Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15); - if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; - if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; - return DETECTION_COLOR_STOPS[2]; -} - -/** FRP → core marker pixel size, clamped to 8..28px. */ -function frpPixelSize(frp) { - return Math.max(8, Math.min(28, Math.round(8 + Math.sqrt(Math.max(0, frp)) * 2))); -} - -/** Quantize a core size to a 2px bucket so the sprite cache stays tiny. */ -function sizeBucket(coreSize) { - return Math.max(8, Math.min(28, Math.round(coreSize / 2) * 2)); -} - -/** - * Build (and cache) a radial-glow sprite: hot near-white center fading - * through the stop color to a transparent edge. The glow lives in the - * sprite itself because global bloom defaults OFF. - * @param {{name: string, color: Cesium.Color}} stop - Color stop. - * @param {number} corePx - Bucketed core size in pixels. - * @returns {string} PNG data URL. - */ -function glowSprite(stop, corePx) { - const key = `${stop.name}:${corePx}`; - const cached = glowSpriteCache.get(key); - if (cached) return cached; - - const dimension = corePx * 2; - const canvas = document.createElement('canvas'); - canvas.width = dimension; - canvas.height = dimension; - const context = canvas.getContext('2d'); - if (!context) return ''; - const radius = dimension / 2; - const rgb = [ - Math.round(stop.color.red * 255), - Math.round(stop.color.green * 255), - Math.round(stop.color.blue * 255), - ].join(','); - const gradient = context.createRadialGradient(radius, radius, 0, radius, radius, radius); - gradient.addColorStop(0, 'rgba(255,255,235,0.95)'); - gradient.addColorStop(0.25, `rgba(${rgb},0.9)`); - gradient.addColorStop(0.55, `rgba(${rgb},0.35)`); - gradient.addColorStop(1, `rgba(${rgb},0)`); - context.fillStyle = gradient; - context.fillRect(0, 0, dimension, dimension); - - const dataUrl = canvas.toDataURL('image/png'); - glowSpriteCache.set(key, dataUrl); - return dataUrl; -} - -/** - * Lazily-cached Cartesian3 anchor at the shared ground floor (DEM/mesh cell - * + lift) when the floor is warm, else ellipsoid height 0 (owner field - * finding 2026-07-21: height-0 anchors read as buried under high terrain at - * close/oblique zoom). Warm-cache read only — floors are warmed in batch by - * renderDetections for the close band; everything else (cell-band context - * registrations, detectable objects) just rides whatever is already warm. - * Fires are static and floor cells latch, so each detection re-allocates at - * most once per floor state (cold 0 → warm DEM). Rendering stays depth-test - * free either way (contacts are ALWAYS visible — never below the surface, - * slightly above is fine). - * @param {Object} fire - Detection record. - * @returns {Cesium.Cartesian3} - */ -function firePosition(fire) { - const height = fireAnchorHeight(fire.lat, fire.lon); - if (!fire.position || fire.positionHeight !== height) { - fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height); - fire.positionHeight = height; - } - return fire.position; -} - -/** - * Occlusion-test anchor for one detection. Returns the render position - * outright for anchors comfortably above the ellipsoid (the common case) and - * a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This - * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} - * is what the sprite and the card are drawn at. - * @param {Object} fire - Detection record. - * @returns {Cesium.Cartesian3} - */ -export function fireCullPosition(fire) { - const position = firePosition(fire); - if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; - if (!fire.cullPosition) { - fire.cullPosition = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, CULL_LIFT_M); - } - return fire.cullPosition; -} - -/** - * Occlusion-test anchor for an aggregated heat cell. Cell centers are built at - * height 0 (exactly on the ellipsoid), which the occluder treats as a limb - * boundary case, so they get the same lift as sub-ellipsoid fire anchors. - * @param {number} lon - Cell center longitude in degrees. - * @param {number} lat - Cell center latitude in degrees. - * @returns {Cesium.Cartesian3} - */ -function cellCullPosition(lon, lat) { - return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M); -} - -/** - * Horizon-cull a billboard collection against an ellipsoid occluder. - * - * The Cesium globe is hidden in this app (Google 3D Tiles render the planet), - * so nothing writes far-side depth — and these sprites are additionally - * always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never - * swallowed by terrain it sits on). Without an explicit occluder pass, fires on - * the opposite side of the Earth shine through the planet at tilted mid-altitude - * views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes. - * - * Pure and allocation-free: reads `billboard.position`, writes `billboard.show` - * only when it actually flips (assigning `show` dirties the collection's vertex - * buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without - * a WebGL scene. - * - * @param {?{length: number, get: function(number): (Object|undefined)}} billboards - * Billboard collection (or collection-shaped stub). - * @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder. - * @param {?Array} [cullPositions=null] Index-aligned lifted - * occlusion-test anchors (see {@link fireCullPosition}); falls back to the - * billboard's own render position wherever an entry is absent. - * @returns {number} Count of billboards left visible (0 when nothing to test). - */ -export function applyHorizonCull(billboards, occluder, cullPositions = null) { - if (!billboards || typeof billboards.get !== 'function') return 0; - if (typeof occluder?.isPointVisible !== 'function') return 0; - const total = Number(billboards.length) || 0; - let visibleCount = 0; - for (let i = 0; i < total; i += 1) { - const billboard = billboards.get(i); - if (!billboard) continue; - const point = (cullPositions && cullPositions[i]) || billboard.position; - const visible = occluder.isPointVisible(point) === true; - if (billboard.show !== visible) billboard.show = visible; - if (visible) visibleCount += 1; - } - return visibleCount; -} - -/** - * True when `screen` is at least LABEL_MIN_SEP_PX away from every accepted - * screen position (greedy declutter accept test). - * @param {Array<{x: number, y: number}>} accepted - Accepted label positions. - * @param {Cesium.Cartesian2} screen - Candidate window coordinates. - * @returns {boolean} - */ -function screenSeparated(accepted, screen) { - const minSq = LABEL_MIN_SEP_PX * LABEL_MIN_SEP_PX; - 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; -} - -/** - * Card model for a click-selected fire — the full-detail card, drawn last - * (on top) and never distance-faded by the overlay. - * Exported for unit tests. - * @param {Object} fire - Detection record. - * @param {number} nowMs - Current epoch milliseconds. - * @returns {Object} firmsLabels entry. - */ -export function buildSelectedFireCard(fire, nowMs) { - const meta = [`${confidenceBucket(fire.confidence)} conf`]; - if (fire.acqMs > 0) { - const age = formatAge(nowMs - fire.acqMs); - if (age) meta.push(`${age} ago`); - } - const sat = satelliteShortName(fire.satellite); - meta.push(sat ? `${fire.sensor || 'VIIRS'} ${sat}` : (fire.sensor || 'sensor n/a')); - return { - id: `selected-fire:${fireDetectionKey(fire)}`, - actionable: true, - position: firePosition(fire), - // Host-side horizon test uses this instead of the render anchor. - cullPosition: fireCullPosition(fire), - gapPx: frpPixelSize(fire.frp), - accent: accentForSeverity(detectionColorStop(fire).name), - title: `FIRE · ${formatFrp(fire.frp)} MW`, - details: [ - meta.join(' · '), - formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''), - ], - selected: true, - priority: Number.MAX_SAFE_INTEGER, - }; -} - -/** - * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail - * "high · 14h · N20". Ages are computed against each detection's acquisition - * time — with the live feed everything reads under 24 h (and a stale cache - * reads truthfully old). Exported for unit tests. - * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. - * @param {number} nowMs - Current epoch milliseconds. - * @returns {Object} firmsLabels entry. - */ -export function buildFireCard(candidate, nowMs) { - const fire = candidate.fire; - const meta = [confidenceBucket(fire.confidence)]; - if (fire.acqMs > 0) { - const age = formatAge(nowMs - fire.acqMs); - if (age) meta.push(age); - } - const sat = satelliteShortName(fire.satellite) || fire.sensor; - if (sat) meta.push(sat); - return { - id: `fire:${fireDetectionKey(fire)}`, - actionable: true, - position: candidate.position, - cullPosition: candidate.cullPosition || candidate.position, - gapPx: frpPixelSize(fire.frp), - accent: accentForSeverity(detectionColorStop(fire).name), - title: `▲ ${formatFrp(fire.frp)} MW`, - details: [meta.join(' · ')], - selected: false, - priority: Number(fire.frp) || 0, - }; -} - -/** - * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. - * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate - * @param {number} nowMs - Current epoch milliseconds. - * @returns {Object} firmsLabels entry. - */ -export function buildCellCard(candidate, nowMs) { - const cell = candidate.cell; - const noun = cell.count === 1 ? 'FIRE' : 'FIRES'; - const parts = [`max ${formatFrp(cell.maxFrp)} MW`]; - if (cell.newestAcqMs > 0) { - const age = formatAge(nowMs - cell.newestAcqMs); - if (age) parts.push(`new ${age}`); - } - return { - id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`, - position: candidate.position, - cullPosition: candidate.cullPosition || candidate.position, - gapPx: 10, - accent: candidate.accent || accentForSeverity('yellow'), - title: `${cell.count} ${noun}`, - details: [parts.join(' · ')], - selected: false, - priority: Number(cell.maxFrp) || 0, - }; -} - -/** - * Add the host-owned layout/fade/collision fields to a source-formatted FIRMS - * card. Selected fires share the collision domain so their protected rect - * excludes ambient cards, but bypass both the 18-card cohort and distance fade. - * @param {Object} card Source-formatted card. - * @param {number} fadeDistance Current LOD fade distance in metres. - * @returns {Object} - */ -export function applyFirmsOverlayPolicy(card, fadeDistance) { - const selected = card?.selected === true; - const rawGap = Number(card?.gapPx) || 10; - const gapPx = Math.max(12, rawGap + 8); - return { - ...card, - variant: selected ? 'selected' : 'card', - protected: selected, - collisionGroup: 'ambient-card', - cardStyle: 'tactical', - gapPx, - leaderOffsetPx: Math.max(2, gapPx - 6), - verticalOnly: true, - viewportMargin: 4, - maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance, - distanceFadeStartRatio: 0.7, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - // Aggregate cells omit actionable because they do not identify one fire. - interactive: card?.actionable === true, - }; -} - -/** - * Heat-normalized cell score → severity accent, mirroring heatColor's - * red/orange/yellow thresholds. - * @param {number} normalized - 0..1 normalized heat score. - * @returns {string} "r, g, b" accent string. - */ -function cellAccent(normalized) { - if (normalized > 0.72) return accentForSeverity('red'); - if (normalized > 0.42) return accentForSeverity('orange'); - return accentForSeverity('yellow'); -} - -/** Coordinate line like "30.512°N 75.831°E". */ -function formatLatLon(lat, lon) { - const latPart = `${Math.abs(lat).toFixed(3)}°${lat >= 0 ? 'N' : 'S'}`; - const lonPart = `${Math.abs(lon).toFixed(3)}°${lon >= 0 ? 'E' : 'W'}`; - return `${latPart} ${lonPart}`; -} - -function formatFrp(frp) { - return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1); -} - -/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ -function formatAge(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; - const hours = deltaMs / 3600000; - if (hours < 1) return '<1h'; - if (hours < 48) return `${Math.round(hours)}h`; - return `${Math.round(hours / 24)}d`; -} - -/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */ -function formatAgoMinutes(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now'; - const minutes = Math.floor(deltaMs / 60000); - if (minutes < 1) return '<1m ago'; - if (minutes < 90) return `${minutes}m ago`; - return `${Math.round(minutes / 60)}h ago`; -} - -/** Normalized 0..1 confidence → low/nominal/high display bucket. */ -function confidenceBucket(confidence) { - if (confidence >= 0.75) return 'high'; - if (confidence >= 0.45) return 'nominal'; - return 'low'; + screenSpaceEventHandlerFactory: + options.screenSpaceEventHandlerFactory ?? + ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), + }); } diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 8d4cc1d8c..442af60b2 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -9,11 +9,11 @@ import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; -import fs from 'node:fs'; +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const LAYER_SOURCE = fs.readFileSync( +const LAYER_SOURCE = readLayerSource( path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'), 'utf8', ); @@ -40,7 +40,7 @@ function makeFire(overrides = {}) { */ function harness({ fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null, - withDataSource = false, + withDataSource = false, feed = null, } = {}) { const hadWindow = Object.hasOwn(globalThis, 'window'); const priorWindow = globalThis.window; @@ -58,6 +58,7 @@ function harness({ const published = []; let handler = null; const layer = createFirmsHeatmapLayer({ + ...(feed ? { feed } : {}), id: 'firms-test', name: 'Fires', overlayHost: { @@ -349,3 +350,21 @@ test('a refresh that drops the selected fire emits an eviction the readout can a h.cleanup(); } }); + +test('disable cancels a pending source and a late response cannot repopulate the layer', async () => { + let resolve, signal; + const h = harness({ withDataSource: true, feed: { getSnapshot(options) { + signal = options.signal; return new Promise(done => { resolve = done; }); + } } }); + try { + const before = h.layer.getStats(); + const pending = h.layer.update(); + h.layer.disable(); + assert.equal(signal.aborted, true); + resolve({ fires: [], fetchedAt: Date.now() }); + await pending; + assert.equal(h.layer.getStats().count, before.count); + assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate); + assert.equal(h.layer.getStats().loading, false); + } finally { h.layer.destroy(h.viewer); h.cleanup(); } +}); diff --git a/src/data/fixtures/README.md b/src/data/fixtures/README.md index fe0fabc73..7f22e94b0 100644 --- a/src/data/fixtures/README.md +++ b/src/data/fixtures/README.md @@ -4,6 +4,6 @@ tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin z12 x935 y1686, captured 2026-07-16 from `api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf` - (22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT - decoding offline — it is a point-in-time congestion snapshot, not a bundled - data layer, and is never served to the app. © TomTom. + (22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures` + browser mode — it is a point-in-time congestion snapshot, not a bundled + data layer, and is never loaded by ordinary application startup. © TomTom. diff --git a/src/data/flights.js b/src/data/flights.js index d3c25b855..8ab095fcc 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -1,5338 +1,75 @@ -/** - * @module flights - * @description Real-time flight tracking layer powered by the OpenSky Network API - * (authenticated via Vite dev-server proxy at /api/opensky). - * - * Rendering strategy: all aircraft are drawn as billboards in a single - * BillboardCollection for GPU-efficient batching (handles 5000+ aircraft). - * Each billboard's alignedAxis is set to the WGS84 ellipsoid surface normal - * so that the rotation value (derived from true_track heading) operates in - * the local tangent plane (0 deg = north, 90 deg = east). - * - * Click-to-track: clicking a billboard creates a tracked Entity whose - * position is driven by a dead-reckoning CallbackProperty. Between API - * refreshes (every ~10-30 s) the aircraft advances smoothly using ENU frame - * math. When a new API fix arrives, a 1-second lerp blends the current - * dead-reckoned position into the corrected fix to avoid visual snapping. - * - * Press Escape or click empty space to deselect a tracked flight — the camera - * is released IN PLACE (no flyTo), so the user keeps the context they were - * looking at (owner decision 2026-07-02). - */ -import * as Cesium from 'cesium'; -import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, -} from './spriteOrder.js'; -import { - bindTrackingClickGesture, - isTrackingClickGesture, - isTrackingSelectionGesture, -} from './trackingClickGesture.js'; -import { createTrail } from './trailRenderer.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; -import { - screenProjectedRotation, - stabilizeScreenRotation, - horizonOccluder, - cameraPoseSignature, -} from './iconOrientation.js'; -import { stickyText, stickyNumber } from './aircraftMeta.js'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; -import { modelAnchorWorld, modelVisualAnchor, trailAnchorForModel, trailHeadStart, visualCenterForModel } from './modelVisualAnchor.js'; -import { aircraftIcon, TRACKED_ICON_PX } from './aircraftIcons.js'; -import { - isTr3b, tr3bAircraftClass, tr3bConvertedIds, tr3bIconKind, tr3bTypeLabel, -} from './tr3bRegistry.js'; -import { cockpitContactDotImage } from './cockpitContactDot.js'; -import { nextCockpitNearContacts } from './cockpitAirLod.js'; -import { - applyTrackedCameraFrame, - trackedModelScaleForPixelCap, -} from './trackedCamera.js'; -import { - courseBetweenCartesians, limitCourseStep, turnRateFromFixHistory, arcOffsetEnu, - lerpAngleDeg, speedRamp, courseSlewCapDps, displayedKinematics, staleCoastLimitSeconds, - liftRepeatedGroundFix, synthesizeForwardKinematicsFix, corridorPathLatLon, - COURSE_HOLD_SPEED_MPS, -} from './motionModel.js'; -import { routePlausible } from './routePlausible.js'; -import { isMilitaryIcao, isMilitaryLayerActive, refreshMilitaryRegistryIfStale, onMilitaryLayerActiveChange } from './militaryRegistry.js'; -import { formatFlightLevel } from './detectionDraw.js'; -import { createGroundSnap } from './groundSnap.js'; -import { trackedModelZoomActive } from './trackedModelRegime.js'; -import { geoidSurfaceLastResortM, pickRenderAltitudeM } from './renderAltitude.js'; -import { allocateCorridorCells, cachedGroundFloor, cachedMeshFloor, coarseFloorCoord, corridorFloorCells, displayFloorHeightM, floorAltitudeM, neighborFloorM, stickyFloorCell, warmGroundFloor, resolveGroundFloorCellsBounded, GROUND_FLOOR_LIFT_M } from './groundFloor.js'; -import { sampleMeshFloorCells } from './meshFloorSampler.js'; -import { ensureGeoidReady, geoidHeight } from './geoid.js'; -import { - advanceFocusEvidenceNowMs, - advanceProjectedSpriteFocus, - clearFocusTarget, - focusNowMs, - getFocusDeemphasisParams, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, - setFocusEvidenceNowMs, - setFocusDeemphasisParams, -} from './focusDeemphasis.js'; -import { - applyAircraftBillboardTreatment, - applyAircraftModelTreatment, - getAircraftRecessionParams, - setAircraftRecessionParams, -} from './aircraftRecession.js'; -import { refreshTrackedReadout, trackedLabelModelFromText } from './trackedReadout.js'; -import { - clearTrackedSubjectContext, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; - -/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ -const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); - -// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes -// taxiing and landing") ----------------------------------------------------------- -// Present-but-grounded planes are RENDERED instead of being skipped: same class -// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata -// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles -// the existing billboard in place, never a removal. Ground planes draw no trails -// and are excluded from the ambient enrichment sweep (click-to-enrich still -// works). In 3D mode they take model slots like airborne planes (owner decision -// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap -// (see _modelDisplayPosition). -// -// TINT: full-strength, same pipeline as airborne (white / amber-military / -// cyan-tracked). Day 1 shipped a slate-gray 50%-alpha "muted" ground tint; the -// owner killed it the same day ("just leave them as white, dude … in NYC I can -// barely see them, extremely grayed out"). "On the ground" reads from the ×0.8 -// scale + missing trail; "feed-dropped, coasting" stays the 45%-alpha stale -// fade — a full-alpha ground icon can never be confused with it. -/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ -const GROUND_SCALE = 0.8; - -/** Fleet (untracked) billboard tint: amber for known-military, white otherwise. - * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ -function _fleetBillboardColor(icao24) { - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; -} - -/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ -function _fleetBillboardScale(icao24, klass) { - return (CLASS_SCALE_2D[klass] || 1) * (_flightData.get(icao24)?.onGround ? GROUND_SCALE : 1); -} - -/** Depth-test policy for aircraft billboards. Round 5 (owner directive - * 2026-07-06: "I just want the planes and their lines to ALWAYS be - * visible... evenly applied"): EVERY contact renders depth-test-free at - * every distance — grounded, low, and airborne alike. The photoreal mesh - * writes depth and residual baro/floor error will always leave some sprite - * geometry at or below it; a uniform rule beats the grounded-only / - * low-AGL-only conditions that kept leaving classes of contacts buried - * (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field - * case). Far-side planes are still removed by the fleet tick's horizon - * occluder, which never depended on depth. Kept as a function so the - * callers' restyle sites stay diff-stable. */ -function _groundDepthDistance() { - return Number.POSITIVE_INFINITY; -} - -// --- 3D model rendering (B3) --------------------------------------------------------- -// When enabled, aircraft render as 3D glTF models once the camera is below MODEL_ALT_CEIL_M -// (zoomed in); higher up they stay flat billboards. Eligibility is FRUSTUM-based (on-screen), and -// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each -// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). -const PLANE_MODEL_URL = '/models/airplane.glb'; -const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) -const MODEL_MIN_PX = 24; // floor so distant models stay visible WITHOUT ballooning into a giant - // min-pixel blob (was 54 — far planes at the All radius became white - // star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent -const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff -export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -const MODEL_NATIVE_RADIUS_M = 34.41; -const MODEL_SCALE = 1; // airplane.glb is transform-applied and baked to real-world meters -// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. -const MODEL_MAX = 150; // 'proximity' cap (the planes immediately around you) -const MODEL_MAX_ALL = 350; // 'all' cap (everything out to ~the horizon) -// Per-mode ADD / KEEP radii. The two modes differ by RADIUS, not just cap — otherwise they look -// IDENTICAL whenever fewer than a cap's worth of planes are in range (field bug: Proximity and All -// rendered the same). 'proximity' = a tight ring; 'all' = roughly to the horizon (state-scale). Each -// band has hysteresis (KEEP > ADD) so a plane doesn't release+reload its model when it straddles the -// add edge (the zoom/pan flicker). Beyond ADD a model would force-clamp to minimumPixelSize into a -// giant floating blob (the old 422 km airport-cluster bug), so far planes stay 2D dots; the cap + -// on-screen priority then spend the model slots on planes you can actually see. -const MODEL_PROX_ADD_M = 150000; // proximity: model NEW planes within 150 km -const MODEL_PROX_KEEP_M = 185000; // proximity: KEEP modeled planes out to 185 km - -const COCKPIT_MODEL_MAX = 60; // max concurrent GLBs in cockpit (never raises the map cap) -const MODEL_ALL_ADD_M = 400000; // all: model NEW planes within 400 km (~to the horizon) -const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km -const MODEL_HEADING_OFFSET_DEG = 180; // airplane.glb nose is opposite Cesium heading-0 -// Owner launch-polish direction: models should read as clean light silhouettes, -// with only a weak diffuse contribution from the existing approved textures. -const MODEL_COLOR_BLEND_AMOUNT = 0.94; -// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its -// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as -// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m -// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled -// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB -// by modelScale.test.mjs. -const MODEL_BELLY_OFFSET_NATIVE = 6.719; -/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs - * vertex-baked to real-world METERS in the airplane.glb axis convention, so - * they render at scale 1 with their own measured belly lift and bounding - * radius. Every other class keeps the shared-airplane.glb formula - * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap - * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in - * both branches. The code-side MIX tint dominates every existing asset so - * class silhouettes stay light without modifying third-party GLBs/textures. */ -/* Specs are static per class, and the detection weld now asks for one per - * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet - * pass — so this is memoized, as militaryFlights.js already does. */ -const _specCache = new Map(); -function _modelSpec(klass) { - const cached = _specCache.get(klass); - if (cached) return cached; - const real = CLASS_MODEL_REAL[klass]; - let spec; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; - spec = { - url, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(url), - trailAnchorNative: trailAnchorForModel(url), - }; - } - _specCache.set(klass, spec); - return spec; -} -/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ -const _groundSnap = createGroundSnap(); -const _scratchGroundCarto = new Cesium.Cartographic(); -const _scratchGroundPos = new Cesium.Cartesian3(); -/** @type {Cesium.PrimitiveCollection|null} */ -let _modelCollection = null; -/** @type {Map} icao24 → model */ -const _models = new Map(); -/** @type {Set} icao24 currently loading (async) */ -const _modelPending = new Set(); -/** @type {Map} icao24 → load generation; bumped on release to invalidate - * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ -const _modelGen = new Map(); -/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle - * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ -let _modelEpoch = 0; -/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs - * layer-state restoration, so this initializer — not the codec — is what the app - * actually starts with; it must stay in lockstep with the `models3d` default in - * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would - * light a button the layer has not armed. */ -let _models3dEnabled = true; -let _models3dMode = 'proximity'; // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) -let _lastModelCapWarnMs = 0; // throttle the "more planes in view than the cap" console notice -// The tracked entity's billboard goes transparent (rather than hidden) once the model takes -// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its -// alpha after the GLB is preloaded so the model is ready to render the instant the billboard -// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not -// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. -let _planeModelLoaded = false; -/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ -let _preloadModel = null; -const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); -const _scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); -const _scratchModelMtx = new Cesium.Matrix4(); -const _scratchModelBS = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); // frustum-visibility test -/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ -const _billboardLimbScale = new WeakMap(); - -/** @constant {string} API_URL - Vite proxy endpoint for OpenSky /states/all */ -const API_URL = '/api/opensky'; -const SOURCE_STALE_MS = 120_000; -/** @constant {number} BACKOFF_INTERVAL - Cooldown (ms) after 429 / auth errors */ -const BACKOFF_INTERVAL = 45000; // 45s on rate limit -/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */ -const ERROR_BACKOFF_INTERVAL = 20000; // transient error retry -/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */ -const POSITION_HISTORY_LIMIT = 5; // keep last N positions per aircraft - -// --------------------------------------------------------------------------- -// Module-level state: billboard collection and per-aircraft lookup maps -// --------------------------------------------------------------------------- - -/** @type {Cesium.BillboardCollection|null} */ -let _billboardCollection = null; -/** @type {Map} icao24 -> billboard primitive */ -let _billboards = new Map(); -/** Stable lightweight records reused by the detection overlay between polls. */ -let _detectionObjects = new Map(); -/** @type {Map} */ -let _flightData = new Map(); -/** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */ -const _focusEvidenceIds = new Set(); -/** @type {Map>} */ -let _positionHistory = new Map(); -/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()) */ -let _geoidReady = false; -/** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ -const _geoidNCache = new Map(); -/** @type {number} Current number of visible aircraft */ -let _count = 0; -/** @type {number|null} Epoch ms of last successful API update */ -let _lastUpdate = null; -/** @type {boolean} True while in a backoff/cooldown window */ -let _backoff = false; -/** @type {number} Epoch ms — earliest time the next fetch is allowed */ -let _retryAt = 0; -/** @type {string|null} Human-readable error string shown in stats chip */ -let _lastError = null; -const _activeUpdateControllers = new Set(); - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); -} -/** @type {number|null} HTTP status of the most recent API response */ -let _lastStatus = null; -/** @type {string} Source used by the latest successful snapshot. */ -let _lastSource = 'OpenSky Network'; -/** @type {string} Completeness boundary for the latest successful snapshot. */ -let _lastCoverage = 'worldwide upstream snapshot'; - -function _flightApiUrl(viewer) { - const cartographic = viewer?.camera?.positionCartographic; - if (!cartographic) return API_URL; - const latitude = Cesium.Math.toDegrees(cartographic.latitude); - const longitude = Cesium.Math.toDegrees(cartographic.longitude); - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return API_URL; - const params = new URLSearchParams({ - lat: latitude.toFixed(4), - lon: longitude.toFixed(4), - }); - return `${API_URL}?${params}`; -} - -// --------------------------------------------------------------------------- -// Click-to-track state -// --------------------------------------------------------------------------- - -/** @type {string|null} ICAO24 of the currently tracked aircraft */ -let _trackedIcao = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: 'OpenSky Network', - coverage: null, -}; -/** @type {Cesium.Entity|null} Entity used for camera tracking */ -let _trackedEntity = null; -/** Disposes the single active tracked-camera framing owner. */ -let _trackedCameraFrameStop = null; -/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a - * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's - * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on - * 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the - * model rides in _modelCollection and is driven per-frame, fully decoupled from the camera. */ -let _trackedModel = null; -/** Bumped on untrack/teardown so an in-flight tracked-model load resolves into a no-op. */ -let _trackedModelGen = 0; -let _trackedModelLoading = false; -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */ -let _clickHandler = null; -/** @type {Cesium.Viewer|null} Cached viewer reference */ -let _viewer = null; -/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ -let _cockpitContactMode = false; -/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ -let _cockpitNearContacts = new Set(); -/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ -let _cockpitSubjectId = null; -/** @type {((event: CustomEvent) => void)|null} */ -let _cockpitModeListener = null; - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - -function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb?.position || !info) return false; - if (_trackedEntity) _trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'flights', - id: icao24, - // Canonical display chain (callsign → registration → hex). Publishing a - // bare `callsign || icao24` here resurrected the pre-enrichment behavior: - // a callsign-less contact reached Context as its raw hex even once adsbdb - // had supplied a registration. Identity below stays `icao24`. - label: _contactLabel(icao24, info), - position: Cesium.Cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; -} - -/** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ -function _contextSubjectMetadata(icao24) { - const described = _describeFlight(icao24); - if (!described) return null; - const altFt = Math.round((described.altitudeM || 0) * 3.28084); - const route = described.route && _routeIsPlausible(icao24, described.route) - ? `${described.route.origin.code} → ${described.route.destination.code}` - : null; - return { - id: icao24, - layerId: 'flights', - layerName: 'Live Flights', - source: 'OpenSky Network', - label: _contactLabel(icao24, _flightData.get(icao24)), - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: _contactLabel(icao24, _flightData.get(icao24)), - operator: described.airline || '', - callsign: described.callsign || '', - registration: described.registration || '', - type: described.typeName || described.typeCode || '', - altitude: described.onGround ? 'on ground' : `${altFt.toLocaleString('en-US')} ft`, - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) ? `${Math.round(described.track)}°` : '', - route: route || '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; -} - -function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; -} - -const COCKPIT_CONTACT_SIZE_PX = 6; -const COCKPIT_CIVILIAN_COLOR = Cesium.Color.fromCssColorString('#DCEEFF'); -const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( - 1000, 3.0, 8000000, 0.5, -); - -function _normalBillboardScaleByDistance() { - // Preserve the established close-range 3× scale. Any smaller owner-visible - // default belongs in a separate evidence-backed proposal. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); -} - -function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); -} - -/** Sprite kind for one contact's billboard. Identity for every aircraft except - * the ones the operator converted into a TR-3B (Easter egg), which draw the - * black-triangle glyph — its thermal-reactive variant while an IR style owns - * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a - * conversion survive the poll reconciler and the two-tier raster swap. */ -const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: _irBoost }); - -/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ -function _applyFleetBillboardPresentation(icao24, bb) { - if (!bb) return; - const limbScale = _billboardLimbScale.get(bb) ?? 1; - const isCockpitContact = _cockpitContactMode && icao24 !== _trackedIcao; - const isCockpitNear = isCockpitContact && _cockpitNearContacts.has(icao24); - if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; - bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = (isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR).withAlpha(freshnessAlpha); - bb.rotation = 0; - return; - } - - const meta = _flightData.get(icao24); - bb.image = aircraftIcon(_iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined); - bb.width = icao24 === _trackedIcao ? 24 : 20; - bb.height = icao24 === _trackedIcao ? 24 : 20; - bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1); -} - -/** - * Refresh the Cockpit AIR near/far band without consulting model state. - * Near contacts keep their 2D aircraft silhouette when 3D is off, loading, or - * capped; only a ready admitted model may take that silhouette over later. - */ -function _refreshCockpitNearContacts() { - if (!_cockpitContactMode || !_viewer?.camera?.positionWC) { - if (_cockpitNearContacts.size) _cockpitNearContacts = new Set(); - return; - } - const previous = _cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - Cesium.Cartesian3.distanceSquared(_viewer.camera.positionWC, bb.position), - ]); - } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - _modelAddDistM(), - _modelKeepDistM(), - ); - _cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of _billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - _applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) _lastCamPoseSig = ''; -} - -/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ -function _setCockpitContactMode(active) { - const next = active === true; - if (_cockpitContactMode === next) return; - _cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else _cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never destroy on entry: tearing down hundreds of live glTF instances - // synchronously blocked Chrome's renderer into Page Unresponsive. - if (_modelCollection) _modelCollection.show = true; - _trail?.setVisible(!next); - if (_trailHeadEntity) _trailHeadEntity.show = !next; - for (const [icao24, bb] of _billboards) _applyFleetBillboardPresentation(icao24, bb); - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; -} - -function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - _cockpitSubjectId = active - ? String(detail?.subjectId || '').trim().toLowerCase() || null - : null; - _setCockpitContactMode(active); -} - -// --------------------------------------------------------------------------- -// Track-history trail state (PRD WS-F F1/F4): a fading polyline behind the -// tracked aircraft. The accumulation array is intentionally SEPARATE from -// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) -// so the visible trail can grow to TRAIL_MAX_POINTS fixes. -// --------------------------------------------------------------------------- - -/** @constant {string} Civilian trail hue (PRD F4, pinned). */ -const TRAIL_COLOR = '#00d4ff'; -/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ -const TRAIL_MAX_POINTS = 400; -/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ -let _trail = null; -/** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE - * dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays - * glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */ -let _trailHeadEntity = null; -/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ -let _trailHeadSeq = 0; -/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ -let _trailPositions = []; -/** @type {number} Monotonic token — invalidates in-flight backfill responses */ -let _trailBackfillToken = 0; - -// --------------------------------------------------------------------------- -// Render-behind smoothing (PRD WS-C C2 — approved product decision): -// the fleet renders at now - RENDER_DELAY_SEC so positions interpolate -// BETWEEN two known fixes instead of extrapolating ahead and snapping back -// when the next poll lands. All consumers (labels, HUD, detection, -// frame_overhead) share this delayed clock; removing the delay reintroduces -// the back/forward oscillation and is a regression. -// --------------------------------------------------------------------------- - -/** @constant {number} Display latency in seconds (= one poll interval). */ -const RENDER_DELAY_SEC = 30; -/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */ -const MISSING_POLL_LIMIT = 3; -// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes -// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS -// the actual landing, so a landed plane's last airborne-classified fixes show -// it low + slow on the runway; when such a plane then drops out of the poll, -// it has landed (the feed reclassified it to ground traffic we filter out), -// not hit a transient gap — evict after ONE missed poll instead of the full -// grace. Thresholds: below ~150 m baro (MSL, so this only fires near -// sea-level-ish fields — deliberately conservative; a high-elevation airport -// ghost just falls back to the normal grace) AND below ~45 kts ground speed -// (≈23 m/s — rollout/taxi; nothing in normal FLIGHT is this slow, so cruise -// planes always keep the full grace that absorbs real feed gaps). -/** @constant {number} Max baro altitude (m, MSL) for the landed fast cull. */ -const LANDED_ALT_MAX_M = 150; -/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ -const LANDED_SPEED_MAX_MPS = 23; -/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ -const LANDED_MISSING_POLL_LIMIT = 1; -// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only -// contacts rendering below the alt ceiling are ever clamped/warmed (terrain -// tops out well under it outside the extreme Himalaya; cruise traffic can't -// be below ground and costs zero lookups). The radius bounds the FLEET clamp -// to viewer-visible traffic — clamping thousands of global contacts would -// need unbounded terrain resolution (the tracked contact clamps regardless). -/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ -const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; -/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */ -const GROUND_FLOOR_CLAMP_RADIUS_KM = 150; -/** @type {Map} icao24 -> consecutive missed polls */ -let _missingPolls = new Map(); - -/** - * Cheap equirectangular distance (km) — plenty accurate for the ~150 km - * ground-floor clamp gate; runs once per contact per poll, so no trig-heavy - * haversine needed. - * @param {number} lat1 @param {number} lon1 @param {number} lat2 @param {number} lon2 - * @returns {number} Approximate great-circle distance in km. - */ -function _approxDistanceKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = (lon2 - lon1) * 111.32 * Math.cos(((lat1 + lat2) / 2) * Math.PI / 180); - return Math.hypot(dLat, dLon); -} - -/** - * True when the aircraft's latest metadata reads "on or about the runway" - * (low + slow) — see the landed fast-cull rationale above. Both gates must - * hold, so a plane missing either datum keeps the normal grace. - * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {boolean} - */ -function _likelyLanded(icao24) { - const info = _flightData.get(icao24); - if (!info) return false; - // Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes - // never heal"): the fast cull only applies to contacts that were AIRBORNE - // this session — its original target, the post-LANDING ghost. OpenSky's - // ground coverage flaps constantly, so fast-culling every grounded contact - // put parked planes in an evict/re-enter churn: each re-entry was a - // brand-new contact with cold floor state (geoid-height first poll), so - // the apron looked half-empty AND perpetually sunken. First-seen-grounded - // contacts now ride the normal MISSING_POLL_LIMIT grace and keep their - // identity (and warmed floors) across feed flaps. - if (info.wasAirborne !== true) return false; - // Was airborne, then grounded, then VANISHED from the poll — landed ghost. - if (info.onGround) return true; - return Number.isFinite(info.altitude) && info.altitude < LANDED_ALT_MAX_M - && Number.isFinite(info.velocity) && info.velocity < LANDED_SPEED_MAX_MPS; -} - -// --------------------------------------------------------------------------- -// Icon orientation (2026-06-10 playtest fix): rotation is computed by -// projecting each aircraft's course vector into WINDOW coordinates -// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera -// pitch/heading, including tracked-entity orbit mode. Rotation passes run on -// the fleet tick only when the camera pose changed (or 1s drift catch-up). -// The same tick horizon-culls billboards: with the Cesium globe hidden there -// is no far-side depth, so planes otherwise show through the planet. -// --------------------------------------------------------------------------- - -/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ -const FLEET_DR_INTERVAL_MS = 80; -/** @constant {number} Max ms between rotation passes while the camera is idle. */ -const ROTATION_REFRESH_MS = 1000; -/** @type {number} Epoch ms of the last fleet dead-reckoning pass */ -let _lastFleetTickMs = 0; -/** @type {string} Camera pose signature at the last rotation pass */ -let _lastCamPoseSig = ''; -/** @type {number} Epoch ms of the last full rotation pass */ -let _lastRotPassMs = 0; -/** @type {number} Last computed rotation for the tracked entity (radians) */ -let _lastTrackedRotation = 0; -/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ -let _preRenderRemove = null; -let _trackedModelPreUpdateRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ -let _moveEndRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; -/** @type {(() => void)|null} militaryRegistry active-transition unsubscribe (M2 handoff sweep) */ -let _milActiveChangeUnsub = null; - -// --------------------------------------------------------------------------- -// Scratch (reusable) variables — avoid per-frame heap allocation -// --------------------------------------------------------------------------- - -const _scratchOffset = new Cesium.Cartesian3(); -const _scratchCarto = new Cesium.Cartographic(); -const _scratchEnu = new Cesium.Matrix4(); -const _scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; -const _scratchRenderTime = new Cesium.JulianDate(); -const _scratchFleetPos = new Cesium.Cartesian3(); -const _scratchDrRaw = new Cesium.Cartesian3(); -const _scratchWarmupTime = new Cesium.JulianDate(); -const _trackedPosHolder = new Cesium.Cartesian3(); - -// --------------------------------------------------------------------------- -// Per-frame cache for the tracked entity's dead-reckoned position. -// The position, alignedAxis, and rotation CallbackProperties all fire each -// render frame; caching avoids running _deadReckon three times. -// --------------------------------------------------------------------------- - -/** @type {Cesium.Cartesian3|null} */ -let _cachedDRPosition = null; -/** @type {number} Frame number for which _cachedDRPosition is valid */ -let _cachedDRFrame = -1; - -/** Course (deg) of the position `_deadReckon` most recently returned — set on - * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same - * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ -let _drCourseDeg = null; -/** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion - * `_deadReckon` just returned, and whether that motion is too slow for ANY - * course source to be trusted (hover/GPS drift — consumers HOLD their - * previous display course instead of chasing noise). */ -let _drSpeedMps = null; -let _drCourseHold = false; -/** Sibling scratch: whether the position `_deadReckon` just returned came from - * EXTRAPOLATION (coasting past the newest fix, or the pre-history warm-up) - * rather than interpolation between two known fixes. The display-floor - * corridor needs it — an interpolating contact is walking TOWARD its newest - * fix, a coasting one is walking AWAY from it along its course, and warming - * the wrong end leaves a coaster permanently ahead of its floor data. */ -let _drExtrapolating = false; -/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ -let _cachedDRCourse = null; -/** Frame-cached siblings of _cachedDRCourse (same discipline). */ -let _cachedDRSpeedMps = null; -let _cachedDRHold = false; -/** Wall-clock of the tracked course limiter's last advance (dt source only — - * the course VALUE lives in the shared per-icao _displayCourse map below). */ -let _trackedCourseMs = 0; -/** Per-aircraft smoothed display course — the SINGLE source of truth for the - * nose direction an aircraft displays. The fleet pass reads/writes it at - * tick cadence for untracked planes; _trackedDisplayCourse reads/writes the - * SAME entry per frame for the tracked plane (the fleet pass skips the - * tracked icao, so exactly one writer owns an entry at a time). Sharing the - * entry — including its slew state — is what makes the tracked↔fleet - * handoff seamless (2026-07-03 field fix: separate states froze the fleet - * entry while tracked, so clicking / un-clicking a 65 kt helicopter FLIPPED - * its nose — "looks like it's going in reverse"). */ -const _displayCourse = new Map(); -/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary - * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ -const COURSE_MAX_DPS = 60; -/** Never spend a long render stall's full elapsed time in one visible course step. */ -const COURSE_SLEW_DT_MAX_SEC = 0.25; - -// --------------------------------------------------------------------------- -// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 -// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart -// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally -// caches per-key on disk forever, negative results included, so repeat -// sessions never re-hit adsbdb). Each key is requested at most once per -// session. Priority jobs (tracked plane, model-eligible planes) jump the -// queue; the ambient fleet sweep (below) fills the back at poll cadence. -// --------------------------------------------------------------------------- -const ENRICH_MAX_INFLIGHT = 4; -/** Min ms between request dispatches — the drip that bounds the fan-out to ≤5/s. */ -const ENRICH_DISPATCH_GAP_MS = 200; -let _enrichActive = 0; -let _enrichLastDispatchMs = 0; -/** @type {ReturnType|null} pending drip wake-up */ -let _enrichDripTimer = null; -const _enrichQueue = []; -const _enrichSeen = new Set(); - -function _enqueueEnrich(key, url, onData, priority = false) { - if (_enrichSeen.has(key)) return; - _enrichSeen.add(key); - const job = { url, onData }; - // Priority (tracked / model-eligible) goes to the FRONT so a deep ambient - // backlog can never delay the plane the user just clicked or zoomed into. - if (priority) _enrichQueue.unshift(job); else _enrichQueue.push(job); - _drainEnrich(); -} - -function _drainEnrich() { - while (_enrichActive < ENRICH_MAX_INFLIGHT && _enrichQueue.length) { - // Drip: at most one dispatch per ENRICH_DISPATCH_GAP_MS. When the gap - // hasn't elapsed yet, park a single wake-up timer and stop — completions - // and enqueues in the meantime re-enter here harmlessly. - const wait = ENRICH_DISPATCH_GAP_MS - (Date.now() - _enrichLastDispatchMs); - if (wait > 0) { - if (!_enrichDripTimer) { - _enrichDripTimer = setTimeout(() => { _enrichDripTimer = null; _drainEnrich(); }, wait); - } - return; - } - _enrichLastDispatchMs = Date.now(); - const job = _enrichQueue.shift(); - _enrichActive += 1; - fetch(job.url) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { if (data && data.found) job.onData(data); }) - .catch(() => { /* enrichment never surfaces errors */ }) - .finally(() => { _enrichActive -= 1; _drainEnrich(); }); - } -} - -function _requestTypeEnrichment(icao24, priority = false) { - if (!/^[0-9a-f]{6}$/i.test(icao24)) return; - _enqueueEnrich(`t:${icao24}`, `/api/adsbdb/type/${icao24.toLowerCase()}`, (data) => { - const meta = _flightData.get(icao24); - if (!meta) return; // evicted while the lookup was in flight - meta.typeCode = data.typeCode || meta.typeCode; - meta.typeName = data.typeName || meta.typeName; - meta.registration = data.registration || meta.registration; - if (meta.typeCode) { - const klass = classifyAircraft({ typeCode: meta.typeCode, category: meta.category }); - if (klass !== meta.klass) { - meta.klass = klass; - const bb = _billboards.get(icao24); - if (bb) _applyFleetBillboardPresentation(icao24, bb); - // Hangar fleet: the class's GLB/scale may have changed — resync the - // live model, any in-flight load, and the tracked standalone model. - _syncModelToClass(icao24); - } - } - if (icao24 === _trackedIcao && _trackedEntity) _updateTrackedLabelModel(icao24); - }, priority); -} - -function _requestRouteEnrichment(icao24) { - const cs = String(_flightData.get(icao24)?.callsign || '').trim().toUpperCase(); - if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve - _enqueueEnrich(`r:${cs}`, `/api/adsbdb/route/${encodeURIComponent(cs)}`, (data) => { - const meta = _flightData.get(icao24); - if (!meta) return; - meta.airline = data.airline || meta.airline; - if (data.origin && data.destination) meta.route = { origin: data.origin, destination: data.destination }; - if (icao24 === _trackedIcao && _trackedEntity) _updateTrackedLabelModel(icao24); - }, true); // route lookups only fire for the TRACKED plane — front of the queue -} - -// --------------------------------------------------------------------------- -// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live -// category field is 0/"no info" for ~94% of planes, so ambient classification -// defaulted nearly the whole fleet to the airliner silhouette). Each poll, -// ON-SCREEN planes — same horizon-occluder + frustum tests the fleet tick's -// model-eligibility pass uses; no new per-plane raycast — that haven't been -// requested this session are enqueued NEAREST-TO-CAMERA FIRST, bounded by: -// - the shared queue's 4-concurrent / 200 ms-drip dispatch (above), -// - ≤ ENRICH_AMBIENT_PER_SWEEP new enqueues per poll (= one poll interval -// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and -// - a ROLLING token-bucket budget (below) bounding the sustained ambient -// request rate (repeat sessions resolve instantly from the proxy's -// permanent disk cache). -// Fail-silent by contract: the sweep never throws into the poll loop, never -// blocks rendering, and never touches tracking state. When a type answer -// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale -// in place (bb.scale composes multiplicatively with scaleByDistance). -// --------------------------------------------------------------------------- -// Rolling ambient budget (2026-07-03 field fix). The old ONE-SHOT session cap -// (300, refilled only in init) burned out in the first two polls of a busy -// region and never recovered — an hours-long session showed airliner -// monoculture in every NEW region until planes were clicked (the tracked path -// is uncapped). Token bucket instead: starts full at the ceiling, refills -// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped -// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains -// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with -// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL -// politeness bound on adsbdb; the 300 ceiling preserves the old first-look -// burst so a fresh region still classifies quickly. -/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */ -const ENRICH_AMBIENT_BUDGET_CEIL = 300; -/** Tokens added back per refill window. */ -const ENRICH_AMBIENT_REFILL_TOKENS = 150; -/** Refill window length (ms). */ -const ENRICH_AMBIENT_REFILL_WINDOW_MS = 5 * 60 * 1000; -/** Max new ambient enqueues per poll sweep (≈ rate × poll interval). */ -const ENRICH_AMBIENT_PER_SWEEP = 150; -let _enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL; -/** Epoch ms the bucket last accounted a refill window from (0 = unset). */ -let _enrichAmbientRefillAnchorMs = 0; - -/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the - * bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens, - * windowMs} — they cannot wait out a real 5-minute window. Read lazily each - * refill so a pre-boot override (or a mid-run windowMs swap) applies. - * Production never sets this; the constants above are the defaults. */ -function _ambientBudgetKnobs() { - const o = (typeof window !== 'undefined' && window.__GEV_ENRICH_AMBIENT_QA) || null; - return { - ceil: Number.isFinite(o?.ceil) && o.ceil > 0 ? o.ceil : ENRICH_AMBIENT_BUDGET_CEIL, - refillTokens: Number.isFinite(o?.refillTokens) && o.refillTokens > 0 ? o.refillTokens : ENRICH_AMBIENT_REFILL_TOKENS, - windowMs: Number.isFinite(o?.windowMs) && o.windowMs > 0 ? o.windowMs : ENRICH_AMBIENT_REFILL_WINDOW_MS, - }; -} - -/** Advance the token bucket: add refillTokens per FULLY elapsed window since - * the anchor, clamp at the ceiling, and move the anchor forward by the whole - * windows consumed (while the bucket sits full this still advances, so idle - * time never banks more than one bucket's worth of burst). */ -function _refillAmbientBudget(nowMs) { - const { ceil, refillTokens, windowMs } = _ambientBudgetKnobs(); - if (!_enrichAmbientRefillAnchorMs) { _enrichAmbientRefillAnchorMs = nowMs; return; } - const windows = Math.floor((nowMs - _enrichAmbientRefillAnchorMs) / windowMs); - if (windows <= 0) return; - _enrichAmbientBudget = Math.min(ceil, _enrichAmbientBudget + windows * refillTokens); - _enrichAmbientRefillAnchorMs += windows * windowMs; -} - -function _sweepAmbientEnrichment() { - _refillAmbientBudget(Date.now()); - if (_enrichAmbientBudget <= 0 || !_viewer || !_billboardCollection || !_billboardCollection.show) return; - try { - const camera = _viewer.camera; - const camPos = camera.positionWC; - const occluder = horizonOccluder(camera); - const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC); - const cand = []; - for (const [icao24, bb] of _billboards) { - if (_enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session - if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only - if (_flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) - if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb - Cesium.Cartesian3.clone(bb.position, _scratchModelBS.center); - if (cull.computeVisibility(_scratchModelBS) === Cesium.Intersect.OUTSIDE) continue; // off-screen - cand.push([icao24, Cesium.Cartesian3.distanceSquared(camPos, bb.position)]); - } - cand.sort((a, b) => a[1] - b[1]); // nearest first — what the user is looking at resolves first - const n = Math.min(cand.length, ENRICH_AMBIENT_PER_SWEEP, _enrichAmbientBudget); - for (let i = 0; i < n; i++) { - _enrichAmbientBudget -= 1; - _requestTypeEnrichment(cand[i][0]); // non-priority: fills the back of the queue - } - } catch { /* ambient enrichment is best-effort — never disturb the poll loop */ } -} - -// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal -// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS -// jitter minted fresh keys every poll → thousands of upstream points per -// minute → Re:Earth proxy failures → geoid-fallback POISON cached at -// sea-level heights → sunken sprites/trails and rejected mesh samples. The -// coarse ~111 m floor cells (viewer-proximate, collected in the poll loop) -// are the only DEM warm the layer needs. - -/** - * Round 6: lifts STALE grounded contacts onto floors that warmed after - * their last feed fix. A parked plane whose transponder went quiet keeps - * coasting on its final meta — if that fix predated the floor warm, it sat - * frozen underground forever. Runs once per poll over the (bounded) flight - * map; touches only grounded, feed-absent, demonstrably-below-floor - * contacts, and patches the stored fix + billboard in place (zero-velocity - * DR renders the patched fix verbatim). - * @param {Set} currentIcaos - Contacts present in THIS poll (already - * floored by the live path — skipped here). - */ -function _refloorStaleGroundedContacts(currentIcaos) { - for (const [icao24, info] of _flightData) { - if (!info?.onGround || currentIcaos.has(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; - const floor = cachedGroundFloor(info.rawLat, info.rawLon); - if (!Number.isFinite(floor)) continue; - const lifted = floor + GROUND_FLOOR_LIFT_M; - if (Number.isFinite(info.renderAltitudeM) && info.renderAltitudeM >= floor - 1) continue; - info.renderAltitudeM = lifted; - info.cullPosition = null; // above the ellipsoid now (or floors say otherwise next poll) - const position = Cesium.Cartesian3.fromDegrees(info.rawLon, info.rawLat, lifted); - const history = _positionHistory.get(icao24); - const newest = history?.[history.length - 1]; - if (newest) newest.position = Cesium.Cartesian3.clone(position, newest.position); - const bb = _billboards.get(icao24); - if (bb) bb.position = position; - } -} - -// --------------------------------------------------------------------------- -// Tracked-display reconciliation. _deadReckon gives the RAW position from real -// fixes; at the warm-up→interpolation handoff (and on feed glitches / backfill -// splices) that raw value can step discontinuously. We absorb a step into a -// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked -// icon, camera, and trail head never visibly jump — with ZERO steady-state lag -// (the correction stays ~0 whenever motion is already continuous). -// --------------------------------------------------------------------------- - -const DR_CORRECTION_MS = 900; -const _drCorrection = new Cesium.Cartesian3(0, 0, 0); -let _drCorrectionStartMs = 0; -const _drPrevRaw = new Cesium.Cartesian3(); -const _drPrevDisplay = new Cesium.Cartesian3(); -let _drPrevMs = 0; -let _drReconcileValid = false; -/** @type {string|null} icao the reconciliation state currently belongs to */ -let _drReconcileIcao = null; - -/** - * Normalize a value to a trimmed lowercase string. - * @param {*} value - Any value (typically a header string or null). - * @returns {string} Lowercase trimmed string, or '' if falsy. - */ -function _toLowerText(value) { - return String(value || '').trim().toLowerCase(); -} - -/** - * Normalize a value to a trimmed string. A whitespace-only field (" ") is - * truthy, so every label chain must trim FIRST and then fall through. - * @param {*} value - Any value (typically a metadata string or null). - * @returns {string} Trimmed string, or '' if falsy. - */ -function _toCleanText(value) { - return String(value || '').trim(); -} - -/** - * The layer's ONE label convention: spoken callsign → tail registration → raw - * ICAO hex. Mirrors militaryFlights.js so the same aircraft reads identically - * in both layers. - * - * Registration is aircraft IDENTITY, not route, so unlike the origin/destination - * line it is NOT plausibility-gated — an adsbdb tail number describes the - * airframe itself and cannot go stale the way a leg can. - * - * This is a DISPLAY string only. Identity everywhere in this layer is `icao24` - * (the `_billboards`/`_flightData` key, the `sourceId` detection declutter hashes, - * the `id` that trackById/Context cohorts resolve) — never the label. - * @param {string} icao24 - ICAO 24-bit transponder address (the identity key). - * @param {Object|null|undefined} info - `_flightData` record for this aircraft. - * @returns {string} Display label; never empty. - */ -function _contactLabel(icao24, info) { - return _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24; -} - -/** - * Map OpenSky proxy response headers into a human-readable auth error string. - * The Vite proxy forwards `x-opensky-auth-mode-used` and `x-opensky-auth-reason` - * headers so the client can display a meaningful diagnostic. - * @param {object} params - * @param {string} params.detail - Error body text from the proxy, if any. - * @param {string} params.authMode - Normalized auth mode header value. - * @param {string} params.authReason - Normalized auth reason header value. - * @returns {string} Concise error description for UI display. - */ -function _deriveOpenSkyAuthError({ detail, authMode, authReason }) { - const reason = _toLowerText(authReason); - const mode = _toLowerText(authMode); - - if (reason === 'oauth_invalid_or_missing') { - return 'OpenSky OAuth client missing/invalid'; - } - if (reason === 'oauth_invalid_credentials') { - return 'OpenSky OAuth rejected credentials'; - } - if (reason === 'basic_invalid_credentials') { - return 'OpenSky username/password rejected'; - } - if (reason === 'missing_basic_creds' || reason === 'missing_oauth_and_basic_creds') { - return 'OpenSky auth missing'; - } - if (reason === 'auth_required') { - return 'OpenSky auth required'; - } - if (reason.startsWith('oauth_') || reason.startsWith('basic_')) { - return 'OpenSky auth invalid'; - } - if (reason === 'forced_anonymous' || mode === 'anon') { - return 'OpenSky auth required'; - } - if (detail) return detail; - return 'OpenSky auth failed'; -} - -/** - * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. - * - * Projects forward from the last known API fix using the aircraft's ground - * velocity and true_track heading. The ENU transform avoids repeated lat/lon - * trig, keeping the per-frame cost low. - * - * If this aircraft is the tracked target and a lerp is in progress (new API - * fix just arrived), the function first blends from the old dead-reckoned - * position toward the corrected fix before projecting forward. - * - * @param {string} icao24 - ICAO 24-bit transponder address of the aircraft. - * @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists. - */ -function _deadReckon(icao24, result) { - const info = _flightData.get(icao24); - if (FOCUS_EVIDENCE_DEV && _focusEvidenceIds.has(icao24)) { - const position = _billboards.get(icao24)?.position; - _drCourseDeg = info?.true_track || 0; - _drSpeedMps = info?.velocity || 0; - _drCourseHold = _drSpeedMps < COURSE_HOLD_SPEED_MPS; - _drExtrapolating = false; - return position ? Cesium.Cartesian3.clone(position, result || new Cesium.Cartesian3()) : null; - } - const history = _positionHistory.get(icao24); - if (!history || history.length === 0) { - _drCourseDeg = null; _drSpeedMps = null; _drCourseHold = false; _drExtrapolating = false; - return null; - } - - const out = result || new Cesium.Cartesian3(); - // Render one poll interval behind real time so we interpolate between - // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchRenderTime - ); - - // Bracketing pair: interpolate — no extrapolation error, no snap-back. - for (let i = history.length - 1; i >= 1; i--) { - const a = history[i - 1]; - const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; - // Course of the DISPLAYED motion. The chord is only trustworthy when the - // segment covers real ground (at hover its direction is GPS jitter; on a - // slow tight turn it STEPS the whole per-segment turn at each boundary), - // so it is blended against the reported per-fix track by displayed - // ground speed — and the track is TIME-INTERPOLATED between the fixes so - // a slow turner's nose advances continuously through the segment instead - // of snapping once per poll. Helicopters always use the reported track - // (rotorcraft chords are noise-dominated at their typical speeds). - const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); - const segSpeed = span > 0 ? chordLenM / span : ((info && info.velocity) || 0); - const fallbackTrack = (info && info.true_track) || 0; - const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; - const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; - const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); - const w = (info && info.klass === 'helicopter') ? 0 : speedRamp(segSpeed); - const chordCourse = w > 0 ? courseBetweenCartesians(a.position, b.position) : null; - _drCourseDeg = chordCourse != null ? lerpAngleDeg(trackCourse, chordCourse, w) : trackCourse; - _drSpeedMps = segSpeed; - _drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; - _drExtrapolating = false; - return Cesium.Cartesian3.lerp(a.position, b.position, t, out); - } - } - - const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference(renderTime, newest.time); - if (elapsedSec <= 0) { - // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking - // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing - // pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind - // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history - // fills, renderTime advances toward the first fix and the icon glides FORWARD into - // the bracketing interpolation above with NO freeze and NO backward snap. (Holding - // the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made - // the icon jump back ~one poll interval the instant interpolation took over.) - const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference(oldest.time, renderTime); // ≥ 0 - return _extrapolateFix(oldest, info, -Math.min(lookbackSec, 60), out, (info && info.turnRateDps) || 0); - } - - // Newest POSITION is older than renderTime. OpenSky can still be receiving - // fresh contact/kinematic messages for that aircraft; freezing at a hard - // 60 s-after-position boundary produced the visible stop → catch-up → stop - // cadence. Coast through the latest real contact plus a bounded grace - // window, with an absolute cap so a stale cached feed cannot drift forever. - const coastLimitSec = staleCoastLimitSeconds({ - // `epochMs` is captured once when the poll is normalized. Avoid allocating - // a Date per aircraft on every 12 Hz fleet tick. - fixEpochMs: Number.isFinite(newest.epochMs) - ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), - lastContactEpochMs: info?.lastContactEpochMs, - // Permit one minute of contact grace but cap any cached-feed drift at - // five minutes. Source backoff is exposed separately as a STALE cue. - minimumSec: 60, - maximumSec: 300, - }); - return _extrapolateFix( - newest, - info, - Math.min(elapsedSec, coastLimitSec), - out, - (info && info.turnRateDps) || 0, - ); -} - -/** - * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a - * constant-rate-turn arc when `turnRateDps` is significant (straight line - * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` - * projects BACKWARD (before the fix) — used for warm-up, estimating where the - * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, - * up = +Z; heading 0 deg = north, 90 deg = east. Sets `_drCourseDeg` to the - * arc's instantaneous end course on every path. - * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). - */ -function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { - const speed = Number.isFinite(fix.velocity) ? fix.velocity : ((info && info.velocity) || 0); - const heading = Number.isFinite(fix.track) ? fix.track : ((info && info.true_track) || 0); - _drSpeedMps = speed; - _drCourseHold = speed < COURSE_HOLD_SPEED_MPS; - _drExtrapolating = true; - if (speed === 0 || dt === 0) { - _drCourseDeg = heading; - return Cesium.Cartesian3.clone(fix.position, out); - } - // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a - // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. - arcOffsetEnu(speed, heading, turnRateDps, dt, _scratchArc); - _drCourseDeg = _scratchArc.endCourseDeg; - Cesium.Cartesian3.fromElements(_scratchArc.east, _scratchArc.north, 0, _scratchOffset); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(fix.position, Cesium.Ellipsoid.WGS84, _scratchEnu); - return Cesium.Matrix4.multiplyByPoint(enu, _scratchOffset, out); -} - -/** - * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) - * predates its oldest real fix — i.e. _deadReckon is extrapolating backward, with no - * real history yet sitting BEHIND the displayed icon. The trail must draw nothing in - * this window (every accumulated point is ahead of the icon). - * @returns {boolean} - */ -function _isTrackWarmingUp() { - if (!_trackedIcao) return false; - const history = _positionHistory.get(_trackedIcao); - if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); -} - -/** Resolve the selected aircraft's actual rendered square extent this frame. */ -function _trackedFocusSizePx(icao24, position) { - const camera = _viewer?.camera; - const scene = _viewer?.scene; - if (!camera?.positionWC || !position || !scene) return 28; - const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); - if (_modelOwnsVisual(icao24)) { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - const scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM, - viewportHeightPx: scene.canvas.clientHeight, - fovyRad: camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - const focalLengthPx = scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); - const projectedDiameterPx = (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; - return Math.max(TRACKED_MODEL_MIN_PX, Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx)); - } - - const billboard = _trackedEntity?.billboard; - const time = _viewer.clock.currentTime; - const width = billboard?.width?.getValue(time) ?? 28; - const height = billboard?.height?.getValue(time) ?? 28; - const scale = billboard?.scale?.getValue(time) - ?? (CLASS_SCALE_2D[_flightData.get(icao24)?.klass] || 1); - const scaleByDistance = billboard?.scaleByDistance?.getValue(time) - ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; - const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); - return Math.max(width, height) * scale * distanceScale; -} - -/** - * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium - * frame number so the position / rotation / trail-head callbacks share ONE computation - * (and one reconciliation-state update) per frame. Returns a stable module holder, or - * null when the aircraft has no fix. - * @param {string} icao24 - * @returns {Cesium.Cartesian3|null} - */ -function _trackedDisplayPosition(icao24) { - const frame = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frame === _cachedDRFrame && icao24 === _drReconcileIcao) return _cachedDRPosition; - - // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting - // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) - const sameTrack = _drReconcileValid && _drReconcileIcao === icao24; - const raw = _deadReckon(icao24, _scratchDrRaw); - _cachedDRCourse = _drCourseDeg; - _cachedDRSpeedMps = _drSpeedMps; - _cachedDRHold = _drCourseHold; - _cachedDRFrame = frame; - _drReconcileIcao = icao24; - if (!raw) { - _cachedDRPosition = null; - _drReconcileValid = false; - clearFocusTarget('flights', icao24); - return null; - } - - const nowMs = Date.now(); - const info = _flightData.get(icao24); - if (sameTrack) { - const dtSec = Math.max(0.001, (nowMs - _drPrevMs) / 1000); - const speed = (info && info.velocity) || 0; - // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. - const plausible = speed * dtSec * 4 + 25; - if (Cesium.Cartesian3.distance(raw, _drPrevRaw) > plausible) { - // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. - Cesium.Cartesian3.subtract(_drPrevDisplay, raw, _drCorrection); - _drCorrectionStartMs = nowMs; - } - } else { - Cesium.Cartesian3.fromElements(0, 0, 0, _drCorrection); - _drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed - } - - const elapsed = nowMs - _drCorrectionStartMs; - const factor = elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; - let display = Cesium.Cartesian3.multiplyByScalar(_drCorrection, factor, _trackedPosHolder); - Cesium.Cartesian3.add(raw, display, display); - // Same display floor the fleet pass applies — otherwise selecting a correctly - // floored grounded billboard swapped it for an unfloored tracked entity and - // dropped the cyan target back under the mesh. Applied HERE, at the single - // point every VISUAL consumer's per-frame position is computed, so the - // readout anchor, detection bracket, trail head and follow-camera all read - // the one floored value (the anti-jitter contract forbids recomputing per - // consumer). NOT the single point for DATA: `_describeFlight` deliberately - // reports sensor truth — see the note there. Skipped while a tracked 3D model - // owns the visual: it rides groundSnap's one-shot sample and moving its input - // would force a re-sample (T7). - display = _floorGroundedDisplayPosition(icao24, info, display, _modelOwnsVisual(icao24), nowMs); - - Cesium.Cartesian3.clone(raw, _drPrevRaw); - Cesium.Cartesian3.clone(display, _drPrevDisplay); - _drPrevMs = nowMs; - _drReconcileValid = true; - _cachedDRPosition = display; - const focusSizePx = _trackedFocusSizePx(icao24, _cachedDRPosition); - // Publish only the exact per-frame display cache the tracked entity/camera - // consumes. Re-running DR from a later frame phase recreates the historical - // target-vs-camera jitter bug. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'flights', - id: icao24, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _cachedDRPosition, - widthPx: focusSizePx, - heightPx: focusSizePx, - }); - return display; -} - -/** The tracked plane's current display position WITHOUT recomputing — the exact value the - * follow-camera already settled on this frame (in the Viewer _onTick). getDetectableObjects + the - * readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would - * re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters - * against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when - * there's no valid fix for the tracked aircraft, so callers fall back to the billboard position. */ -function _trackedDisplayCached() { - return (_drReconcileValid && _drReconcileIcao === _trackedIcao) ? _cachedDRPosition : null; -} - -/** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ -const _trackedVisualPos = new Cesium.Cartesian3(); -const _trackedTrailPos = new Cesium.Cartesian3(); -/** Scratch for the tracked model's world-space origin (the envelope centre). */ -const _scratchTrailClip = new Cesium.Cartesian3(); -/** Scratch for the shortened trail-head start. Owned by the head-segment - * callback alone, so nothing else can overwrite it mid-frame. */ -const _scratchTrailHead = new Cesium.Cartesian3(); - -/** - * The position the tracked aircraft is VISUALLY at this frame — the translation its - * 3D model is actually rendering with when the model owns the visual, otherwise the - * cached dead-reckoned position the billboard uses. - * - * This exists because a grounded plane's model rides a one-shot ground snap while its - * billboard deliberately stays at the reported (buried) altitude — a ~100 m vertical - * split at an inland airport. Anything anchored to the display position while the model - * is what you can see drifts below the aircraft and only converges as the coarse floor - * cell warms ("the buoy"), sometimes never. - * - * It reads `modelMatrix`, which `_updateTrackedModel` already wrote this frame — no - * sampling, no `_modelDisplayPosition` call from postRender, and no new dead reckoning, - * so the follow-camera anti-jitter contract on `gevDisplayPosition` is untouched. - */ -function _trackedVisualCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - return modelVisualAnchor( - _trackedModel.modelMatrix, - spec.visualCenterNative, - Number.isFinite(_trackedModel.computedScale) ? _trackedModel.computedScale : spec.scale, - _trackedVisualPos, - ); - } - return _trackedDisplayCached(); -} - -/** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on - * the visual centre; only the trail moves to the model's lower-centre hardpoint. */ -/** The tracked model's rendered bounding radius (m), or 0 when no model of this - * contact is drawing. Carries Cesium's effective `computedScale`, which may be - * above `scale` to satisfy minimumPixelSize — the trail head has to be judged - * against the size the operator SEES, not the nominal one. */ -/** World-space origin of the tracked model, or null when no model of this - * contact is drawing. This is the centre the rendered bounding sphere is - * measured from, so the trail clip and the envelope agree on one frame. */ -function _trackedModelCenterWorld() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return null; - return Cesium.Matrix4.getTranslation(_trackedModel.modelMatrix, _scratchTrailClip); -} - -function _trackedModelEnvelopeM() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return 0; - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const scale = Number.isFinite(_trackedModel.computedScale) - ? _trackedModel.computedScale - : spec.scale; - return spec.nativeRadiusM * scale; -} - -function _trackedTrailCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - // Through the model's OWN render chain (modelVisualAnchor's hand-rolled - // half-correction put this offset on the lateral axis — see - // modelAnchorWorld). - return modelAnchorWorld(_trackedModel, spec.trailAnchorNative, _trackedTrailPos); - } - return _trackedDisplayCached(); -} - -/** Smoothed world course for the tracked aircraft this frame. Reads the - * frame-cached course (set by _trackedDisplayPosition — the follow-camera's - * own computation), NEVER re-runs _deadReckon. Safe to call more than once - * per frame: the second call sees dt≈0 and the limiter is a no-op. - * - * The smoothed value lives in the SHARED per-icao _displayCourse entry (see - * its declaration): on click the limiter continues from whatever nose the - * fleet pass was displaying, and on untrack the fleet pass continues from - * whatever nose this path last wrote — the tracked and fleet consumers of - * the same aircraft can never disagree across the handoff. */ -function _trackedDisplayCourse() { - const info = _flightData.get(_trackedIcao); - const fallback = (info && info.true_track) || 0; - const cacheValid = _drReconcileValid && _drReconcileIcao === _trackedIcao && _cachedDRCourse != null; - const raw = cacheValid ? _cachedDRCourse : fallback; - const nowMs = Date.now(); - const dt = _trackedCourseMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _trackedCourseMs) / 1000) - : 0; - _trackedCourseMs = nowMs; - const prev = _displayCourse.get(_trackedIcao); - // Hover hold: at near-zero displayed speed both the chord and the reported - // track are noise — keep the last stable nose direction instead of chasing. - if (cacheValid && _cachedDRHold && prev != null) return prev; - const cap = courseSlewCapDps(cacheValid ? _cachedDRSpeedMps : ((info && info.velocity) ?? NaN), COURSE_MAX_DPS); - const course = limitCourseStep(prev, raw, cap, dt); - _displayCourse.set(_trackedIcao, course); - return course; -} - -/** Reset reconciliation + per-frame cache when tracking stops or switches - * target. Deliberately does NOT touch _displayCourse: the smoothed course - * entry belongs to the AIRCRAFT (not to the tracked session) and must - * survive the handoff back to the fleet pass. */ -function _resetTrackedDisplay() { - _drReconcileValid = false; - _drReconcileIcao = null; - _cachedDRFrame = -1; - _cachedDRPosition = null; - _cachedDRCourse = null; - _cachedDRSpeedMps = null; - _cachedDRHold = false; - _trackedCourseMs = 0; -} - -/** - * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, - * horizon-culls billboards beyond the limb (no far-side depth with the globe - * hidden), and refreshes screen-projected icon rotations whenever the camera - * pose changed (plus a 1s drift catch-up while idle). Driven by - * scene.preRender — NOT camera.changed, whose granularity is globally - * degraded by other layers mutating camera.percentageChanged. - * @returns {void} - */ - -/** Model tint, mirroring the billboard color rules. */ -function _modelColor(icao24) { - if (icao24 === _trackedIcao) return Cesium.Color.CYAN; - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; -} - -/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). */ -function _modelRegimeActive() { - if (!_models3dEnabled) return false; - const h = _viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; -} - -/** Hysteresis latch for the tracked contact's zoom regime, plus the selection it - * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER - * ceiling instead of inheriting the previous target's looser EXIT band. */ -let _trackedZoomLatched = false; -let _trackedZoomLatchIcao = null; - -/** Bounded on-demand loading for the tracked model. The tracked regime is - * DEFAULT-ON and its driver runs every `scene.preUpdate`, so a missing or - * corrupt GLB — or a dead network — would otherwise spin load→reject at frame - * rate for as long as the contact stays selected. Failures are counted PER - * SELECTION: a short backoff absorbs a transient blip, then the layer stops - * asking until the operator selects something else. The billboard is the - * visual throughout (the handoff only fades it once a model actually renders), - * so a latched failure degrades to exactly the pre-3D presentation. */ -const TRACKED_MODEL_MAX_LOAD_FAILS = 3; -const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; -let _trackedModelFailIcao = null; -let _trackedModelFailCount = 0; -let _trackedModelRetryAtMs = 0; - -/** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to the original - * layer) never makes `_trackedIcao` *observably* change, so the guard never - * fires. Without the reset, a contact dropped inside the hysteresis band comes - * back as a MODEL above the ENTER ceiling, and a contact whose GLB had already - * failed out would never get its retries back. */ -function _resetTrackedSelectionState() { - _trackedZoomLatched = false; - _trackedZoomLatchIcao = null; - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; -} - -/** Whether the driver may start another tracked-model load this frame. */ -function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (_trackedModelFailIcao !== _trackedIcao) return true; // untried selection - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) return false; - return nowMs >= _trackedModelRetryAtMs; -} - -/** Record a rejected tracked-model load and arm the backoff / give-up latch. */ -function _noteTrackedModelLoadFailure(url, err) { - if (_trackedModelFailIcao !== _trackedIcao) { - _trackedModelFailIcao = _trackedIcao; - _trackedModelFailCount = 0; - } - _trackedModelFailCount += 1; - _trackedModelRetryAtMs = Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Flights] tracked 3D model gave up after ${_trackedModelFailCount} failed loads of ${url} — ` - + 'this contact stays 2D until another is selected', - err, - ); - } -} - -/** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the - * DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is - * what the camera is pointed at, and zooming in on a target should resolve it - * into an aircraft without the operator arming anything. The toggle keeps - * owning the FLEET (`_modelRegimeActive`), which is the draw-call budget. - * - * Thresholds + hysteresis live in trackedModelRegime.js: enter at - * TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance, - * deliberately NEARER than the fleet's 800 km ceiling this used to inherit), - * hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary - * cannot flap billboard↔model. See that module's header for why the tracked - * contact now goes 3D closer in than the fleet does. - * - * In cockpit you are sitting 7 m behind and 2.6 m above your own aircraft's - * origin, so its ~26 m airframe would fill the visor. First-person means your - * own airframe is not drawn. - */ -function _trackedModelRegimeActive() { - if (_trackedZoomLatchIcao !== _trackedIcao) { - _trackedZoomLatchIcao = _trackedIcao; - _trackedZoomLatched = false; - } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. - if (!_trackedIcao || _cockpitContactMode || isTr3b(_trackedIcao)) { - _trackedZoomLatched = false; - return false; - } - _trackedZoomLatched = trackedModelZoomActive( - _viewer?.camera?.positionCartographic?.height, - _trackedZoomLatched, - ); - return _trackedZoomLatched; -} - -/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ -function _syncTracked2dRotation() { - if (!_trackedIcao || !_viewer) return; - const pos = _trackedDisplayCached() || _billboards.get(_trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation(_lastTrackedRotation, projected, 0); - if (rotation !== null) _lastTrackedRotation = rotation; -} - -/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses - * at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ -function _modelCap() { - const mapCap = _models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is - // already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal - // retraversal, the cloud pass) and every model is its own draw call. - return _cockpitContactMode ? Math.min(COCKPIT_MODEL_MAX, mapCap) : mapCap; -} - -/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */ -function _modelAddDistM() { - return _models3dMode === 'all' ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; -} -/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */ -function _modelKeepDistM() { - return _models3dMode === 'all' ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; -} - -/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one - * mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium - * clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at - * the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the - * scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */ -function _modelMatrix(pos, headingDeg, result = _scratchModelMtx) { - _scratchModelHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + MODEL_HEADING_OFFSET_DEG); - _scratchModelHpr.pitch = 0; - _scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, _scratchModelHpr, Cesium.Ellipsoid.WGS84, undefined, result, - ); -} - -/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the - * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked - * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion - * matches picked-object IDs, and every billboard AND model in this layer carries its - * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the - * object itself. Built lazily — only when a sample actually fires (one-shot). */ -function _groundSampleExclusions() { - const out = [..._billboards.keys()]; - if (_trackedEntity) out.push(_trackedEntity); - return out; -} - -/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position - * verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near - * the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports, - * hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested - * model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides - * a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js, - * CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a - * bounded last-known and resamples), plus - * the belly offset so it sits on its gear rather than sinking to the model-origin - * fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss) - * there is no safe depth-tested placement at all: this returns null, the caller - * keeps the 2D billboard visible and the model hidden, and it retries later. Once - * a contact has resolved once, a later outage holds that measurement inside - * groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D - * because a resample is mid-backoff. */ -function _modelDisplayPosition(icao24, pos, result) { - const meta = _flightData.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = _groundSnap.heightFor(_viewer, icao24, pos, _groundSampleExclusions); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchGroundCarto); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Cesium.Ellipsoid.WGS84, result); -} - -/** Atomically hand one fleet contact from its billboard to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the billboard owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ -function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { - if (!model) { - bb.show = true; - return false; - } - const displayPos = _modelDisplayPosition(icao24, pos, _scratchGroundPos); - if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; - return false; - } - // The matrix is written BEFORE the readiness test on purpose: a model can flip - // `ready` during scene update after this tick, and a first rendered frame on a - // stale load-start matrix is the one-frame jump this ordering prevents. - _modelMatrix(displayPos, course, model.modelMatrix); - if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; - return false; - } - beforeShow?.(); - if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders - return true; -} - -/** - * Whether a 3D model — not the billboard — is what the user actually SEES for - * this contact, and therefore whether the display floor should stand aside. - * - * Model EXISTENCE is not ownership. A fleet model that is still loading, or has - * no resolved ground to stand on, stays hidden while `bb.show` stays true (the - * gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model - * is retained but hidden whenever the model regime is off — 3D disabled, camera - * zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore - * suppressed the clamp in exactly the states where the BILLBOARD is the visual, - * putting the burial straight back. - * - * Cesium's own default `show === true` is the reason admission clears it - * explicitly: a fleet model registered in `_models` the instant its glTF resolves - * would otherwise claim ownership — at the identity matrix — for the frames - * between admission and the next fleet tick, while the BILLBOARD was still the - * visual. Ownership means actually rendering, and every site that sets `show` - * true does so only after committing a matrix. - * - * The two halves: - * - Fleet: the rendering test alone. Safe for the ground snap either way, - * because a fleet model is positioned from the RAW dead-reckon, not from - * the billboard. - * - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked - * model is fed from `_trackedDisplayPosition`, so once it is live the clamp - * must stand aside: two different chains would otherwise be deciding one - * contact's ground, and the billboard's is the one the operator is not - * looking at (T7). The regime check is what makes a regime - * flip take effect on the same frame rather than waiting for - * `_updateTrackedModel` to clear `show`; the rendering test is what keeps a - * null or still-loading tracked model from claiming a visual it is not - * drawing yet. - * @param {string} icao24 - * @returns {boolean} - */ -function _modelOwnsVisual(icao24) { - if (icao24 === _trackedIcao) { - return _trackedModelRegimeActive() && _modelIsRendering(_trackedModel); - } - return _modelIsRendering(_models.get(icao24)); -} - -/** A model is the visual only once it actually draws — loaded AND shown. - * - * `show` is sufficient evidence of a safe placement because only ONE site ever - * sets it true for a fleet model (`_driveFleetModelHandoff`, after the matrix is - * committed) and one for the tracked model (`_updateTrackedModel`, likewise); - * everything else — admission, an unresolved ground, a not-yet-ready glTF, the - * limb cull, a regime exit — only ever clears it. */ -function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; -} - -/** @type {Cesium.Cartographic} Scratch for the grounded display-floor read. */ -const _scratchDisplayCarto = new Cesium.Cartographic(); -/** @type {Map} Per-grounded-contact - * display-floor state: the cell it is currently reading (boundary hysteresis), - * the last input position and the effective floor that produced the cached - * output (rebuild skip), the last floor that actually RESOLVED for it plus the - * tier it came from (the hold, below), whether that floor is a REHYDRATED SEED - * rather than something measured while the contact stood here (`seeded` — it - * ranks below live evidence), and the value the downward ease is currently - * displaying while it approaches a lower floor. - * Dropped with the contact, and whenever it stops being a grounded billboard. */ -const _displayFloorState = new Map(); -/** @constant {number} Cap on NEW cells the display corridors may add to one - * poll's warm/sample batch — a view full of ground traffic must not balloon - * it. Cells the poll already collected are free (deduped before budgeting). */ -const DISPLAY_CORRIDOR_CELL_BUDGET = 64; -/** @constant {number} Cells any single contact may claim in the first pass, so - * one long corridor cannot spend the whole budget while other contacts get - * nothing. Leftovers are handed out in a second pass. */ -const DISPLAY_CORRIDOR_FAIR_SHARE = 4; -/** @constant {number} How far ahead a COASTING contact's corridor reaches: - * two poll intervals, so the ground it covers before the next batch lands is - * already warm. */ -const DISPLAY_CORRIDOR_LOOKAHEAD_SEC = RENDER_DELAY_SEC * 2; -/** @type {number} Poll counter handed to the corridor allocator: it rotates - * runs of EQUALLY needy contacts so a tie larger than the budget cycles across - * polls instead of the same prefix winning forever. */ -let _corridorEpoch = 0; -/** @constant {number} Corridors are only collected this close to the viewer: - * the mesh sampler ignores anything past 15 km, and a far contact's exact - * datum is subpixel. */ -const DISPLAY_CORRIDOR_RADIUS_KM = 25; - -/** @constant {number} How far a contact may travel from the cell that supplied - * its held floor before that floor stops describing the ground under it. - * - * Sized to the same worst-case ground segment `CORRIDOR_MAX_CELLS` is sized - * for — a 27 m/s rollout covers ~810 m in one poll interval — with headroom - * for a couple of polls of coasting. That is ~9 cells: aprons, taxiways and - * runways really are flat at that scale. An early draft used 5 km, which is - * ~45 cells and can leave the airfield entirely — the KAUS note in this file - * records a 21 m spread across the field alone — so the bound is the distance - * the contact can actually have travelled since the measurement rather than a - * comfortable-looking number. */ -const HELD_FLOOR_MAX_DRIFT_KM = 1; -/** @constant {number} Minimum gap between adjacent-cell probes for one contact - * whose held floor is missing or came from a borrowed tier. A contact standing - * on its own resolved floor never probes at all. - * - * This per-contact throttle is the ONLY rationing on the probe path, and that - * is deliberate. A probe is eight synchronous `Map` reads against the shared - * floor cache — no fetch, no `sampleHeight`, nothing async — so it cannot - * queue work anywhere; every DEM request is driven by `warmGroundFloor` from - * the poll loop, already bounded by DISPLAY_CORRIDOR_CELL_BUDGET and the - * resolver's single-flight chain. Measured worst case (`scripts/qa-floorhold- - * probe-cost.mjs`): 200 synchronized all-cold contacts probing on the SAME - * tick cost 2.1 ms, 2.6% of one 80 ms fleet tick, and 2.1 ms per second of - * wall clock sustained under this throttle. An earlier draft added a global - * per-tick budget with a fairness queue on top of that; it protected ~2 ms and - * cost two starvation defects, so it was deleted. Nothing can starve here - * because there is no shared resource to be starved of. */ -const NEIGHBOR_FLOOR_PROBE_MS = 500; -/** @constant {number} Time constant of the downward floor ease. A floor that - * drops UNDER a contact standing on a borrowed one is approached - * exponentially: each tick closes `1 - e^(-dt/TAU)` of the remaining gap, so - * at the 80 ms fleet cadence a tick moves ~20% of what is left and the value - * is within a few centimetres inside ~1.6 s. FLOOR_EASE_MAX_STEP caps that - * fraction so a delayed tick cannot close more. - * - * Exponential rather than a fixed-duration interpolation because the target - * MOVES: a second, lower neighbour can warm mid-ease. A from/duration ease - * re-evaluated against a new target jumps by the eased fraction of the change - * (measured: a 100 m single-tick drop late in an ease). Approaching from the - * CURRENTLY DISPLAYED value has no such seam — retargeting is just a different - * destination for the same continuous follow, and the per-tick bound holds - * however often the target moves. - * - * Rises are never eased: up is the safe direction, and easing up would park - * the contact under the mesh for the duration — the exact failure this whole - * path exists to prevent. - * - * Reachability note (2026-08-21, after neighborFloorM moved to a low lean): - * the ordinary arrival flows no longer produce a downward move at all — a - * borrowed floor is now the apron rather than a roof, so the contact rises - * once and stays (`scripts/qa-floorhold-staircase.mjs`: one step, zero float, - * in every scenario that settles). This machinery still guards the re-latch - * paths — a lower neighbour warming later, or an own-cell resolve below a - * hold — which the pins exercise directly. Whether those paths are worth the - * code is a follow-up judgement, deliberately not made in this change. */ -const FLOOR_EASE_TAU_MS = 360; -/** @constant {number} Hard ceiling on the fraction of the remaining gap ONE - * tick may close, whatever its dt. The exponential alone is timing-dependent: - * it closes 19.9% at the 80 ms fleet cadence but 28.4% at 120 ms and 75% after - * a 500 ms stall (a hidden tab, a long frame, a GC pause), which would turn a - * delayed tick back into the snap this approach exists to prevent. Clamping - * the fraction rather than dt keeps the never-snap property a property of the - * code instead of a property of the schedule; a stalled tick simply resumes - * the approach at full rate rather than jumping most of the way. */ -const FLOOR_EASE_MAX_STEP = 0.22; -/** @constant {number} Distance from the target at which the ease finishes - * exactly, so a parked contact stops rebuilding its position. Two orders of - * magnitude under GROUND_FLOOR_LIFT_M — invisible. */ -const FLOOR_EASE_EPSILON_M = 0.02; -/** - * The floor to stand a grounded contact on while its own cell is unresolved. - * - * Owner directive (2026-08-21, after a Re:Earth outage buried a parked contact - * at a Texas field): "hold the last known altitude until a fresh one comes in. - * Never render otherwise." Two tiers, strongest first: - * own — a floor this contact's OWN cell resolved to while it stood there. - * Nothing weaker can improve on it, and its cell warming again is picked - * up by the live read, not here. Valid only within - * HELD_FLOOR_MAX_DRIFT_KM of where it was measured. - * neighbor — the LOWEST of at least two resolved ADJACENT cells (~111 m away, - * the same apron). Two at least, because one reading cannot be checked - * against anything, and the LOWEST because a high reading beside a cold - * cell is as likely to be a terminal roof as ground — see neighborFloorM. - * - * BOTH are validated measurements out of the shared floor cache. A third tier - * that read the rendered mesh directly, where no DEM existed to check it, was - * built and then REMOVED: run against a real GPU with the proxy down it - * recorded a coarse-LOD 20.6 m for ground that is really ~122 m (see the note - * in meshFloorSampler.js). Nothing in this chain is a guess. - * - * A neighbour hold keeps re-probing at the throttle so it can UPGRADE: a parked - * contact that first answered from one neighbour would otherwise keep it after - * a better one warmed. A held value is never dropped for nothing — a failed - * probe leaves the previous answer standing — and a re-probe that lands LOWER - * eases rather than steps (see the ease note in the clamp below). - * - * A REHYDRATED SEED is the one exception to the tier order. A floor parked by - * `_retireDisplayFloorState` and picked back up on a later re-ground was - * measured while the contact stood somewhere it no longer necessarily is: it is - * a memory, not a reading, and it outranks nothing. So `seeded` demotes an - * `own` floor below the neighbour tier — when two fresh adjacent cells can - * answer, they answer, and the seed is discarded. It serves only while nothing - * fresh contradicts it, which is exactly the flap case it exists for (a rotation - * outruns its own cells, so nothing nearby is warm either). Without this a - * contact that re-grounded 0.5 km away kept a 200 m floor while its new - * neighbourhood read 100 m and 105 m, and rendered 100 m in the air. - * - * @param {object} state - The contact's `_displayFloorState` entry (mutated). - * @param {{lat: number, lon: number}} cell - Cell the display is reading now. - * @param {number} nowMs - Tick clock. - * @returns {number|null} Floor to use, or null when nothing anywhere can answer. - */ -function _heldDisplayFloorM(state, cell, nowMs) { - if (state.heldTier && !(Number.isFinite(state.heldM) && state.heldCell - && _approxDistanceKm(cell.lat, cell.lon, state.heldCell.lat, state.heldCell.lon) - <= HELD_FLOOR_MAX_DRIFT_KM)) { - // Out of range: the held value is no longer a measurement of anywhere this - // contact has been. Drop it rather than stretch it. - _dropHeldFloor(state); - } - if (state.heldTier === 'own' && !state.seeded) return state.heldM; - if (state.probeMs != null && nowMs - state.probeMs < NEIGHBOR_FLOOR_PROBE_MS) return state.heldM; - state.probeMs = nowMs; - const near = neighborFloorM(cell); - if (near != null) return _adoptHeldFloorM(state, near, cell, 'neighbor'); - return state.heldM; -} - -/** Records a held floor and the tier it came from; returns it. Every caller - * passes a floor measured for the cell the contact is reading NOW, so an - * adoption always clears the seed flag: live evidence has arrived. */ -function _adoptHeldFloorM(state, floorM, cell, tier) { - state.heldM = floorM; - state.heldCell = cell; - state.heldTier = tier; - state.seeded = false; - return floorM; -} - -/** Forgets the held floor and everything that describes where it came from, - * leaving the rest of the contact's display state alone. */ -function _dropHeldFloor(state) { - state.heldM = null; - state.heldCell = null; - state.heldTier = null; - state.seeded = false; -} - -/** @constant {number} How long a retired hold stays usable as a rehydration - * seed — three poll intervals. - * - * Deleting the state outright was the first cut, and an owner sighting found - * what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good - * ground, then suddenly popped below the ground, then popped back up". - * OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and - * the fix's own height source switches at the same moment, from the resolved - * surface to baro + geoid N, which at a sea-level field IS the geoid and sits - * below the runway. A single airborne poll therefore wiped the only thing that - * was hiding that: the contact came back grounded with no prior, outrunning - * its own floor cells at 23 m/s, and rendered at the geoid until something - * ahead of it warmed (`scripts/qa-floorhold-staircase.mjs` §F1: 12 of 22 - * grounded ticks below the runway, and not recovering). - * - * So a retired hold is PARKED, not destroyed, and a contact that re-grounds - * soon after picks it back up. What makes that safe is the bound already in - * `_heldDisplayFloorM`: a seed only answers within HELD_FLOOR_MAX_DRIFT_KM of - * where it was measured AND only while no fresh neighbour contradicts it, so a - * genuine departure-and-landing-elsewhere still starts clean — the grace window - * is belt to those braces, retiring the seed outright once a contact has been - * airborne long enough to have gone anywhere. */ -const FLOOR_SEED_GRACE_MS = 90_000; - -/** Whether a parked seed has been away longer than the grace window. - * - * ONE judgement, asked from BOTH sides of the park, because neither side sees - * the whole story on its own. While the contact keeps reporting, the retire - * path asks it and drops the entry. But a contact can be parked and then make - * no calls at all — off the poll for a long-haul cruise, out of the corridor - * radius, tab hidden — and then re-ground; nothing ran in between, so an - * expiry checked only on the retire path never fires and an arbitrarily old - * measurement walks back in (measured: parked 198 s, still reused). The - * rehydration side therefore asks the same question against the wall clock - * before it clears `retiredMs`. - * @param {object} state @param {number} nowMs - Tick clock. */ -function _seedExpired(state, nowMs) { - return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS; -} - -/** Retires a contact's display-floor state. Called the moment it stops being a - * grounded billboard — airborne, model-owned, or gone. - * - * The floor itself is kept as a rehydration seed for FLOOR_SEED_GRACE_MS (see - * above) and MARKED as one; everything that describes the contact's CURRENT - * rendering is cleared, so a re-ground recomputes from scratch and cannot be - * mistaken for a hold release. Nothing visual is touched, so the T7 - * model-ownership gate is unaffected. - * @param {string} icao24 @param {number} nowMs - Tick clock. */ -function _retireDisplayFloorState(icao24, nowMs) { - const state = _displayFloorState.get(icao24); - if (!state) return; - if (state.retiredMs == null) { - state.retiredMs = nowMs; - state.seeded = true; // what it answers with next is a memory, not a reading - state.heldActive = false; // a later landing is an arrival, not a release - state.easedM = null; - state.easeMs = null; - state.probeMs = null; // re-ground may probe immediately - state.out = null; - state.effectiveM = null; - Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO, state.in); // invalidate the memo - } else if (_seedExpired(state, nowMs)) { - _displayFloorState.delete(icao24); // airborne long enough to be anywhere - } -} - -/** - * Floors a GROUNDED contact's DISPLAYED position onto the local ground. - * - * `renderAltitudeM` is chosen once per poll from the floor of the FIX's coarse - * cell. The position that renders is the dead-reckoned one, which drifts away - * from that fix for the whole segment — and for up to 300 s / several hundred - * metres while a ground contact coasts through its stale-feed grace. Across a - * graded apron (KAUS spans ~119–140 m ellipsoidal) that drift buries the - * sprite under the mesh it is now over; `scripts/qa-floor-verify.mjs` measured - * −15.5 m. A second, smaller share comes from the fix-time floor itself: a - * taxiing contact whose current cell is still cold falls back to the PREVIOUS - * fix's cell (see the grounded `surfaceM` chain) and nothing revisits that - * height once the cell warms — the stale re-floor sweep deliberately skips - * contacts present in the poll. Both are cured by reading the floor at the - * coordinate actually being displayed. - * - * Discipline: - * - READ-ONLY against the shared floor cache. No latch, no heal, no sampling. - * Keeping cold cells rare is `_collectDisplayCorridorCells`'s job, not this - * one's. A cold cell used to mean NO clamp at all, which was only safe while - * the un-clamped height was a real reading — and for a grounded contact with - * no altitude data it is not: the poll path's last resort is the geoid, tens - * of metres under the mesh at an inland field. When the cell cannot answer, - * `_heldDisplayFloorM` holds the last floor that DID (owner, 2026-08-21). - * Still never an invented surface: every tier is a measurement, and when - * none exists the position passes through as before. - * - Grounded contacts only. Airborne heights are the fix-time clamp's job. - * - NEVER when a 3D model owns the visual (T7): the model rides groundSnap's - * one-shot tileset sample and the billboard hides behind it, so clamping the - * hidden billboard would put a SECOND ground chain on one contact — and the - * one the operator is not looking at. (The original rationale was narrower: - * lifting the datum dragged groundSnap's input past its 50 m - * move-invalidation and forced a re-sample every frame. groundSnap now - * measures that distance on the ellipsoid, so a purely vertical change costs - * nothing; the gate stays for the reason above.) Same gate the military - * layer's grounded billboard lift uses. - * - * @param {string} icao24 - Contact key (owns one `_displayFloorState` entry). - * @param {object|null|undefined} info - `_flightData` record for this contact. - * @param {Cesium.Cartesian3|null} pos - Dead-reckoned display position. - * @param {boolean} modelOwnsVisual - Whether a 3D model is drawing this contact. - * @param {number} [nowMs] - Tick clock, passed by both callers so the release - * ease advances on the same clock the rest of the tick uses. - * @returns {Cesium.Cartesian3|null} `pos` itself when nothing moves (the common - * case — no allocation, no rebuild), otherwise the lifted position. - */ -function _floorGroundedDisplayPosition(icao24, info, pos, modelOwnsVisual, nowMs = Date.now()) { - if (!pos || !info?.onGround || modelOwnsVisual) { - _retireDisplayFloorState(icao24, nowMs); - return pos; - } - const state = _displayFloorState.get(icao24); - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchDisplayCarto); - // Boundary hysteresis: a position jittering across a cell edge would flip - // floors at fleet-tick rate (see stickyFloorCell). - const cell = stickyFloorCell( - Cesium.Math.toDegrees(carto.latitude), Cesium.Math.toDegrees(carto.longitude), state?.cell, - ); - const floor = cachedGroundFloor(cell.lat, cell.lon); - const next = state || { - cell, in: new Cesium.Cartesian3(), out: null, effectiveM: null, - heldM: null, heldCell: null, heldTier: null, heldActive: false, seeded: false, - probeMs: null, easedM: null, easeMs: null, retiredMs: null, - }; - // Back on the ground. Judge the seed's AGE here, before `retiredMs` is - // cleared: a contact that made no calls while it was away never reached the - // retire path's own expiry branch, so this is the only place that can tell an - // hour-old measurement from a three-poll-old one. - if (_seedExpired(next, nowMs)) _dropHeldFloor(next); - // The seed (if any survived) is live again, and the drift bound plus the - // neighbour tier in the hold chain decide whether it still describes ground - // this contact is on. - next.retiredMs = null; - // The floor to clamp against: this cell when it has one, otherwise the last - // one that resolved for this contact (never the geoid the poll path fell to). - // Snapshot what the contact was standing on BEFORE the chain overwrites it — - // the ease decision below needs the previous value, and `_heldDisplayFloorM` - // adopts into the same fields. - const wasHeld = next.heldActive; - const stoodOnM = next.heldM; - let effective = floor; - if (Number.isFinite(effective)) { - _adoptHeldFloorM(next, effective, cell, 'own'); - next.heldActive = false; - } else { - effective = _heldDisplayFloorM(next, cell, nowMs); - next.heldActive = Number.isFinite(effective); - } - // The floor moved DOWN under a contact that was standing on a BORROWED one. - // Dropping it by that difference in a single tick is the snap the owner asked - // not to have, so approach it instead. Two ways in, and both need it: - // - the real floor arrives below the hold (releasing the hold); - // - a re-probe finds a LOWER neighbour than the one being held, which the - // 12 m spread bound can make a large step on a mesa edge (200 m held, a - // 120 m neighbour warms, the bounded answer is 132 m) — and can happen - // AGAIN while the first approach is still running. - // Scoped to a borrowed floor on purpose: an ordinary cell-to-cell change - // between two resolved floors is the existing path and keeps its timing. - if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM) - && Number.isFinite(effective) && effective < stoodOnM) { - next.easedM = stoodOnM; // start from where the contact is actually drawn - next.easeMs = nowMs; - } - if (next.easedM != null) { - if (!Number.isFinite(effective) || effective >= next.easedM) { - // Nothing to approach, or the floor rose: take it whole and stop. - next.easedM = null; - next.easeMs = null; - } else { - // Exponential approach from the DISPLAYED value. The target may have - // moved since last tick; that changes only where this is heading, never - // where it is, so there is no seam to jump across. - const dtMs = Math.max(0, nowMs - next.easeMs); - next.easeMs = nowMs; - const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS)); - let value = next.easedM + (effective - next.easedM) * closed; - if (Math.abs(value - effective) <= FLOOR_EASE_EPSILON_M) { - value = effective; // arrive exactly, so a parked contact stops rebuilding - next.easedM = null; - next.easeMs = null; - } else { - next.easedM = value; - } - effective = value; - } - } - // Same input position AND the same EFFECTIVE floor ⇒ the same answer as last - // tick, so skip the rebuild. A clamped stationary contact (parked, coasting - // on a zero-velocity fix) hits this every tick; without it the identical - // Cartesian was rebuilt at ~12 Hz forever. The test is deliberately on the - // OUTPUT of the hold chain, not on its inputs: keying it to the raw cell - // floor let a parked contact whose cell never warmed return a memoized - // unresolved answer forever, so an adjacent-cell floor warming later was - // never adopted. One owned entry per grounded - // contact — O(grounded), dropped on eviction, on destroy, and the moment the - // contact stops being a grounded billboard. - if (state && state.effectiveM === effective && Cesium.Cartesian3.equals(pos, state.in)) { - return state.out || pos; - } - const lifted = displayFloorHeightM(carto.height, effective); - next.cell = cell; - next.effectiveM = effective; - Cesium.Cartesian3.clone(pos, next.in); - if (lifted == null) { - next.out = null; - } else { - // The cache OWNS its output: returning a shared scratch would let the next - // contact in the fleet loop overwrite a position already handed out. - next.out = Cesium.Cartesian3.fromRadians( - carto.longitude, carto.latitude, lifted, Cesium.Ellipsoid.WGS84, - next.out || new Cesium.Cartesian3(), - ); - } - if (!state) _displayFloorState.set(icao24, next); - return next.out || pos; -} - -/** @type {Cesium.Cartographic} Scratch for the corridor's display-end read. */ -const _scratchCorridorCarto = new Cesium.Cartographic(); -/** @type {Cesium.Cartesian3} Scratch for the corridor's dead-reckon probe. */ -const _scratchCorridorPos = new Cesium.Cartesian3(); - -/** - * Adds the cells each grounded contact's DISPLAY is about to render over to - * this poll's floor warm/sample batch. - * - * The poll loop otherwise collects FIX cells only, so the clamp above has data - * exactly where the contact ISN'T. A contact taxiing at 10 m/s crosses a - * ~111 m cell every ~11 s while the batch runs once per 30 s poll, so it stays - * permanently ahead of its own floor data (the "taxiing cache" failure mode, at - * cell granularity) and the clamp silently passes. - * - * The corridor therefore follows the direction the display is actually MOVING, - * which is not always toward the fix: - * - INTERPOLATING between two fixes — the display is walking to the newest - * fix, so that fix is the endpoint (exact, no projection error). - * - EXTRAPOLATING — coasting past the newest fix on a stale feed, or the - * pre-history warm-up — the display travels along its course AWAY from that - * fix. Aiming at the fix here warms the BACKTRAIL while the contact stays - * one sampling cycle ahead and buried, so the endpoint is the position its - * own kinematics put it at two poll intervals from now. - * - * Runs EVERY poll, warm cells included, for the same reason the fix cells do: - * `warmGroundFloor` skips cells that already have a real DEM, but the mesh - * sampler needs to see a cell again AFTER its DEM prior lands, since a sample - * without that prior is rejected. Offering a cell once would leave it DEM-only - * for the session — at fields where the photogrammetric mesh sits well above - * bare earth that is still metres of burial. - * - * Budgeting is need-ranked and dedupe-first: cells this poll already collected - * cost NOTHING (a parked contact's corridor is its own fix cell, so it never - * competes), candidates are ordered by how many of their cells are actually - * cold, and each takes at most DISPLAY_CORRIDOR_FAIR_SHARE before anyone takes - * seconds. Insertion-order spending starved the contacts that needed it most. - * - * Purely additive to the existing batch: same fire-and-forget DEM resolve, same - * one-shot DEM-validated mesh sampler. No latch, no heal. - * - * @param {Array<{lat: number, lon: number}>} out - This poll's warm points. - * @param {number|null} viewerLat @param {number|null} viewerLon - Viewer subpoint. - */ -function _collectDisplayCorridorCells(out, viewerLat, viewerLon) { - if (viewerLat == null || viewerLon == null) return; - // Cells the poll already collected (grounded + low-airborne fix cells). - const seen = new Set(); - for (const p of out) { - const c = coarseFloorCoord(p.lat, p.lon); - seen.add(`${c.lat},${c.lon}`); - } - - const candidates = []; - for (const [icao24, info] of _flightData) { - if (!info?.onGround) continue; - // T7: a contact whose 3D model is the visual never reads a display floor. - if (_modelOwnsVisual(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; - if (_approxDistanceKm(viewerLat, viewerLon, info.rawLat, info.rawLon) > DISPLAY_CORRIDOR_RADIUS_KM) continue; - const dr = _deadReckon(icao24, _scratchCorridorPos); - if (!dr) continue; - // Read the sibling scratches IMMEDIATELY, before any other _deadReckon call. - const extrapolating = _drExtrapolating; - const speedMps = Number.isFinite(_drSpeedMps) ? _drSpeedMps : (info.velocity || 0); - const courseDeg = _drCourseDeg != null ? _drCourseDeg : (info.true_track || 0); - const c = Cesium.Cartographic.fromCartesian(dr, Cesium.Ellipsoid.WGS84, _scratchCorridorCarto); - const lat = Cesium.Math.toDegrees(c.latitude); - const lon = Cesium.Math.toDegrees(c.longitude); - const cells = corridorFloorCells(corridorPathLatLon({ - extrapolating, - displayLat: lat, - displayLon: lon, - courseDeg, - speedMps, - // Same turn the dead-reckon integrates — a sustained-turn taxi leaves a - // straight tangent within a few hundred metres. - turnRateDps: info.turnRateDps || 0, - fixLat: info.rawLat, - fixLon: info.rawLon, - lookaheadSec: DISPLAY_CORRIDOR_LOOKAHEAD_SEC, - })); - let cold = 0; - for (const cell of cells) { - if (cachedGroundFloor(cell.lat, cell.lon) == null) cold += 1; - } - candidates.push({ cells, cold, speedMps }); - } - _corridorEpoch += 1; - for (const cell of allocateCorridorCells( - candidates, seen, DISPLAY_CORRIDOR_CELL_BUDGET, DISPLAY_CORRIDOR_FAIR_SHARE, _corridorEpoch, - )) { - out.push(cell); - } -} - -/** Test seam for the display-floor clamp (unit-tested against real Cesium math - * with seeded mesh cells — the drift mechanism is otherwise only reachable - * through a live poll + render loop). */ -export function _floorGroundedDisplayPositionForTest( - info, pos, modelOwnsVisual, icao24 = '__test__', nowMs = Date.now(), -) { - return _floorGroundedDisplayPosition(icao24, info, pos, modelOwnsVisual, nowMs); -} - -/** Test hook: drops the per-contact display-floor state (hysteresis + rebuild - * cache) so each case starts clean. There is no cross-contact state to reset — - * the clamp is per-contact all the way down. */ -export function _clearDisplayFloorStateForTest() { - _displayFloorState.clear(); -} - -/** Spec identity for a LOADED model: URL and scale together (same-URL classes - * differ by scale — airliner vs quadjet both ship airplane.glb). */ -const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; -}; -/** Class-change model sync (enrichment AND poll-path klass updates): when the - * aircraft's live model or in-flight load no longer matches its class's spec, - * drop it so the eligibility pass reloads the right asset at the right scale. - * Gap-proof: the fleet billboard is re-shown BEFORE the release so the - * contact never goes invisible for the tick gap; _releaseModel's generation - * bump also invalidates any pending load. The tracked standalone model gets - * the same rule (its billboard entity is always the fallback visual). */ -function _syncModelToClass(icao24) { - const key = _specKeyFor(_flightData.get(icao24)?.klass); - const current = _models.get(icao24); - if ((current && current._gevSpecKey !== key) || (!current && _modelPending.has(icao24))) { - const bb = _billboards.get(icao24); - if (bb && icao24 !== _trackedIcao) bb.show = true; - _releaseModel(icao24); - } - if (icao24 === _trackedIcao && _trackedModel && _trackedModel._gevSpecKey !== key) { - _releaseTrackedModel(); - } -} - -/** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles - * map LUMINANCE, so mid-gray textured models read cold and vanish into - * terrain. While a boost style is active every model renders flat white - * (hottest); per-spec color/tint restores on style exit. Driven by ui.js - * setStyle via the `irBoost` layer param. */ -let _irBoost = false; -/** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16): - * the white tint alone is applied to the MATERIAL, so Cesium still - * sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high - * sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed. - * LightingModel.UNLIT emits the flat white directly, orientation be damned. - * CRITICAL (field-verified via scene.pick): assigning customShader to an - * already-READY model is a silent no-op — the property sets but the shader - * program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD - * (see setParams), so every boosted model gets the shader AT CREATION. - * One shared shader instance — stateless, safe across models. */ -const _IR_UNLIT_SHADER = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT }); -/** Flip the whole 3D fleet's boost state by dropping models so the eligibility - * pass reloads them with creation-time boost options (both directions — a - * boosted model must not stay flat white back in Normal). Destroying 350 - * GPU-backed models synchronously inside the style handler stalls the render - * thread (review P1; same failure the cockpit path documents), so the release - * is BATCHED through the fleet tick: each tick drops a bounded slice, showing - * each plane's billboard first (gap-proof per plane, no double-image window). - * Models are tagged with the boost state they loaded under, so queue entries - * whose model already matches the current state (rapid style cycling, or a - * reload that already happened) are skipped. In-flight loads are invalidated - * immediately (cheap gen bumps); the tracked model is a single primitive and - * reloads synchronously. */ -const IR_RELOAD_BATCH = 40; -let _irReloadQueue = null; -function _reloadModelsForIrBoost() { - _irReloadQueue = [..._models.keys()]; - for (const icao of _modelPending) { - if (!_models.has(icao)) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - } - _releaseTrackedModel(); -} -function _drainIrReloadQueue() { - if (!_irReloadQueue) return; - const batch = _irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = _models.get(icao); - if (!model || model._gevIrBoost === _irBoost) continue; // already right state - const bb = _billboards.get(icao); - if (bb && icao !== _trackedIcao) bb.show = true; - _releaseModel(icao); - } - if (_irReloadQueue.length === 0) _irReloadQueue = null; -} - -/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ -async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === _trackedIcao) return; - _requestTypeEnrichment(icao24, true); // model-eligible: about to render in 3D — jump the ambient backlog - if (_models.has(icao24) || _modelPending.has(icao24)) return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL. - if ((_models.size + _modelPending.size) >= _modelCap()) return; - const epoch = _modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = _modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - _modelPending.add(icao24); - let model = null; - // Spec identity captured at load START — if enrichment reclassifies the - // aircraft mid-load, the post-await admission below rejects the stale asset. - // Boost state likewise: the creation options bake it in, so a mid-load - // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(_flightData.get(icao24)?.klass); - const loadIrBoost = _irBoost; - try { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: spec.url, - asynchronous: false, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // Launch presentation keeps the code-side tint dominant for every approved - // model; IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: _irBoost ? 1.0 : spec.blendAmount, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === _modelEpoch) { _modelPending.delete(icao24); _cleanupModelGen(icao24); } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== _modelEpoch) { try { model.destroy(); } catch { /* gone */ } return; } - _modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = (_modelGen.get(icao24) || 0) !== gen - || !_modelRegimeActive() || !_modelCollection || _modelCollection.isDestroyed() - || !_flightData.has(icao24) || icao24 === _trackedIcao - || _models.has(icao24) || _models.size >= _modelCap() - // Class reclassified mid-load → this GLB/scale is for the OLD class. - || _specKeyFor(_flightData.get(icao24)?.klass) !== specKey - // IR boost flipped mid-load → this model baked the wrong shader/tint. - || _irBoost !== loadIrBoost; - if (stale) { - try { model.destroy(); } catch { /* already gone */ } - _cleanupModelGen(icao24); // bound the map - return; - } - // Keep the pick identity explicit on the resolved primitive. This also - // protects injected/custom loaders that do not copy the creation option. - model.id = icao24; - model._gevSpecKey = specKey; // class-change sync compares against this - model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this - // Admitted, not yet the visual. Cesium's default is show=true, which would let - // an unplaced primitive claim ownership from the billboard for the frames - // between admission and the next fleet tick (and draw at the identity matrix, - // i.e. the Earth's centre). The handoff turns it on once it has a matrix. - model.show = false; - _modelCollection.add(model); - _models.set(icao24, model); - _planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard -} - -/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ -function _releaseModel(icao24) { - const m = _models.get(icao24); - const pending = _modelPending.has(icao24); - // Only bump the generation when there's something to invalidate (an in-flight load or a - // live model) — so removing never-modeled aircraft doesn't grow _modelGen. - if (m || pending) { - _modelGen.set(icao24, (_modelGen.get(icao24) || 0) + 1); - } - if (m) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - _models.delete(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); -} - -/** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ -function _cleanupModelGen(icao24) { - if (!_modelPending.has(icao24) && !_models.has(icao24)) _modelGen.delete(icao24); -} - -/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ -function _releaseModels() { - _irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of _modelPending) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - if (_modelCollection && !_modelCollection.isDestroyed()) { - for (const m of _models.values()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - } - _models.clear(); -} - -/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ -function _releaseTrackedModel() { - _trackedModelGen++; - _trackedModelLoading = false; - if (_trackedModel) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(_trackedModel); } catch { /* gone */ } } - _trackedModel = null; - } -} - -/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet - * cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the - * follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed - * in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */ -function _updateTrackedModel() { - const active = _trackedIcao && _trackedModelRegimeActive() - && _modelCollection && !_modelCollection.isDestroyed(); - if (!active) { if (_trackedModel) _trackedModel.show = false; return; } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = _trackedDisplayPosition(_trackedIcao) || _billboards.get(_trackedIcao)?.position; - if (!pos) { if (_trackedModel) _trackedModel.show = false; return; } - if (!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed()) { - _trackedModelLoading = true; - const gen = _trackedModelGen; - const trackedSpec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const trackedKey = _specKeyFor(_flightData.get(_trackedIcao)?.klass); - const trackedIrBoost = _irBoost; - Cesium.Model.fromGltfAsync({ - url: trackedSpec.url, - asynchronous: false, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: _irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // The tracked aircraft uses the same dominant light tint as the fleet; - // IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: _irBoost ? 1.0 : trackedSpec.blendAmount, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: _trackedIcao, - }).then((m) => { - // Untracked / re-tracked / torn down during the load → drop it. - if (gen !== _trackedModelGen || !_modelCollection || _modelCollection.isDestroyed()) { try { m.destroy(); } catch { /* gone */ } return; } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if (_specKeyFor(_flightData.get(_trackedIcao)?.klass) !== trackedKey || _irBoost !== trackedIrBoost) { - try { m.destroy(); } catch { /* gone */ } - _trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = _trackedIcao; - m._gevSpecKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed - // Seed the world transform before the primitive enters the scene. A model - // can become ready+shown between render phases; leaving Cesium's identity - // default here produces a one-frame jump to the Earth's center. - const currentPos = _trackedDisplayCached() - || _billboards.get(_trackedIcao)?.position; - if (currentPos) { - const displayPos = _modelDisplayPosition(_trackedIcao, currentPos, _scratchGroundPos); - if (displayPos) _modelMatrix(displayPos, _trackedDisplayCourse(), m.modelMatrix); - } - _trackedModel = m; - _trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; - _modelCollection.add(m); - _planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up - }).catch((err) => { - if (gen !== _trackedModelGen) return; // superseded load — not this selection's failure - _trackedModelLoading = false; - _noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; // billboard carries the visual until the model is ready - } - if (_trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition(_trackedIcao, pos, _scratchGroundPos); - if (!displayPos) { - _trackedModel.show = false; // no ground evidence → the billboard carries it - return; - } - _modelMatrix(displayPos, _trackedDisplayCourse(), _trackedModel.modelMatrix); - if (!_trackedModel.ready) return; - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - _trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance(_viewer.camera.positionWC, displayPos), - viewportHeightPx: _viewer.scene.canvas.clientHeight, - fovyRad: _viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - _trackedModel.show = true; - } -} - -function _fleetTick() { - if (!_viewer || !_billboardCollection || !_billboardCollection.show) return; - const scene = _viewer.scene; - const camera = _viewer.camera; - const nowMs = focusNowMs(Date.now()); - - // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz - // primitive rebuild needed here anymore.) - - if ((nowMs - _lastFleetTickMs) < FLEET_DR_INTERVAL_MS) return; - const tickDtSec = _lastFleetTickMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _lastFleetTickMs) / 1000) - : 0.08; - _lastFleetTickMs = nowMs; - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (_cockpitContactMode) _refreshCockpitNearContacts(); - const poseSig = cameraPoseSignature(camera); - // Only the nearby Cockpit silhouettes need projected course; far dots are - // rotation-free. The per-contact gate below keeps the pip path cheap. - const doRotations = (poseSig !== _lastCamPoseSig || (nowMs - _lastRotPassMs) >= ROTATION_REFRESH_MS); - if (doRotations) { - _lastCamPoseSig = poseSig; - _lastRotPassMs = nowMs; - } - - const occluder = horizonOccluder(camera); - const focusTarget = getFocusTarget(); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if (!useModels && (_models.size || _modelPending.size)) _releaseModels(); - - // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the - // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen - // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, - // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots - // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). - // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an - // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap - // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum - // filtering dropped near off-screen planes entirely. This does both right. - let modelEligible = null; - if (useModels) { - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC); - // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. - const cand = []; - for (const [icao, bb] of _billboards) { - if (icao === _trackedIcao) continue; - // A converted TR-3B renders as a billboard and can never take a model, so - // it must not occupy a CAP SLOT either — excluded here at selection time, - // not just at the handoff below, or accumulated conversions would starve - // ordinary contacts of 3D models. (The handoff guard stays as defence.) - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03: "3D mode is respected regardless of whether a plane is on the - // ground or in the air — no distinction"). The cap + nearest-first ordering - // below already bound airport clusters; grounded placement is handled by the - // one-shot ground snap in _modelDisplayPosition. - const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); - if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible - Cesium.Cartesian3.clone(bb.position, _scratchModelBS.center); - cand.push([icao, d2, cull.computeVisibility(_scratchModelBS) !== Cesium.Intersect.OUTSIDE]); - } - cand.sort((a, b) => a[1] - b[1]); // nearest first - if (cand.length > cap && (nowMs - _lastModelCapWarnMs) > 5000) { - console.warn(`[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${_models3dMode}). On-screen planes are prioritized.`); - _lastModelCapWarnMs = nowMs; - } - modelEligible = new Set(); - // 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see). - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (inF && _models.has(icao)) modelEligible.add(icao); } - // 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap). - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } - // 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a - // retained off-screen model can never starve an on-screen plane that wants one; dropping it is - // invisible and it re-adds the moment it's back in view). - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && _models.has(icao)) modelEligible.add(icao); } - // 4. ADD off-screen NEW inside the add radius with any leftover slots. - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } - const toRelease = []; - for (const icao of _models.keys()) { - if (icao !== _trackedIcao && !modelEligible.has(icao)) toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); - } - - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao) continue; // tracked entity owns its own motion - - const info = _flightData.get(icao24); - - const dr = _deadReckon(icao24, _scratchFleetPos); - // The dead-reckoned point drifts away from the fix whose cell supplied the - // height, so a grounded contact's sprite ends up under the mesh it taxied - // (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and - // never while a 3D model owns the visual (T7). - const display = _floorGroundedDisplayPosition(icao24, info, dr, _modelOwnsVisual(icao24), nowMs); - // Gate the write — assigning Billboard.position dirties the whole - // collection's vertex buffer, so skip sub-meter moves. - if (display && Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0) { - bb.position = display; - } - - // Round 6: occlusion-test a LIFTED point for contacts rendering below - // (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a - // sub-ellipsoid point near the limb "beyond the horizon" and the fleet - // pass would hide a plane that is really just low over high-N terrain - // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). - const beyondHorizon = !occluder.isPointVisible(info?.cullPosition || bb.position); - // A billboard flipping INTO view (horizon reveal while the camera idles) - // gets its rotation refreshed THIS tick even without a pose change — - // otherwise it reappears wearing its stale (often creation-north) nose for - // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false - // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; - if (beyondHorizon) { - // Also hide any 3D model — otherwise a model that crossed the limb would keep - // rendering through the hidden globe at its last matrix. - const m = _models.get(icao24); - if (m && m.show) m.show = false; - continue; - } - - // One order-independent write site composes freshness × focus × limb haze - // for alpha, and base class/ground scale × limb taper for scale. Cesium's - // locked NearFarScalar remains a separate multiplicative stage. This - // narrowly amends always-visible rendering without count culling or zero - // alpha; nearer focus behavior remains tunable rather than universal. - const cameraDistanceM = Cesium.Cartesian3.distance(camera.positionWC, bb.position); - const distanceScale = nearFarScalarValueAtDistance(bb.scaleByDistance, cameraDistanceM); - const focus = advanceProjectedSpriteFocus( - bb, - bb.position, - scene, - camera, - nowMs, - focusTarget, - undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, - ); - const isCockpitNear = _cockpitContactMode && _cockpitNearContacts.has(icao24); - const baseColor = _cockpitContactMode && !isCockpitNear - ? (isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR) - : _fleetBillboardColor(icao24); - const treatment = applyAircraftBillboardTreatment({ - billboard: bb, - baseScale: _cockpitContactMode && !isCockpitNear ? 1 : _fleetBillboardScale(icao24, info?.klass), - baseAlpha: _missingPolls.get(icao24) ? 0.45 : 1, - baseColor, - focusFactor: focus.factor, - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }); - _billboardLimbScale.set(bb, treatment.factors.scale); - // Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas - // has no mipmaps, so no single texture stays crisp across the ~25–150 - // device-px range scaleByDistance produces. Swap between the 64 px fleet - // raster and the 192 px close raster on the billboard's ACTUAL on-screen - // size — post-treatment bb.scale, so focus/limb recession counts — with - // hysteresis so zoom oscillation never thrashes the atlas. - if (!_cockpitContactMode || isCockpitNear) { - const glyphDevPx = (bb.width || 20) * (bb.scale || 1) - * distanceScale * (globalThis.devicePixelRatio || 1); - const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; - if (wantLarge !== !!bb._gevIconLarge) { - bb._gevIconLarge = wantLarge; - bb.image = aircraftIcon(_iconKind(icao24, info?.klass), wantLarge ? TRACKED_ICON_PX : undefined); - } - } - - // Smoothed display course: the path direction _deadReckon just reported - // for THIS aircraft (nothing else calls _deadReckon in between), rate- - // limited so segment-boundary course steps glide instead of snapping. - // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering - // aircraft (hold flag) keeps its previous nose direction outright. - const rawCourse = _drCourseDeg != null ? _drCourseDeg : ((info && info.true_track) || 0); - const prevCourse = _displayCourse.get(icao24); - const course = (_drCourseHold && prevCourse != null) - ? prevCourse - : limitCourseStep( - prevCourse, rawCourse, - courseSlewCapDps(_drSpeedMps != null ? _drSpeedMps : ((info && info.velocity) ?? NaN), COURSE_MAX_DPS), - tickDtSec, - ); - _displayCourse.set(icao24, course); - - // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the - // billboard stays shown until the model is actually READY to render, so a plane is never both - // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every - // tick regardless so it's framed the instant it becomes ready. - // Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and - // there is no GLB for it, so the model handoff is suppressed rather than - // fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying - // the getNearby/getDetectableObjects `bb.show` visibility guards), so a - // converted contact still works in Contacts and Cockpit. - if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { - _ensureModel(icao24); - const model = _models.get(icao24); - const ownsVisual = _driveFleetModelHandoff( - icao24, - model, - bb, - dr, - course, - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write — otherwise - // any alpha change would repaint the ordinary tint over the hot - // white. Boosted models also skip the recession fade: hot targets - // stay full-strength at any range (billboards keep their normal - // fade — full-opacity glyph walls read as overwhelming). - baseColor: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - alpha: _irBoost ? 1 : treatment.alpha, - }); - }, - ); - if (ownsVisual) continue; // skip billboard rotation - } - - if ((!_cockpitContactMode || isCockpitNear) && (doRotations || revealed)) { - const rot = screenProjectedRotation(scene, bb.position, course, bb.rotation); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; - } - } - } -} - -/** - * Build a public descriptor for one aircraft using its best current position - * (dead-reckoned when history exists, billboard position otherwise). - * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Descriptor with a cloned position, or null if the aircraft is unknown. - */ -function _describeFlight(icao24) { - // DATA, NOT PIXELS — deliberately UNFLOORED (2026-08-19). The display floor - // lifts grounded contacts onto the visible mesh so the sprite you see is not - // buried; that is a rendering correction, not a measurement. This descriptor - // feeds query/analyst/subject APIs — `findByQuery` (voice track-by-name), - // `getTrackedInfo` (cockpit + readout altitude), `getTrackedSubject` - // (proximity counts and distances) — where the honest answer is what the - // aircraft REPORTED, not where its icon was nudged to avoid clipping tiles. - // `altitudeM` is therefore the barometric/aviation value and `renderAltitudeM` - // the fix-time datum, neither of them the floored display height. No - // user-visible surface renders `position` as the plane's on-screen location — - // every visual consumer reads the floored per-frame cache instead (see - // `_trackedDisplayPosition`). If that ever changes, floor this path too. - const info = _flightData.get(icao24); - const bb = _billboards.get(icao24); - const basePos = _deadReckon(icao24) || (bb ? bb.position : null); - if (!basePos) return null; - const displayed = displayedKinematics({ - derivedSpeedMps: _drSpeedMps, - derivedTrackDeg: _drCourseDeg, - reportedSpeedMps: info?.velocity, - reportedTrackDeg: info?.true_track, - }); - const carto = Cesium.Cartographic.fromCartesian(basePos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) return null; - return { - icao24, - callsign: String(info?.callsign || '').trim() || null, - position: Cesium.Cartesian3.clone(basePos), - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - // The cockpit instrument reports aviation altitude, not the Cesium - // ellipsoid height of the camera/ground-clamped render position. The - // latter can be slightly negative over terrain near the surface. - altitudeM: Number.isFinite(info?.altitude) ? info.altitude : carto.height, - renderAltitudeM: Number.isFinite(info?.renderAltitudeM) ? info.renderAltitudeM : carto.height, - onGround: info?.onGround === true, - velocityMps: displayed.speedMps, - track: displayed.trackDeg, - stale: Boolean(_missingPolls.get(icao24) || _backoff), - airline: info?.airline ?? null, - // CLASS label follows the TR-3B conversion so every downstream card - // (cockpit, Contacts, analyst) agrees with the triangle on screen. - typeName: tr3bTypeLabel(icao24, info?.typeName ?? null), - typeCode: tr3bTypeLabel(icao24, info?.typeCode ?? null), - // IDENTITY, deliberately NOT converted: registration is the airframe's tail - // number and feeds `_contactLabel`'s callsign → registration → hex chain, so - // a converted contact keeps the label convention every other contact uses. - // Trimmed like `callsign` above so every consumer (cockpit readout, voice - // narration, getTrackedSubject) can use it as a label link without - // re-guarding a whitespace-only enrichment value. - registration: _toCleanText(info?.registration) || null, - origin: info?.route && _routeIsPlausible(icao24, info.route) ? info.route.origin.code : null, - destination: info?.route && _routeIsPlausible(icao24, info.route) ? info.route.destination.code : null, - route: info?.route && _routeIsPlausible(icao24, info.route) ? { - origin: { ...info.route.origin }, - destination: { ...info.route.destination }, - } : null, - }; -} - -/** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {Cesium.Cartesian3} position - New fix position, appended at the head. - */ -function _appendTrailFix(position) { - _trailPositions.push(position); - if (_trailPositions.length > TRAIL_MAX_POINTS) _trailPositions.shift(); - _refreshTrailDisplay(); -} - -/** - * Renders the trail with its head clamped to the render-behind display - * position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the - * displayed aircraft (PRD C2 delayed clock) — drawing them verbatim makes - * the trail extend in FRONT of the icon. The head is refreshed ~1Hz from - * the fleet tick so it stays glued to the moving aircraft. - */ -function _refreshTrailDisplay() { - // The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest - // fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - // now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane. - // The cheap per-frame _trailHeadEntity segment bridges the last body point to the - // delayed dead-reckoned head, so the body primitive only rebuilds on a real fix - // (poll cadence), never at motion cadence. - if (!_trail) return; - _trail.setPositions(_trailPositions.length > 1 ? _trailPositions.slice(0, -1) : _trailPositions); -} - -/** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an OpenSky track backfill. - * @param {string} icao24 - ICAO 24-bit transponder address being tracked. - */ -function _startTrail(icao24) { - _trailBackfillToken += 1; - _trailPositions = []; - const history = _positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join the trail via _appendTrailFix - // as they age past the delay. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { - _trailPositions.push(Cesium.Cartesian3.clone(fix.position)); - } - } - if (!_trail && _viewer) { - _trail = createTrail(_viewer, { color: TRAIL_COLOR, width: 2.5 }); - } - _trail?.setVisible(!_cockpitContactMode); - // Live head segment: last fix → current dead-reckoned icon, updated every frame via - // a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the - // trail primitive which fully rebuilds on setPositions). Keeps the head glued to the - // 12 Hz icon instead of lagging ~1 s behind it. - if (!_trailHeadEntity && _viewer) { - _trailHeadEntity = _viewer.entities.add({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick - // owner so a click on the head segment never reads as empty space. - id: `gev-trail:fl-head-${++_trailHeadSeq}`, - show: !_cockpitContactMode, - polyline: { - positions: new Cesium.CallbackProperty(() => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — which is ~now, AHEAD of the delayed icon — - // so the segment would draw IN FRONT of the plane. Likewise during warm-up the - // icon predates all real history, so there is no valid body point behind it. - if (!_trackedIcao || _trailPositions.length < 2 || _isTrackWarmingUp()) return []; - const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = _trailPositions[_trailPositions.length - 2]; - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. - // See trailHeadStart. Read `head` in place and clone only on the draw - // path — a suppressed parked contact runs this every frame. - const from = trailHeadStart( - start, head, _trackedModelCenterWorld(), _trackedModelEnvelopeM(), _scratchTrailHead, - ); - if (!from) return []; - return [from, Cesium.Cartesian3.clone(head)]; - }, false), - width: 2.5, - material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads). - depthFailMaterial: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) - }, - }); - } - _refreshTrailDisplay(); - - const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 - : Infinity; - _backfillTrail(icao24, _trailBackfillToken, oldestFixEpochSec); -} - -/** - * Fire-and-forget OpenSky /tracks backfill (PRD F1). On success, splices - * waypoints strictly older than the oldest seeded fix AHEAD of the locally - * accumulated fine segment, capped at TRAIL_MAX_POINTS (newest kept). Any - * failure (404/429/timeout/malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO 24-bit transponder address being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ -async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let path = null; - try { - const response = await fetch('/api/opensky-track?icao24=' + encodeURIComponent(icao24), { - signal: AbortSignal.timeout(8000), - }); - if (!response.ok) return; - const data = await response.json(); - path = Array.isArray(data?.path) ? data.path : null; - } catch { - return; // silent fallback to the accumulated trail - } - if (!path || token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - // OpenSky track waypoints: [time, latitude, longitude, baro_altitude, true_track, on_ground] - // Height-datum fix (Task 6): /tracks only ever reports barometric/MSL altitude - // (no per-waypoint geo_altitude in this endpoint), so waypoint render height is - // the documented visual FALLBACK baroM + geoidHeight(waypointLat, waypointLon) - // — geometrically approximate, not exact, same honesty caveat as the live - // baro-fallback branch of pickRenderAltitudeM. - await ensureGeoidReady(); - const parsed = []; - for (const waypoint of path) { - if (!Array.isArray(waypoint)) continue; - const [time, lat, lon, baroAlt] = waypoint; - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, baroAlt }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06 — mirror of - // militaryFlights.js): resolve the coarse ellipsoidal ground along the track - // and floor every waypoint at it so low baro segments never dive below the - // mesh; a no-baro waypoint (predominantly taxi/ground segments in /tracks) - // sits ON the surface when the floor is known. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - const older = []; - let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet - for (const { lat, lon, baroAlt } of parsed) { - const baroM = Number.isFinite(baroAlt) ? baroAlt + geoidHeight(lat, lon) : null; - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // No baro + unresolved floor: hold the previous waypoint's altitude - // (continuity — never a dive/spike to a made-up height). Leading points - // with nothing to carry keep the old 10 km airborne default. - if (altM == null) altM = lastAltM != null ? lastAltM : 10000; - lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); - } - - _trailPositions = older.concat(_trailPositions); - if (_trailPositions.length > TRAIL_MAX_POINTS) { - _trailPositions = _trailPositions.slice(_trailPositions.length - TRAIL_MAX_POINTS); - } - _refreshTrailDisplay(); -} - -/** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ -function _clearTrail() { - _trailBackfillToken += 1; - _trailPositions = []; - if (_trail) _trail.clear(); - if (_trailHeadEntity && _viewer && !_viewer.isDestroyed()) { - try { _viewer.entities.remove(_trailHeadEntity); } catch { /* already gone */ } - } - _trailHeadEntity = null; -} - -/** - * Destroy the trail primitive entirely (layer disable/teardown). - */ -function _destroyTrail() { - _clearTrail(); - if (_trail) { - _trail.destroy(); - _trail = null; - } -} - -/** - * Stop tracking the currently followed aircraft. - * - * Restores the hidden billboard, removes the tracked Entity, resets lerp - * state, and RELEASES the camera IN PLACE — no flyTo. Deselect used to fly - * an ~80 km pulled-back overview; the owner field-ruled that wrong - * (2026-07-02: "it randomly zooms way up and loses my context"). The camera - * now simply stays at its current position/orientation, immediately free to - * orbit/zoom. Applies to every deselect path: click-empty-space, Escape, - * aged-out plane, layer disable, and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ -function _clearTracking(skipViewerUntrack = false, { - evicted = false, - origin = 'programmatic', -} = {}) { - _trackedCameraFrameStop?.(); - _trackedCameraFrameStop = null; - if (!_trackedIcao) { - clearFocusTarget('flights'); - return; - } - const clearedIcao = _trackedIcao; - clearFocusTarget('flights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (_billboards.has(_trackedIcao)) { - const bb = _billboards.get(_trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground/military-aware restore (a plane untracked while taxiing must come - // back muted-gray at ground scale, not white at full scale). - bb.color = _fleetBillboardColor(_trackedIcao); - bb.scale = _fleetBillboardScale(_trackedIcao, _flightData.get(_trackedIcao)?.klass); - bb.rotation = _lastTrackedRotation; - } - _lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the - // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner - // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity - // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where - // the follow left it and the user can immediately orbit/zoom. - if (_viewer && !skipViewerUntrack) { - _viewer.trackedEntity = undefined; - } - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - _trackedIcao = null; - _applyFleetBillboardPresentation(clearedIcao, _billboards.get(clearedIcao)); - clearTrackedSubjectContext('flights'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'flights', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache + reconciliation state so a same-frame re-track - // cannot read the previous aircraft's cached/smoothed position. - _resetTrackedDisplay(); - _clearTrail(); -} - -function _normalizeTrackedIcao(candidate) { - const normalized = String(candidate ?? '').trim().toLowerCase(); - return normalized || null; -} - -function _isUsableOpenSkyState(state) { - if (!Array.isArray(state) || typeof state[0] !== 'string' || !_normalizeTrackedIcao(state[0])) { - return false; - } - return Number.isFinite(state[5]) && Number.isFinite(state[6]); -} - -/** - * Whether the Military layer suppresses this civil duplicate right now. - * - * The dedicated Military layer owns icon/track/click for known-military - * contacts, so the OpenSky duplicate is dropped while that layer is on. Two - * contacts are exempt, both for the same reason — this layer still owns them: - * - * - the CURRENTLY tracked one, which hands off on untrack; and - * - a target this layer is holding on its deferred-restore latch. - * - * The second exemption is what makes a shared/local Follow of a mil-registry - * hex restorable at all. The accepted-snapshot id set is built BEFORE this - * suppression runs, so without it the target is provably present in a healthy - * feed yet has no billboard, `trackById` fails, and the restore reports - * "feed unavailable" about a feed that was perfectly fine. - * - * @param {string} icao24 - Normalized ICAO 24-bit address. - * @returns {boolean} True when the civil duplicate must be dropped. - */ -function _militaryLayerSuppresses(icao24) { - if (!isMilitaryLayerActive()) return false; - if (icao24 === _trackedIcao) return false; - if (icao24 === _pendingTrackingRestore?.id) return false; - return true; -} - -function _applyPendingTrackingRestore() { - const pending = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration) return false; - if (!_billboardCollection?.show || !_billboards.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _pendingTrackingRestore = null; -} - -/** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" + - * "ORIG → DEST". The route line is gated by - * routePlausible so a wrong-leg adsbdb route is hidden, not displayed. - * While the plane is in its missed-poll grace (coasting on dead reckoning - * with sticky metadata), the first line carries a "· STALE" cue — the fleet's - * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity - * owns the visual), so without this the readout would present last-known - * velocity/altitude as live. */ -function _trackedLabelText(icao24) { - const info = _flightData.get(icao24); - if (!info) return icao24; - // A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)` - // kept it, then .trim() emptied it → the callsign slot dropped out of the - // readout. `_contactLabel` trims FIRST, then falls through registration to - // the ICAO hex, so a callsign-less enriched contact heads its readout with - // the tail number rather than raw hex. - const cs = _contactLabel(icao24, info); - const altFt = Math.round((info.altitude || 0) * 3.28084); - const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`; - const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : ''; - const stale = (_missingPolls.get(icao24) || _backoff) ? 'STALE' : ''; - const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')]; - // Converted contacts report their class as TR-3B and nothing else — the - // operator/type identity is exactly what the Easter egg is replacing. - const ident = isTr3b(icao24) - ? tr3bTypeLabel(icao24) - : [info.airline, info.typeName || info.typeCode].filter(Boolean).join(' · '); - if (ident) lines.push(ident); - if (info.route && _routeIsPlausible(icao24, info.route)) { - lines.push(`${info.route.origin.code} → ${info.route.destination.code}`); - } - return lines.join('\n'); -} - -/** Write the explicit tracked presentation model and refresh its host entry. */ -function _updateTrackedLabelModel(icao24) { - if (!_trackedEntity || icao24 !== _trackedIcao) return; - _trackedEntity.gevLabelModel = trackedLabelModelFromText( - _trackedLabelText(icao24), - '#39d0ff', - ); - refreshTrackedReadout(_trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); -} - -/** Re-image the tracked entity's billboard from the current class/conversion. */ -function _syncTrackedBillboardImage() { - if (!_trackedIcao || !_trackedEntity?.billboard) return; - _trackedEntity.billboard.image = aircraftIcon( - _iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), - TRACKED_ICON_PX, - ); -} - -/** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Converting drops any 3D model so the triangle owns the visual; the - * billboard image, tracked entity, and tracked card are all re-derived here. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ -function _refreshTr3bContact(icao24) { - const id = String(icao24 || '').trim().toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - // Drop the 3D handoff for this contact — the fleet tick now skips it, so - // an already-loaded model would otherwise linger with the billboard hidden. - if (_models.has(id) || _modelPending.has(id)) _releaseModel(id); - const bb = _billboards.get(id); - if (bb && id !== _trackedIcao) bb.show = true; // horizon cull re-asserts next tick - if (id === _trackedIcao) { - _releaseTrackedModel(); - _syncTracked2dRotation(); - } - } - const bb = _billboards.get(id); - if (bb) _applyFleetBillboardPresentation(id, bb); - if (id === _trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); - } - _viewer?.scene?.requestRender?.(); - return _billboards.has(id) || id === _trackedIcao; -} - -/** Re-image every converted contact this layer owns (IR style flip). */ -function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (_billboards.has(id) || id === _trackedIcao) _refreshTr3bContact(id); - } -} - -/** - * Seed only the mutable state needed to exercise tracked-card refreshes through - * the production poll reconciler. Tests still call `flightsLayer.update()`; - * this seam avoids constructing the browser-only Cesium layer lifecycle. - * @param {object} state - * @param {string} state.icao24 - * @param {object} state.entity - * @param {object} state.meta - * @param {object} state.billboard - * @param {object} state.billboardCollection - * @param {object} state.viewer - * @param {Array} [state.history=[]] - * @param {boolean} [state.tracked=true] - * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed - * by icao24, for the billboard-hidden/model-shown handoff state. - */ -export function _setTrackedFlightRefreshStateForTest({ - icao24, - entity, - meta, - billboard, - billboardCollection, - viewer, - history = [], - tracked = true, - models = [], - modelCollection = null, -}) { - _viewer = viewer; - _modelCollection = modelCollection; - _billboardCollection = billboardCollection; - _billboards = new Map([[icao24, billboard]]); - _models.clear(); - for (const [key, model] of models) _models.set(key, model); - _detectionObjects = new Map(); - _flightData = new Map([[icao24, meta]]); - _positionHistory = new Map([[icao24, history]]); - _missingPolls = new Map(); - _displayCourse.clear(); - _geoidNCache.clear(); - _trackedIcao = tracked ? icao24 : null; - _trackedEntity = tracked ? entity : null; - _trackedModel = null; - _cancelPendingTrackingRestore(); - _trackedModelLoading = false; - // NOTE: deliberately does NOT reset the per-selection latches. They are - // production state owned by the tracking lifecycle (_resetTrackedSelectionState), - // and clearing them here would mask exactly the deselect→re-track hole this - // seam is used to test. - _backoff = false; - _retryAt = 0; -} - -/** Seed the authoritative snapshot outcome used by share-Follow tests. */ -export function _setFlightTrackingRefreshOutcomeForTest({ - status = 'accepted', - ids = [], - source = 'OpenSky Network', - coverage = 'test', -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch, - status, - ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), - source, - coverage, - }; -} - -/** Add a cached contact so tests can model a target arriving on a later feed. */ -export function _addFlightTrackingCandidateForTest({ icao24, meta, billboard, history = [] }) { - _billboards.set(icao24, billboard); - _flightData.set(icao24, meta); - _positionHistory.set(icao24, history); -} - -/** Expose the military-suppression decision for the civil duplicate. */ -export function _militaryLayerSuppressesForTest(icao24) { - return _militaryLayerSuppresses(icao24); -} - -/** Arm the deferred restore latch directly, without a full setParams turn. */ -export function _armFlightTrackingRestoreForTest(id, origin = 'share-restore') { - _pendingTrackingRestore = id === null - ? null - : { id, generation: _trackingIntentGeneration, origin }; -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingFlightTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated feed refresh. */ -export function _applyPendingFlightTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Set the exact Cockpit subject through the production state transition for focused tests. */ -export function _setCockpitDetectionSubjectForTest(active, subjectId = null) { - _applyCockpitState({ active, subjectId }); -} - -/** Evaluate the TRACKED contact's zoom regime through the production predicate. - * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) - * and otherwise only observable through a live scene, so tests drive it here. */ -export function _trackedModelRegimeActiveForTest() { - return _trackedModelRegimeActive(); -} - -/** Run one frame of the production tracked-model driver (normally a - * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ -export function _updateTrackedModelForTest() { - return _updateTrackedModel(); -} - -/** Evaluate the production tracked-billboard handoff colour for focused tests. */ -export function _trackedBillboardColorForTest() { - return _modelOwnsVisual(_trackedIcao) ? CYAN_TRANSPARENT : Cesium.Color.CYAN; -} - -/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ -export function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { - return _driveFleetModelHandoff( - icao24, - _models.get(icao24), - _billboards.get(icao24), - position, - course, - ); -} - -/** Exercise the exact asynchronous fleet loader and return its admitted model. */ -export async function _ensureFleetModelForTest(icao24) { - await _ensureModel(icao24); - return _models.get(icao24) || null; -} - -/** Plausibility check anchored to the plane's billboard position (coarse is - * fine here — this gates a LABEL, and it must not touch the tracked frame - * cache). Missing data → true (never hide what we can't judge). */ -function _routeIsPlausible(icao24, route) { - const info = _flightData.get(icao24); - const bb = _billboards.get(icao24); - if (!info || !bb || !bb.position) return true; - const carto = Cesium.Cartographic.fromCartesian(bb.position, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) return true; - return routePlausible({ - latDeg: Cesium.Math.toDegrees(carto.latitude), - lonDeg: Cesium.Math.toDegrees(carto.longitude), - altitudeM: info.altitude ?? null, - verticalRateMps: info.verticalRate ?? null, - origin: route.origin, - destination: route.destination, - }); -} - -/** - * Begin tracking a specific aircraft by ICAO24 address. - * - * Clears any existing tracked flight, hides its billboard, and creates a - * new Entity with: - * - A CallbackProperty position driven by dead-reckoning (_deadReckon). - * - A CallbackProperty alignedAxis set to the surface normal at the - * dead-reckoned position (keeps the icon tangent to the earth). - * - A CallbackProperty rotation from the aircraft's true_track heading. - * - An explicit host presentation model with callsign, flight level, speed, - * identity, and plausible route text. - * - * The viewer's trackedEntity is set to this entity so the camera follows it. - * - * @param {string} icao24 - ICAO 24-bit transponder address to track. - */ -function _trackFlight(icao24, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb || !info) return; - - _trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - _cachedDRFrame = -1; - _lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - _releaseModel(icao24); - - // Hide the billboard — the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, - // with discontinuity reconciliation). Falls back to the last billboard position when - // the aircraft has no fix. - const getTrackedPosition = () => _trackedDisplayPosition(icao24) || bb.position; - - // Dead-reckoning position property — smooth continuous motion between API updates. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the - // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the - // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). - // Keep Cesium's generated entity ID: re-init without destroy can temporarily - // overlap collections, and an explicit ICAO ID would throw on that duplicate. - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - // Force Cesium's built-in EntityView and our close-range camera guard to - // use the same local frame. AUTO can select a velocity frame while the - // model matrix uses aircraft orientation; alternating between those - // frames makes the target oscillate forward/back on screen. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon(_iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), TRACKED_ICON_PX), - width: 28, - height: 28, - scale: CLASS_SCALE_2D[_flightData.get(_trackedIcao)?.klass] || 1, - // Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: new Cesium.CallbackProperty(() => ( - _modelOwnsVisual(_trackedIcao) ? CYAN_TRANSPARENT : Cesium.Color.CYAN - ), false), - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the cyan icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: new Cesium.CallbackProperty(() => { - const tracked = _flightData.get(_trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !_viewer) return _lastTrackedRotation; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rot = stabilizeScreenRotation(_lastTrackedRotation, projected); - if (rot !== null) _lastTrackedRotation = rot; - return _lastTrackedRotation; - }, false), - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `flights:${icao24}`; - _trackedEntity.gevLabelModel = trackedLabelModelFromText(_trackedLabelText(icao24), '#39d0ff'); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const followRange = Math.min(Math.max((info.altitude || 1500) * 1.1 + 2500, 3000), 30000); - _trackedEntity.viewFrom = new Cesium.Cartesian3(0, -followRange * 0.8, followRange * 0.55); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the - // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which - // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it - // the way a click (idle camera) does. - // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target - // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in - // postRender (which would jitter the label against the now-stable plane). - _trackedEntity.gevDisplayPosition = _trackedDisplayCached; - // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera - // anti-jitter contract and must keep returning the cached DR position. Presentation - // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. - _trackedEntity.gevVisualPosition = _trackedVisualCached; - refreshTrackedReadout(_trackedEntity); - _viewer.camera.cancelFlight(); - // Camera follows the tracked entity - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - - // Track-history trail (PRD F1): seed from local history + async backfill. - // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the - // plane takes off while tracked, the on_ground→false transition in update() - // starts one. - _requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue - _requestRouteEnrichment(icao24); - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Grounded flights positions - // are already surface-clamped (the surfaceM chain), so seeds/appends drape. - _startTrail(icao24); - - _publishTrackedSelection(icao24, origin); - - console.log(`[Data:Flights] Tracking ${_contactLabel(icao24, info)} (${icao24})`); -} - -/** - * Immediate military-suppression handoff sweep (pre-ship audit M2). - * - * The poll-time suppression branch in update() only reconciles every 30 s, so - * toggling the Military layer showed duplicate icons (military ON: both layers - * render the same aircraft ~3-4 km apart) or holes (military OFF: suppressed - * aircraft absent) for up to a full poll. Fired synchronously by the registry - * on the active-state TRANSITION: - * - * - activated → suppress known-military billboards NOW (mirror of the - * poll-time branch, tracked aircraft excluded — it hands off on untrack); - * - deactivated → the suppressed aircraft's state was deleted, so bring the - * next OpenSky poll forward instead of waiting out the interval (update() - * itself still honors _retryAt backoff). - * - * @param {boolean} active - New military-layer active state. - * @returns {void} - */ -function _onMilitaryActiveChange(active) { - if (!_viewer || !_billboardCollection) return; - if (active) { - for (const [icao24, bb] of _billboards) { - if (!isMilitaryIcao(icao24) || icao24 === _trackedIcao) continue; - _billboardCollection.remove(bb); - _billboards.delete(icao24); - _releaseModel(icao24); // military-suppression: drop any 3D model too - _flightData.delete(icao24); - _positionHistory.delete(icao24); - _displayCourse.delete(icao24); - _groundSnap.forget(icao24); - _missingPolls.delete(icao24); - } - _count = _billboards.size; - } else if (_billboardCollection.show) { - // Fire-and-forget refresh; only while the layer is actually enabled. - void flightsLayer.update(_viewer); - } -} - -/** - * Map one aircraft's internal poll record to a plain JSON-safe analyst - * record (analyst query engine seam). Pure — no Cesium types, no fetches; - * enrichment fields read the CACHED adsbdb values only. Missing/unknown - * fields are null, never NaN/undefined. The route-plausibility verdict is - * computed by the CALLER (it needs the billboard position) and passed in, - * so an implausible cached route is never surfaced as fact. - * @param {string} icao24 - ICAO 24-bit transponder address. - * @param {Object|null|undefined} info - `_flightData` record for this aircraft. - * @param {{military?: boolean, routeOk?: boolean}} [flags] - Shared-registry - * military flag + route-plausibility verdict. - * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, - * lon: number|null, altitudeM: number|null, speedMps: number|null, - * heading: number|null, verticalRateMps: number|null, onGround: boolean, - * military: boolean, aircraftClass: string|null, originCountry: string|null, - * operator: string|null, routeOrigin: string|null, routeDestination: string|null}} - */ -export function mapAnalystRecord(icao24, info, { military = false, routeOk = false } = {}) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const callsign = text(info?.callsign); - return { - // Display identity for the narration layer. `id` is NOT a queryable field - // (see ANALYST_LAYERS) and follow-ups carry whole records, so this is a - // label, not a key — the engine keys on `icao24` below. - id: callsign || text(info?.registration) || icao24, - icao24, - callsign, - lat: num(info?.rawLat), - lon: num(info?.rawLon), - altitudeM: num(info?.altitude), // barometric/MSL — the aviation field, not the render height - speedMps: num(info?.velocity), - heading: num(info?.true_track), - verticalRateMps: num(info?.verticalRate), - onGround: info?.onGround === true, - military, - // A converted contact reports the class it RENDERS as, so an analyst - // filter/superlative agrees with the triangle on screen. - aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), - originCountry: text(info?.originCountry), - operator: text(info?.airline), - routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, - routeDestination: routeOk ? text(info?.route?.destination?.code) : null, - }; -} - -/** Resolve a JSON-safe evidence position into ECEF. DEV-only caller. */ -function _focusEvidencePosition(record) { - const cartesian = record?.cartesian; - if (Array.isArray(cartesian) && cartesian.length >= 3 - && cartesian.slice(0, 3).every(Number.isFinite)) { - return Cesium.Cartesian3.fromElements(cartesian[0], cartesian[1], cartesian[2]); - } - if (!Number.isFinite(record?.longitude) || !Number.isFinite(record?.latitude)) return null; - return Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000, - ); -} - -/** Replace the real fleet with deterministic explicit-position contacts. */ -function _setFocusEvidenceAircraft(records = []) { - if (!FOCUS_EVIDENCE_DEV || !_billboardCollection || !_viewer) return { ok: false, count: 0 }; - if (_trackedIcao) _clearTracking(); - _releaseModels(); - for (const bb of _billboards.values()) _billboardCollection.remove(bb); - _billboards.clear(); - _flightData.clear(); - _positionHistory.clear(); - _displayCourse.clear(); - _missingPolls.clear(); - _focusEvidenceIds.clear(); - - for (const record of Array.isArray(records) ? records : []) { - const id = String(record?.id || '').trim().toLowerCase(); - const position = _focusEvidencePosition(record); - if (!id || !position) continue; - const klass = record.klass || 'airliner'; - const altitudeM = Number.isFinite(record.altitudeM) - ? record.altitudeM - : (Cesium.Cartographic.fromCartesian(position)?.height || 3_000); - const meta = { - callsign: String(record.callsign || id).toUpperCase(), - altitude: altitudeM, - renderAltitudeM: altitudeM, - velocity: Number.isFinite(record.velocityMps) ? record.velocityMps : 0, - true_track: Number.isFinite(record.trackDeg) ? record.trackDeg : 90, - klass, - onGround: false, - wasAirborne: true, - turnRateDps: 0, - lastContactEpochMs: Date.now(), - rawLat: record.latitude ?? null, - rawLon: record.longitude ?? null, - cullPosition: null, - }; - _flightData.set(id, meta); - _focusEvidenceIds.add(id); - const bb = _billboardCollection.add({ - position, - image: aircraftIcon(_iconKind(id, klass)), - width: 20, - height: 20, - scale: _fleetBillboardScale(id, klass), - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: _fleetBillboardColor(id), - sizeInMeters: false, - scaleByDistance: _normalBillboardScaleByDistance(), - disableDepthTestDistance: _groundDepthDistance(), - id, - show: true, - }); - _billboards.set(id, bb); - } - _count = _billboards.size; - _lastFleetTickMs = 0; - _viewer.scene.requestRender?.(); - return { ok: true, count: _count }; -} - -/** Update explicit evidence positions without rebuilding billboards. */ -function _moveFocusEvidenceAircraft(records = []) { - if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 }; - let moved = 0; - for (const record of Array.isArray(records) ? records : []) { - const id = String(record?.id || '').trim().toLowerCase(); - if (!_focusEvidenceIds.has(id)) continue; - const position = _focusEvidencePosition(record); - const bb = _billboards.get(id); - if (!position || !bb) continue; - bb.position = position; - const meta = _flightData.get(id); - if (meta) { - if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; - if (Number.isFinite(record.velocityMps)) meta.velocity = record.velocityMps; - } - moved += 1; - } - _lastFleetTickMs = 0; - _viewer?.scene?.requestRender?.(); - return { ok: true, moved }; -} - -/** JSON-safe visual snapshot for the evidence report. */ -function _focusEvidenceSnapshot() { - if (!FOCUS_EVIDENCE_DEV || !_viewer) return []; - return [..._focusEvidenceIds].map((id) => { - const bb = _billboards.get(id); - const screen = bb?.position - ? Cesium.SceneTransforms.worldToWindowCoordinates(_viewer.scene, bb.position) - : null; - return { - id, - show: bb?.show === true, - scale: bb?.scale ?? null, - alpha: bb?.color?.alpha ?? null, - x: screen?.x ?? null, - y: screen?.y ?? null, - cameraDistanceM: bb?.position - ? Cesium.Cartesian3.distance(_viewer.camera.positionWC, bb.position) - : null, - }; - }); -} - -/** - * Flights data-layer descriptor. - * Conforms to the layer manager interface: init / enable / disable / update / destroy / getStats. - * @type {object} - */ -const flightsLayer = { - id: 'flights', - name: 'Live Flights', - icon: '✈️', - source: 'OpenSky Network', - // Browser-harness seam: isolates synthetic display-floor scenarios without - // changing any production lifecycle or cache policy. - _clearDisplayFloorStateForTest, - /** @type {number} Polling interval (ms) between update() calls */ - updateInterval: 30000, - - /** - * Initialize the flights layer. - * Creates the BillboardCollection, resets all state, and installs the - * click-to-track handler on the scene canvas. - * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. - */ - init(viewer) { - clearFocusTarget('flights'); - _focusEvidenceIds.clear(); - _viewer = viewer; - _billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(_billboardCollection); - registerSpriteCollection('flights', _billboardCollection); - _modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(_modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!_preloadModel) { - const epoch = _modelEpoch; - Cesium.Model.fromGltfAsync({ url: PLANE_MODEL_URL, asynchronous: false }) - .then((m) => { - if (epoch === _modelEpoch) { _preloadModel = m; _planeModelLoaded = true; } - else { try { m.destroy(); } catch { /* gone */ } } - }) - .catch(() => { /* tracked plane just stays a billboard a beat longer */ }); - } - _billboards = new Map(); - _detectionObjects = new Map(); - _flightData = new Map(); - _positionHistory = new Map(); - _displayCourse.clear(); - _groundSnap.clear(); - _count = 0; - _lastUpdate = null; - _backoff = false; - _retryAt = 0; - _lastError = null; - _lastStatus = null; - _lastSource = 'OpenSky Network'; - _lastCoverage = 'worldwide upstream snapshot'; - _trackedIcao = null; - _resetTrackedSelectionState(); - _trackedEntity = null; - _cockpitSubjectId = null; - _cockpitContactMode = document.body.classList.contains('cockpit-mode'); - _cockpitNearContacts = new Set(); - if (!_cockpitModeListener) { - _cockpitModeListener = (event) => _applyCockpitState(event?.detail); - window.addEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - } - // Fresh session — full bucket, anchor re-seeded on the first sweep. - _enrichAmbientBudget = _ambientBudgetKnobs().ceil; - _enrichAmbientRefillAnchorMs = 0; - - _installClickHandler(viewer); - - // React to Military-layer toggles IMMEDIATELY (suppress/restore sweep) - // instead of waiting out the 30 s poll (M2). - if (!_milActiveChangeUnsub) { - _milActiveChangeUnsub = onMilitaryLayerActiveChange(_onMilitaryActiveChange); - } - - restoreSpriteOrder(viewer); - - console.log('[Data:Flights] Initialized with billboard icons'); +import { createCivilFlightLayer } from '../layers/flights/index.js'; +import { createOpenSkySource } from '../sources/live/standalone.js'; +import * as picking from './pickRegistry.js'; +import * as sprites from './spriteOrder.js'; +import * as trails from './trailRenderer.js'; +import * as aircraftPresentation from './tr3bRegistry.js'; +import * as camera from './trackedCamera.js'; +import * as militaryRegistry from './militaryRegistry.js'; +import * as labels from './detectionDraw.js'; +import * as groundFloor from './groundFloor.js'; +import * as meshFloor from './meshFloorSampler.js'; +import * as geoid from './geoid.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as groundSnap from './groundSnap.js'; +import * as recession from './aircraftRecession.js'; + +const flightsLayer = createCivilFlightLayer({ + source: createOpenSkySource(), + resolveAsset: (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, }, - - /** - * Show the billboard collection and re-install the click handler. - * @param {Cesium.Viewer} viewer - */ - enable(viewer) { - if (_billboardCollection) _billboardCollection.show = true; - holdContinuousRender('flights'); // per-frame animator (perf wave 2) - if (_modelCollection) _modelCollection.show = true; - _setCockpitContactMode(document.body.classList.contains('cockpit-mode')); - // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop - // only reads geoidHeight() synchronously after this resolves (guarded by - // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. - if (!_geoidReady) { - ensureGeoidReady() - .then(() => { _geoidReady = true; }) - .catch(() => { /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); - } - _installClickHandler(viewer); - registerPickOwner('flights', (pickedId) => _billboards.has(pickedId)); - // Force a fresh rotation pass on the first tick after re-enable - _lastCamPoseSig = ''; - if (!_preRenderRemove && viewer?.scene) { - _preRenderRemove = viewer.scene.preRender.addEventListener(_fleetTick); - } - if (!_trackedModelPreUpdateRemove && viewer?.scene) { - _trackedModelPreUpdateRemove = viewer.scene.preUpdate.addEventListener(_updateTrackedModel); - } - if (!_moveEndRemove && viewer?.camera) { - // Arrival polish (field test 2026-07-03: "planes look weird when you first - // come to them"): when a camera move SETTLES (voice fly-to, fast pan - // release), force a full rotation pass on the very next frame. The - // pose-signature gate alone can eat the settle — the final easing frames - // of a flight land inside one quantization bucket (10 m / 0.06°), leaving - // every icon wearing its last mid-flight rotation for up to - // ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs - // on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one - // extra rotation pass per completed camera gesture — nothing per-frame. - _moveEndRemove = viewer.camera.moveEnd.addEventListener(() => { - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; - }); - } - restoreSpriteOrderOnEnable('flights', viewer); - }, - - /** - * Hide all flight billboards and tear down click/keyboard handlers. - * Also clears any active flight tracking so the camera is released. - * @param {Cesium.Viewer} viewer - */ - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - if (_billboardCollection) _billboardCollection.show = false; - releaseContinuousRender('flights'); - _releaseModels(); - if (_modelCollection) _modelCollection.show = false; - _clearTracking(); - _destroyTrail(); - // Remove click handler + keydown listener while disabled to avoid - // intercepting input when the layer is off - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('flights'); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - }, - - /** - * Fetch the latest aircraft state vectors from the OpenSky proxy and - * reconcile them with the billboard collection. - * - * Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors - * with exponential-ish backoff. On success, adds/updates/removes - * billboards and position history, triggers lerp blending for the - * tracked aircraft, and updates its label text. - * - * @param {Cesium.Viewer} viewer - * @returns {Promise} - */ - async update(viewer, { signal = null } = {}) { - const nowMs = Date.now(); - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - ids: new Set(), - source: _lastSource, - coverage: _lastCoverage, - }; - if (_retryAt && nowMs < _retryAt) { - _backoff = true; - return; - } - - const resourceController = new AbortController(); - _activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - const response = await fetch(_flightApiUrl(viewer || _viewer), { signal: updateSignal }); - _lastStatus = response.status; - const responseSource = response.headers.get('x-flight-source'); - const responseCoverage = response.headers.get('x-flight-coverage'); - const authMode = _toLowerText( - response.headers.get('x-opensky-auth-mode-used') || response.headers.get('x-opensky-auth') - ); - const authReason = _toLowerText(response.headers.get('x-opensky-auth-reason')); - - if (response.status === 429) { - console.warn('[Data:Flights] Rate limited, backing off to 30s'); - _backoff = true; - _retryAt = nowMs + BACKOFF_INTERVAL; - _lastError = authMode && authMode !== 'anon' - ? 'OpenSky rate limited' - : 'OpenSky rate limited (anonymous)'; - return; - } - - if (response.status === 401 || response.status === 403) { - console.warn(`[Data:Flights] OpenSky unavailable (${response.status}), backing off`); - _backoff = true; - _retryAt = nowMs + BACKOFF_INTERVAL; - let detail = ''; - try { - const body = await response.json(); - updateSignal.throwIfAborted(); - detail = typeof body?.error === 'string' ? body.error.trim() : ''; - } catch { - detail = ''; - } - _lastError = _deriveOpenSkyAuthError({ - detail, - authMode, - authReason, - }); - return; - } - - if (!response.ok) { - console.warn(`[Data:Flights] API returned ${response.status}`); - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - let detail = ''; - try { - const body = await response.json(); - updateSignal.throwIfAborted(); - detail = typeof body?.error === 'string' ? body.error.trim() : ''; - } catch { - detail = ''; - } - _lastError = detail || `OpenSky HTTP ${response.status}`; - return; - } - - const data = await response.json(); - updateSignal.throwIfAborted(); - if (!data || !Array.isArray(data.states)) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed OpenSky response'; - return; - } - - const usableStates = data.states.filter(_isUsableOpenSkyState); - if (data.states.length > 0 && usableStates.length === 0) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed OpenSky aircraft rows'; - return; - } - - const sourceEpochMs = Number.isFinite(Number(data.time)) && Number(data.time) > 0 - ? Number(data.time) * 1000 - : null; - const sourceAgeMs = sourceEpochMs == null ? 0 : Math.max(0, Date.now() - sourceEpochMs); - const sourceStale = sourceAgeMs > SOURCE_STALE_MS; - _backoff = sourceStale; - _retryAt = 0; - _lastError = sourceStale - ? `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old` - : null; - _lastSource = responseSource || 'OpenSky Network'; - _lastCoverage = responseCoverage || 'worldwide upstream snapshot'; - const currentIcaos = new Set(); - const acceptedSnapshotIcaos = new Set(); - const now = Cesium.JulianDate.now(); - // Field-test round 3 (2026-07-06, Austin fleet-underground): viewer - // subpoint + collected floor cells for the viewer-proximate low-contact - // clamp below — one carto read per poll, one batch warm after the loop. - const viewerCarto = (viewer || _viewer)?.camera?.positionCartographic || null; - const viewerLatDeg = viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) : null; - const viewerLonDeg = viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : null; - const floorWarmPoints = []; - - // Destructure OpenSky state vector array (indices per API spec): - // [0] icao24, [1] callsign, [2] origin_country, [3] time_position, - // [4] last_contact, [5] longitude, [6] latitude, [7] baro_altitude, - // [8] on_ground, [9] velocity, [10] true_track, [11] vertical_rate, - // [12] sensors, [13] geo_altitude (WGS84 ellipsoidal — the CORRECT - // globe-render height when present; height-datum fix Task 6). - // Keep military classification fresh while the military layer is off - refreshMilitaryRegistryIfStale(); - - for (const state of usableStates) { - const [rawIcao24, callsign, origin_country, time_position, last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, , , geo_alt] = state; - const icao24 = _normalizeTrackedIcao(rawIcao24); - const category = Number.isFinite(state[17]) ? state[17] : null; // extended=1 emitter category - const vertical_rate = Number.isFinite(state[11]) ? state[11] : null; // m/s, + = climbing - acceptedSnapshotIcaos.add(icao24); - const onGround = on_ground === true; - - // Known-military aircraft: the dedicated military layer wins - // (icon + track + click) while it is enabled — suppress the - // OpenSky duplicate entirely (except a currently tracked one, - // which hands off on untrack). - const isMil = isMilitaryIcao(icao24); - if (isMil && _militaryLayerSuppresses(icao24)) { - const dupe = _billboards.get(icao24); - if (dupe) { - _billboardCollection.remove(dupe); - _billboards.delete(icao24); - _releaseModel(icao24); // military-suppression: drop any 3D model too - _flightData.delete(icao24); - _positionHistory.delete(icao24); - _displayCourse.delete(icao24); - _groundSnap.forget(icao24); - _missingPolls.delete(icao24); - _geoidNCache.delete(icao24); - } - continue; - } - - currentIcaos.add(icao24); - _missingPolls.delete(icao24); - // Sticky merge: OpenSky intermittently drops callsign/velocity/track for - // aircraft it still positions — hold last-known-good instead of - // regressing to the ICAO hex / a 0° (north) heading. Bounded by the - // MISSING_POLL_LIMIT eviction below, which deletes the whole entry. - const prevMeta = _flightData.get(icao24); - // Grounded planes with no baro reading sit at 0 m, not the 10 km - // airborne default (a parked plane must never float). - // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky - // barometric/MSL altitude read by labels (FL/altitude readout), - // route-plausibility, and follow-camera range heuristics. It is - // NEVER overwritten or renamed. Where the aircraft actually RENDERS - // on the ellipsoidal globe is a SEPARATE value (renderAltitudeM, - // below) — geo_altitude when OpenSky reports it (already WGS84 - // ellipsoidal), else baro+geoid as a visual fallback, else ground - // surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM, - // never `alt` directly. - const alt = stickyNumber(baro_alt, prevMeta?.altitude, onGround ? 0 : 10000); - - // geoid undulation N: cached per-aircraft (negligible drift — see - // task brief) once the geoid grid has loaded; unavailable pre-load - // just means the baro fallback branch below adds N=0 for a beat. - let geoidN = _geoidNCache.get(icao24); - if (geoidN === undefined && _geoidReady) { - geoidN = geoidHeight(lat, lon); - _geoidNCache.set(icao24, geoidN); - } - - // on_ground surface prior: ONLY synchronous warm-cache reads here — - // never a per-aircraft network fetch inside the poll loop (see the - // batch resolve call below, which fills this cache for NEXT poll). A - // Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied): - // the grounded surface is the round-4 choke point and nothing else — - // rendered-mesh cell first, real (never fallback-poisoned) DEM cell - // second. The old exact-5-decimal warm chain is GONE: it minted a new - // key per parked-jitter poll for every grounded contact ON EARTH, - // hammering Re:Earth into the very failures that poisoned the cache. - let surfaceM = null; - if (onGround) { - surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell) - // Taxiing crosses into a fresh cold cell every poll — always one - // step ahead of the warm batch — so fall back to LAST poll's cell - // (warmed by last poll's batch; aprons are flat across adjacent - // 111 m cells). Round-5 verify caught taxiing contacts stuck at - // the geoid without this (round 2's lesson, at cell granularity). - if (surfaceM == null && Number.isFinite(prevMeta?.rawLat) && Number.isFinite(prevMeta?.rawLon)) { - surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon); - } - // Grounded contacts near the viewer feed the floor warm/sampler - // (the only ones whose exact height is visible; far contacts are - // subpixel and always-on-top anyway). - if (viewerLatDeg != null && - _approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <= GROUND_FLOOR_CLAMP_RADIUS_KM) { - floorWarmPoints.push({ lat, lon }); - } - // Last synchronous resort for a BRAND-NEW grounded contact with NO - // altitude data at all (nothing warm yet, not even the coarse - // cell): the geoid surface. At the sea-level airports where most - // grounded traffic sits, geoidN IS the local ellipsoidal ground to - // within metres — instantly right — and at elevated fields it is - // far less wrong than the raw 0 m ellipsoid default for the one - // poll until the coarse cell warms. STRICTLY gated on "no geo, no - // baro": a reported baro already reflects the field elevation, and - // pickRenderAltitudeM's surfaceM branch would let this crude guess - // outrank it (caught by the ground-3d track regression). - // - // 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds - // one more gate — a contact that already HAS a render height keeps - // it. Leaving surfaceM null routes it through the sentinel path - // below, which holds that height. - if (surfaceM == null) { - surfaceM = geoidSurfaceLastResortM({ - geoAltM: geo_alt, - baroAltM: baro_alt, - priorRenderM: prevMeta?.renderAltitudeM, - geoidN, - }); - } - } - - const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null; - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: Number.isFinite(baro_alt) ? baro_alt : null, - onGround, - surfaceM, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks, - // in priority order: - // (1) hold the previous geoid-corrected render height if we have one — - // a one-poll baro dropout must NOT snap the plane down by the geoid - // undulation N (~46 m in London) and back up next poll. `alt` stays - // sticky for labels, so holding the last render height keeps the two - // layers consistent through the gap. - // (2) otherwise the SAME default policy `alt` already uses, so the two - // never disagree on the genuine "no data yet" case (a never-reported - // aircraft has no prior render height, so it lands here unchanged). - let renderAltitudeM; - if (pickedAltM != null) { - renderAltitudeM = pickedAltM; - } else if (Number.isFinite(prevMeta?.renderAltitudeM)) { - renderAltitudeM = prevMeta.renderAltitudeM; - } else { - renderAltitudeM = alt; - } - // Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3): - // floor a low airborne contact's render height at the local coarse - // ground so it can never dive below the mesh. Round 3 (Austin - // fleet-underground): baro can read BELOW an elevated field — SWA696 - // showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi - // traffic that OpenSky hasn't flagged on_ground yet renders from that - // baro, so the whole fleet sat buried at AUS in 2D. Clamping every - // global contact would need unbounded terrain resolution; instead the - // clamp covers the TRACKED contact (always) plus every low contact - // within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones - // whose burial is visible. Cells warm in one batch after the loop. - // Airborne only (grounded planes keep the surface-cache path above). - if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M && - (icao24 === _trackedIcao || - (viewerLatDeg != null && - _approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <= GROUND_FLOOR_CLAMP_RADIUS_KM))) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, cachedGroundFloor(lat, lon)); - floorWarmPoints.push({ lat, lon }); - } - - const position = Cesium.Cartesian3.fromDegrees(lon, lat, renderAltitudeM); - // Landing/takeoff transition: the on_ground flip restyles IN PLACE. - const groundFlipped = !!prevMeta && (prevMeta.onGround === true) !== onGround; - // Either flip direction retires the model's ground snap: a departing plane - // flies free of it, a landing plane earns a fresh sample where it rolls out. - if (groundFlipped) _groundSnap.forget(icao24); - - // Store flight metadata for click-to-track labels - const cat = stickyNumber(category, prevMeta?.category, null); - const meta = { - callsign: stickyText(callsign, prevMeta?.callsign), - altitude: alt, - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitude` — never rename/replace it (labels, - // FL readout, route-plausibility, and follow-camera range math all - // still read `altitude`/baro). - geoAltitudeM, - renderAltitudeM, - onGround, - // Round 7: sticky airborne history — the landed fast-cull only - // applies to contacts that actually flew this session. - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for contacts rendering - // at/below the ellipsoid (fleet pass reads it — see the occluder - // note there). Null for the overwhelmingly common airborne case. - cullPosition: renderAltitudeM < 10 ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) : null, - velocity: stickyNumber(velocity, prevMeta?.velocity, 0), - true_track: stickyNumber(true_track, prevMeta?.true_track, 0), - category: cat, - // An adsbdb-enriched type code outranks the coarse OpenSky category. - klass: classifyAircraft({ typeCode: prevMeta?.typeCode ?? null, category: cat }), - turnRateDps: prevMeta?.turnRateDps || 0, - verticalRate: stickyNumber(vertical_rate, prevMeta?.verticalRate, null), - // Analyst seam: OpenSky origin_country (state[2]) — additive, sticky - // like callsign so a transient blank row doesn't blank the field. - originCountry: stickyText(origin_country, prevMeta?.originCountry) || null, - // OpenSky distinguishes the last position epoch from the last - // transponder message. The fleet coast horizon uses this actual - // contact time so a temporarily old position does not hard-freeze - // while fresh velocity/track messages are still arriving. - lastContactEpochMs: stickyNumber( - Number.isFinite(last_contact) ? last_contact * 1000 : null, - prevMeta?.lastContactEpochMs, - null, - ), - // adsbdb enrichment — written by the enrichment callbacks, carried across polls: - typeCode: prevMeta?.typeCode ?? null, - typeName: prevMeta?.typeName ?? null, - registration: prevMeta?.registration ?? null, - airline: prevMeta?.airline ?? null, - route: prevMeta?.route ?? null, - // The RAW poll fix lat/lon (this tick's OpenSky state-vector - // coords, pre-dead-reckon) — kept distinct from the continuously - // dead-reckoned billboard position for any consumer that needs the - // actual reported fix. - rawLat: lat, - rawLon: lon, - }; - _flightData.set(icao24, meta); - - const isTracked = icao24 === _trackedIcao; - - // Append to position history stamped with the FEED's fix epoch - // (time_position), not client receipt time — OpenSky positions arrive - // 5-15s stale and receipt-time stamping is what caused the - // back/forward oscillation. Only append when the fix actually - // advances, so stale repeats don't create zero-dt segments. - const fixEpochMs = Number.isFinite(time_position) && time_position > 0 - ? time_position * 1000 - : Date.now(); - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); - if (!_positionHistory.has(icao24)) { - _positionHistory.set(icao24, []); - } - const history = _positionHistory.get(icao24); - const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { - // Per-fix kinematics: the fix's own velocity/track ride along so the - // extrapolation paths use the values that BELONG to the fix they - // extend, not whatever the latest poll reported. - history.push({ - time: fixTime, - epochMs: fixEpochMs, - position: position.clone(), - velocity: meta.velocity, - track: meta.true_track, - }); - if (history.length > POSITION_HISTORY_LIMIT) { - history.shift(); - } - // Turn rate from the fix-track history — computed once per new fix - // (≤5 samples), consumed by the extrapolation paths at tick rate. - meta.turnRateDps = turnRateFromFixHistory(history); - // Trail accumulation is separate from the 5-fix DR history (PRD F1) - // so the visible trail keeps growing while tracked. Ground traffic - // appends nothing — a touchdown freezes the existing trail. - // Round 2 (owner): ground traffic appends too — taxi history stays - // live after touchdown (grounded flights positions are already - // surface-clamped via the surfaceM chain, so the ground leg drapes). - if (isTracked) _appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = _modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - // Apply fresh kinematics only from a forward synthetic fix. Mutating - // the historical fix reprojects the entire stale interval and snaps - // the rendered aircraft when course or speed changes late. - const kinematicsChanged = newest.velocity !== meta.velocity - || newest.track !== meta.true_track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.velocity, - track: meta.true_track, - turnRateDps: meta.turnRateDps, - }); - if (synthetic) { - history.push(synthetic); - if (history.length > POSITION_HISTORY_LIMIT) history.shift(); - meta.turnRateDps = turnRateFromFixHistory(history); - if (isTracked) _appendTrailFix(synthetic.position.clone()); - } - } - } - - if (_billboards.has(icao24)) { - const bb = _billboards.get(icao24); - // Reclassify if the category resolved/changed (first extended poll); - // a ground flip (landing/takeoff) re-scales the SAME billboard in - // place — the transition is a restyle, never a removal. - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - if (!isTracked) { - // Refresh affiliation hue without clobbering the tick-owned - // freshness × focus × horizon alpha composition. - bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1); - } - if (prevMeta?.klass !== meta.klass || groundFlipped || _cockpitContactMode) { - _applyFleetBillboardPresentation(icao24, bb); - } - // Poll-path class change (category updates): same model resync rule - // as the enrichment path — the class's GLB/scale may have changed. - if (prevMeta?.klass !== meta.klass) _syncModelToClass(icao24); - } else { - const bb = _billboardCollection.add({ - position, - image: aircraftIcon(_iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: _fleetBillboardScale(icao24, meta.klass), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? Cesium.Color.CYAN : _fleetBillboardColor(icao24), - sizeInMeters: false, - scaleByDistance: _normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: _groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - _billboards.set(icao24, bb); - _applyFleetBillboardPresentation(icao24, bb); - } - - // Takeoff while TRACKED: ground traffic drew no trail, so start one - // from the fresh airborne history (touchdown needs no action — the - // append gate above simply freezes the existing trail). - if (isTracked && groundFlipped && !meta.onGround) _startTrail(icao24); - - // If this is the tracked aircraft, update label text - // (position updates automatically via dead-reckoning CallbackProperty) - if (isTracked && _trackedEntity) { - _updateTrackedLabelModel(icao24); - } - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // OpenSky routinely drops aircraft for a single poll; immediate removal - // made planes blink and yanked the camera off actively tracked flights. - // EXCEPTION: likely-landed planes (last fix low + slow) get only - // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a - // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of _billboards) { - if (currentIcaos.has(icao24)) continue; - const misses = (_missingPolls.get(icao24) || 0) + 1; - const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; - if (misses < limit) { - _missingPolls.set(icao24, misses); - if (icao24 === _trackedIcao && _trackedEntity) { - // Honest readout: the tracked plane has no faded billboard (its - // entity owns the visual), so refresh the label — with icao24 now - // in _missingPolls, _trackedLabelText appends the STALE cue so - // last-known velocity/altitude aren't presented as live. - _updateTrackedLabelModel(icao24); - } - continue; - } - _missingPolls.delete(icao24); - - // If the tracked flight is truly gone, clear tracking BEFORE deleting - // its state (M3 ordering, keep it): teardown reads the maps this loop - // is about to delete (billboard restore, DR cache reset), and we must - // never leave the camera mid-follow with stale tracking state. The - // camera is then RELEASED IN PLACE — it stays where the follow left - // it, fully free (owner decision 2026-07-02: no overview flyTo). - if (icao24 === _trackedIcao) { - _clearTracking(false, { evicted: true }); - } - - _billboardCollection.remove(bb); - _billboards.delete(icao24); - _releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - _flightData.delete(icao24); - _positionHistory.delete(icao24); - _displayCourse.delete(icao24); - _groundSnap.forget(icao24); - _geoidNCache.delete(icao24); - _displayFloorState.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - _lastCamPoseSig = ''; - - // Ambient type enrichment: give ON-SCREEN planes real types (bounded - // sweep — see _sweepAmbientEnrichment; internally fail-silent). - _sweepAmbientEnrichment(); - - // 2026-08-19: the loop above only ever collects FIX cells, but a grounded - // contact renders across every cell its dead-reckoned position drifts - // through. Add those too, so the display clamp has data where the contact - // actually is instead of silently passing. - _collectDisplayCorridorCells(floorWarmPoints, viewerLatDeg, viewerLonDeg); - - // Field-test round 3: one batch warm of the viewer-proximate low-contact - // floor cells collected in the loop (fire-and-forget, single-flight; - // read synchronously by NEXT poll's clamp — the military-layer pattern). - warmGroundFloor(floorWarmPoints); - // Round 4: sample the RENDERED mesh for those same cells (one-shot per - // cell, budget-capped, viewer-proximate, google-3d regime only). Own - // billboards/models are excluded so a vertical probe can't land on an - // aircraft instead of the pavement. - sampleMeshFloorCells(_viewer?.scene, floorWarmPoints, { - excludeObjects: [..._billboards.values(), ..._models.values(), _trackedModel].filter(Boolean), - viewerLat: viewerLatDeg, - viewerLon: viewerLonDeg, - }); - - // Round 5: the grounded exact-key warm that used to live here is gone — - // see the note where _warmGroundedAircraftSurfaceCache was removed. The - // viewer-proximate coarse warm + mesh sampler above cover everything - // whose height is actually visible. - // Round 6: re-floor STALE grounded contacts. A parked plane whose - // transponder went quiet stops receiving poll updates, so a floor that - // warms AFTER its last fix never applied — it sat frozen at the geoid - // (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever). - // Grounded contacts are static, so lifting the stored fix + billboard - // in place is safe (the DR extrapolates a zero-velocity fix). - _refloorStaleGroundedContacts(currentIcaos); - - _count = _billboards.size; - // Freshness belongs to the source snapshot, not the moment this browser - // received a cached 200 response. - _lastUpdate = sourceEpochMs ?? Date.now(); - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'accepted', - ids: acceptedSnapshotIcaos, - source: _lastSource, - coverage: _lastCoverage, - }; - console.log(`[Data:Flights] Updated: ${_count} aircraft`); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Flights update aborted', 'AbortError'); - } - console.warn('[Data:Flights] Fetch error:', e); - _backoff = true; - _retryAt = Date.now() + ERROR_BACKOFF_INTERVAL; - _lastError = 'OpenSky network error'; - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - /** - * Fully tear down the flights layer — remove primitives, handlers, - * tracked entities, and clear all internal state maps. - * @param {Cesium.Viewer} viewer - */ - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('flights'); // direct-destroy path (perf wave 2 fix) - _clearTracking(); - _destroyTrail(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - if (_milActiveChangeUnsub) { - _milActiveChangeUnsub(); - _milActiveChangeUnsub = null; - } - document.removeEventListener('keydown', _onKeyDown); - if (_cockpitModeListener) { - window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - _cockpitModeListener = null; - } - unregisterPickOwner('flights'); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - _releaseModels(); - if (_billboardCollection) { - viewer.scene.primitives.remove(_billboardCollection); - _billboardCollection = null; - } - if (_modelCollection) { - viewer.scene.primitives.remove(_modelCollection); // removing destroys it + its models - _modelCollection = null; - } - _modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - _modelPending.clear(); - _modelGen.clear(); - if (_preloadModel) { try { _preloadModel.destroy(); } catch { /* gone */ } _preloadModel = null; } - _planeModelLoaded = false; - _billboards.clear(); - _detectionObjects.clear(); - _flightData.clear(); - _positionHistory.clear(); - _displayCourse.clear(); - _groundSnap.clear(); - _displayFloorState.clear(); - _enrichQueue.length = 0; - _enrichSeen.clear(); - if (_enrichDripTimer) { clearTimeout(_enrichDripTimer); _enrichDripTimer = null; } - _missingPolls.clear(); - _focusEvidenceIds.clear(); - _count = 0; - _lastUpdate = null; - _cockpitContactMode = false; - _cockpitNearContacts = new Set(); - _cockpitSubjectId = null; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - ids: new Set(), - source: _lastSource, - coverage: _lastCoverage, - }; - _resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - _viewer = null; - }, - - /** - * Live layer params. - * `models3d` toggles 3D glTF model rendering for the FLEET (altitude-gated): when on, - * surrounding aircraft become 3D models once the camera is zoomed in past MODEL_ALT_CEIL_M. - * The TRACKED contact is NOT gated by this — it takes its 3D model by camera distance - * regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). - * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). - * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedFlightsTrackingId?: string|null}} params - */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedFlightsTrackingId')) { - _cancelPendingTrackingRestore(); - } - if (typeof params.models3d === 'boolean' && params.models3d !== _models3dEnabled) { - _models3dEnabled = params.models3d; - if (!_models3dEnabled) { - _releaseModels(); - _syncTracked2dRotation(); - // Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the - // tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it - // here would double-image the tracked plane when 3D is turned off mid-track. - if (_billboardCollection) for (const [icao, bb] of _billboards) { if (icao !== _trackedIcao) bb.show = true; } - } - } - if ((params.models3dMode === 'proximity' || params.models3dMode === 'all') && params.models3dMode !== _models3dMode) { - // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. - _models3dMode = params.models3dMode; - if (_cockpitContactMode) { - _refreshCockpitNearContacts(); - _lastFleetTickMs = 0; - } - } - if (typeof params.irBoost === 'boolean' && params.irBoost !== _irBoost) { - _irBoost = params.irBoost; - _reloadModelsForIrBoost(); - // Sprites don't reload with the models — swap the TR-3B glyph between its - // cold and thermal-reactive variants directly. Bounded by the operator's - // own conversions, so this never touches the ordinary fleet. - _refreshTr3bForStyle(); - } - if (Object.hasOwn(params, 'selectedFlightsTrackingId')) { - const requested = _normalizeTrackedIcao(params.selectedFlightsTrackingId); - if (requested === _trackedIcao) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedIcao) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedIcao) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - getParams() { - return { - models3d: _models3dEnabled, - models3dMode: _models3dMode, - irBoost: _irBoost, - selectedFlightsTrackingId: _trackedIcao, - }; - }, - - /** - * Re-render a contact whose TR-3B conversion just flipped (Easter egg). - * Callers own the registry write; this only re-derives what renders. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when this layer owns the contact. - */ - refreshTr3b(icao24) { return _refreshTr3bContact(icao24); }, - - /** - * Return a subsample of currently visible aircraft for detection overlay - * rendering (e.g. bounding boxes drawn on-screen by the CCTV detection layer). - * - * Uses a deterministic stride + seed to select a spatially distributed - * subset without sorting or shuffling. - * - * @param {object} [options] - * @param {number} [options.maxCount] - Maximum number of objects to return. - * @param {number} [options.seed] - Deterministic offset into the stride pattern. - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!_billboardCollection || !_billboardCollection.show) return []; - // Compute a stride that evenly samples the billboard map. - // seed shifts the starting offset so successive calls can sample - // different aircraft without shuffling the underlying Map order. - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _billboards.size; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max(1, Math.ceil(_billboards.size / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [icao24, bb] of _billboards) { - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (_cockpitContactMode && icao24.toLowerCase() === _cockpitSubjectId) continue; - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so the detection box - // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. - const model = _models.get(icao24); - const modelOwnsVisual = _modelOwnsVisual(icao24); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; - const info = _flightData.get(icao24); - let object = _detectionObjects.get(icao24); - if (!object) { - object = { sourceId: icao24, type: 'AIR', _weldPos: new Cesium.Cartesian3() }; - _detectionObjects.set(icao24, object); - } - // WELD: anchor to whatever actually owns the visual. A model-owned contact is - // read straight off the translation the fleet tick already wrote into its - // modelMatrix, so bracket and label sit on the aircraft you can see instead of - // on the buried billboard position, which for a grounded plane is ~100 m below - // and rises only as the coarse ground-floor cell warms. Zero extra sampling and - // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep - // `bb.position` — sprite and bracket are co-located there, so association holds. - const spec = modelOwnsVisual ? _modelSpec(info?.klass) : null; - const pos = isTracked - ? (_trackedVisualCached() || bb.position) - : (modelOwnsVisual - ? modelVisualAnchor( - model.modelMatrix, - spec.visualCenterNative, - Number.isFinite(model.computedScale) ? model.computedScale : spec.scale, - object._weldPos || (object._weldPos = new Cesium.Cartesian3()), - ) - : bb.position); - if (!pos) continue; - object.position = pos; - object.skipLabel = isTracked; - // Card text only — declutter/cohort identity is `object.sourceId` (icao24). - const id = _contactLabel(icao24, info); - if (object.id !== id) object.id = id; - const altitude = info?.altitude; - if (object._altitude !== altitude) { - object._altitude = altitude; - object.metric = formatFlightLevel(altitude); // altitude is metres - } - result.push(object); - if (result.length >= maxCount) break; - } - if (_detectionObjects.size > _billboards.size + 512) { - for (const icao24 of _detectionObjects.keys()) { - if (!_billboards.has(icao24)) _detectionObjects.delete(icao24); - } - } - return result; - }, - - /** - * Find a single aircraft by free-text query. - * Match priority: exact icao24 hex, exact callsign, callsign prefix, - * then callsign substring (all case-insensitive, trimmed). - * @param {string} query - ICAO24 hex or full/partial callsign. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Best match with a cloned, dead-reckoned position, or null if none. - */ - findByQuery(query) { - if (!_flightData || _flightData.size === 0) return null; - const q = String(query || '').trim().toLowerCase(); - if (!q) return null; - - // Registration is searched alongside callsign because it is what the - // operator SEES: a callsign-less contact reads as its tail number on the - // card, in the analyst's answer, and in the Contacts list. Matching only - // callsigns meant "follow 6606" — and the analyst → track_entity handoff - // the tool instructions prescribe — answered "nothing matched" for the - // very identity the app had just shown. Ranking is shared with the - // military layer (contactMatch.js) so the two cannot disagree, and it is - // strictly tiered so a registration can never out-rank a real callsign on - // feed order alone. - let best = null; - for (const [icao24, info] of _flightData) { - const candidate = { - tier: rankContactMatch({ - query: q, - hex: icao24, - callsign: info?.callsign, - registration: info?.registration, - }), - id: icao24, - }; - if (!contactMatchWins(candidate, best)) continue; - best = candidate; - if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; - } - return best ? _describeFlight(best.id) : null; - }, - - /** - * Find aircraft near a given ECEF position, sorted ascending by distance. - * Return shape mirrors militaryFlightsLayer.getNearby (id/icao24/position/distance). - * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates. - * @param {number} range - Maximum distance in meters (Infinity if not finite). - * @param {number} [maxCount=50] - Maximum number of results to return. - * @param {object} [options] Query membership options. - * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. - * @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>} - */ - getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if (!center || !_billboardCollection || !_billboardCollection.show) return []; - - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 50; - const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - - const now = Cesium.JulianDate.now(); - const nearby = []; - - for (const [icao24, bb] of _billboards) { - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so proximity counts - // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. - if (!aircraftIncludedInNearby({ - isTracked, - billboardShown: bb.show, - modelRendering: _modelOwnsVisual(icao24), - includeHidden, - })) continue; - - const trackedPos = isTracked ? _trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; - if (!pos) continue; - - const distance = Cesium.Cartesian3.distance(center, pos); - if (distance > maxRange) continue; - - const info = _flightData.get(icao24); - const callsign = info?.callsign?.trim() || null; - nearby.push({ - // Label. Callers that need identity read `icao24` (Context cohorts do). - id: _contactLabel(icao24, info), - icao24, - callsign, - position: pos, - distance, - // Filter surface: the cockpit next/previous path matches on THIS field - // (militaryAwareness.aircraftClassMatchesFilter), so a converted contact - // has to report the class it renders as or a `tr3b` filter skips it. - aircraftClass: tr3bAircraftClass(icao24, String(info?.klass || '').trim().toLowerCase() || null), - altitudeM: info?.altitude ?? null, - velocityMps: info?.velocity ?? null, - track: info?.true_track ?? null, - }); - } - - nearby.sort((a, b) => a.distance - b.distance); - return nearby.slice(0, limit); - }, - - /** - * Return id/label/position for up to maxCount currently rendered aircraft. - * Cheap snapshot for voice-tool framing — billboard positions only, no - * dead reckoning and no cloning. - * @param {number} [maxCount=500] - Maximum number of entries to return. - * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} - */ - /** - * Whether this layer still carries a contact, in O(1). - * - * Presence consumers must not infer absence from `getAllPositions`: it stops - * at its cap, and this layer routinely carries ~11k contacts against a - * 1,000-row cap, so "not in the returned rows" is not "gone". Id matching - * mirrors trackById: exact key first, then lowercase. - * A disabled layer keeps its records but hides the collection, so it must - * decline rather than answer from data the user can no longer see — - * otherwise a preserved subject reads as fresh off stale hidden state. - * @param {string} icao24 Contact identifier. - * @returns {boolean|null} Presence, or null when the layer is disabled or - * holds no data and therefore cannot answer. - */ - hasContact(icao24) { - if (!_billboardCollection || !_billboardCollection.show || _billboards.size === 0) return null; - if (!icao24) return false; - const id = String(icao24).trim(); - return _billboards.has(id) || _billboards.has(id.toLowerCase()); - }, - - getAllPositions(maxCount = 500) { - if (!_billboardCollection || _billboards.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 500; - - const result = []; - for (const [icao24, bb] of _billboards) { - const pos = bb.position; - if (!pos) continue; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) continue; - const info = _flightData.get(icao24); - result.push({ - id: icao24, // identity (trackById resolves this) - label: _contactLabel(icao24, info), - position: pos, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - airline: info?.airline ?? null, - typeName: info?.typeName ?? null, - typeCode: info?.typeCode ?? null, - registration: info?.registration ?? null, - origin: info?.route && _routeIsPlausible(icao24, info.route) ? info.route.origin.code : null, - destination: info?.route && _routeIsPlausible(icao24, info.route) ? info.route.destination.code : null, - }); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Snapshot the layer's in-memory 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, no - * enrichment fetches (cached adsbdb values only). 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 (!_billboardCollection || !_billboardCollection.show || _flightData.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const [icao24, info] of _flightData) { - const routeOk = !!info?.route && _routeIsPlausible(icao24, info.route); - result.push(mapAnalystRecord(icao24, info, { military: isMilitaryIcao(icao24), routeOk })); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Start camera-tracking an aircraft by ICAO24 address. - * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {boolean} True if the aircraft exists and tracking started. - */ - trackById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if (!_billboards.has(id)) return false; - if (_isExplicitTrackingOrigin(origin)) _cancelPendingTrackingRestore(); - if (_trackedIcao === id) return _publishTrackedSelection(id, origin); - _trackFlight(id, { origin }); - return true; - }, - - /** Resolve a shared Follow target only against the latest accepted refresh. */ - async resolveTrackingRestoreTarget(icao24, { signal = null, origin = 'share-restore' } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = _normalizeTrackedIcao(icao24); - if (!id) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - if (outcome.status !== 'accepted') { - return { - status: 'source-unavailable', - reason: 'OpenSky snapshot unavailable', - refreshEpoch: outcome.epoch, - source: outcome.source, - coverage: outcome.coverage, - }; - } - if (!outcome.ids.has(id)) { - return { - status: 'missing', - reason: 'target-absent-from-snapshot', - refreshEpoch: outcome.epoch, - source: outcome.source, - coverage: outcome.coverage, - }; - } - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const followed = this.trackById(id, { origin }); - return followed - ? { - status: 'found', - refreshEpoch: outcome.epoch, - source: outcome.source, - coverage: outcome.coverage, - } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }, - - /** Reapply the canonical follow frame without recreating the selected flight. */ - refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24 || _cockpitContactMode || !_viewer || !_trackedEntity) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if ( - id !== _trackedIcao - || !_viewer.entities?.contains?.(_trackedEntity) - || _viewer.trackedEntity !== _trackedEntity - ) return false; - _trackedCameraFrameStop?.(); - _viewer.camera.cancelFlight(); - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - _publishTrackedSelection(id, origin); - return true; - }, - - /** - * Stop tracking the currently followed aircraft (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Tracked aircraft info, or null when nothing is tracked. - */ - getTrackedInfo() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described) return null; - const { position, ...rest } = described; - return rest; - }, - - /** - * Return the current aircraft as a Context subject without changing - * tracking or camera ownership. - * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} - * Detached subject descriptor, or null when no aircraft is tracked. - */ - getTrackedSubject() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described?.position) return null; - return { - layerId: 'flights', - id: described.icao24, - // Same label chain as getNearby/getDetectableObjects: a callsign-less - // contact reads as its registration, never as the raw ICAO hex. - label: described.callsign - || _toCleanText(described.registration) - || described.icao24, - position: Cesium.Cartesian3.clone(described.position), - }; - }, - - ...(FOCUS_EVIDENCE_DEV ? { - __focusEvidence: Object.freeze({ - setAircraft: _setFocusEvidenceAircraft, - moveAircraft: _moveFocusEvidenceAircraft, - snapshot: _focusEvidenceSnapshot, - setTuning({ focus = {}, horizon = {} } = {}) { - return { - focus: setFocusDeemphasisParams(focus), - horizon: setAircraftRecessionParams(horizon), - }; - }, - getTuning() { - return { - focus: { ...getFocusDeemphasisParams() }, - horizon: { ...getAircraftRecessionParams() }, - }; - }, - takeFrameClock(startMs = 1_000_000_000) { - if (!_viewer || !Number.isFinite(startMs)) return { ok: false, nowMs: null }; - _viewer.useDefaultRenderLoop = false; - setFocusEvidenceNowMs(startMs); - _lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; - // Cross a browser task boundary, then close Cesium's pending-loop - // latch. Any already-queued callback still observes the false gate, - // while manual evidence renders can begin without waiting on VSYNC. - return new Promise((resolve) => setTimeout(() => { - if (_viewer?._cesiumWidget) _viewer._cesiumWidget._renderLoopRunning = false; - resolve({ ok: true, nowMs: startMs }); - }, 0)); - }, - advanceFrameClock(deltaMs = FLEET_DR_INTERVAL_MS) { - return advanceFocusEvidenceNowMs(deltaMs); - }, - releaseFrameClock() { - setFocusEvidenceNowMs(null); - if (_viewer) _viewer.useDefaultRenderLoop = true; - }, - }), - } : {}), - - /** - * Return layer health/status for the HUD stats chip. - * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} - */ - getStats() { - const retryInSec = _retryAt ? Math.max(0, Math.ceil((_retryAt - Date.now()) / 1000)) : 0; - return { - count: _count, - lastUpdate: _lastUpdate, - stale: _backoff, - error: _lastError, - status: _lastStatus, - retryInSec, - source: _lastSource, - coverage: _lastCoverage, - }; - }, -}; - -/** - * Global keydown handler — Escape deselects the tracked flight. - * @param {KeyboardEvent} e - */ -function _onKeyDown(e) { - if (e.key === 'Escape' && _trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -/** - * Install a LEFT_CLICK handler on the scene canvas for flight selection. - * - * Picking logic checks both `picked.primitive` and `picked.id` because - * different CesiumJS versions surface BillboardCollection hits differently. - * Also registers a global keydown listener for the Escape key. - * - * Idempotent — returns immediately if a handler is already installed. - * - * @param {Cesium.Viewer} viewer - */ -function _installClickHandler(viewer) { - if (_clickHandler) return; // already installed - - // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our - // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity - // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch - // (viewer.trackedEntity briefly undefined) doesn't self-clear. - if (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (_trackedIcao && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindTrackingClickGesture(_clickHandler, (click, gesture) => { - // Camera drags never select or deselect, even if they finish over a plane. - // Duration alone is allowed through so a stationary long press can still - // select/switch contacts; the destructive empty-space branch below applies - // the full travel + duration click classifier. - if (!isTrackingSelectionGesture(gesture)) return; - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference. A globe click must not fall through to the - // normal empty-space deselection path; cockpit has explicit exit controls. - if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking the tracked entity itself — ignore (don't deselect) - if (picked.id === _trackedEntity) return; - - // Clicking the plane we're ALREADY tracking (its standalone 3D model or - // any pick carrying its icao) — same no-op as the 2D tracked-entity click - // above. H1: the model used to have no pick id, so this fell through to - // "empty space" and deselected the very plane being tracked. - if (_trackedIcao) { - const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; - if (picked.primitive === _trackedModel || rawPick === _trackedIcao) return; - } - - // For BillboardCollection picks, the billboard may be at picked.primitive or picked.id - const billboard = picked.primitive; - if (billboard && billboard.id && _billboards.has(billboard.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Some CesiumJS versions surface the id as a string on picked.id instead - if (picked.id && typeof picked.id === 'string' && _billboards.has(picked.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); - return; - } - } - - // A pick that belongs to a sibling layer (military aircraft, satellite, - // vessel, station, CCTV camera…) is not "empty space" — leave tracking - // alone and let that layer handle it. resolvePickId String()-coerces the - // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the - // registry predicates can recognize them (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('flights', pickedId)) return; - } - - // Clicked empty space — deselect only for a clean, short click. A slow - // stationary press may select above, but cannot release existing tracking. - if (!isTrackingClickGesture(gesture)) return; - if (_trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }); - - document.addEventListener('keydown', _onKeyDown); -} - +}); +export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js'; +export const _floorGroundedDisplayPositionForTest = + flightsLayer.testing._floorGroundedDisplayPositionForTest; +export const _clearDisplayFloorStateForTest = + flightsLayer.testing._clearDisplayFloorStateForTest; +export const _setTrackedFlightRefreshStateForTest = + flightsLayer.testing._setTrackedFlightRefreshStateForTest; +export const _setFlightTrackingRefreshOutcomeForTest = + flightsLayer.testing._setFlightTrackingRefreshOutcomeForTest; +export const _addFlightTrackingCandidateForTest = + flightsLayer.testing._addFlightTrackingCandidateForTest; +export const _militaryLayerSuppressesForTest = + flightsLayer.testing._militaryLayerSuppressesForTest; +export const _armFlightTrackingRestoreForTest = + flightsLayer.testing._armFlightTrackingRestoreForTest; +export const _pendingFlightTrackingRestoreForTest = + flightsLayer.testing._pendingFlightTrackingRestoreForTest; +export const _applyPendingFlightTrackingRestoreForTest = + flightsLayer.testing._applyPendingFlightTrackingRestoreForTest; +export const _setCockpitDetectionSubjectForTest = + flightsLayer.testing._setCockpitDetectionSubjectForTest; +export const _trackedModelRegimeActiveForTest = + flightsLayer.testing._trackedModelRegimeActiveForTest; +export const _updateTrackedModelForTest = + flightsLayer.testing._updateTrackedModelForTest; +export const _trackedBillboardColorForTest = + flightsLayer.testing._trackedBillboardColorForTest; +export const _driveFleetModelHandoffForTest = + flightsLayer.testing._driveFleetModelHandoffForTest; +export const _ensureFleetModelForTest = + flightsLayer.testing._ensureFleetModelForTest; +export const mapAnalystRecord = flightsLayer.mapAnalystRecord; export default flightsLayer; diff --git a/src/data/flowTiles.js b/src/data/flowTiles.js index e4e6b29b2..07efb0d9a 100644 --- a/src/data/flowTiles.js +++ b/src/data/flowTiles.js @@ -1,161 +1,6 @@ -import { PbfReader } from 'pbf'; -import { VectorTile } from '@mapbox/vector-tile'; -import { tilesForBounds } from './tomtomTiles.js'; - -/** - * @file TomTom traffic-flow vector-tile client: fetch + MVT decode. - * - * Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy - * (the TomTom key never reaches the browser) and decodes the Mapbox Vector - * Tile layer "Traffic flow" into plain lon/lat polylines with congestion - * attributes. Consumed by the traffic layer's live mode - * (`src/data/traffic.js` → `src/data/flowMatch.js`). - * - * Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string, - * closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed - * ratio (1 = free flow). Features with a missing/non-finite `traffic_level` - * are skipped unless `road_closure` is true (closures decode with level 0). - * - * Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and - * tree-shakeable; decoding happens client-side so the proxy stays a dumb - * binary cache. - * - * @module data/flowTiles - */ - -export { tilesForBounds }; - -/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */ -const FLOW_LAYER_NAME = 'Traffic flow'; -/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */ -const DECODE_CACHE_TTL_MS = 120_000; -/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */ -const DECODE_CACHE_MAX_ENTRIES = 64; - -/** - * Decoded-tile cache keyed by "z/x/y". - * @type {Map} - */ -const _decodeCache = new Map(); -/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */ -let _tilesFetched = 0; - -/** - * Decode one TomTom flow tile (MVT protobuf) into flow segments. - * - * @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes. - * @param {number} z - Tile zoom (for tile-local → lon/lat projection). - * @param {number} x - Tile column. - * @param {number} y - Tile row. - * @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} - * Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers - * or tiles without a "Traffic flow" layer (defensive — a corrupt tile must - * not kill the traffic layer). - */ -export function decodeFlowTile(data, z, x, y) { - let layer; - try { - const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; - const tile = new VectorTile(new PbfReader(bytes)); - layer = tile.layers[FLOW_LAYER_NAME]; - } catch { - return []; - } - if (!layer) return []; - - const segments = []; - for (let i = 0; i < layer.length; i++) { - let feature; - let geometry; - try { - feature = layer.feature(i); - geometry = feature.toGeoJSON(x, y, z).geometry; - } catch { - continue; // one malformed feature must not drop the tile - } - const props = feature.properties || {}; - const closure = props.road_closure === true || props.road_closure === 'true'; - const rawLevel = props.traffic_level; - const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel); - // Skip features we can't color — unless closed (closures render dot-free - // regardless of level, so they stay useful without one). - if (!hasLevel && !closure) continue; - const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0; - const roadType = typeof props.road_type === 'string' ? props.road_type : ''; - - const lines = geometry.type === 'LineString' - ? [geometry.coordinates] - : geometry.type === 'MultiLineString' - ? geometry.coordinates - : []; - for (const coords of lines) { - if (!Array.isArray(coords) || coords.length < 2) continue; - segments.push({ coords, trafficLevel, roadType, closure }); - } - } - return segments; -} - -/** Insert into the decode cache with oldest-entry eviction. */ -function cacheSet(key, entry) { - if (!_decodeCache.has(key) && _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES) { - const oldest = _decodeCache.keys().next().value; - _decodeCache.delete(oldest); - } - _decodeCache.set(key, entry); -} - -/** - * Fetch + decode all flow tiles covering the given bounds. - * - * Tiles are fetched from the local proxy in parallel; each decoded tile is - * cached in memory for 120 s (keyed z/x/y), so repeat calls for the same - * viewport are free. Partial tile failures return the segments that DID - * decode (last-good philosophy); the promise rejects only when every tile - * failed (e.g. keyless 503, aborted signal, proxy down). - * - * @param {{south:number, west:number, north:number, east:number}} bounds - Degrees. - * @param {Object} [opts] - * @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled). - * @param {number} [opts.zoom=12] - Flow tile zoom level. - * @returns {Promise>} - * Flat array of flow segments across all covering tiles. - */ -export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) { - const tiles = tilesForBounds(bounds, zoom); - if (tiles.length === 0) return []; - const now = Date.now(); - - const results = await Promise.allSettled(tiles.map(async ({ z, x, y }) => { - const key = `${z}/${x}/${y}`; - const cached = _decodeCache.get(key); - if (cached && now - cached.at < DECODE_CACHE_TTL_MS) return cached.segments; - - _tilesFetched += 1; - const res = await fetch(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { signal }); - if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`); - const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y); - cacheSet(key, { at: Date.now(), segments }); - return segments; - })); - - const fulfilled = results.filter((r) => r.status === 'fulfilled'); - if (fulfilled.length === 0) { - throw results[0].reason instanceof Error ? results[0].reason : new Error('flow fetch failed'); - } - return fulfilled.flatMap((r) => r.value); -} - -/** - * Session diagnostics for `getStats()` surfaces. - * @returns {{tilesFetched:number}} Count of tile requests issued to the proxy - * this session (decode-cache hits excluded). - */ -export function getFlowSessionStats() { - return { tilesFetched: _tilesFetched }; -} - -/** Clear the decode cache (tests + layer teardown). Session stats persist. */ -export function resetFlowTileCache() { - _decodeCache.clear(); -} +import { createFlowTileSource } from '../layers/traffic/flowSource.js'; +export { tilesForBounds } from './tomtomTiles.js'; +export { decodeFlowTile } from '../layers/traffic/flowDecode.js'; +const source = createFlowTileSource(); +export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } = + source; diff --git a/src/data/installationFeedback.js b/src/data/installationFeedback.js index 974568a45..f33ce59ab 100644 --- a/src/data/installationFeedback.js +++ b/src/data/installationFeedback.js @@ -5,14 +5,17 @@ export function installationFeedback(stats = {}, now = Date.now()) { timeout: 'Overpass timed out', query_failed: 'Overpass could not complete the query', }; - const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable'; - if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…'; + const reason = + reasons[stats.failureReason] || 'Overpass temporarily unavailable'; + if (stats.loading) + return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…'; if (stats.retryAt > 0) { const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000)); return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`; } if (stats.status === 'unavailable') return reason; - if (stats.status === 'zoom-in') return 'Zoom in to search mapped installations'; + if (stats.status === 'zoom-in') + return 'Zoom in to search mapped installations'; if (stats.stale) return 'Showing cached mapped sites'; if (stats.status === 'idle') return 'Mapped sites not loaded'; return 'Mapped sites loaded'; diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e75e..9e50a3efe 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -365,21 +366,21 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an // first-run session actually boots from. const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); - assert.match(source, /^let _models3dEnabled = true;$/m, + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); + assert.match(source, /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m, `${name}: the fleet starts armed, matching the codec default`); - assert.match(source, /^let _models3dMode = 'proximity';/m, + assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m, `${name}: and starts in proximity, matching the codec default`); } - const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8'); - assert.match(ui, /^\s*this\._models3dEnabled = true;$/m, + const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8'); + assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m, 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives'); - assert.match(ui, /this\._models3dMode = 'proximity';/, + assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/, 'ui.js: and believes the mode is proximity'); const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8'); assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/, 'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so'); - assert.match(ui, /this\._models3dBtn\?\.setAttribute\('aria-pressed', String\(this\._models3dEnabled\)\)/, + assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/, 'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only'); assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/, 'index.html: and the Proximity/All row paints open with it'); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index b90d1b66b..764005568 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -354,6 +354,17 @@ export function createLocalGeoJsonLayer( let _destroyed = false; let _loadPromise = null; let _loadController = null; + /** + * The parsed bundled dataset, kept across disable/enable so a re-enable + * rebuilds entities without refetching. The Cesium entities themselves are + * NOT kept: a hidden data source still costs every frame (DataSourceDisplay + * walks every visualizer over every entity regardless of `show`), and the + * ~11k entities of the three bundled layers hold hundreds of MB, so leaving + * them parked after a toggle-off made the whole scene render ~2× slower for + * the rest of the session (owner field test 2026-09-13). + * @type {Array|null} + */ + let _cachedFeatures = null; /** * Globe-LOD active set: the record ids allowed to carry a live stem right * now. This bounds geometry refreshes and ground-sample work to the @@ -432,6 +443,27 @@ export function createLocalGeoJsonLayer( host: overlayHost, }); + /** + * Take the built entities out of the scene entirely. The parsed features + * stay cached, so the next enable() rebuilds without a fetch; what must not + * survive a disable is the per-frame visualizer walk and the entity memory. + * @param {object} viewer + */ + const releaseDataSource = (viewer) => { + if (!_dataSource) return; + const source = _dataSource; + _dataSource = null; + _stemRecords = []; + _stemGeometryDirty = true; + _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; + removeEntityContextsForLayer(id); + try { + viewer?.dataSources?.remove(source, true); + } catch { + /* already gone */ + } + }; + const disableLayer = (viewer) => { _enabled = false; clearGroundRetryRender(); @@ -440,7 +472,7 @@ export function createLocalGeoJsonLayer( _lastLodBudgetLimit = 0; _lodComputed = false; _lastLodProbeMs = Number.NEGATIVE_INFINITY; - if (_dataSource) _dataSource.show = false; + releaseDataSource(viewer); _overlayPublisher.hide(); clearSelectedEntityContextForLayer(id); if (viewer?.selectedEntity?.__localLayerId === id) { @@ -529,20 +561,26 @@ export function createLocalGeoJsonLayer( // windows (before vs after the add settles) need different cleanup. let addedToScene = false; try { - const response = await fetch(url, { - signal: _loadController.signal, - }); - if (_destroyed) return; - // A 404 returns an HTML body that would otherwise die in JSON.parse - // one line later, reported as a parse error for a missing file. - if (!response.ok) { - throw new Error(`HTTP ${response.status ?? '?'}`); - } - const text = await response.text(); - if (_destroyed) return; - const lines = text.split('\n').filter((l) => l.trim().length > 0); + let features = _cachedFeatures; + if (!features) { + const response = await fetch(url, { + signal: _loadController.signal, + }); + if (_destroyed) return; + // A 404 returns an HTML body that would otherwise die in JSON.parse + // one line later, reported as a parse error for a missing file. + if (!response.ok) { + throw new Error(`HTTP ${response.status ?? '?'}`); + } + const text = await response.text(); + if (_destroyed) return; + const lines = text + .split('\n') + .filter((l) => l.trim().length > 0); - const features = lines.map((line) => JSON.parse(line)); + features = lines.map((line) => JSON.parse(line)); + _cachedFeatures = features; + } const geojson = { type: 'FeatureCollection', @@ -991,9 +1029,14 @@ export function createLocalGeoJsonLayer( } // Honor a disable() that landed while we were awaiting the fetch/parse: - // disable() runs before _dataSource exists, so its show=false is a no-op — - // reading _enabled here (rather than forcing true) respects the toggle-off. - if (_dataSource) _dataSource.show = _enabled; + // disable() runs before _dataSource exists, so its release is a no-op — + // release the finished build here instead of parking it hidden in the + // scene (the parsed features stay cached for the next enable). + if (!_enabled) { + releaseDataSource(viewer); + return; + } + if (_dataSource) _dataSource.show = true; viewer.scene.requestRender?.(); }, @@ -1016,6 +1059,7 @@ export function createLocalGeoJsonLayer( } _overlayPublisher.destroy(); _dataSource = null; + _cachedFeatures = null; _stemRecords = []; _count = 0; _lastUpdate = null; diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs index 984e6e4af..245867155 100644 --- a/src/data/localGeojsonLifecycle.test.mjs +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -54,7 +54,8 @@ test('destroy removes context records and permits a fresh replacement', async t const oldEntity = [...getContextStore().entities.values()][0].entity; assert.equal(getContextStore().entities.size, 1); env.layer.disable(env.viewer); - assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records'); + assert.equal(getContextStore().entities.size, 0, 'disable releases its records with the source'); + assert.equal(env.sources.size, 0, 'disable removes the built source from the scene'); env.layer.destroy(env.viewer); assert.equal(getContextStore().entities.size, 0); assert.equal(env.sources.size, 0); @@ -126,17 +127,41 @@ test('concurrent enable and disable/re-enable share one pending dataset load', a assert.equal([...env.sources][0].show, true); }); -test('disable during loading keeps the completed source hidden until re-enabled', async t => { +test('disable during loading releases the completed source; re-enable rebuilds without refetching', async t => { const env = harness(t); const release = deferred(); - t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); }); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); }); const loading = env.layer.enable(env.viewer); env.layer.disable(env.viewer); release.resolve(); await loading; - assert.equal([...env.sources][0].show, false); + assert.equal(env.sources.size, 0, 'a build that finishes after disable() is released, not parked hidden'); assert.equal(env.listeners.size, 0); + assert.equal(getContextStore().entities.size, 0); await env.layer.enable(env.viewer); - assert.equal([...env.sources][0].show, true); assert.equal(env.sources.size, 1); + assert.equal([...env.sources][0].show, true); + assert.equal(fetchMock.mock.callCount(), 1, 'the parsed dataset is cached across the toggle'); +}); + +test('a toggle-off frees the entities and a toggle-on rebuilds them from the cached dataset', async t => { + const env = harness(t); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => response()); + await env.layer.enable(env.viewer); + const first = [...env.sources][0]; + assert.equal(first.entities.values.length, 1); + assert.equal(getContextStore().entities.size, 1); + env.layer.disable(env.viewer); + assert.equal(env.sources.size, 0, 'no data source stays registered while the layer is off'); + assert.equal(getContextStore().entities.size, 0); + assert.equal(env.layer.getLodDiagnostics().total, 0); + assert.equal(env.layer.getStats().count, 1, 'the row keeps reporting the dataset size'); + await env.layer.enable(env.viewer); + const second = [...env.sources][0]; + assert.notEqual(second, first, 're-enable builds a fresh source'); + assert.equal(second.show, true); + assert.equal(second.entities.values.length, 1); + assert.equal(getContextStore().entities.size, 1); + assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch for a bundled dataset'); + assert.equal(env.layer.getLodDiagnostics().total, 1); }); diff --git a/src/data/manager.js b/src/data/manager.js index 9ba4ae24c..682f6115b 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -1,5 +1,6 @@ import { governorRequestRender } from '../renderGovernor.js'; -import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; +import { LayerPanel } from '../ui/layers.js'; +export { layerFeedState } from '../ui/layers.js'; import { markDetectionSourcesChanged } from './detection.js'; function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); @@ -11,15 +12,6 @@ function cloneLayerParams(value) { return value; } -const FEED_STATE_LABELS = Object.freeze({ - nominal: 'ON', - loading: 'LOADING', - degraded: 'DEGRADED', - stale: 'STALE', - fallback: 'FALLBACK', - unavailable: 'UNAVAILABLE', -}); - const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', @@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) { return null; } -/** - * Normalize heterogeneous layer stats into one honest control-chip state. - * @param {object|null} stats Layer getStats() result. - * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state. - */ -export function layerFeedState(stats = {}) { - const state = stats || {}; - const status = typeof state.status === 'string' ? state.status.toLowerCase() : ''; - const source = `${state.source || ''} ${state.coverage || ''}`; - const hasExplicitFallback = typeof state.fallback === 'boolean'; - const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate); - const presentedError = state.error || state.lastError || state.managerRefreshError; - if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable'; - if ( - (presentedError || state.unavailable === true || state.available === false) - && !hasPriorData - && !GUIDANCE_STATUSES.includes(status) - ) { - return 'unavailable'; - } - if (state.loading) return 'loading'; - // Guidance states ask the user to act (zoom in, run a search) — normal - // operation, not feed faults. One honesty carve-out: layers keep their - // rendered records through the guidance state, so a genuinely stale cache - // still reads STALE; a guidance prompt alone never reads DEGRADED. - if (GUIDANCE_STATUSES.includes(status)) { - return state.stale ? 'stale' : 'nominal'; - } - if ( - state.fallback === true - || status === 'fallback' - || state.mode === 'sim' - || /\bfallback\b/i.test(source) - || (!hasExplicitFallback && /\badsb\.lol\b/i.test(source)) - ) { - return 'fallback'; - } - if (state.stale || status === 'stale') return 'stale'; - if ( - state.degraded - || presentedError - || state.unavailable === true - || state.available === false - ) return 'degraded'; - return 'nominal'; -} - /** * DataLayerManager — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. @@ -1894,6 +1839,9 @@ export class DataLayerManager { * Should be called when the viewer is being torn down. */ async destroyAll() { + this._layerPanel?.destroy(); + this._layerPanel = null; + this._toggleContainer = null; for (const layerId of [...this.layers.keys()]) { await this.destroyLayer(layerId); } @@ -2019,98 +1967,6 @@ export class DataLayerManager { this._renderToggles(); } - _renderToggles() { - if (!this._toggleContainer) return; - this._toggleContainer.innerHTML = ''; - - for (const layer of this.getAll()) { - if (!layer.showInTogglePanel) continue; - const row = document.createElement('div'); - row.className = 'data-toggle-row'; - row.dataset.layerId = layer.id; - - const topRow = document.createElement('div'); - topRow.className = 'data-toggle-top'; - - const left = document.createElement('div'); - left.className = 'data-toggle-left'; - left.innerHTML = `${layer.icon}${layer.name}`; - - const right = document.createElement('div'); - right.className = 'data-toggle-right'; - - const count = document.createElement('span'); - count.className = 'data-count'; - count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—'; - - const toggle = document.createElement('button'); - toggle.type = 'button'; - toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`; - this._syncToggleButton(toggle, layer); - toggle.addEventListener('click', async () => { - // Native `disabled` immediately evicts keyboard focus in Chromium. Keep - // the lifecycle control focusable while it is busy, and enforce the - // same single-flight interaction contract through ARIA instead. - if (toggle.getAttribute('aria-disabled') === 'true') return; - toggle.setAttribute('aria-disabled', 'true'); - toggle.setAttribute('aria-busy', 'true'); - try { - await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' }); - } catch (error) { - console.warn(`[Data] ${layer.id} toggle error:`, error); - } finally { - const current = this.getAll().find(({ id }) => id === layer.id); - if (current) this._syncToggleButton(toggle, current); - } - }); - - right.appendChild(count); - right.appendChild(toggle); - topRow.appendChild(left); - topRow.appendChild(right); - - const bottomRow = document.createElement('div'); - bottomRow.className = 'data-toggle-meta'; - bottomRow.textContent = this._buildMetaText(layer); - - row.appendChild(topRow); - row.appendChild(bottomRow); - - // Optional per-layer sub-controls (chips + color legend). The click - // listener is delegated and attached once here, so it survives - // _refreshTogglePanel — which only rewrites the container's contents. - const rowModule = this.layers.get(layer.id)?.module; - if (typeof rowModule?.getRowControls === 'function') { - // A layer whose controls settle asynchronously (a chunked catalog load - // that can also fail) pushes a re-render through this; nothing else - // would repaint the row before its next scheduled refresh. - rowModule.setRowControlsListener?.(() => this._refreshTogglePanel()); - const controls = document.createElement('div'); - controls.className = 'data-toggle-controls'; - controls.addEventListener('click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); - if (!button || button.disabled) return; - // Re-read the live descriptor rather than trusting the rendered - // chip, so a stale row can never apply an inverted toggle. - const chip = this._rowControlsFor(layer.id)?.chips - ?.find((entry) => entry.id === button.dataset.chipId); - if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); - }); - row.appendChild(controls); - this._syncRowControls(controls, layer); - } - - this._toggleContainer.appendChild(row); - } - } - - /** - * Read a layer's optional row-control descriptor, tolerating a throw so one - * misbehaving layer cannot blank the whole panel. Resolved from the registry - * rather than the `getAll()` projection, which deliberately omits `module`. - * @param {string} layerId Registered layer id. - * @returns {{ chips?: Array, legend?: Array }|null} Descriptor. - */ _rowControlsFor(layerId) { const module = this.layers.get(layerId)?.module; if (typeof module?.getRowControls !== 'function') return null; @@ -2122,186 +1978,25 @@ export class DataLayerManager { } } - /** - * Render a layer's row chips and color legend, and keep the whole block - * hidden while the layer is off (or while a dependency owner has surrendered - * it) so a quiet row stays quiet. - * - * Chip BUTTONS are reconciled in place, keyed by chip id, rather than - * rebuilt: this runs on every panel refresh — including the one the chip's - * own click triggers — and replacing the node would drop keyboard focus - * mid-interaction. Legend entries hold no focus and no listeners, so they - * are replaced freely. - * @param {HTMLElement|null} container The row's `.data-toggle-controls` node. - * @param {object} layer Registered layer entry. - */ - _syncRowControls(container, layer) { - if (!container) return; - const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; - const chips = controls?.chips || []; - const legend = controls?.legend || []; - container.hidden = chips.length === 0 && legend.length === 0; - - for (const node of [...container.children]) { - if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove(); - } - - const stale = new Map(); - for (const node of [...container.children]) { - if (node.dataset?.chipId) stale.set(node.dataset.chipId, node); - } - - for (const chip of chips) { - let button = stale.get(chip.id); - stale.delete(chip.id); - if (!button) { - button = document.createElement('button'); - button.type = 'button'; - button.dataset.chipId = chip.id; - container.appendChild(button); - } - const state = chip.state || (chip.active ? 'active' : 'idle'); - button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`; - if (button.textContent !== chip.label) button.textContent = chip.label; - button.title = chip.title || ''; - button.disabled = Boolean(chip.disabled); - button.setAttribute('aria-pressed', chip.active ? 'true' : 'false'); - button.setAttribute('aria-busy', chip.busy ? 'true' : 'false'); - } - for (const node of stale.values()) node.remove(); - - for (const item of legend) { - const entry = document.createElement('span'); - entry.className = 'data-toggle-legend-item'; - if (item.blurb) entry.title = item.blurb; - const swatch = document.createElement('span'); - swatch.className = 'data-toggle-legend-swatch'; - swatch.style.background = item.color; - const text = document.createElement('span'); - text.textContent = `${item.label} ${this._formatCount(item.count)}`; - entry.append(swatch, text); - container.appendChild(entry); - } - } - - _refreshTogglePanel() { - if (!this._toggleContainer) return; - // Skip DOM churn while hidden; visibilitychange (main.js) triggers one - // refresh on return. (perf wave 2) - if (typeof document !== 'undefined' && document.hidden) { - this._panelRefreshPendingOnVisible = true; - return; - } - for (const layer of this.getAll()) { - const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`); - if (!row) continue; - - const btn = row.querySelector('.data-toggle-btn'); - if (btn) { - this._syncToggleButton(btn, layer); - } - - const count = row.querySelector('.data-count'); - if (count) { - count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—'; - } - - const meta = row.querySelector('.data-toggle-meta'); - if (meta) { - meta.textContent = this._buildMetaText(layer); - } - - this._syncRowControls(row.querySelector('.data-toggle-controls'), layer); - } - } - - _buildMetaText(layer) { - const stats = layer.stats || {}; - const feedState = layerFeedState(stats); - const stateLabel = FEED_STATE_LABELS[feedState]; - const source = stats.source || layer.source; - const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); - if (lifecycleState === 'enabling' || lifecycleState === 'disabling') { - return `${lifecycleState.toUpperCase()} · ${source}`; - } - if (layer.lifecycleUncertain) { - return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`; - } - const presentedError = stats.error || stats.lastError || stats.managerRefreshError; - if (presentedError) { - if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { - return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`; - } - return `${stateLabel} · ${source} · ${presentedError}`; - } - // A guidance status carries its prompt in `statusMessage`, not `error`, so - // the row still tells the operator what to do without reporting a fault. - if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase()) - && typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) { - return `${source} · ${stats.statusMessage.trim()}`; - } - const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never'; - if (stats.loading) { - const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? stats.loadingLabel.trim() - : 'loading...'; - return `${source} · ${loadingLabel}`; - } - if (feedState === 'fallback') { - const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? stats.loadingLabel.trim() - : (stats.coverage || ago); - return `${stateLabel} · ${source} · ${detail}`; - } - if (feedState === 'stale') { - const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0 - ? ` · retrying in ${stats.retryInSec}s` - : ''; - return `${stateLabel} · ${source} · ${ago}${retry}`; - } - if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { - return `${source} · ${stats.loadingLabel.trim()}`; - } - return `${source} · ${ago}`; - } - - _syncToggleButton(button, layer) { - const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off'; - const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling'; - const uncertain = Boolean(layer.lifecycleUncertain); - button.classList.toggle('active', layer.enabled); - button.classList.toggle('transitioning', transitioning); - button.classList.toggle('enabling', layer.lifecycleState === 'enabling'); - button.classList.toggle('disabling', layer.lifecycleState === 'disabling'); - button.classList.toggle('lifecycle-uncertain', uncertain); - for (const state of Object.keys(FEED_STATE_LABELS)) { - button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state); - } - button.dataset.feedState = transitioning - ? layer.lifecycleState - : (uncertain ? 'uncertain' : feedState); - // A busy toggle remains the keyboard focus owner. `aria-disabled` plus the - // click guard above prevents repeat activation without the focus loss caused - // by native `disabled`. - button.disabled = false; - button.setAttribute('aria-disabled', String(transitioning)); - button.setAttribute('aria-busy', String(transitioning)); - button.textContent = transitioning - ? layer.lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF')); - button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`); - } - - _formatCount(n) { - if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; - return String(n); - } - - _timeAgo(timestamp) { - const diff = Math.floor((Date.now() - timestamp) / 1000); - if (diff < 5) return 'just now'; - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - return `${Math.floor(diff / 3600)}h ago`; + _getLayerPanel() { + if (!this._layerPanel) this._layerPanel = new LayerPanel({ + getLayers: () => this.getAll(), + isEnabled: (id) => this.isEnabled(id), + setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options), + setLayerParams: (id, params, options) => this.setLayerParams(id, params, options), + getRowControls: (id) => this._rowControlsFor(id), + hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function', + subscribeRowControls: (id, listener) => { + const module = this.layers.get(id)?.module; + module?.setRowControlsListener?.(listener); + return () => module?.setRowControlsListener?.(null); + }, + onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; }, + }); + return this._layerPanel; } + _renderToggles() { this._getLayerPanel().mount(this._toggleContainer); } + _refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); } + _buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); } + _syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); } } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 73e3d4bad..e2f084d62 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator' }, appendChild(child) { this.children.push(child); return child; }, addEventListener() {}, + removeEventListener() {}, setAttribute(name, value) { this.attributes[name] = String(value); }, querySelector(selector) { if (selector.startsWith('[data-layer-id="')) { @@ -2756,6 +2757,7 @@ function makeControlElement() { }, focus() { if (globalThis.document) globalThis.document.activeElement = this; }, addEventListener(name, handler) { this.listeners[name] = handler; }, + removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; }, setAttribute(name, value) { this.attributes[name] = String(value); }, getAttribute(name) { return this.attributes[name] ?? null; }, closest(selector) { @@ -3267,3 +3269,36 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', ( assert.match(text, /Zoom in to search mapped installations$/); assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/); }); + + +test('panel remount releases old listeners and destruction revokes retained controls', async () => { + const originalDocument = globalThis.document; + globalThis.document = { createElement: makeControlElement, activeElement: null }; + const manager = new DataLayerManager({}); + const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 }); + let rowListener; + layer.module.getRowControls = () => ({ chips: [] }); + layer.module.setRowControlsListener = (listener) => { rowListener = listener; }; + manager.register(layer.module); + try { + const first = makeControlElement(); + manager.buildTogglePanel(first); + const oldButton = first.querySelector('.data-toggle-btn'); + const retainedClick = oldButton.listeners.click; + const second = makeControlElement(); + manager.buildTogglePanel(second); + assert.equal(oldButton.listeners.click, undefined); + await retainedClick(); + assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions'); + const currentButton = second.querySelector('.data-toggle-btn'); + assert.equal(typeof currentButton.listeners.click, 'function'); + assert.equal(typeof rowListener, 'function'); + await manager.destroyAll(); + assert.equal(currentButton.listeners.click, undefined); + assert.equal(rowListener, null); + } finally { + await manager.destroyAll(); + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js index ca100eeb5..5692f8027 100644 --- a/src/data/militaryAwareness.js +++ b/src/data/militaryAwareness.js @@ -1,1948 +1,46 @@ -import * as Cesium from 'cesium'; -import flightsLayer from './flights.js'; -import militaryFlightsLayer from './militaryFlights.js'; -import aisLiveVesselsLayer from './aisLiveVessels.js'; -import militaryInstallationsLayer from './militaryInstallations.js'; -import { - AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, - AWARENESS_RADIUS_M, - AWARENESS_RELATIONSHIP, - findByDoublingRadius, - formatAwarenessDistance, - formatAwarenessLabel, - getAwarenessNavigationTargets, - summarizeAwarenessCohort, -} from './militaryAwarenessEngine.js'; -import { announceNavigationAuthority } from '../navigationPolicy.js'; -import { celestialScreenAngle, getKeyholeGeometry } from '../celestialRing.js'; -import { bearingBetweenCoordinates } from '../cockpitMath.js'; -import { cameraPoseSignature } from './iconOrientation.js'; -import { - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, -} from '../renderGovernor.js'; +import { createAwarenessLayer } from '../layers/awareness/index.js'; +import flights from './flights.js'; +import military from './militaryFlights.js'; +import vessels from './aisLiveVessels.js'; +import installations from './militaryInstallations.js'; +import * as navigation from '../navigationPolicy.js'; +import * as geometry from '../celestialRing.js'; +import * as render from '../renderGovernor.js'; -const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; -const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations']; -const DEPENDENCIES = [...AIRCRAFT_DEPENDENCIES, ...DEFERRED_DEPENDENCIES]; -const AWARENESS_REFRESH_MS = 750; -/** @constant {number} Refresh cadence while the camera pose is CHANGING. - * Owner playtest 2026-08-18: the Contacts direction arrows and card readouts - * "feel sluggish when you look around" — at the parked 750 ms cadence the - * arrows lag the view by up to three quarters of a second. */ -const AWARENESS_MOTION_REFRESH_MS = 175; -/** @constant {number} How long the pose signature must stay UNCHANGED before - * the view counts as parked again. The signature is quantized, so a slow drag - * crosses a bin only every few frames; without this hysteresis every crossing - * read as a motion-end and refreshed at nearly display rate. */ -const AWARENESS_MOTION_SETTLE_MS = 250; -const AWARENESS_REEVALUATE_DISTANCE_M = 250; -const AWARENESS_PAGE_ROTATE_MS = 10000; -const AWARENESS_PAGE_SIZE = 3; -const AWARENESS_MAX_EXAMPLES = 10; -// Navigation walks the whole 250 km cohort, not the ten rows the panel shows, -// so these caps sit far above any realistic in-range population rather than at -// the display limit. They are still FINITE: an unbounded materialization would -// let a pathological feed sort an eleven-thousand-contact array on every -// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort` -// derives `count` from the full in-range set before any slice. -export const AWARENESS_QUERY_LIMIT = 20000; -const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000; -const CONTEXT_RIM_HEIGHT_M = 2500; -const VESSEL_FOCUS_RADIUS_M = 3000; -const DIRECTION_SCRATCH = Array.from({ length: 3 }, () => new Cesium.Cartesian3()); -const SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic(); -const TARGET_CARTOGRAPHIC_SCRATCH = Array.from({ length: 3 }, () => new Cesium.Cartographic()); -const SOURCE_LABEL = { - flights: 'OpenSky', - military: 'adsb.lol', - 'ais-live-vessels': 'AISStream', - 'military-installations': 'OpenStreetMap', -}; - -/** - * Whether focusing a Context target may take ownership of the camera. - * Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable - * there but must not start a competing flyTo animation. - * - * @param {string} layerId Context target layer id. - * @param {HTMLElement|null} [body=document.body] Document body to inspect. - * @returns {boolean} Whether the target may start a camera flight. - */ -export function contextTargetFlyToAllowed(layerId, body = globalThis.document?.body) { - const nonAircraft = layerId === 'ais-live-vessels' || layerId === 'military-installations'; - return !nonAircraft || !body?.classList?.contains('cockpit-mode'); -} - -/** - * Decide whether a source-scoped context clear belongs to the current subject. - * @param {{layerId?: string}|null} subject Current awareness subject. - * @param {{layerId?: string}|null} cleared Cleared source detail. - * @returns {boolean} Whether the current subject should be cleared. - */ -export function awarenessClearMatchesSubject(subject, cleared) { - return Boolean(subject?.layerId && cleared?.layerId === subject.layerId); -} - -/** - * Refresh cadence for the Contacts direction arrows and card readouts. - * - * A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms - * one. This is safe for requestRenderMode because the cadence is applied inside - * `scene.preRender`: a faster interval can only ever consume frames the camera - * movement is ALREADY forcing. A parked scene renders no frames at all, so no - * extra refresh happens and no timer is introduced — the render governor and - * the idle-leak fix both depend on parked staying quiet. - * @param {boolean} cameraMoving Whether the camera pose changed since the last frame. - * @returns {number} Minimum ms between refreshes. - */ -export function awarenessRefreshIntervalMs(cameraMoving) { - return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS; -} - -/** - * Decide, for one rendered frame, whether the view counts as MOVING and whether - * the Contacts readout may refresh. - * - * Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a - * slow drag crosses a bin only every few frames: treating a single unchanged - * frame as "parked" made every crossing look like a motion-end and refreshed at - * nearly display rate. Motion therefore ends only after the pose has been - * unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD - * floor in every state — no bin-crossing pattern, and no motion-end settle, can - * refresh faster than AWARENESS_MOTION_REFRESH_MS. - * - * @param {object} input - * @param {number} input.nowMs This frame's clock. - * @param {number} input.lastRefreshMs When the readout last refreshed. - * @param {number} input.lastPoseChangeMs When the camera pose last changed bins. - * @param {boolean} input.wasMoving Whether the previous frame counted as moving. - * @returns {{moving: boolean, refresh: boolean}} - */ -export function awarenessRefreshDecision({ nowMs, lastRefreshMs, lastPoseChangeMs, wasMoving }) { - const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS; - // The single frame where hysteresis expires: settle the readout at rest - // instead of leaving it up to a parked interval stale. - const settling = Boolean(wasMoving) && !moving; - const sinceRefresh = nowMs - lastRefreshMs; - const refresh = sinceRefresh >= awarenessRefreshIntervalMs(true) - && (moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false)); - return { moving, refresh }; -} - -/** - * Decide whether a source-scoped clear was an eviction rather than a deselect. - * - * Both arrive on the same events: the owning layer calls the same teardown for - * "the user clicked away" and "this contact aged out of the feed". Only the - * origin separates them, and it matters — a deliberate clear takes the Contact - * panel down, while an eviction must keep it up in its CONTACT LOST state, or - * the panel disappears out from under its own PREVIOUS/NEXT controls. - * @param {{reason?: string}|null} cleared Cleared source detail. - * @returns {boolean} Whether the subject left the feed on its own. - */ -export function awarenessClearIsEviction(cleared) { - return cleared?.reason === 'evicted'; -} - -/** - * Decide whether proximity cohorts need an expensive rescan. - * @param {object} input Refresh evidence. - * @param {boolean} input.force Explicit invalidation. - * @param {boolean} input.hasResults Whether a prior evaluation exists. - * @param {number} input.movementM Subject displacement since evaluation. - * @param {boolean} input.sourceRevisionChanged Whether any source state changed. - * @returns {boolean} Whether cohorts should be evaluated again. - */ -export function awarenessRefreshRequired({ force, hasResults, movementM, sourceRevisionChanged }) { - return Boolean( - force - || !hasResults - || sourceRevisionChanged - || !Number.isFinite(movementM) - || movementM >= AWARENESS_REEVALUATE_DISTANCE_M, - ); -} - -/** - * Whether a refresh tick could confirm the subject in its source collection. - * UNCHECKED means the tick did not look — it must never change the verdict. - */ -const SUBJECT_PRESENCE = Object.freeze({ - LIVE: 'live', - MISSING: 'missing', - UNCHECKED: 'unchecked', -}); - -const state = { - viewer: null, - dataManager: null, - enabled: false, - // Set once a refresh observes the subject gone from a still-reporting source. - // The Contact readout turns this into its CONTACT LOST hold state. - subjectMissing: false, - // The dedicated Context chooser exposes a passive shell. Operational source - // layers activate only after Contacts is explicitly selected. - passive: true, - ownedDependencies: new Set(), - subject: null, - results: null, - visual: null, - panel: null, - panelOwned: false, - subjectListener: null, - contextListener: null, - clearListener: null, - subjectClearListener: null, - runtimeListenersAttached: false, - preRenderRemover: null, - panelClickListener: null, - directionRoot: null, - compassRing: null, - compassLabels: [], - compassHeading: null, - directionMarkers: [], - navigationHistory: [], - navigationVisited: new Set(), - navigationIndex: -1, - suppressedHistoryKey: null, - pendingSelectionKey: null, - lastSubjectRefreshMs: 0, - /** Camera-pose signature observed on the previous rendered frame. */ - lastCameraPoseSig: '', - /** When the pose signature last changed bins (hysteresis anchor). */ - lastCameraPoseChangeMs: 0, - /** Whether the view currently counts as moving (hysteretic, not per-frame). */ - cameraMoving: false, - lastEvaluatedPosition: null, - sourceRevision: '', - panelMarkup: '', - directionFrame: null, - lastDirectionUpdateMs: 0, - activationId: 0, - autoFocusAttempted: false, - autoFocusRetryPending: false, - pageTimer: null, - cohortPages: new Map(), -}; - -function ensurePanel() { - if (state.panel) return state.panel; - const existing = document.getElementById('military-awareness-panel'); - const panel = existing || document.createElement('aside'); - state.panelOwned = !existing; - if (!existing) { - panel.id = 'military-awareness-panel'; - } - state.panelClickListener = (event) => { - const action = event.target.closest('button[data-awareness-action]'); - if (action) { - event.preventDefault(); - if (action.dataset.awarenessAction === 'previous') navigateHistory(-1, { origin: 'user' }); - if (action.dataset.awarenessAction === 'next') navigateHistory(1, { origin: 'user' }); - if (action.dataset.awarenessAction === 'focus') focusCurrentSubject({ origin: 'user' }); - return; - } - const target = event.target.closest('button[data-awareness-layer][data-awareness-id]'); - if (!target) return; - event.preventDefault(); - requestFocus(target.dataset.awarenessLayer, target.dataset.awarenessId, false, { origin: 'user' }); - }; - panel.addEventListener('click', state.panelClickListener); - if (!existing) document.body.appendChild(panel); - state.panel = panel; - return panel; -} - -function hidePanel() { - if (state.panel) { - const markup = ` - ${state.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'} - ${state.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'} - `; - state.panel.hidden = false; - if (state.panelMarkup !== markup) { - state.panel.innerHTML = markup; - state.panelMarkup = markup; - } - } - if (state.directionRoot) state.directionRoot.hidden = true; -} - -function sourceState(layerId) { - const lifecycle = state.dataManager?.getLayerLifecycleState?.(layerId) || null; - const enabled = lifecycle?.enabled === true || state.dataManager?.isEnabled(layerId) === true; - const enabling = lifecycle?.lifecycleState === 'enabling'; - const moduleStats = state.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {}; - const stats = enabling - ? { ...moduleStats, loading: true, status: 'loading' } - : moduleStats; - // A deferred source that has started but not settled is not evidence of an - // empty cohort. Keep it explicitly unavailable so the panel cannot flash a - // false all-clear, even if a lifecycle adapter briefly reports the requested - // visibility intent as enabled. - // - // `enabling` and the never-answered predicate cover two DIFFERENT windows, and - // both are needed because these dependencies do not settle the same way. - // - // - military-installations is covered by `enabling` alone. Its enable() is - // SYNCHRONOUS and the manager awaits update(), which owns the first - // Overpass fetch, so the lifecycle stays `enabling` for that whole window - // however long the fetch takes. Confirmed live: a held 17 s first fetch - // read `enabling` across 34 samples with the panel non-numeric throughout, - // and a failing one settled to `enabled` with status 'unavailable'. Its - // getStats() also reports loading while a request is in progress — - // setInstallationStatus is only ever called with - // loading/zoom-in/ready/stale/empty/unavailable. - // - ais-live-vessels is what the predicate below is FOR. Its enable() and - // update() both resolve as soon as the first /api/ais-live poll answers, - // so the lifecycle settles to `enabled` — but until the server-side socket - // delivers a position, firstConnectPhase is 'loading' and getStats() - // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED - // status. That zero is an absence of evidence, not evidence of absence, - // and neither the lifecycle nor the status list can tell. - // - // Deliberately `loading === true` and not truthiness: layers that never report - // a busy flag (flights, military) must keep answering for themselves, and a - // source that HAS answered once (lastUpdate set) keeps its last real count - // through every later poll rather than blanking to `?` on each refresh. - const neverAnswered = stats.loading === true && !stats.lastUpdate; - const unavailable = enabling - || !enabled - || neverAnswered - || ['unavailable', 'zoom-in'].includes(stats.status) - || Boolean(stats.error && stats.count === 0); - return { available: !unavailable, stale: Boolean(stats.stale), stats }; -} - -function collectSourceStates() { - return Object.fromEntries(DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)])); -} - -function sourceRevision(sourceStates) { - return DEPENDENCIES.map((layerId) => { - const source = sourceStates[layerId]; - const stats = source?.stats || {}; - return [ - layerId, - source?.available, - source?.stale, - stats.lastUpdate || null, - stats.count ?? null, - stats.status || null, - stats.error || null, - ]; - }).map((parts) => parts.join(':')).join('|'); -} - -function isSame(subject, item, prefix, key) { - return subject?.layerId === prefix && String(subject.id) === String(item?.[key]); -} - -/** - * Summarize viewport-backed installations without implying radius-complete coverage. - * @param {Array} items Loaded installation matches. - * @param {object} source Current source availability and staleness. - * @returns {object} Awareness summary with viewport-honest reasoning. - */ -export function summarizeInstallationViewport(items, source) { - const summary = summarizeAwarenessCohortForNavigation(items, source); - if (summary.count === null) return source.stats?.statusMessage - ? { ...summary, reason: source.stats.statusMessage } - : summary; - return { - ...summary, - reason: summary.count - ? 'mapped matches from the loaded viewport' - : 'viewport feed is not a complete 250 km survey', - }; -} - -function summarizeAwarenessCohortForNavigation(items, source, { - displayLimit = AWARENESS_MAX_EXAMPLES, - navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES, -} = {}) { - const summary = summarizeAwarenessCohort(items, { ...source, limit: navigationLimit }); - if (summary.count === null) return summary; - return { - ...summary, - nearest: summary.nearest.slice(0, displayLimit), - navigationNearest: summary.nearest, - }; -} - -/** - * The per-layer counts the Contacts panel is showing, as one flat block. - * - * Three honest numbers were reaching the operator at once: this cohort count - * (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the - * layer-wide loaded total in the coverage note. After the camera dives to a - * tracked contact the flights layer reloads by viewport, so the loaded set can - * hold a fraction of the cohort — 8 against the panel's 42 in the field. The - * numbers are all correct and the disagreement still reads as chaos. - * - * Derived from the same snapshot the panel renders (`cohort.summary.count` via - * `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort - * whose feed cannot answer reports 'unknown' rather than a misleading zero. - * @param {object|null} snapshot `getContextSnapshot()` result. - * @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string, - * flights: number|string, military: number|string, vessels: number|string}|null} - * Panel-equivalent counts. - */ -export function contactsWindowFromSnapshot(snapshot) { - if (!snapshot?.subject) return null; - const countFor = (cohortId) => { - const cohort = Array.isArray(snapshot.cohorts) - ? snapshot.cohorts.find((item) => item?.id === cohortId) - : null; - return Number.isFinite(cohort?.count) ? cohort.count : 'unknown'; - }; - const flights = countFor('flights'); - const military = countFor('military'); - return { - centeredOn: snapshot.subject.label || snapshot.subject.id || null, - radiusKm: Number.isFinite(snapshot.radiusM) - ? Math.round(snapshot.radiusM / 1000) - : null, - aircraft: Number.isFinite(flights) && Number.isFinite(military) - ? flights + military - : 'unknown', +const layer = createAwarenessLayer({ + services: { flights, military, - vessels: countFor('ais-live-vessels'), - }; -} - -/** Build the read-only Awareness snapshot shared with compact HUD consumers. */ -export function buildAwarenessContextSnapshot(results, navigation = {}, { subjectPresent = true } = {}) { - if (!results) return null; - return { - subject: { ...results.subject }, - // Whether the subject is still reported by its source. The cockpit Contact - // readout holds its last-known values behind a CONTACT LOST cue when this - // is false, rather than presenting frozen geometry as a live reading. - subjectPresent: subjectPresent !== false, - evaluatedAt: results.evaluatedAt, - radiusM: results.radiusM, - cohorts: results.cohorts.map((cohort) => ({ - id: cohort.id, - label: cohort.label, - source: cohort.source, - coverage: cohort.coverage || null, - relationship: cohort.summary.relationship, - count: cohort.summary.count, - reason: cohort.summary.reason, - nearest: cohort.summary.nearest.slice(), - })), - navigation: { ...navigation }, - }; -} - -/** - * THE aircraft-proximity engine. One computation, two consumers: the Contacts - * panel window and the voice analyst's entity-centred "how many nearby". - * - * They used to be separate. The panel read live billboard positions through - * `getNearby` with a 20 000 cap; the analyst re-derived its own answer from - * last-fix coordinates over a 2 000-record slice. Same question, same centre, - * two numbers — and in the owner's trial the spoken answer (15) and the panel - * (111) disagreed badly enough that the model narrated the difference away. - * Routing both through here makes them the same number BY CONSTRUCTION, so - * they cannot drift again. - * - * The subject is excluded from its own window, which is why the panel reads - * "contacts around X" rather than "including X". - * @param {Cesium.Cartesian3} position Window centre. - * @param {object} [options] - * @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius. - * @param {object|null} [options.subject=null] Contact at the centre, excluded. - * @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts - * plus the combined aircraft count, or null without a position. - */ -export function collectAircraftProximityWindow(position, { - radiusM = AWARENESS_RADIUS_M, - subject = null, -} = {}) { - if (!position) return null; - const flights = flightsLayer - .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { includeHidden: true }) - .filter((item) => !subject || !isSame(subject, item, 'flights', 'icao24')); - const military = militaryFlightsLayer - .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { includeHidden: true }) - .filter((item) => !subject || !isSame(subject, item, 'military', 'icao24')); - return { flights, military, aircraft: flights.length + military.length }; -} - -function evaluateSubject(subject, sourceStates = collectSourceStates()) { - const position = subject?.position; - if (!position) return null; - const flightsState = sourceStates.flights; - const militaryState = sourceStates.military; - const vesselsState = sourceStates['ais-live-vessels']; - const installationsState = sourceStates['military-installations']; - // Same engine the voice analyst calls — see collectAircraftProximityWindow. - const { flights, military } = collectAircraftProximityWindow(position, { subject }); - const vessels = aisLiveVesselsLayer.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) - .filter((item) => !isSame(subject, item, 'ais-live-vessels', 'mmsi')); - const installations = militaryInstallationsLayer.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) - .filter((item) => !isSame(subject, item, 'military-installations', 'id')); - return { - subject, - evaluatedAt: Date.now(), - radiusM: AWARENESS_RADIUS_M, - cohorts: [ - { id: 'flights', label: 'Flights', source: flightsState.stats.source || SOURCE_LABEL.flights, summary: summarizeAwarenessCohortForNavigation(flights, flightsState) }, - { id: 'military', label: 'Military flights', source: militaryState.stats.source || SOURCE_LABEL.military, summary: summarizeAwarenessCohortForNavigation(military, militaryState) }, - { id: 'ais-live-vessels', label: 'AIS vessels', source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], summary: summarizeAwarenessCohortForNavigation(vessels, vesselsState) }, - { - id: 'military-installations', - label: 'Mapped installations', - source: installationsState.stats.source || SOURCE_LABEL['military-installations'], - coverage: 'CURRENT VIEWPORT ONLY', - summary: summarizeInstallationViewport(installations, installationsState), - }, - ], - }; -} - -function rowHtml(cohort) { - const summary = cohort.summary; - const count = summary.count === null ? '?' : String(summary.count); - const page = state.cohortPages.get(cohort.id) || 0; - const nearest = summary.nearest.slice(page, page + AWARENESS_PAGE_SIZE).map((item) => { - const label = formatAwarenessLabel(item); - const targetId = item.icao24 || item.mmsi || item.id; - if (!targetId) { - return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; - } - const accessibleLabel = label === '—' ? 'Unavailable' : label; - return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; - }).join(''); - const pageCount = Math.max(1, Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE)); - const pageLabel = pageCount > 1 ? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}` : ''; - const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : ''; - return ` - ${escapeHtml(cohort.label)}${count}${pageLabel} - ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)} - ${nearest ? `${nearest}` : ''} - `; -} - -/** Focus a nearby example through the source layer that owns its selection. */ -function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) { - if (!layerId || !id) return false; - if (layerId === 'flights') { - return flightsLayer.refocusTrackedById?.(id, { origin }) || flightsLayer.trackById(id, { origin }); - } - if (layerId === 'military') { - return militaryFlightsLayer.refocusTrackedById?.(id, { origin }) || militaryFlightsLayer.trackById(id, { origin }); - } - if (layerId === 'military-installations') { - if (!contextTargetFlyToAllowed(layerId)) return selectKnownContextTarget(layerId, id); - // focusById flies the camera; it never assigns a tracked entity, so the - // stamp has to be announced here. - announceNavigationAuthority('context-installation-focus'); - return militaryInstallationsLayer.focusById(id); - } - if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id)) return false; - - const vessel = aisLiveVesselsLayer.getAllPositions(12000).find((item) => String(item.id) === String(id)); - if (!contextTargetFlyToAllowed(layerId) || !vessel?.position || !state.viewer) return true; - announceNavigationAuthority('context-vessel-focus'); - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.4 }, - ); - return true; -} - -function subjectKey(subject) { - return subject ? `${subject.layerId}:${subject.id}` : ''; -} - -function normalizeContextId(value) { - return String(value || '').trim().toLowerCase(); -} - -function normalizedSubjectKey(subject) { - if (!subject) return ''; - return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`; -} - -function resetNavigationVisitedCycle() { - state.navigationVisited.clear(); - const currentKey = subjectKey(state.subject); - if (currentKey) state.navigationVisited.add(currentKey); -} - -/** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */ -export function _getAwarenessNavigationStateForTest() { - return { - historyKeys: state.navigationHistory.map(subjectKey), - navigationVisitedKeys: [...state.navigationVisited], - historyLength: state.navigationHistory.length, - navigationIndex: state.navigationIndex, - suppressedHistoryKey: state.suppressedHistoryKey, - pendingSelectionKey: state.pendingSelectionKey, - }; -} - -function selectKnownContextTarget(layerId, id) { - const key = `${layerId}:${id}`; - const known = [ - state.subject, - ...state.navigationHistory, - ...(state.results?.cohorts || []).flatMap((cohort) => ( - cohort.id === layerId ? (cohort.summary.navigationNearest || cohort.summary.nearest || []) : [] - )), - ].find((item) => item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key); - if (!known?.position) return false; - if (contextTargetFlyToAllowed(layerId)) releaseAircraftTracking(); - selectSubject({ - ...known, - layerId, - id: String(known.id || known.mmsi), - label: known.label || known.name || known.callsign || String(id), - position: Cesium.Cartesian3.clone(known.position), - }); - return true; -} - -function requestFocus(layerId, id, preserveHistory = false, { origin = 'programmatic' } = {}) { - const key = `${layerId}:${id}`; - state.pendingSelectionKey = key; - if (preserveHistory) state.suppressedHistoryKey = key; - const focused = focusNearbyTarget(layerId, id, { origin }); - state.pendingSelectionKey = null; - state.suppressedHistoryKey = null; - return focused; -} - -function focusSubject(subject, preserveHistory = false, options = {}) { - if (!subject) return false; - return requestFocus(subject.layerId, subject.id, preserveHistory, options); -} - -function focusCurrentSubject(options = {}) { - return focusSubject(state.subject, true, options); -} - -function isFlightLayer(layerId) { - return layerId === 'flights' || layerId === 'military'; -} - -function normalizeAircraftClass(value) { - return String(value || '').trim().toLowerCase(); -} - -function aircraftClassMatchesFilter(item, aircraftClass) { - const requested = normalizeAircraftClass(aircraftClass); - if (!requested) return true; - const candidates = [ - item?.aircraftClass, - item?.klass, - item?.type, - item?.typeCode, - item?.typeName, - ] - .map((value) => normalizeAircraftClass(value)) - .filter(Boolean); - if (!candidates.length) return false; - return candidates.some((candidate) => candidate === requested || candidate.includes(requested)); -} - -function selectNavigationTargets(sourceCohorts, subject, visitedKeys, { - targetLayer = null, - aircraftClass = null, - aircraftOnly = false, -} = {}) { - let targets = getAwarenessNavigationTargets(sourceCohorts, subject, visitedKeys); - if (aircraftOnly) { - targets = targets.filter((target) => isFlightLayer(target.layerId)); - } - if (targetLayer) { - targets = targets.filter((target) => target.layerId === targetLayer); - } - if (aircraftClass) { - targets = targets.filter((target) => aircraftClassMatchesFilter(target.item, aircraftClass)); - } - return targets; -} - -/** - * Resolve the source-owned aircraft that currently owns Cesium tracking. - * Both flight layers may briefly retain local state during a cross-layer - * handoff, so the viewer's normalized tracked identity is the tie-breaker. - * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} - * Detached awareness subject, or null when no flight owns the follow camera. - */ -function currentTrackedFlightSubject() { - const trackedKey = normalizeContextId(state.viewer?.trackedEntity?.gevTrackedId); - if (!trackedKey) return null; - const subjects = [ - flightsLayer.getTrackedSubject?.(), - militaryFlightsLayer.getTrackedSubject?.(), - ]; - return subjects.find((subject) => normalizedSubjectKey(subject) === trackedKey) || null; -} - -function alternativeFlightAvailable({ - targetLayer = null, - aircraftClass = null, -} = {}) { - if (!isFlightLayer(state.subject?.layerId) || !state.subject?.position) return false; - if (targetLayer && !isFlightLayer(targetLayer)) return false; - - const flights = targetLayer === 'military' - ? [] - : flightsLayer.getNearby( - state.subject.position, - AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, - 2, - { includeHidden: true }, - ).filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); - - const military = targetLayer === 'flights' - ? [] - : militaryFlightsLayer.getNearby( - state.subject.position, - AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, - 2, - { includeHidden: true }, - ).filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); - - return [ - ['flights', flights], - ['military', military], - ].some(([layerId, items]) => items.some((item) => ( - `${layerId}:${item.icao24 || item.id}` !== subjectKey(state.subject) - ))); -} - -function closestFlightWithinRadius(radiusM, visitedKeys, excludeVisited, { - targetLayer = null, - aircraftClass = null, -} = {}) { - if (!state.subject?.position) return null; - const visited = new Set(visitedKeys); - if (targetLayer && !isFlightLayer(targetLayer)) return null; - const candidates = [ - ...(targetLayer === 'military' ? [] : flightsLayer.getNearby( - state.subject.position, - radiusM, - 25000, - { includeHidden: true }, - ) - .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) - .map((item) => ({ layerId: 'flights', id: String(item.icao24), item }))), - ...(targetLayer === 'flights' ? [] : militaryFlightsLayer.getNearby( - state.subject.position, - radiusM, - 5000, - { includeHidden: true }, - ) - .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) - .map((item) => ({ layerId: 'military', id: String(item.icao24), item }))), - ].filter((target) => { - const key = `${target.layerId}:${target.id}`; - if (key === subjectKey(state.subject)) return false; - return !excludeVisited || !visited.has(key); - }); - candidates.sort((a, b) => ( - (a.item.distanceM ?? a.item.distance ?? Infinity) - - (b.item.distanceM ?? b.item.distance ?? Infinity) - )); - return candidates[0] || null; -} - -function findExpandedFlightTarget(options = {}) { - if (!isFlightLayer(state.subject?.layerId)) return null; - let visitedKeys = [...state.navigationVisited]; - const search = () => findByDoublingRadius( - (radiusM) => closestFlightWithinRadius(radiusM, visitedKeys, true, options), - { - initialRadiusM: AWARENESS_RADIUS_M, - maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, - }, - ); - const target = search(); - if (target) return target; - resetNavigationVisitedCycle(); - visitedKeys = [...state.navigationVisited]; - return search(); -} - -function subjectCohortFeedUnknown() { - const cohort = state.results?.cohorts?.find((item) => item.id === state.subject?.layerId); - return cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN - && cohort.summary.count === null; -} - -/** - * Resolves NEXT availability from the same three branches used by navigation. - * Forward history remains usable while a feed is unavailable; discovering a - * new nearby or expanded target requires a known subject cohort. - * @param {Object} availability Navigation branch availability. - * @returns {boolean} Whether NEXT has a path that can run. - */ -export function canNavigateAwarenessNext({ - hasForwardHistory = false, - hasExpandedFlightTarget = false, - hasNearbyTarget = false, - subjectCohortUnknown = false, -} = {}) { - return Boolean( - hasForwardHistory - || (!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)) - ); -} - -function canNavigateNext({ - targetLayer = null, - aircraftClass = null, -} = {}) { - return canNavigateAwarenessNext({ - hasForwardHistory: findCompatibleHistoryIndex( - state.navigationHistory, - state.navigationIndex, - 1, - { targetLayer, aircraftClass, resolveItem: historySubjectItem }, - ) !== -1, - hasNearbyTarget: selectNavigationTargets( - state.results?.cohorts, - state.subject, - [...state.navigationVisited], - { targetLayer, aircraftClass }, - ).length > 0, - hasExpandedFlightTarget: alternativeFlightAvailable({ - targetLayer, - aircraftClass, - }), - subjectCohortUnknown: subjectCohortFeedUnknown(), - }); -} - -/** - * Walk navigation history for the next entry that can still be focused. - * - * A history entry only holds a SNAPSHOT of a contact; the live contact behind - * it can be evicted from its layer at any time (the cull that drives - * CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the - * only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead - * entry strands the operator there for the rest of the session, because the - * index never advances past it. - * - * `state.navigationIndex` moves only on a successful focus: a caller that - * falls through to the live cohort must still splice forward history from the - * position the operator actually occupies. - * @param {number} direction Walk direction (negative = previous). - * @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters. - * @returns {boolean} Whether a history entry was focused. - */ -function focusCompatibleHistory(direction, { - targetLayer, - aircraftClass, - aircraftOnly, - origin = 'programmatic', -}) { - let cursor = state.navigationIndex; - for (;;) { - const historyIndex = findCompatibleHistoryIndex( - state.navigationHistory, - cursor, - direction, - { targetLayer, aircraftClass, aircraftOnly, resolveItem: historySubjectItem }, - ); - if (historyIndex === -1) return false; - if (focusSubject(state.navigationHistory[historyIndex], true, { origin })) { - state.navigationIndex = historyIndex; - return true; - } - // Contact evicted since it entered history — step over it. The scan is - // strictly monotonic, so this terminates at the end of history. - cursor = historyIndex; - } -} - -function navigateHistory(direction, { - targetLayer = null, - aircraftClass = null, - aircraftOnly = false, - origin = 'programmatic', -} = {}) { - if (focusCompatibleHistory(direction, { - targetLayer, aircraftClass, aircraftOnly, origin, - })) return true; - - if (direction < 0) return false; - - let targets = selectNavigationTargets( - state.results?.cohorts, - state.subject, - [...state.navigationVisited], - { targetLayer, aircraftClass, aircraftOnly }, - ); - const targetHistoryFullyVisited = targets.length > 0 && targets.every((target) => target.visited); - const subjectCohortUnknown = subjectCohortFeedUnknown(); - if (targetHistoryFullyVisited && isFlightLayer(state.subject?.layerId) && !subjectCohortUnknown) { - const expanded = findExpandedFlightTarget({ targetLayer, aircraftClass }); - if (expanded) { - return requestFocus(expanded.candidate.layerId, expanded.candidate.id, false, { origin }); - } - } - if (targetHistoryFullyVisited) { - resetNavigationVisitedCycle(); - targets = selectNavigationTargets( - state.results?.cohorts, - state.subject, - [...state.navigationVisited], - { targetLayer, aircraftClass, aircraftOnly }, - ); - } - const hasNearbyFlight = targets.some((target) => isFlightLayer(target.layerId)); - if (isFlightLayer(state.subject?.layerId) && !hasNearbyFlight && !subjectCohortUnknown) { - const expanded = findExpandedFlightTarget({ targetLayer, aircraftClass }); - if (expanded) { - return requestFocus(expanded.candidate.layerId, expanded.candidate.id, false, { origin }); - } - } - if (subjectCohortFeedUnknown()) return false; - const candidate = targets[0]; - if (!candidate) return false; - return requestFocus(candidate.layerId, candidate.id, false, { origin }); -} - -function historySubjectItem(subject) { - if (!subject) return null; - const cohort = state.results?.cohorts?.find((item) => item.id === subject.layerId); - const items = cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; - return items.find((item) => String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id)) - || subject; -} - -/** Retain filter metadata when a production subject enters navigation history. */ -export function historySubjectSnapshot(subject, sourceItem = null) { - const aircraftClass = sourceItem?.aircraftClass - || sourceItem?.klass - || sourceItem?.type - || subject?.aircraftClass - || null; - return aircraftClass ? { ...subject, aircraftClass } : { ...subject }; -} - -function historySourceItem(subject) { - if (!isFlightLayer(subject?.layerId) || !subject?.position) return null; - // The current sweep already holds this contact's record. Reusing it keeps - // selection O(1) against the cohort instead of paying a fresh full-layer - // proximity scan per selection — the burst cost when NEXT walks a cohort. - const cohort = state.results?.cohorts?.find((item) => item.id === subject.layerId); - const items = cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; - const fromSweep = items.find( - (item) => String(item?.icao24 || item?.id) === String(subject.id), - ); - if (fromSweep) return fromSweep; - const layer = subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer; - return layer.getNearby(subject.position, 1000, 25, { includeHidden: true }) - .find((item) => String(item?.icao24 || item?.id) === String(subject.id)) || null; -} - -/** Find the next history entry compatible with requested navigation filters. */ -export function findCompatibleHistoryIndex(history, startIndex, direction, { - targetLayer = null, - aircraftClass = null, - aircraftOnly = false, - resolveItem = (subject) => subject, -} = {}) { - const step = direction < 0 ? -1 : 1; - for (let index = startIndex + step; index >= 0 && index < history.length; index += step) { - const subject = history[index]; - if (aircraftOnly && !isFlightLayer(subject?.layerId)) continue; - if (targetLayer && subject?.layerId !== targetLayer) continue; - if (aircraftClass && !aircraftClassMatchesFilter(resolveItem(subject), aircraftClass)) continue; - return index; - } - return -1; -} - -function navigationControlsHtml() { - const canPrevious = state.navigationIndex > 0; - return ` - PREVIOUS - FOCUS - NEXT - `; -} - -function navigationState() { - return { - canPrevious: state.navigationIndex > 0, - canFocus: Boolean(state.subject), - canNext: canNavigateNext(), - }; -} - -/** Stable identity for a delegated Contacts-panel button across live repaints. */ -export function awarenessPanelControlKey(element) { - const control = element?.closest?.( - 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', - ); - if (!control) return null; - if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`; - return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`; -} - -/** Capture stable identity so a live repaint can restore only the same control. */ -export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) { - if (!panel?.contains?.(activeElement)) return null; - if (activeElement?.matches?.('[data-awareness-focus-continuation]')) { - return { key: 'continuation' }; - } - const key = awarenessPanelControlKey(activeElement); - if (!key) return null; - return { key }; -} - -/** Restore the same control, or continue beyond the list when it is no longer rendered. */ -export function restoreAwarenessPanelFocus(panel, snapshot) { - if (!panel || !snapshot) return null; - const continuation = panel.querySelector('[data-awareness-focus-continuation]'); - const controls = [...panel.querySelectorAll( - 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', - )].filter((control) => !control.disabled); - const retained = snapshot.key === 'continuation' - ? continuation - : controls.find((control) => awarenessPanelControlKey(control) === snapshot.key); - const target = retained - || continuation; - target?.focus?.({ preventScroll: true }); - return target || null; -} - -function escapeHtml(value) { - return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]); -} - -function renderResults() { - if (!state.enabled || !state.results) return hidePanel(); - const panel = ensurePanel(); - const { subject, cohorts } = state.results; - const markup = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW - ${navigationControlsHtml()} - ${cohorts.map(rowHtml).join('')} - Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.`; - panel.hidden = false; - if (state.panelMarkup !== markup) { - const focusSnapshot = captureAwarenessPanelFocus(panel); - panel.innerHTML = markup; - state.panelMarkup = markup; - restoreAwarenessPanelFocus(panel, focusSnapshot); - } -} - -function rotateAwarenessPages() { - if (!state.enabled || !state.results) return; - let changed = false; - for (const cohort of state.results.cohorts) { - const pageCount = Math.max(1, Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE)); - if (pageCount <= 1) continue; - const current = state.cohortPages.get(cohort.id) || 0; - state.cohortPages.set(cohort.id, ((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) * AWARENESS_PAGE_SIZE); - changed = true; - } - if (changed) { renderResults(); scheduleDirectionOverlayUpdate(true); } -} - -function startAwarenessPageRotation() { - if (!state.pageTimer) state.pageTimer = window.setInterval(rotateAwarenessPages, AWARENESS_PAGE_ROTATE_MS); -} - -function stopAwarenessPageRotation() { - if (state.pageTimer) window.clearInterval(state.pageTimer); - state.pageTimer = null; -} - -function ensureDirectionOverlay() { - if (state.directionRoot || !state.viewer) return state.directionRoot; - const root = document.createElement('div'); - root.id = 'military-awareness-direction-overlay'; - root.hidden = true; - root.setAttribute('aria-hidden', 'true'); - - const compass = document.createElement('div'); - compass.className = 'military-awareness-compass-ring'; - const cardinals = [ - ['N', 0], ['E', 90], ['S', 180], ['W', 270], - ]; - state.compassLabels = cardinals.map(([label, bearing]) => { - const element = document.createElement('span'); - element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`; - element.textContent = label; - element.dataset.bearing = String(bearing); - root.appendChild(element); - return element; - }); - const heading = document.createElement('span'); - heading.className = 'military-awareness-compass-heading'; - root.append(compass, heading); - - state.directionMarkers = Array.from({ length: 3 }, () => { - const marker = document.createElement('div'); - marker.className = 'military-awareness-direction-marker'; - const arrow = document.createElement('span'); - arrow.className = 'military-awareness-direction-arrow'; - arrow.textContent = '➜'; - const label = document.createElement('span'); - label.className = 'military-awareness-direction-label'; - marker.append(arrow, label); - marker._arrow = arrow; - marker._label = label; - marker._lastAngle = 0; - root.appendChild(marker); - return marker; - }); - - state.compassRing = compass; - state.compassHeading = heading; - state.viewer.container.appendChild(root); - state.directionRoot = root; - return root; -} - -function scheduleDirectionOverlayUpdate(force = false) { - if (state.directionFrame !== null || !state.enabled) return; - const now = Date.now(); - if (!force && now - state.lastDirectionUpdateMs < awarenessRefreshIntervalMs(state.cameraMoving)) return; - state.directionFrame = window.requestAnimationFrame(() => { - state.directionFrame = null; - state.lastDirectionUpdateMs = Date.now(); - updateDirectionOverlay(); - }); -} - -function cancelDirectionOverlayUpdate() { - if (state.directionFrame !== null) window.cancelAnimationFrame(state.directionFrame); - state.directionFrame = null; - state.lastDirectionUpdateMs = 0; -} - -function updateDirectionOverlay() { - const root = ensureDirectionOverlay(); - if (!root || !state.enabled || !state.subject?.position || !state.results) { - if (root) root.hidden = true; - return; - } - - const canvas = state.viewer.scene.canvas; - const width = canvas.clientWidth || canvas.width; - const height = canvas.clientHeight || canvas.height; - const geometry = getKeyholeGeometry(width, height); - if (!(geometry.radius > 0)) { - root.hidden = true; - return; - } - - root.hidden = false; - // Keep the compass comfortably inside the shader keyhole so its cardinals - // remain visible around the subject instead of hiding under edge panels. - const compassRadius = Math.max(120, Math.min(geometry.radius - 48, geometry.radius * 0.66)); - state.compassRing.style.left = `${geometry.centerX - compassRadius}px`; - state.compassRing.style.top = `${geometry.centerY - compassRadius}px`; - state.compassRing.style.width = `${compassRadius * 2}px`; - state.compassRing.style.height = `${compassRadius * 2}px`; - - const cameraHeading = state.viewer.camera.heading || 0; - const headingDeg = (Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360; - state.compassRing.style.setProperty('--compass-rotation', `${-headingDeg}deg`); - state.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`; - state.compassHeading.style.left = `${geometry.centerX}px`; - state.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`; - for (const label of state.compassLabels) { - const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing)); - const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO; - const labelRadius = compassRadius - 17; - label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`; - label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`; - } - - const directionalCohort = state.results.cohorts.find((cohort) => cohort.id === state.subject.layerId) - || state.results.cohorts.find((cohort) => cohort.id === 'military'); - const page = state.cohortPages.get(directionalCohort?.id) || 0; - const military = directionalCohort?.summary.nearest.slice(page, page + AWARENESS_PAGE_SIZE) || []; - for (let index = 0; index < state.directionMarkers.length; index++) { - const marker = state.directionMarkers[index]; - const item = military[index]; - if (!item?.position) { - marker.hidden = true; - continue; - } - const direction = Cesium.Cartesian3.subtract(item.position, state.subject.position, DIRECTION_SCRATCH[index]); - if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) { - marker.hidden = true; - continue; - } - Cesium.Cartesian3.normalize(direction, direction); - const projection = celestialScreenAngle( - Cesium.Cartesian3.dot(direction, state.viewer.camera.rightWC), - Cesium.Cartesian3.dot(direction, state.viewer.camera.upWC), - marker._lastAngle, - ); - if (projection.stable) marker._lastAngle = projection.angle; - // Every detected contact shares the compass rim. Keeping the bearing - // markers on one radius makes them read as compass contacts rather than - // unconstrained labels floating over the map. - const markerRadius = compassRadius; - marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`; - marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`; - marker.style.opacity = String(projection.opacity); - marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`; - marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`; - const label = formatAwarenessLabel(item); - const subjectCartographic = Cesium.Cartographic.fromCartesian(state.subject.position, undefined, SUBJECT_CARTOGRAPHIC_SCRATCH); - const targetCartographic = Cesium.Cartographic.fromCartesian(item.position, undefined, TARGET_CARTOGRAPHIC_SCRATCH[index]); - const bearing = subjectCartographic && targetCartographic - ? bearingBetweenCoordinates( - Cesium.Math.toDegrees(subjectCartographic.latitude), - Cesium.Math.toDegrees(subjectCartographic.longitude), - Cesium.Math.toDegrees(targetCartographic.latitude), - Cesium.Math.toDegrees(targetCartographic.longitude), - ) - : null; - const bearingText = Number.isFinite(bearing) ? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°` : 'BRG —'; - const courseText = Number.isFinite(item.track) ? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°` : ''; - marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; - marker.hidden = false; - } -} - -function clearVisual() { - if (state.visual?.entities && state.viewer) { - for (const entity of state.visual.entities) state.viewer.entities.remove(entity); - // Idle mode renders only on request; a removed ring must not linger. - governorRequestRender('awareness-visual'); - } - state.visual = null; -} - -function renderVisual(subject) { - if (!state.viewer || !subject?.position) return; - const cartographic = Cesium.Cartographic.fromCartesian(subject.position); - if (!cartographic) return; - const groundCenter = Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, 0); - const key = `${subject.layerId}:${subject.id}`; - if (state.visual?.key === key) { - // Large ellipse geometry is more reliable as a ConstantPositionProperty. - // Move it only after a meaningful displacement so live tracking does not - // churn ten large geometries for sub-pixel motion every 750 ms. - if (Cesium.Cartesian3.distance(state.visual.center, groundCenter) >= 250) { - for (const entity of state.visual.entities) entity.position.setValue(groundCenter); - Cesium.Cartesian3.clone(groundCenter, state.visual.center); - // Content actually changed — buy exactly one frame instead of holding. - governorRequestRender('awareness-visual'); - } - return; - } - clearVisual(); - - const entities = []; - - entities.push(state.viewer.entities.add({ - position: groundCenter, - ellipse: { - semiMajorAxis: AWARENESS_RADIUS_M, - semiMinorAxis: AWARENESS_RADIUS_M, - fill: false, - outline: true, - outlineColor: Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72), - height: CONTEXT_RIM_HEIGHT_M, - }, - })); - - state.visual = { key, entities, center: Cesium.Cartesian3.clone(groundCenter) }; - governorRequestRender('awareness-visual'); -} - -function selectSubject(subject) { - if (!state.enabled || !subject?.position) return; - startAwarenessPageRotation(); - const key = subjectKey(subject); - state.navigationVisited.add(key); - const suppressHistory = state.suppressedHistoryKey === key; - state.pendingSelectionKey = null; - state.suppressedHistoryKey = null; - if (!suppressHistory) { - const current = state.navigationHistory[state.navigationIndex]; - if (subjectKey(current) !== key) { - state.navigationHistory.splice(state.navigationIndex + 1); - state.navigationHistory.push({ - ...historySubjectSnapshot(subject, historySourceItem(subject)), - position: Cesium.Cartesian3.clone(subject.position), - }); - state.navigationIndex = state.navigationHistory.length - 1; - } - } - state.subject = subject; - // A newly chosen contact starts present; the next refresh re-decides. - state.subjectMissing = false; - refreshSelectedSubject(true); -} - -/** - * Resolve the currently displayed position for a selected live subject. Flight - * tracking owns the motion callback, so awareness consumes its cached display - * position instead of creating a second tracker or extrapolation path. - * @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject. - * @param {object} [options] Position materialization controls. - * @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run. - * @returns {Cesium.Cartesian3|null} A cloned live position when one is available. - */ -function resolveSubjectPosition(subject, { allowCollectionMaterialization = true } = {}) { - if (!subject?.position) return null; - if (subject.layerId === 'flights' || subject.layerId === 'military') { - const trackedPosition = state.viewer?.trackedEntity?.gevDisplayPosition?.(); - if (trackedPosition) { - return { - position: Cesium.Cartesian3.clone(trackedPosition), - // Only the follow camera's own contact proves presence this way; a - // different tracked entity says nothing about this subject. - presence: String(state.viewer?.trackedEntity?.gevTrackedId || '') === subjectKey(subject) - ? SUBJECT_PRESENCE.LIVE - : SUBJECT_PRESENCE.UNCHECKED, - }; - } - // Cockpit already materializes its tracked position on a fixed 20 Hz loop. - // If that frame-owned cache is temporarily unavailable, keep the last - // awareness position instead of allocating/scanning up to 1,000 contacts. - if (!allowCollectionMaterialization) { - return { position: Cesium.Cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.UNCHECKED }; - } - const layer = subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; - return collectionSubjectPosition(subject, layer.getAllPositions(1000), layer); - } - if (subject.layerId === 'ais-live-vessels') { - if (!allowCollectionMaterialization) { - return { position: Cesium.Cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.UNCHECKED }; - } - return collectionSubjectPosition( - subject, - aisLiveVesselsLayer.getAllPositions(12000), - aisLiveVesselsLayer, - ); - } - // Mapped installations come from static geometry, not a live feed: there is - // nothing to be culled from. - return { position: Cesium.Cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.LIVE }; -} - -/** - * Locate a subject inside its layer's live collection, keeping the last known - * position when it is gone. - * - * Presence comes from the layer's O(1) `hasContact`, never from the rows: - * `getAllPositions` stops at its cap and the flights layer routinely carries - * ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone". - * The rows are only consulted for a fresher position — a contact past the cap - * keeps its last known position while still reading as present. - * @param {{id: string, position: Cesium.Cartesian3}} subject Current subject. - * @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows. - * @param {{hasContact?: function}} layer The owning source layer. - * @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict. - */ -function collectionSubjectPosition(subject, rows, layer) { - const collection = Array.isArray(rows) ? rows : []; - const current = collection.find((item) => String(item.id) === String(subject.id)); - // null/undefined means the layer cannot answer (disabled or not yet loaded). - const known = layer?.hasContact?.(subject.id); - const presence = known === true - ? SUBJECT_PRESENCE.LIVE - : (known === false ? SUBJECT_PRESENCE.MISSING : SUBJECT_PRESENCE.UNCHECKED); - return { - position: current?.position - ? Cesium.Cartesian3.clone(current.position) - : Cesium.Cartesian3.clone(subject.position), - presence, - }; -} - -/** - * Re-resolve a live subject's DISPLAY label from the layer that owns it. - * - * The subject snapshot is captured once at selection time, but a contact's - * label INPUTS can arrive later: adsbdb enrichment supplies a registration - * seconds after a callsign-less aircraft is selected, so every other surface - * swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex. - * The owning layer's tracked-subject accessor already applies the layer's - * label convention (callsign → registration → icao24), so ask it rather than - * re-deriving the chain here. - * - * Identity (`subject.id`) is NEVER re-derived — only the rendered string. - * @param {{layerId: string, id: string, label: string}} subject Current subject. - * @returns {string} The current label, or the cached one when the owning layer - * has no tracked subject (e.g. a cross-layer handoff is in flight). - */ -function resolveSubjectLabel(subject) { - if (!isFlightLayer(subject?.layerId)) return subject?.label; - const layer = subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; - const tracked = layer.getTrackedSubject?.(); - if (tracked?.label && String(tracked.id) === String(subject.id)) return tracked.label; - return subject.label; -} - -/** Refresh proximity counts/distances against the subject's current live position. */ -function refreshSelectedSubject(force = false) { - if (!state.enabled || !state.subject) return; - const sources = collectSourceStates(); - const nextSourceRevision = sourceRevision(sources); - const sourceRevisionChanged = nextSourceRevision !== state.sourceRevision; - const resolved = resolveSubjectPosition(state.subject, { - allowCollectionMaterialization: !document.body?.classList?.contains('cockpit-mode') || sourceRevisionChanged, - }); - if (!resolved) return; - const { position, presence } = resolved; - // UNCHECKED leaves the verdict alone: this tick simply did not look. - if (presence === SUBJECT_PRESENCE.LIVE) state.subjectMissing = false; - else if (presence === SUBJECT_PRESENCE.MISSING) state.subjectMissing = true; - const nextLabel = resolveSubjectLabel(state.subject); - const labelChanged = nextLabel !== state.subject.label; - state.subject = { ...state.subject, position, label: nextLabel }; - const movementM = state.lastEvaluatedPosition - ? Cesium.Cartesian3.distance(state.lastEvaluatedPosition, position) - : Infinity; - if (!awarenessRefreshRequired({ - force, - hasResults: Boolean(state.results), - movementM, - sourceRevisionChanged, - })) { - state.results.subject = state.subject; - renderVisual(state.subject); - // The panel markup embeds `subject.label`, so a label that changed while - // the contact was stationary (async enrichment answering after selection) - // is render-worthy ON ITS OWN. Without this the standalone Context panel - // kept the ICAO hex until unrelated movement, a source-revision bump, or - // page rotation happened to repaint it. Gated on an ACTUAL change so the - // common no-op refresh (every 750 ms) still costs no repaint. - if (labelChanged) renderResults(); - scheduleDirectionOverlayUpdate(force); - return; - } - state.results = evaluateSubject(state.subject, sources); - state.sourceRevision = nextSourceRevision; - state.lastEvaluatedPosition = Cesium.Cartesian3.clone(position, state.lastEvaluatedPosition); - for (const cohort of state.results.cohorts) { - const maxPage = Math.max(0, Math.floor(Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE) * AWARENESS_PAGE_SIZE); - state.cohortPages.set(cohort.id, Math.min(state.cohortPages.get(cohort.id) || 0, maxPage)); - } - renderVisual(state.subject); - renderResults(); - scheduleDirectionOverlayUpdate(force); -} - -function subjectFromContext(record) { - if (!record || !['ais-live-vessels', 'military-installations'].includes(record.layerId)) return null; - const latitude = Number(record.latitude); - const longitude = Number(record.longitude); - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; - return { - layerId: record.layerId, - id: record.properties?.mmsi || record.id, - label: record.label || record.id, - position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), - }; -} - -/** - * Record that the current subject left its feed on its own. - * - * The subject, its cohort results and its navigation history all survive: the - * Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS / - * NEXT stay operable so the operator can step off. Nulling the subject here - * instead would take the panel — and those controls — off screen. - */ -function markSubjectEvicted() { - if (!state.subject) return; - state.subjectMissing = true; -} - -function clearAwarenessSubject() { - state.autoFocusRetryPending = false; - state.subject = null; - state.subjectMissing = false; - state.results = null; - state.navigationHistory = []; - state.navigationVisited.clear(); - state.navigationIndex = -1; - state.suppressedHistoryKey = null; - state.pendingSelectionKey = null; - state.lastEvaluatedPosition = null; - state.sourceRevision = ''; - state.cohortPages.clear(); - stopAwarenessPageRotation(); - clearVisual(); - hidePanel(); -} - -/** - * Quantized camera-pose signature, or '' when the camera cannot report a full - * pose yet. An unknown pose reads as PARKED, so a camera that is still coming - * up can never be mistaken for continuous movement. - * @param {Cesium.Camera|null|undefined} camera - * @returns {string} - */ -function cameraMotionSignature(camera) { - if (!camera?.positionWC || !Number.isFinite(camera.heading)) return ''; - return cameraPoseSignature(camera); -} - -/** - * Whether an evaluation actually has a contact to point an arrow at. - * - * A cohort reports `count: null` when its feed is unavailable or stale, and `0` - * when the feed is healthy but empty. Only a positive count puts a marker on - * the compass rim, so anything else means there is nothing to animate. - * @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results - * @returns {boolean} - */ -export function awarenessResultsAreLive(results) { - const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : []; - return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0); -} - -/** - * Whether Contacts genuinely has per-frame work right now. - * - * The direction arrows are screen-projected against the camera basis, so they - * must be redrawn every frame while the view MOVES. Parked, or with no live - * cohort to point at — nothing selected, every feed empty, every feed failed — - * there is nothing to animate and Contacts must not be the reason the whole - * scene keeps repainting. - * @param {object} [snapshot] Explicit state, for tests. - * @returns {boolean} - */ -export function awarenessNeedsContinuousRender({ - cameraMoving = state.cameraMoving, - hasSubject = Boolean(state.subject), - hasLiveResults = awarenessResultsAreLive(state.results), -} = {}) { - return Boolean(cameraMoving && hasSubject && hasLiveResults); -} - -/** - * Take or drop the continuous-render hold to match the current need. Idempotent - * (the governor is identity-keyed), and self-healing: the release decision is - * made on a frame the hold itself guaranteed, so a hold can never strand the - * scene in continuous mode. - * @returns {void} - */ -function syncAwarenessRenderHold() { - if (state.enabled && awarenessNeedsContinuousRender()) holdContinuousRender('military-awareness'); - else releaseContinuousRender('military-awareness'); -} - -function attachRuntimeListeners() { - if (state.runtimeListenersAttached || !state.viewer) return; - window.addEventListener('gev:awareness-subject-selected', state.subjectListener); - window.addEventListener('gev:entity-selected', state.contextListener); - window.addEventListener('gev:entity-selection-cleared', state.clearListener); - window.addEventListener('gev:awareness-subject-cleared', state.subjectClearListener); - state.preRenderRemover = state.viewer.scene.preRender.addEventListener(() => { - if (!state.enabled) return; - const now = Date.now(); - // "Did the camera move" uses the SAME quantized pose signature the fleet - // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than - // camera.changed, whose granularity is globally degraded by other layers - // mutating camera.percentageChanged. - const poseSig = cameraMotionSignature(state.viewer.camera); - if (poseSig !== state.lastCameraPoseSig) { - state.lastCameraPoseSig = poseSig; - state.lastCameraPoseChangeMs = now; - } - const decision = awarenessRefreshDecision({ - nowMs: now, - lastRefreshMs: state.lastSubjectRefreshMs, - lastPoseChangeMs: state.lastCameraPoseChangeMs, - wasMoving: state.cameraMoving, - }); - state.cameraMoving = decision.moving; - syncAwarenessRenderHold(); - if (!decision.refresh) return; - state.lastSubjectRefreshMs = now; - if (state.subject && ['flights', 'military', 'ais-live-vessels'].includes(state.subject.layerId)) { - refreshSelectedSubject(); - } else if (!state.passive && state.autoFocusRetryPending) { - state.autoFocusRetryPending = false; - focusAttentionTarget(); - } else { - scheduleDirectionOverlayUpdate(); - } - }); - state.runtimeListenersAttached = true; -} - -function detachRuntimeListeners() { - if (state.runtimeListenersAttached) { - window.removeEventListener('gev:awareness-subject-selected', state.subjectListener); - window.removeEventListener('gev:entity-selected', state.contextListener); - window.removeEventListener('gev:entity-selection-cleared', state.clearListener); - window.removeEventListener('gev:awareness-subject-cleared', state.subjectClearListener); - } - state.preRenderRemover?.(); - state.preRenderRemover = null; - state.lastCameraPoseSig = ''; - state.lastCameraPoseChangeMs = 0; - state.cameraMoving = false; - // No frames will arrive to run the per-frame release once the listener is - // gone, so drop the hold here. - releaseContinuousRender('military-awareness'); - cancelDirectionOverlayUpdate(); - state.runtimeListenersAttached = false; -} - -/** - * Release any aircraft-owned follow camera before a non-aircraft context - * selection takes ownership. Both layers are safe no-ops when idle and release - * Cesium tracking in place, so the subsequent vessel/site framing starts from - * the current view without the previous aircraft continuing to drag it. - */ -function releaseAircraftTracking() { - flightsLayer.stopTracking?.(); - militaryFlightsLayer.stopTracking?.(); -} - -function refreshAfterDeferredDependency(activationId) { - if (!state.enabled || state.passive || activationId !== state.activationId) return; - if (state.subject) { - refreshSelectedSubject(true); - return; - } - if (state.autoFocusRetryPending) { - state.autoFocusRetryPending = !focusAttentionTarget(); - } -} - -async function enableDependencies(activationId = state.activationId) { - const aircraftPending = []; - for (const layerId of DEPENDENCIES) { - if (!state.enabled) return; - if (!state.dataManager) continue; - const alreadyEnabled = state.dataManager.isEffectivelyEnabled?.(layerId) - ?? state.dataManager.isEnabled(layerId); - if (alreadyEnabled) continue; - state.ownedDependencies.add(layerId); - // Each layer serializes its own lifecycle internally. Start them together - // so a slow or unavailable live feed cannot prevent the remaining context - // sources from turning on. - let pending; - try { - // Register the ON intent synchronously. If Contacts is turned OFF in the - // same turn, DataLayerManager then sees OFF as the newest intent and a - // stale deferred ON cannot resurrect a released dependency. - pending = Promise.resolve(state.dataManager.setEnabled(layerId, true)); - } catch (error) { - pending = Promise.reject(error); - } - if (AIRCRAFT_DEPENDENCIES.includes(layerId)) { - aircraftPending.push(pending); - } else { - // Vessels and mapped installations enrich an already usable aircraft - // context. Their settlement must never hold the Contacts transaction or - // Cockpit gate open, and the activation token prevents a late result - // from repainting or refocusing a newer/closed session. - pending.then( - () => refreshAfterDeferredDependency(activationId), - () => refreshAfterDeferredDependency(activationId), - ); - } - } - await Promise.allSettled(aircraftPending); -} - -function releaseOwnedDependencies(releaseActivationId) { - const owned = [...state.ownedDependencies]; - state.ownedDependencies.clear(); - if (!state.dataManager || !owned.length) return Promise.resolve(); - return Promise.allSettled(owned.map((layerId) => state.dataManager.setEnabled(layerId, false))) - .then(() => { - if (state.enabled && state.activationId !== releaseActivationId) { - return enableDependencies(state.activationId); - } - return null; - }); -} - -function activateOperationalContext() { - const activationId = ++state.activationId; - state.autoFocusRetryPending = false; - const initialTrackedSubject = currentTrackedFlightSubject(); - if (initialTrackedSubject) selectSubject(initialTrackedSubject); - return enableDependencies(activationId).then(() => { - if (!state.enabled || state.passive || activationId !== state.activationId) return; - const trackedSubject = currentTrackedFlightSubject(); - if (trackedSubject) { - selectSubject(trackedSubject); - return; - } - if (state.subject) { - selectSubject(state.subject); - return; - } - if (state.autoFocusAttempted) return; - state.autoFocusRetryPending = !focusAttentionTarget(); - }).catch((error) => console.warn('[Global Context] dependency enable failed', error)); -} - -/** - * Pick the observed candidate closest to the current view. This is intentionally - * a navigation preference, not a risk, capability, or affiliation calculation. - * @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates. - * @returns {Object|null} The best currently observable candidate. - */ -function closestToCurrentView(candidates) { - if (!Array.isArray(candidates) || !candidates.length) return null; - const cameraPosition = state.viewer?.camera?.positionWC; - if (!cameraPosition) return candidates.find((candidate) => candidate?.position) || null; - - let closest = null; - let closestDistance = Infinity; - for (const candidate of candidates) { - if (!candidate?.position) continue; - const distance = Cesium.Cartesian3.distance(cameraPosition, candidate.position); - if (Number.isFinite(distance) && distance < closestDistance) { - closest = candidate; - closestDistance = distance; - } - } - return closest; -} - -/** - * Focus a single observed, context-priority target after activation. Civilian - * and military aircraft compete in one nearest-to-view pool; military is - * concatenated first so exact distance ties resolve to military under the - * strict comparison. An AIS vessel is only a fallback and is never inferred - * to be military. There is deliberately no distance cap. - * @returns {boolean} Whether a target was selected and framed. - */ -function focusAttentionTarget() { - if (!state.enabled || state.subject || !state.viewer || state.autoFocusAttempted) return false; - - const nearestFlight = closestToCurrentView([ - ...militaryFlightsLayer.getAllPositions(800) - .map((item) => ({ ...item, layerId: 'military' })), - ...flightsLayer.getAllPositions(1000) - .map((item) => ({ ...item, layerId: 'flights' })), - ]); - if (nearestFlight) { - const layer = nearestFlight.layerId === 'military' ? militaryFlightsLayer : flightsLayer; - if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) { - state.autoFocusAttempted = true; - return true; - } - } - - const vessel = closestToCurrentView(aisLiveVesselsLayer.getAllPositions(12000)); - if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false; - - state.autoFocusAttempted = true; - if (!contextTargetFlyToAllowed('ais-live-vessels')) return true; - - announceNavigationAuthority('context-vessel-autofocus', { - cancelPendingSelection: false, - }); - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.6 }, - ); - return true; -} - -const militaryAwarenessLayer = { - id: 'military-awareness', - name: 'Global Context', - icon: '◎', - source: 'Open-source proximity context', - // Context is entered from its dedicated right rail, not as a raw layer. - showInTogglePanel: false, - updateInterval: 0, - statsRefreshInterval: 1000, - attachDataManager(dataManager) { state.dataManager = dataManager; }, - setParams(params = {}) { - if (typeof params.passive !== 'boolean') return; - const wasPassive = state.passive; - state.passive = params.passive; - if (state.enabled && wasPassive && !state.passive) activateOperationalContext(); + vessels, + installations, + navigation, + geometry, + render, }, - /** @returns {{ passive: boolean }} Current runtime parameters. */ - getParams() { - return { passive: state.passive }; - }, - init(viewer) { - detachRuntimeListeners(); - state.viewer = viewer; - state.subjectListener = (event) => { - if (!state.enabled) return; - selectSubject(event.detail); - }; - state.contextListener = (event) => { - if (!state.enabled) return; - const subject = subjectFromContext(event.detail); - if (!subject) return; - if (contextTargetFlyToAllowed(subject.layerId)) releaseAircraftTracking(); - selectSubject(subject); - }; - state.clearListener = (event) => { - if (!state.enabled || state.pendingSelectionKey) return; - if (!awarenessClearMatchesSubject(state.subject, event.detail)) return; - // An eviction keeps the subject so the readout can hold last-known - // values; only a deliberate clear tears the selection down. - if (awarenessClearIsEviction(event.detail)) { - markSubjectEvicted(); - return; - } - clearAwarenessSubject(); - }; - state.subjectClearListener = (event) => { - if (!state.enabled) return; - const cleared = event.detail; - if (state.pendingSelectionKey) return; - if ( - awarenessClearMatchesSubject(state.subject, cleared) - && String(state.subject?.id) === String(cleared?.id) - ) { - if (awarenessClearIsEviction(cleared)) { - // Deliberately does NOT set autoFocusAttempted: the subject survives, - // so the entry fallback has nothing to replace and the settlement - // guard below stays scoped to real deselects. - markSubjectEvicted(); - return; - } - // A deliberate clear during dependency settlement must win over the - // entry fallback; otherwise Contacts can silently select a replacement. - state.autoFocusAttempted = true; - clearAwarenessSubject(); - } - }; - }, - enable() { - state.enabled = true; - // NO unconditional continuous-render hold here. Contacts animates per frame - // only while the VIEW is moving (the direction arrows are screen-projected); - // parked, it is a throttled readout with nothing to animate. The hold is - // taken and released per frame by syncAwarenessRenderHold(). - attachRuntimeListeners(); - startAwarenessPageRotation(); - state.autoFocusAttempted = false; - state.autoFocusRetryPending = false; - ensurePanel(); - hidePanel(); - if (!state.passive) return activateOperationalContext(); - return true; - }, - disable() { - state.enabled = false; - releaseContinuousRender('military-awareness'); - detachRuntimeListeners(); - stopAwarenessPageRotation(); - state.cohortPages.clear(); - const releaseActivationId = ++state.activationId; - state.autoFocusAttempted = false; - state.autoFocusRetryPending = false; - state.subject = null; - state.results = null; - state.lastSubjectRefreshMs = 0; - state.lastEvaluatedPosition = null; - state.sourceRevision = ''; - state.navigationHistory = []; - state.navigationVisited.clear(); - state.navigationIndex = -1; - state.suppressedHistoryKey = null; - state.pendingSelectionKey = null; - clearVisual(); - hidePanel(); - // The manager must not publish this coordinator as settled OFF while its - // owned dependency releases are still issuing newer absolute intents. - // Context mode handoffs restore the pre-entry snapshot only after this - // promise resolves, preventing those releases from superseding restore. - state.passive = true; - return releaseOwnedDependencies(releaseActivationId); - }, - update() { - refreshSelectedSubject(); - return Promise.resolve(); - }, - destroy() { - this.disable(); - detachRuntimeListeners(); - state.panel?.removeEventListener('click', state.panelClickListener); - state.panelClickListener = null; - if (state.panelOwned) state.panel?.remove(); - else if (state.panel) state.panel.replaceChildren(); - state.panel = null; - state.panelOwned = false; - state.directionRoot?.remove(); - state.directionRoot = null; - state.compassRing = null; - state.compassLabels = []; - state.compassHeading = null; - state.directionMarkers = []; - state.panelMarkup = ''; - state.subjectListener = null; - state.contextListener = null; - state.clearListener = null; - state.subjectClearListener = null; - state.viewer = null; - state.dataManager = null; - }, - getStats() { - return { count: state.results ? 1 : 0, lastUpdate: state.results?.evaluatedAt || null, stale: false, error: null, status: state.enabled ? 'ready' : 'idle' }; - }, - /** Return the latest read-only context result for compact HUD consumers. */ - getContextSnapshot() { - if (!state.enabled || !state.subject) return null; - if (!state.results) { - return buildAwarenessContextSnapshot({ - subject: { ...state.subject }, - evaluatedAt: null, - radiusM: AWARENESS_RADIUS_M, - cohorts: [], - }, navigationState(), { - subjectPresent: !state.subjectMissing, - }); - } - return buildAwarenessContextSnapshot(state.results, navigationState(), { - subjectPresent: !state.subjectMissing, - }); - }, - /** - * Release Contact-owned camera tracking without discarding the selected - * subject. Reset-to-globe uses this route so the normal Context FOCUS action - * can explicitly return to the same contact, while delayed activation work - * cannot silently reclaim the camera after the reset. - * @returns {boolean} Whether a Contact subject remains selected. - */ - releaseCameraOwnership({ preserveVesselSelection = false, origin = 'programmatic' } = {}) { - ++state.activationId; - state.autoFocusAttempted = true; - state.autoFocusRetryPending = false; - - const preservedSelectionKey = subjectKey(state.subject) || 'camera-release'; - state.pendingSelectionKey = preservedSelectionKey; - try { - flightsLayer.stopTracking?.({ origin }); - militaryFlightsLayer.stopTracking?.({ origin }); - if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.(); - } finally { - if (state.pendingSelectionKey === preservedSelectionKey) { - state.pendingSelectionKey = null; - } - } - return Boolean(state.subject); - }, - navigatePrevious(options = {}) { return navigateHistory(-1, options); }, - focusCurrent(options = {}) { return focusCurrentSubject(options); }, - navigateNext(options = {}) { return navigateHistory(1, options); }, - /** Select a context target through its owning layer's established tracker. */ - focusTarget(layerId, id, options = {}) { return requestFocus(layerId, id, false, options); }, -}; - -export default militaryAwarenessLayer; +}); +export const contextTargetFlyToAllowed = layer.contextTargetFlyToAllowed; +export const awarenessClearMatchesSubject = layer.awarenessClearMatchesSubject; +export const awarenessRefreshIntervalMs = layer.awarenessRefreshIntervalMs; +export const awarenessRefreshDecision = layer.awarenessRefreshDecision; +export const awarenessClearIsEviction = layer.awarenessClearIsEviction; +export const awarenessRefreshRequired = layer.awarenessRefreshRequired; +export const summarizeInstallationViewport = + layer.summarizeInstallationViewport; +export const contactsWindowFromSnapshot = layer.contactsWindowFromSnapshot; +export const buildAwarenessContextSnapshot = + layer.buildAwarenessContextSnapshot; +export const collectAircraftProximityWindow = + layer.collectAircraftProximityWindow; +export const _getAwarenessNavigationStateForTest = + layer._getAwarenessNavigationStateForTest; +export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext; +export const historySubjectSnapshot = layer.historySubjectSnapshot; +export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex; +export const awarenessPanelControlKey = layer.awarenessPanelControlKey; +export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus; +export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus; +export const awarenessResultsAreLive = layer.awarenessResultsAreLive; +export const awarenessNeedsContinuousRender = + layer.awarenessNeedsContinuousRender; +export { AWARENESS_QUERY_LIMIT } from '../layers/awareness/index.js'; +export default layer; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index c16b9ae94..4fd72612c 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -42,7 +43,7 @@ import { } from './militaryAwarenessEngine.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; -const militaryAwarenessSource = fs.readFileSync( +const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), 'utf8', ); @@ -297,7 +298,7 @@ test('Contacts live repaint restores only stable identity and never retargets an }); test('Contacts continuation target is the visible explanatory note', () => { - const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8'); + const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url)); assert.match( source, /Open-source mapped\/observed context\./, @@ -1352,12 +1353,12 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // disable() hides the collection but keeps the records, so a map lookup // alone would report a preserved subject as FRESH from hidden stale data. for (const [name, source, guard] of [ - ['flights', fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'), - /hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/], - ['militaryFlights', fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'), - /hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/], - ['aisLiveVessels', fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'), - /hasContact\(mmsi\) \{\s*\n\s*if \(!state\.enabled \|\| !state\.vesselMap \|\| state\.vesselMap\.size === 0\) return null;/], + ['flights', readLayerSource(new URL('./flights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + ['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + ['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), + /hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/], ]) { assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`); } @@ -1527,14 +1528,14 @@ test('a deliberate source clear still fully clears the subject', () => { test('production eviction sites actually tag their clears', () => { // The event contract above is worthless if the real cull paths never set the // origin, so pin the three production call sites. - const flightsSource = fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'); - const militarySource = fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'); - const vesselsSource = fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'); + const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url)); + const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url)); + const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)); for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) { assert.match( source, - /if \(icao24 === _trackedIcao\) \{\s*\n\s*_clearTracking\(false, \{ evicted: true \}\);/, + /if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/, `${name} must mark its aged-out cull as an eviction`, ); assert.match( @@ -1553,9 +1554,9 @@ test('production eviction sites actually tag their clears', () => { // The tag alone is not enough — see the behavioral test in // firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod, // whose registration sweep deletes the record the clear needs to see. - const firmsSource = fs.readFileSync(new URL('./firmsHeatmap.js', import.meta.url), 'utf8'); + const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url)); const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });'); - const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);'); + const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);'); assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction'); assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding'); assert.ok( @@ -1588,9 +1589,9 @@ test('cockpit blocks only non-aircraft Context camera flights', () => { }); test('vessel entry and selection framing both use the 3 km focus radius', () => { - assert.match(militaryAwarenessSource, /const VESSEL_FOCUS_RADIUS_M = 3000;/); + assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/); const focusRadiusUses = militaryAwarenessSource.match( - /new Cesium\.BoundingSphere\(vessel\.position, VESSEL_FOCUS_RADIUS_M\)/g, + /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, ) || []; assert.equal(focusRadiusUses.length, 2); }); diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index 220747dcd..a37fc0933 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -1,3878 +1,67 @@ -import * as Cesium from 'cesium'; -import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { - bindTrackingClickGesture, - isTrackingClickGesture, - isTrackingSelectionGesture, -} from './trackingClickGesture.js'; -import { createTrail } from './trailRenderer.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; -import { - screenProjectedRotation, - stabilizeScreenRotation, - horizonOccluder, - cameraPoseSignature, -} from './iconOrientation.js'; -import { stickyText, stickyNumber } from './aircraftMeta.js'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from './aircraftClass.js'; -import { modelAnchorWorld, modelVisualAnchor, trailAnchorForModel, trailHeadStart, visualCenterForModel } from './modelVisualAnchor.js'; -import { aircraftIcon, TRACKED_ICON_PX } from './aircraftIcons.js'; -import { - isTr3b, tr3bAircraftClass, tr3bConvertedIds, tr3bIconKind, tr3bTypeLabel, -} from './tr3bRegistry.js'; -import { cockpitContactDotImage } from './cockpitContactDot.js'; -import { nextCockpitNearContacts } from './cockpitAirLod.js'; -import { - applyTrackedCameraFrame, - trackedModelScaleForPixelCap, -} from './trackedCamera.js'; -import { - courseBetweenCartesians, limitCourseStep, turnRateFromFixHistory, arcOffsetEnu, - lerpAngleDeg, speedRamp, courseSlewCapDps, displayedKinematics, staleCoastLimitSeconds, - liftRepeatedGroundFix, synthesizeForwardKinematicsFix, COURSE_HOLD_SPEED_MPS, -} from './motionModel.js'; -import { setMilitaryLayerActive, registerMilitaryIcaos } from './militaryRegistry.js'; -import { formatFlightLevel } from './detectionDraw.js'; -import { createGroundSnap } from './groundSnap.js'; -import { trackedModelZoomActive } from './trackedModelRegime.js'; -import { pickRenderAltitudeM } from './renderAltitude.js'; -import { cachedGroundFloor, floorAltitudeM, warmGroundFloor, resolveGroundFloorCellsBounded, GROUND_FLOOR_LIFT_M } from './groundFloor.js'; -import { sampleMeshFloorCells } from './meshFloorSampler.js'; -import { ensureGeoidReady, geoidHeight } from './geoid.js'; -import { - advanceProjectedSpriteFocus, - clearFocusTarget, - focusNowMs, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, -} from './focusDeemphasis.js'; -import { - applyAircraftBillboardTreatment, - applyAircraftModelTreatment, -} from './aircraftRecession.js'; -import { refreshTrackedReadout, trackedLabelModelFromText } from './trackedReadout.js'; -import { - clearTrackedSubjectContext, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -/** - * @module militaryFlights - * @description Real-time military flight tracking layer powered by the adsb.lol API. - * - * Renders aircraft as amber chevron billboards in a single BillboardCollection - * for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft - * spawns an Entity the camera follows, whose position is driven by a - * dead-reckoning CallbackProperty. The whole fleet renders at - * now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes - * instead of extrapolating ahead and snapping back when the next poll lands. - * - * Billboard orientation uses alignedAxis set to the WGS84 surface normal at each - * position so that the rotation angle (heading/track) operates in the local tangent - * plane where 0 deg = north; near nadir the icons switch to screen-aligned - * rotation (camera.heading - track) with hysteresis. - */ - -/** @constant {string} API endpoint proxied to adsb.lol military feed */ -const API_URL = '/api/adsblol/mil'; -/** @constant {number} Milliseconds to wait before retrying after a transient error */ -const ERROR_BACKOFF_INTERVAL = 20000; -/** @constant {number} Longer cooldown (ms) after a 429 rate-limit, mirroring flights.js */ -const BACKOFF_INTERVAL = 45000; -/** @constant {number} Max position samples retained per aircraft for dead reckoning */ -const POSITION_HISTORY_LIMIT = 5; -/** @constant {number} Base billboard display scale */ -const BILLBOARD_SCALE = 0.7; - -/** @constant {Cesium.Color} Default amber tint for untracked military billboards */ -const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); -/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */ -const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); - -// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- -// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate -// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude -// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable, -// sticky metadata updating normally. The on-ground flip restyles the existing -// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes -// draw no trails. In 3D mode they take model slots like airborne planes (owner -// decision 2026-07-03 — no air/ground distinction), placed by the one-shot -// ground snap (see _modelDisplayPosition). -// -// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field -// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can -// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail; -// "feed-dropped, coasting" stays the 45%-alpha stale fade. -/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ -const GROUND_SCALE = 0.8; - -/** Depth-test policy for aircraft billboards (mirror of flights.js — see the - * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact - * renders depth-test-free at every distance — a uniform always-visible rule; - * the fleet tick's horizon occluder still removes far-side contacts. */ -function _groundDepthDistance() { - return Number.POSITIVE_INFINITY; -} - -// --- 3D model rendering (mirrors flights.js) ----------------------------------------- -// When enabled, military aircraft render as 3D glTF jet models once the camera is below -// MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is -// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') -// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion -// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer -// apart from the commercial flights layer. -const JET_MODEL_URL = '/models/jet.glb'; -const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) -const MODEL_MIN_PX = 24; // floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) -const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff -export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -const MODEL_NATIVE_RADIUS_M = 29.83; -// jet.glb is transform-applied at real-world scale — native bounding radius -// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching -// flights.js world sizes. At the old copied ×24, models rendered -// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by -// modelScale.test.mjs. -const MODEL_SCALE = 1; -// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity -// and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). -const MODEL_MAX = 150; // 'proximity' cap -const MODEL_MAX_ALL = 350; // 'all' cap -const MODEL_PROX_ADD_M = 150000; // proximity: model NEW planes within 150 km -const MODEL_PROX_KEEP_M = 185000; // proximity: KEEP modeled planes out to 185 km - -// Cockpit keeps the standard Display radii but lowers the GLB budget. Contacts -// inside the selected band remain AIR silhouettes when they cannot own a model; -// contacts outside the band use compact dots. -const COCKPIT_MODEL_MAX = 60; // max concurrent GLBs in cockpit (never raises the map cap) -const MODEL_ALL_ADD_M = 400000; // all: model NEW planes within 400 km (~to the horizon) -const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km -const MODEL_HEADING_OFFSET_DEG = 180; // every aircraft GLB is exported nose -X in the shared transform-applied convention -// Preserve the layer's amber identity while reducing approved texture/livery -// contribution to a weak diffuse hint, matching civilian launch presentation. -const MODEL_COLOR_BLEND_AMOUNT = 0.94; -// airplane.glb (the shared 747) constants for this layer's heavy classes. The -// asset has its former 24× runtime calibration baked into transform-applied -// meter-scale geometry; these values mirror flights.js and are regression-pinned. -const PLANE_MODEL_URL = '/models/airplane.glb'; -const PLANE_MODEL_SCALE = 1; -const PLANE_NATIVE_RADIUS_M = 34.41; -const PLANE_BELLY_OFFSET_NATIVE = 6.719; -/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ -const _specCache = new Map(); -function _modelSpec(klass) { - let spec = _specCache.get(klass); - if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { url: real.url, scale: 1, nativeRadiusM: real.radiusM, bellyM: real.bellyM, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(real.url), trailAnchorNative: trailAnchorForModel(real.url) }; - } else if (klass === 'airliner' || klass === 'quadjet' || klass === 'glider') { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: PLANE_MODEL_URL, scale, nativeRadiusM: PLANE_NATIVE_RADIUS_M, bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL) }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: JET_MODEL_URL, scale, nativeRadiusM: MODEL_NATIVE_RADIUS_M, bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, visualCenterNative: visualCenterForModel(JET_MODEL_URL), trailAnchorNative: trailAnchorForModel(JET_MODEL_URL) }; - } - _specCache.set(klass, spec); - return spec; -} -// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this -// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node -// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). -// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model -// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to -// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. -const MODEL_BELLY_OFFSET_NATIVE = 5.631; -/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ -const _groundSnap = createGroundSnap(); -const _scratchGroundCarto = new Cesium.Cartographic(); -const _scratchGroundPos = new Cesium.Cartesian3(); -/** @type {Cesium.PrimitiveCollection|null} */ -let _modelCollection = null; -/** @type {Map} icao24 → model */ -const _models = new Map(); -/** @type {Set} icao24 currently loading (async) */ -const _modelPending = new Set(); -/** @type {Map} icao24 → load generation; bumped on release to invalidate - * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ -const _modelGen = new Map(); -/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle - * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ -let _modelEpoch = 0; -/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs - * layer-state restoration, so this initializer — not the codec — is what the app - * actually starts with; it must stay in lockstep with the `models3d` default in - * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would - * light a button the layer has not armed. */ -let _models3dEnabled = true; -let _models3dMode = 'proximity'; // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) -let _lastModelCapWarnMs = 0; // throttle the "more planes in view than the cap" console notice -// The tracked entity's billboard goes transparent (rather than hidden) once the model takes -// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its -// alpha after the GLB is preloaded so the model is ready to render the instant the billboard -// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not -// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. -let _planeModelLoaded = false; -/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ -let _preloadModel = null; -/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ -const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); -const _scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); -const _scratchModelMtx = new Cesium.Matrix4(); -const _scratchModelBS = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); // frustum-visibility test -/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ -const _billboardLimbScale = new WeakMap(); - -// --------------------------------------------------------------------------- -// Module-level state -// --------------------------------------------------------------------------- - -/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ -let _billboardCollection = null; -/** @type {Map} ICAO hex -> billboard primitive */ -let _billboards = new Map(); -/** Stable lightweight records reused by the detection overlay between polls. */ -let _detectionObjects = new Map(); -/** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ -let _flightData = new Map(); -/** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ -let _positionHistory = new Map(); -/** @type {number} Current number of visible aircraft */ -let _count = 0; -/** @type {number|null} Epoch ms of last successful API update */ -let _lastUpdate = null; -/** @type {boolean} True when in error-backoff mode */ -let _backoff = false; -/** @type {number} Epoch ms after which the next retry is allowed */ -let _retryAt = 0; -/** @type {string|null} Human-readable description of the last error */ -let _lastError = null; -const _activeUpdateControllers = new Set(); - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); -} -/** @type {number|null} HTTP status code from the last API response */ -let _lastStatus = null; -/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ -let _geoidReady = false; -/** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ -const _geoidNCache = new Map(); - -// -- Click-to-track state -- -/** @type {string|null} ICAO hex of the currently tracked aircraft */ -let _trackedIcao = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: 'adsb.lol', -}; -/** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ -let _trackedEntity = null; -/** Disposes the single active tracked-camera framing owner. */ -let _trackedCameraFrameStop = null; -/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on - * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere - * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ -let _trackedModel = null; -let _trackedModelGen = 0; -let _trackedModelLoading = false; -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ -let _clickHandler = null; -/** @type {Cesium.Viewer|null} Cached viewer reference */ -let _viewer = null; -/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ -let _cockpitContactMode = false; -/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ -let _cockpitNearContacts = new Set(); -/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ -let _cockpitSubjectId = null; -/** @type {((event: CustomEvent) => void)|null} */ -let _cockpitModeListener = null; - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - -function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb?.position || !info) return false; - if (_trackedEntity) _trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'military', - id: icao24, - label: _toCleanText(info.callsign) || _toCleanText(info.registration) || icao24, - position: Cesium.Cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; -} - -/** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ -function _contextSubjectMetadata(icao24) { - const described = _describeFlight(icao24); - if (!described) return null; - const info = _flightData.get(icao24); - const label = described.callsign || described.registration || icao24; - return { - id: icao24, - layerId: 'military', - layerName: 'Military Flights', - source: 'adsb.lol', - label, - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: label, - operator: _toCleanText(info?.operator) || '', - callsign: described.callsign || '', - registration: described.registration || '', - // Converted contacts report their class as TR-3B — the same override - // the readout and Contacts card show. - type: tr3bTypeLabel(icao24, _toCleanText(info?.type) || ''), - altitude: described.onGround ? 'on ground' : _formatAltitude(info?.altitudeFt), - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) ? `${Math.round(described.track)}°` : '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; -} - -function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; -} - -const COCKPIT_CONTACT_SIZE_PX = 6; -const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( - 1000, 3.0, 8000000, 0.5, -); - -function _normalBillboardScaleByDistance() { - // Match commercial flights and preserve the established close-range 3× - // default. Smaller owner-visible scaling must be proposed separately. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); -} - -function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); -} - -function _militaryBillboardScale(icao24) { - const meta = _flightData.get(icao24); - return BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); -} - -/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ -function _applyFleetBillboardPresentation(icao24, bb) { - if (!bb) return; - const limbScale = _billboardLimbScale.get(bb) ?? 1; - const isCockpitContact = _cockpitContactMode && icao24 !== _trackedIcao; - const isCockpitNear = isCockpitContact && _cockpitNearContacts.has(icao24); - if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; - bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); - bb.rotation = 0; - return; - } - - const meta = _flightData.get(icao24); - bb.image = aircraftIcon(_iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined); - bb.width = icao24 === _trackedIcao ? 24 : 20; - bb.height = icao24 === _trackedIcao ? 24 : 20; - bb.scale = _militaryBillboardScale(icao24) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); -} - -/** Refresh the AIR-only Cockpit near band independently from model admission. */ -function _refreshCockpitNearContacts() { - if (!_cockpitContactMode || !_viewer?.camera?.positionWC) { - if (_cockpitNearContacts.size) _cockpitNearContacts = new Set(); - return; - } - const previous = _cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - Cesium.Cartesian3.distanceSquared(_viewer.camera.positionWC, bb.position), - ]); - } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - _modelAddDistM(), - _modelKeepDistM(), - ); - _cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of _billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - _applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) _lastCamPoseSig = ''; -} - -/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ -function _setCockpitContactMode(active) { - const next = active === true; - if (_cockpitContactMode === next) return; - _cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else _cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. - if (_modelCollection) _modelCollection.show = true; - _trail?.setVisible(!next); - if (_trailHeadEntity) _trailHeadEntity.show = !next; - for (const [icao24, bb] of _billboards) _applyFleetBillboardPresentation(icao24, bb); - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; -} - -function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - _cockpitSubjectId = active - ? String(detail?.subjectId || '').trim().toLowerCase() || null - : null; - _setCockpitContactMode(active); -} - -// --------------------------------------------------------------------------- -// Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the -// tracked aircraft. The accumulation array is intentionally SEPARATE from -// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) -// so the visible trail can grow to TRAIL_MAX_POINTS fixes. -// --------------------------------------------------------------------------- - -/** @constant {string} Military trail hue (PRD F4, pinned). */ -const TRAIL_COLOR = '#FFB800'; -/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ -const TRAIL_MAX_POINTS = 400; -/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ -let _trail = null; -/** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ -let _trailHeadEntity = null; -/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ -let _trailHeadSeq = 0; -/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ -let _trailPositions = []; -/** @type {number} Monotonic token — invalidates in-flight backfill responses */ -let _trailBackfillToken = 0; - -// --------------------------------------------------------------------------- -// Render-behind smoothing (mirrors flights.js): the fleet renders at -// now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes -// instead of extrapolating ahead and snapping back when the next poll lands. -// Removing the delay reintroduces the back/forward oscillation and is a -// regression. -// --------------------------------------------------------------------------- - -/** @constant {number} Display latency in seconds (= one poll interval). */ -const RENDER_DELAY_SEC = 15; -/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */ -const MISSING_POLL_LIMIT = 3; -// --- Landed-plane fast cull (mirror of flights.js; owner field report -// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown). -// The feed's ground flag lags the actual landing, so a landed plane's last -// airborne fixes show it low + slow on the runway; when it then drops out of -// the poll it has landed, not hit a transient gap — evict after ONE missed -// poll. Thresholds: below ~500 ft baro (≈150 m MSL — near-sea-level fields -// only; a high-elevation airport ghost falls back to the normal grace) AND -// below ~45 kts ground speed (≈23 m/s — rollout/taxi; nothing in normal -// FLIGHT is this slow, so cruise planes always keep the full grace). -/** @constant {number} Max baro altitude (ft, MSL) for the landed fast cull (~150 m). */ -const LANDED_ALT_MAX_FT = 500; -/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ -const LANDED_SPEED_MAX_MPS = 23; -/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ -const LANDED_MISSING_POLL_LIMIT = 1; -// Field-test fix (RS46, 2026-07-06): only contacts rendering below this -// ellipsoidal height get the below-ground floor clamp + a coarse floor-cell -// warm. Terrain outside the extreme Himalaya tops out well under this, so -// cruise traffic (which can never be below ground) costs zero terrain -// lookups; low pattern/heli work — the class that actually clips hillsides — -// gets the floor. -/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ -const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; -/** @type {Map} icao24 -> consecutive missed polls */ -let _missingPolls = new Map(); - -// --------------------------------------------------------------------------- -// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment -// degenerates when the camera looks straight down (the normal is parallel to -// the view direction and the shader's screen-projected angle is 0/0). Near -// nadir we switch to screen-aligned billboards with -// rotation = camera.heading - track (screen-up points at azimuth -// camera.heading; a CW track t appears t-h from screen-up; billboard rotation -// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations -// are continuous at the boundary with this formula. -// --------------------------------------------------------------------------- - -/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ -const FLEET_DR_INTERVAL_MS = 80; -/** @constant {number} Max ms between rotation passes while the camera is idle. */ -const ROTATION_REFRESH_MS = 1000; -/** @type {number} Epoch ms of the last fleet dead-reckoning pass */ -let _lastFleetTickMs = 0; -/** @type {string} Camera pose signature at the last rotation pass */ -let _lastCamPoseSig = ''; -/** @type {number} Epoch ms of the last full rotation pass */ -let _lastRotPassMs = 0; -/** @type {number} Last computed rotation for the tracked entity (radians) */ -let _lastTrackedRotation = 0; -/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ -let _preRenderRemove = null; -let _trackedModelPreUpdateRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ -let _moveEndRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; - -// -- Scratch variables (reused each frame to avoid GC pressure) -- -const _scratchOffset = new Cesium.Cartesian3(); -const _scratchCarto = new Cesium.Cartographic(); -const _scratchEnu = new Cesium.Matrix4(); -const _scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; -const _scratchRenderTime = new Cesium.JulianDate(); -const _scratchFleetPos = new Cesium.Cartesian3(); -const _scratchDrRaw = new Cesium.Cartesian3(); -const _scratchWarmupTime = new Cesium.JulianDate(); -const _trackedPosHolder = new Cesium.Cartesian3(); - -// -- Per-frame dead-reckoning cache for the tracked entity -- -/** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ -let _cachedDRPosition = null; -/** @type {number} Frame number the cache was last populated for */ -let _cachedDRFrame = -1; - -/** Course (deg) of the position `_deadReckon` most recently returned — set on - * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same - * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ -let _drCourseDeg = null; -/** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion - * `_deadReckon` just returned, and whether that motion is too slow for ANY - * course source to be trusted (hover/GPS drift — consumers HOLD their - * previous display course instead of chasing noise). */ -let _drSpeedMps = null; -let _drCourseHold = false; -/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ -let _cachedDRCourse = null; -/** Frame-cached siblings of _cachedDRCourse (same discipline). */ -let _cachedDRSpeedMps = null; -let _cachedDRHold = false; -/** Wall-clock of the tracked course limiter's last advance (dt source only — - * the course VALUE lives in the shared per-icao _displayCourse map below). */ -let _trackedCourseMs = 0; -/** Per-aircraft smoothed display course — the SINGLE source of truth for the - * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 - * field fix). The fleet pass reads/writes it at tick cadence for untracked - * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for - * the tracked plane (the fleet pass skips the tracked icao, so exactly one - * writer owns an entry at a time). Sharing the entry — including its slew - * state — keeps the tracked↔fleet handoff seamless (separate states froze - * the fleet entry while tracked → nose flip on click / click-away). */ -const _displayCourse = new Map(); -/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary - * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ -const COURSE_MAX_DPS = 60; -/** Never spend a long render stall's full elapsed time in one visible course step. */ -const COURSE_SLEW_DT_MAX_SEC = 0.25; - -// -- Tracked-display reconciliation (see flights.js for the rationale): absorb a raw -// position step (warm-up→interpolation handoff, feed glitch, backfill splice) into a -// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked icon, -// camera, and trail head never visibly jump — with zero steady-state lag. -- -const DR_CORRECTION_MS = 900; -const _drCorrection = new Cesium.Cartesian3(0, 0, 0); -let _drCorrectionStartMs = 0; -const _drPrevRaw = new Cesium.Cartesian3(); -const _drPrevDisplay = new Cesium.Cartesian3(); -let _drPrevMs = 0; -let _drReconcileValid = false; -/** @type {string|null} icao the reconciliation state currently belongs to */ -let _drReconcileIcao = null; - -/** - * Coerce a value to a finite number, returning null if not possible. - * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). - * @param {*} value - Raw value from the API response - * @returns {number|null} Finite number or null - */ -function _toFiniteNumber(value) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const n = Number.parseFloat(value.trim()); - return Number.isFinite(n) ? n : null; - } - return null; -} - -/** - * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. - * @param {*} value - Raw value to stringify - * @returns {string} Trimmed string - */ -function _toCleanText(value) { - return String(value || '').trim(); -} - -/** - * Format an altitude value in feet for display, with fallback text. - * @param {number|null|undefined} altitudeFt - Barometric altitude in feet - * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") - */ -function _formatAltitude(altitudeFt) { - if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; - return `${Math.round(altitudeFt)} ft`; -} - -/** - * True when the aircraft's latest metadata reads "on or about the runway" - * (low + slow) — see the landed fast-cull rationale above. Both gates must - * hold, so a plane missing either datum keeps the normal grace. - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @returns {boolean} - */ -function _likelyLanded(icao24) { - const info = _flightData.get(icao24); - if (!info) return false; - // Round 7 (mirror of flights.js): fast cull only for contacts seen - // AIRBORNE this session — parked contacts ride the normal grace so feed - // flaps don't churn their identity/floor state. - if (info.wasAirborne !== true) return false; - if (info.onGround) return true; - return Number.isFinite(info.altitudeFt) && info.altitudeFt < LANDED_ALT_MAX_FT - && Number.isFinite(info.speedMps) && info.speedMps < LANDED_SPEED_MAX_MPS; -} - -/** - * Build the multi-line presentation text for the protected tracked host card. - * Lines: callsign, type/registration, operator/altitude. - * While the plane is in its missed-poll grace (coasting on dead reckoning - * with sticky metadata) the first line carries a "· STALE" cue — the fleet's - * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity - * owns the visual), so without this the readout would present last-known - * altitude/speed as live. - * @param {Object|null} info - Flight metadata from _flightData - * @param {string} icao24 - ICAO hex identifier (fallback display name) - * @returns {string} Newline-separated label text - */ -function _buildTrackedLabel(info, icao24) { - const stale = (_missingPolls.get(icao24) || _backoff) ? ' · STALE' : ''; - const callsign = (_toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24) + stale; - // Converted contacts report their class as TR-3B — that override is exactly - // what the Easter egg replaces the real type with. - const type = tr3bTypeLabel(icao24, _toCleanText(info?.type) || 'Type unknown'); - const registration = _toCleanText(info?.registration) || 'Reg unknown'; - const operator = _toCleanText(info?.operator) || 'Operator unknown'; - const altitude = _formatAltitude(info?.altitudeFt); - const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; - const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; - return [ - callsign, - `${type} · ${registration}`, - `${operator} · ${tail}`, - ].join('\n'); -} - -/** Write the explicit tracked presentation model and refresh its host entry. */ -function _updateTrackedLabelModel(icao24) { - if (!_trackedEntity || icao24 !== _trackedIcao) return; - _trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(_flightData.get(icao24), icao24), - '#ffd166', - ); - refreshTrackedReadout(_trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); -} - -/** Sprite kind for one contact's billboard. Identity for every aircraft except - * the ones the operator converted into a TR-3B (Easter egg), which draw the - * black-triangle glyph — its thermal-reactive variant while an IR style owns - * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a - * conversion survive the poll reconciler and the two-tier raster swap. */ -const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: _irBoost }); - -/** Re-image the tracked entity's billboard from the current class/conversion. */ -function _syncTrackedBillboardImage() { - if (!_trackedIcao || !_trackedEntity?.billboard) return; - _trackedEntity.billboard.image = aircraftIcon( - _iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), - TRACKED_ICON_PX, - ); -} - -/** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Mirror of flights.js: converting drops any 3D model so the triangle - * owns the visual, then the billboard, tracked entity, and card re-derive. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ -function _refreshTr3bContact(icao24) { - const id = String(icao24 || '').trim().toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - if (_models.has(id) || _modelPending.has(id)) _releaseModel(id); - const modelled = _billboards.get(id); - if (modelled && id !== _trackedIcao) modelled.show = true; // horizon cull re-asserts next tick - if (id === _trackedIcao) { - _releaseTrackedModel(); - _syncTracked2dRotation(); - } - } - const bb = _billboards.get(id); - if (bb) _applyFleetBillboardPresentation(id, bb); - if (id === _trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); - } - _viewer?.scene?.requestRender?.(); - return _billboards.has(id) || id === _trackedIcao; -} - -/** Re-image every converted contact this layer owns (IR style flip). */ -function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (_billboards.has(id) || id === _trackedIcao) _refreshTr3bContact(id); - } -} - -/** - * Seed only the mutable state needed to exercise tracked-card refreshes through - * the production military poll reconciler. Tests still call - * `militaryFlightsLayer.update()` rather than invoking the writer directly. - * @param {object} state - * @param {string} state.icao24 - * @param {object} state.entity - * @param {object} state.meta - * @param {object} state.billboard - * @param {object} state.billboardCollection - * @param {object} state.viewer - * @param {Array} [state.history=[]] - * @param {boolean} [state.tracked=true] - * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed - * by icao24, for the billboard-hidden/model-shown handoff state. - */ -export function _setTrackedMilitaryRefreshStateForTest({ - icao24, - entity, - meta, - billboard, - billboardCollection, - viewer, - history = [], - tracked = true, - models = [], - modelCollection = null, -}) { - _viewer = viewer; - _modelCollection = modelCollection; - _billboardCollection = billboardCollection; - _billboards = new Map([[icao24, billboard]]); - _models.clear(); - for (const [key, model] of models) _models.set(key, model); - _detectionObjects = new Map(); - _flightData = new Map([[icao24, meta]]); - _positionHistory = new Map([[icao24, history]]); - _missingPolls = new Map(); - _displayCourse.clear(); - _geoidNCache.clear(); - _trackedIcao = tracked ? icao24 : null; - _trackedEntity = tracked ? entity : null; - _trackedModel = null; - _cancelPendingTrackingRestore(); - _trackedModelLoading = false; - // NOTE: deliberately does NOT reset the per-selection latches. They are - // production state owned by the tracking lifecycle (_resetTrackedSelectionState), - // and clearing them here would mask exactly the deselect→re-track hole this - // seam is used to test. - _backoff = false; - _retryAt = 0; -} - -/** Seed the authoritative snapshot outcome used by share-Follow tests. */ -export function _setMilitaryTrackingRefreshOutcomeForTest({ - status = 'accepted', - ids = [], - source = 'adsb.lol', -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch, - status, - ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), - source, - }; -} - -/** Add a cached contact so tests can model a target arriving on a later feed. */ -export function _addMilitaryTrackingCandidateForTest({ icao24, meta, billboard, history = [] }) { - _billboards.set(icao24, billboard); - _flightData.set(icao24, meta); - _positionHistory.set(icao24, history); -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingMilitaryTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated feed refresh. */ -export function _applyPendingMilitaryTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Set the exact Cockpit subject through the production state transition for focused tests. */ -export function _setCockpitDetectionSubjectForTest(active, subjectId = null) { - _applyCockpitState({ active, subjectId }); -} - -/** Evaluate the TRACKED contact's zoom regime through the production predicate. - * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) - * and otherwise only observable through a live scene, so tests drive it here. */ -export function _trackedModelRegimeActiveForTest() { - return _trackedModelRegimeActive(); -} - -/** Run one frame of the production tracked-model driver (normally a - * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ -export function _updateTrackedModelForTest() { - return _updateTrackedModel(); -} - -/** Evaluate the production tracked-billboard handoff colour for focused tests. */ -export function _trackedBillboardColorForTest() { - return _modelOwnsVisual(_trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR; -} - -/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ -export function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { - return _driveFleetModelHandoff( - icao24, - _models.get(icao24), - _billboards.get(icao24), - position, - course, - ); -} - -/** Exercise the exact asynchronous fleet loader and return its admitted model. */ -export async function _ensureFleetModelForTest(icao24) { - await _ensureModel(icao24); - return _models.get(icao24) || null; -} - -/** - * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. - * - * Renders one poll interval behind real time so positions interpolate between - * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the - * newest fix is older than the delayed render time (stale position), projects - * forward using the aircraft's ground speed and track through the freshest - * source contact plus a bounded grace window. - * - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. - * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history - */ -function _deadReckon(icao24, result) { - const history = _positionHistory.get(icao24); - const info = _flightData.get(icao24); - if (!history || history.length === 0) { - _drCourseDeg = null; _drSpeedMps = null; _drCourseHold = false; - return null; - } - - const out = result || new Cesium.Cartesian3(); - // Render one poll interval behind real time so we interpolate between - // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchRenderTime - ); - - // Bracketing pair: interpolate — no extrapolation error, no snap-back. - for (let i = history.length - 1; i >= 1; i--) { - const a = history[i - 1]; - const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; - // Course of the DISPLAYED motion. The chord is only trustworthy when the - // segment covers real ground (at hover its direction is GPS jitter; on a - // slow tight turn it STEPS the whole per-segment turn at each boundary), - // so it is blended against the reported per-fix track by displayed - // ground speed — and the track is TIME-INTERPOLATED between the fixes so - // a slow turner's nose advances continuously through the segment instead - // of snapping once per poll. Helicopters always use the reported track - // (rotorcraft chords are noise-dominated at their typical speeds). - const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); - const segSpeed = span > 0 ? chordLenM / span : ((info && info.speedMps) || 0); - const fallbackTrack = (info && info.track) || 0; - const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; - const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; - const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); - const w = (info && info.klass === 'helicopter') ? 0 : speedRamp(segSpeed); - const chordCourse = w > 0 ? courseBetweenCartesians(a.position, b.position) : null; - _drCourseDeg = chordCourse != null ? lerpAngleDeg(trackCourse, chordCourse, w) : trackCourse; - _drSpeedMps = segSpeed; - _drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; - return Cesium.Cartesian3.lerp(a.position, b.position, t, out); - } - } - - const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference(renderTime, newest.time); - if (elapsedSec <= 0) { - // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking - // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing - // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time - // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history - // fills, renderTime advances toward the first fix and the icon glides FORWARD into - // the bracketing interpolation above with NO freeze and NO backward snap. (Holding - // the oldest fix froze the icon until enough history accrued, then jumped.) - const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference(oldest.time, renderTime); // ≥ 0 - return _extrapolateFix(oldest, info, -Math.min(lookbackSec, 60), out, (info && info.turnRateDps) || 0); - } - - // Continue through fresh adsb.lol contacts even when the position epoch has - // not advanced, but keep an absolute stale-feed drift ceiling. - const coastLimitSec = staleCoastLimitSeconds({ - // Captured at poll normalization so the fleet tick does not allocate a - // Date for every aircraft twelve times per second. - fixEpochMs: Number.isFinite(newest.epochMs) - ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), - lastContactEpochMs: info?.lastContactEpochMs, - }); - return _extrapolateFix( - newest, - info, - Math.min(elapsedSec, coastLimitSec), - out, - (info && info.turnRateDps) || 0, - ); -} - -/** - * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a - * constant-rate-turn arc when `turnRateDps` is significant (straight line - * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` - * projects BACKWARD (before the fix) — used for warm-up, estimating where the - * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, - * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets - * `_drCourseDeg` to the arc's instantaneous end course on every path. - * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). - */ -function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { - const speed = Number.isFinite(fix.velocity) ? fix.velocity : ((info && info.speedMps) || 0); - const heading = Number.isFinite(fix.track) ? fix.track : ((info && info.track) || 0); - _drSpeedMps = speed; - _drCourseHold = speed < COURSE_HOLD_SPEED_MPS; - if (speed === 0 || dt === 0) { - _drCourseDeg = heading; - return Cesium.Cartesian3.clone(fix.position, out); - } - // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a - // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. - arcOffsetEnu(speed, heading, turnRateDps, dt, _scratchArc); - _drCourseDeg = _scratchArc.endCourseDeg; - Cesium.Cartesian3.fromElements(_scratchArc.east, _scratchArc.north, 0, _scratchOffset); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(fix.position, Cesium.Ellipsoid.WGS84, _scratchEnu); - return Cesium.Matrix4.multiplyByPoint(enu, _scratchOffset, out); -} - -/** - * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) - * predates its oldest real fix — _deadReckon is extrapolating backward with no real - * history yet behind the displayed icon, so the trail must draw nothing. - * @returns {boolean} - */ -function _isTrackWarmingUp() { - if (!_trackedIcao) return false; - const history = _positionHistory.get(_trackedIcao); - if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); -} - -/** Resolve the selected aircraft's actual rendered square extent this frame. */ -function _trackedFocusSizePx(icao24, position) { - const camera = _viewer?.camera; - const scene = _viewer?.scene; - if (!camera?.positionWC || !position || !scene) return 28; - const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); - if (_modelOwnsVisual(icao24)) { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - const scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM, - viewportHeightPx: scene.canvas.clientHeight, - fovyRad: camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - const focalLengthPx = scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); - const projectedDiameterPx = (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; - return Math.max(TRACKED_MODEL_MIN_PX, Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx)); - } - - const billboard = _trackedEntity?.billboard; - const time = _viewer.clock.currentTime; - const width = billboard?.width?.getValue(time) ?? 28; - const height = billboard?.height?.getValue(time) ?? 28; - const scale = billboard?.scale?.getValue(time) - ?? (BILLBOARD_SCALE * (CLASS_SCALE_2D[_flightData.get(icao24)?.klass] || 1)); - const scaleByDistance = billboard?.scaleByDistance?.getValue(time) - ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; - const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); - return Math.max(width, height) * scale * distanceScale; -} - -/** - * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium - * frame number so the position / rotation / trail-head callbacks share ONE computation - * (and one reconciliation-state update) per frame. Returns a stable module holder, or - * null when the aircraft has no fix. - * @param {string} icao24 - * @returns {Cesium.Cartesian3|null} - */ -function _trackedDisplayPosition(icao24) { - const frame = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frame === _cachedDRFrame && icao24 === _drReconcileIcao) return _cachedDRPosition; - - // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting - // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) - const sameTrack = _drReconcileValid && _drReconcileIcao === icao24; - const raw = _deadReckon(icao24, _scratchDrRaw); - _cachedDRCourse = _drCourseDeg; - _cachedDRSpeedMps = _drSpeedMps; - _cachedDRHold = _drCourseHold; - _cachedDRFrame = frame; - _drReconcileIcao = icao24; - if (!raw) { - _cachedDRPosition = null; - _drReconcileValid = false; - clearFocusTarget('militaryFlights', icao24); - return null; - } - - const nowMs = Date.now(); - const info = _flightData.get(icao24); - if (sameTrack) { - const dtSec = Math.max(0.001, (nowMs - _drPrevMs) / 1000); - const speed = (info && info.speedMps) || 0; - // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. - const plausible = speed * dtSec * 4 + 25; - if (Cesium.Cartesian3.distance(raw, _drPrevRaw) > plausible) { - // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. - Cesium.Cartesian3.subtract(_drPrevDisplay, raw, _drCorrection); - _drCorrectionStartMs = nowMs; - } - } else { - Cesium.Cartesian3.fromElements(0, 0, 0, _drCorrection); - _drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed - } - - const elapsed = nowMs - _drCorrectionStartMs; - const factor = elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; - const display = Cesium.Cartesian3.multiplyByScalar(_drCorrection, factor, _trackedPosHolder); - Cesium.Cartesian3.add(raw, display, display); - - Cesium.Cartesian3.clone(raw, _drPrevRaw); - Cesium.Cartesian3.clone(display, _drPrevDisplay); - _drPrevMs = nowMs; - _drReconcileValid = true; - _cachedDRPosition = display; - const focusSizePx = _trackedFocusSizePx(icao24, _cachedDRPosition); - // Publish the exact frame cache shared by entity + follow camera. A second - // DR sample here would advance reconciliation in a different frame phase - // and visibly jitter the focus rectangle against the tracked aircraft. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'militaryFlights', - id: icao24, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _cachedDRPosition, - widthPx: focusSizePx, - heightPx: focusSizePx, - }); - return display; -} - -/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already - * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at - * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the - * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ -function _trackedDisplayCached() { - return (_drReconcileValid && _drReconcileIcao === _trackedIcao) ? _cachedDRPosition : null; -} - -/** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ -const _trackedVisualPos = new Cesium.Cartesian3(); -const _trackedTrailPos = new Cesium.Cartesian3(); -/** Scratch for the tracked model's world-space origin (the envelope centre). */ -const _scratchTrailClip = new Cesium.Cartesian3(); -/** Scratch for the shortened trail-head start. Owned by the head-segment - * callback alone, so nothing else can overwrite it mid-frame. */ -const _scratchTrailHead = new Cesium.Cartesian3(); - -/** - * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D - * model is actually rendering with when the model owns the visual, otherwise the cached - * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. - * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, - * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its - * follow-camera anti-jitter contract untouched. - */ -function _trackedVisualCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - return modelVisualAnchor( - _trackedModel.modelMatrix, - spec.visualCenterNative, - Number.isFinite(_trackedModel.computedScale) ? _trackedModel.computedScale : spec.scale, - _trackedVisualPos, - ); - } - return _trackedDisplayCached(); -} - -/** The tracked model's rendered bounding radius (m), or 0 when no model of this - * contact is drawing. Carries Cesium's effective `computedScale`, which may be - * above `scale` to satisfy minimumPixelSize — the trail head has to be judged - * against the size the operator SEES, not the nominal one. */ -/** World-space origin of the tracked model, or null when no model of this - * contact is drawing. This is the centre the rendered bounding sphere is - * measured from, so the trail clip and the envelope agree on one frame. */ -function _trackedModelCenterWorld() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return null; - return Cesium.Matrix4.getTranslation(_trackedModel.modelMatrix, _scratchTrailClip); -} - -function _trackedModelEnvelopeM() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return 0; - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const scale = Number.isFinite(_trackedModel.computedScale) - ? _trackedModel.computedScale - : spec.scale; - return spec.nativeRadiusM * scale; -} - -/** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on - * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ -function _trackedTrailCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - // Through the model's OWN render chain (modelVisualAnchor's hand-rolled - // half-correction put this offset on the lateral axis — see - // modelAnchorWorld). - return modelAnchorWorld(_trackedModel, spec.trailAnchorNative, _trackedTrailPos); - } - return _trackedDisplayCached(); -} - -/** Smoothed world course for the tracked aircraft this frame. Reads the - * frame-cached course (set by _trackedDisplayPosition — the follow-camera's - * own computation), NEVER re-runs _deadReckon. Safe to call more than once - * per frame: the second call sees dt≈0 and the limiter is a no-op. - * - * The smoothed value lives in the SHARED per-icao _displayCourse entry (see - * its declaration): on click the limiter continues from whatever nose the - * fleet pass was displaying, and on untrack the fleet pass continues from - * whatever nose this path last wrote — tracked and fleet consumers of the - * same aircraft can never disagree across the handoff (mirror of flights.js). */ -function _trackedDisplayCourse() { - const info = _flightData.get(_trackedIcao); - const fallback = (info && info.track) || 0; - const cacheValid = _drReconcileValid && _drReconcileIcao === _trackedIcao && _cachedDRCourse != null; - const raw = cacheValid ? _cachedDRCourse : fallback; - const nowMs = Date.now(); - const dt = _trackedCourseMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _trackedCourseMs) / 1000) - : 0; - _trackedCourseMs = nowMs; - const prev = _displayCourse.get(_trackedIcao); - // Hover hold: at near-zero displayed speed both the chord and the reported - // track are noise — keep the last stable nose direction instead of chasing. - if (cacheValid && _cachedDRHold && prev != null) return prev; - const cap = courseSlewCapDps(cacheValid ? _cachedDRSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS); - const course = limitCourseStep(prev, raw, cap, dt); - _displayCourse.set(_trackedIcao, course); - return course; -} - -/** Reset reconciliation + per-frame cache when tracking stops or switches - * target. Deliberately does NOT touch _displayCourse: the smoothed course - * entry belongs to the AIRCRAFT (not to the tracked session) and must - * survive the handoff back to the fleet pass. */ -function _resetTrackedDisplay() { - _drReconcileValid = false; - _drReconcileIcao = null; - _cachedDRFrame = -1; - _cachedDRPosition = null; - _cachedDRCourse = null; - _cachedDRSpeedMps = null; - _cachedDRHold = false; - _trackedCourseMs = 0; -} - -/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ -function _modelColor(icao24) { - if (icao24 === _trackedIcao) return TRACKED_ICON_COLOR; - return MIL_ICON_COLOR; -} - -/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). Mirror of flights.js. */ -function _modelRegimeActive() { - if (!_models3dEnabled) return false; - const h = _viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; -} - -/** Hysteresis latch for the tracked contact's zoom regime, plus the selection it - * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER - * ceiling instead of inheriting the previous target's looser EXIT band. */ -let _trackedZoomLatched = false; -let _trackedZoomLatchIcao = null; - -/** Bounded on-demand loading for the tracked model (mirror of flights.js). The - * tracked regime is DEFAULT-ON and its driver runs every `scene.preUpdate`, so - * a missing or corrupt GLB — or a dead network — would otherwise spin - * load→reject at frame rate for as long as the contact stays selected. - * Failures are counted PER SELECTION: a short backoff absorbs a transient - * blip, then the layer stops asking until the operator selects something else. - * The billboard is the visual throughout (the handoff only fades it once a - * model actually renders), so a latched failure degrades to exactly the - * pre-3D presentation. */ -const TRACKED_MODEL_MAX_LOAD_FAILS = 3; -const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; -let _trackedModelFailIcao = null; -let _trackedModelFailCount = 0; -let _trackedModelRetryAtMs = 0; - -/** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to this layer) never - * makes `_trackedIcao` *observably* change, so the guard never fires. Without - * the reset, a contact dropped inside the hysteresis band comes back as a - * MODEL above the ENTER ceiling, and a contact whose GLB had already failed - * out would never get its retries back. Mirror of flights.js. */ -function _resetTrackedSelectionState() { - _trackedZoomLatched = false; - _trackedZoomLatchIcao = null; - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; -} - -/** Whether the driver may start another tracked-model load this frame. */ -function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (_trackedModelFailIcao !== _trackedIcao) return true; // untried selection - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) return false; - return nowMs >= _trackedModelRetryAtMs; -} - -/** Record a rejected tracked-model load and arm the backoff / give-up latch. */ -function _noteTrackedModelLoadFailure(url, err) { - if (_trackedModelFailIcao !== _trackedIcao) { - _trackedModelFailIcao = _trackedIcao; - _trackedModelFailCount = 0; - } - _trackedModelFailCount += 1; - _trackedModelRetryAtMs = Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Military] tracked 3D model gave up after ${_trackedModelFailCount} failed loads of ${url} — ` - + 'this contact stays 2D until another is selected', - err, - ); - } -} - -/** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the - * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET - * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live - * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the - * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km - * ceiling this used to inherit), hand back only above - * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. - * First-person means your own airframe is not drawn in cockpit — the eye sits - * metres from its origin. - */ -function _trackedModelRegimeActive() { - if (_trackedZoomLatchIcao !== _trackedIcao) { - _trackedZoomLatchIcao = _trackedIcao; - _trackedZoomLatched = false; - } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. Mirror of flights.js. - if (!_trackedIcao || _cockpitContactMode || isTr3b(_trackedIcao)) { - _trackedZoomLatched = false; - return false; - } - _trackedZoomLatched = trackedModelZoomActive( - _viewer?.camera?.positionCartographic?.height, - _trackedZoomLatched, - ); - return _trackedZoomLatched; -} - -/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ -function _syncTracked2dRotation() { - if (!_trackedIcao || !_viewer) return; - const pos = _trackedDisplayCached() || _billboards.get(_trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation(_lastTrackedRotation, projected, 0); - if (rotation !== null) _lastTrackedRotation = rotation; -} - -/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the - * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ -function _modelCap() { - const mapCap = _models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. - return _cockpitContactMode ? Math.min(COCKPIT_MODEL_MAX, mapCap) : mapCap; -} - -/** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ -function _modelAddDistM() { - return _models3dMode === 'all' ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; -} -function _modelKeepDistM() { - return _models3dMode === 'all' ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; -} - -/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable - * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on - * the last-written transform, and the per-frame tracked write made it flicker). */ -function _modelMatrix(pos, headingDeg, result = _scratchModelMtx, offsetDeg = MODEL_HEADING_OFFSET_DEG) { - _scratchModelHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg); - _scratchModelHpr.pitch = 0; - _scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, _scratchModelHpr, Cesium.Ellipsoid.WGS84, undefined, result, - ); -} - -/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of - * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its - * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches - * picked-object IDs — every billboard AND model here carries its icao as `id` — plus - * the tracked entity object itself. Built lazily, only when a sample actually fires. */ -function _groundSampleExclusions() { - const out = [..._billboards.keys()]; - if (_trackedEntity) out.push(_trackedEntity); - return out; -} - -/** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). - * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin - * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly - * offset so they sit on their - * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the - * depth-test-free billboard visible and the model hidden while this returns null; a - * contact that has already resolved once holds that measurement through a later - * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ -function _modelDisplayPosition(icao24, pos, result) { - const meta = _flightData.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = _groundSnap.heightFor(_viewer, icao24, pos, _groundSampleExclusions); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchGroundCarto); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Cesium.Ellipsoid.WGS84, result); -} - -/** Atomically hand one fleet contact from its billboard to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the billboard owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ -function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { - if (!model) { - bb.show = true; - return false; - } - const displayPos = _modelDisplayPosition(icao24, pos, _scratchGroundPos); - if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; - return false; - } - // The matrix is written BEFORE the readiness test on purpose: a model can flip - // `ready` during scene update after this tick, and a first rendered frame on a - // stale load-start matrix is the one-frame jump this ordering prevents. - _modelMatrix( - displayPos, - course, - model.modelMatrix, - _modelSpec(_flightData.get(icao24)?.klass).headingOffsetDeg, - ); - if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; - return false; - } - beforeShow?.(); - if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders - return true; -} - -/** A model owns the visual only while it is ready, shown, and (for the tracked - * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ -function _modelOwnsVisual(icao24) { - if (icao24 === _trackedIcao) { - return _trackedModelRegimeActive() && _modelIsRendering(_trackedModel); - } - return _modelIsRendering(_models.get(icao24)); -} - -/** `show` is sufficient evidence of a safe placement because only the handoff and - * the tracked driver ever set it true, and both do so after committing a matrix; - * everything else — admission, unresolved ground, an unready glTF, the limb cull, - * a regime exit — only ever clears it. Mirror of flights.js. */ -function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; -} - -/** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ -const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; -}; -/** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the - * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ -function _syncModelToClass(icao24) { - const key = _specKeyFor(_flightData.get(icao24)?.klass); - const current = _models.get(icao24); - if ((current && current._gevSpecKey !== key) || (!current && _modelPending.has(icao24))) { - const bb = _billboards.get(icao24); - if (bb && icao24 !== _trackedIcao) bb.show = true; - _releaseModel(icao24); - } - if (icao24 === _trackedIcao && _trackedModel && _trackedModel._gevSpecKey !== key) { - _releaseTrackedModel(); - } -} - -/** IR hot-target mode — mirror of flights.js: under the luminance-mapped - * NVG/FLIR post-styles every model renders flat white (hottest); the dominant - * amber/tracked tint restores on style exit. */ -let _irBoost = false; -/** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR - * field rounds): material-stage tint still sun-shades, so side-on planes - * read near-black under a high sun; UNLIT emits flat white regardless. - * customShader on a READY model is a silent no-op (field-verified), so the - * boost flips by release-and-reload — creation-time options carry it. */ -const _IR_UNLIT_SHADER = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT }); -/** Boost flip = BATCHED release through the fleet tick (mirror of flights.js — - * destroying the whole fleet synchronously in the style handler stalls the - * render thread). Models tagged with their load-time boost state; stale queue - * entries skip; in-flight loads invalidated immediately; tracked reloads - * synchronously (single primitive). */ -const IR_RELOAD_BATCH = 40; -let _irReloadQueue = null; -function _reloadModelsForIrBoost() { - _irReloadQueue = [..._models.keys()]; - for (const icao of _modelPending) { - if (!_models.has(icao)) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - } - _releaseTrackedModel(); -} -function _drainIrReloadQueue() { - if (!_irReloadQueue) return; - const batch = _irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = _models.get(icao); - if (!model || model._gevIrBoost === _irBoost) continue; // already right state - const bb = _billboards.get(icao); - if (bb && icao !== _trackedIcao) bb.show = true; - _releaseModel(icao); - } - if (_irReloadQueue.length === 0) _irReloadQueue = null; -} - -/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ -async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === _trackedIcao) return; - if (_models.has(icao24) || _modelPending.has(icao24)) return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). - if ((_models.size + _modelPending.size) >= _modelCap()) return; - const epoch = _modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = _modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - _modelPending.add(icao24); - let model = null; - // Spec identity captured at load START (mirror of flights.js): a mid-load - // reclassification makes the post-await admission reject the stale asset. - // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(_flightData.get(icao24)?.klass); - const loadIrBoost = _irBoost; - try { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: spec.url, - asynchronous: false, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: _irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === _modelEpoch) { _modelPending.delete(icao24); _cleanupModelGen(icao24); } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== _modelEpoch) { try { model.destroy(); } catch { /* gone */ } return; } - _modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = (_modelGen.get(icao24) || 0) !== gen - || !_modelRegimeActive() || !_modelCollection || _modelCollection.isDestroyed() - || !_flightData.has(icao24) || icao24 === _trackedIcao - || _models.has(icao24) || _models.size >= _modelCap() - // Class reclassified mid-load → this GLB/scale is for the OLD class. - || _specKeyFor(_flightData.get(icao24)?.klass) !== specKey - // IR boost flipped mid-load → this model baked the wrong shader/tint. - || _irBoost !== loadIrBoost; - if (stale) { - try { model.destroy(); } catch { /* already gone */ } - _cleanupModelGen(icao24); // bound the map - return; - } - // Keep the pick identity explicit on the resolved primitive. This also - // protects injected/custom loaders that do not copy the creation option. - model.id = icao24; - model._gevSpecKey = specKey; // class-change sync compares against this - model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this - // Admitted, not yet the visual. Cesium's default is show=true, which would let - // an unplaced primitive claim ownership from the billboard for the frames - // between admission and the next fleet tick (and draw at the identity matrix, - // i.e. the Earth's centre). The handoff turns it on once it has a matrix. - model.show = false; - _modelCollection.add(model); - _models.set(icao24, model); - _planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard -} - -/** Remove the 3D model for ONE aircraft (removal / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ -function _releaseModel(icao24) { - const m = _models.get(icao24); - const pending = _modelPending.has(icao24); - // Only bump the generation when there's something to invalidate (an in-flight load or a - // live model) — so removing never-modeled aircraft doesn't grow _modelGen. - if (m || pending) { - _modelGen.set(icao24, (_modelGen.get(icao24) || 0) + 1); - } - if (m) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - _models.delete(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); -} - -/** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ -function _cleanupModelGen(icao24) { - if (!_modelPending.has(icao24) && !_models.has(icao24)) _modelGen.delete(icao24); -} - -/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ -function _releaseModels() { - _irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of _modelPending) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - if (_modelCollection && !_modelCollection.isDestroyed()) { - for (const m of _models.values()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - } - _models.clear(); -} - -/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ -function _releaseTrackedModel() { - _trackedModelGen++; - _trackedModelLoading = false; - if (_trackedModel) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(_trackedModel); } catch { /* gone */ } } - _trackedModel = null; - } -} - -/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The - * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ -function _updateTrackedModel() { - const active = _trackedIcao && _trackedModelRegimeActive() - && _modelCollection && !_modelCollection.isDestroyed(); - if (!active) { if (_trackedModel) _trackedModel.show = false; return; } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = _trackedDisplayPosition(_trackedIcao) || _billboards.get(_trackedIcao)?.position; - if (!pos) { if (_trackedModel) _trackedModel.show = false; return; } - if (!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed()) { - _trackedModelLoading = true; - const gen = _trackedModelGen; - const trackedSpec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const trackedKey = _specKeyFor(_flightData.get(_trackedIcao)?.klass); - const trackedIrBoost = _irBoost; - Cesium.Model.fromGltfAsync({ - url: trackedSpec.url, - asynchronous: false, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: _irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: _irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: _trackedIcao, - }).then((m) => { - if (gen !== _trackedModelGen || !_modelCollection || _modelCollection.isDestroyed()) { try { m.destroy(); } catch { /* gone */ } return; } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if (_specKeyFor(_flightData.get(_trackedIcao)?.klass) !== trackedKey || _irBoost !== trackedIrBoost) { - try { m.destroy(); } catch { /* gone */ } - _trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = _trackedIcao; - m._gevSpecKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed - // Seed the world transform before the primitive enters the scene. A model - // can become ready+shown between render phases; leaving Cesium's identity - // default here produces a one-frame jump to the Earth's center. - const currentPos = _trackedDisplayCached() - || _billboards.get(_trackedIcao)?.position; - if (currentPos) { - const displayPos = _modelDisplayPosition(_trackedIcao, currentPos, _scratchGroundPos); - if (displayPos) { - _modelMatrix(displayPos, _trackedDisplayCourse(), m.modelMatrix, trackedSpec.headingOffsetDeg); - } - } - _trackedModel = m; - _trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; - _modelCollection.add(m); - _planeModelLoaded = true; - }).catch((err) => { - if (gen !== _trackedModelGen) return; // superseded load — not this selection's failure - _trackedModelLoading = false; - _noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; - } - if (_trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition(_trackedIcao, pos, _scratchGroundPos); - if (!displayPos) { - _trackedModel.show = false; // no ground evidence → the billboard carries it - return; - } - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - _modelMatrix(displayPos, _trackedDisplayCourse(), _trackedModel.modelMatrix, spec.headingOffsetDeg); - if (!_trackedModel.ready) return; - _trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance(_viewer.camera.positionWC, displayPos), - viewportHeightPx: _viewer.scene.canvas.clientHeight, - fovyRad: _viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - _trackedModel.show = true; - } -} - -/** - * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, - * horizon-culls billboards beyond the limb (no far-side depth with the globe - * hidden), and refreshes screen-projected icon rotations whenever the camera - * pose changed (plus a 1s drift catch-up while idle). Driven by - * scene.preRender — NOT camera.changed, whose granularity is globally - * degraded by other layers mutating camera.percentageChanged. - * @returns {void} - */ -function _fleetTick() { - if (!_viewer || !_billboardCollection || !_billboardCollection.show) return; - const scene = _viewer.scene; - const camera = _viewer.camera; - const nowMs = focusNowMs(Date.now()); - - // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz - // primitive rebuild here. The body rebuilds only when a real fix arrives.) - - if ((nowMs - _lastFleetTickMs) < FLEET_DR_INTERVAL_MS) return; - const tickDtSec = _lastFleetTickMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _lastFleetTickMs) / 1000) - : 0.08; - _lastFleetTickMs = nowMs; - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (_cockpitContactMode) _refreshCockpitNearContacts(); - const poseSig = cameraPoseSignature(camera); - // Only nearby Cockpit silhouettes need projected course; far dots remain - // rotation-free through the per-contact gate below. - const doRotations = (poseSig !== _lastCamPoseSig || (nowMs - _lastRotPassMs) >= ROTATION_REFRESH_MS); - if (doRotations) { - _lastCamPoseSig = poseSig; - _lastRotPassMs = nowMs; - } - - const occluder = horizonOccluder(camera); - const focusTarget = getFocusTarget(); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if (!useModels && (_models.size || _modelPending.size)) _releaseModels(); - - // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — - // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen - // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP - // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). - let modelEligible = null; - if (useModels) { - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC); - const cand = []; - for (const [icao, bb] of _billboards) { - if (icao === _trackedIcao) continue; - // A converted TR-3B renders as a billboard and can never take a model, so - // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded - // at selection time, not just at the handoff below. - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded - // placement is handled by the one-shot ground snap in _modelDisplayPosition). - const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); - if (d2 > keepDistSq) continue; // beyond keep radius → never eligible - Cesium.Cartesian3.clone(bb.position, _scratchModelBS.center); - cand.push([icao, d2, cull.computeVisibility(_scratchModelBS) !== Cesium.Intersect.OUTSIDE]); - } - cand.sort((a, b) => a[1] - b[1]); - if (cand.length > cap && (nowMs - _lastModelCapWarnMs) > 5000) { - console.warn(`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${_models3dMode}). On-screen prioritized.`); - _lastModelCapWarnMs = nowMs; - } - modelEligible = new Set(); - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (inF && _models.has(icao)) modelEligible.add(icao); } // 1. KEEP on-screen - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 2. ADD on-screen - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && _models.has(icao)) modelEligible.add(icao); } // 3. KEEP off-screen (can't starve visible) - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 4. ADD off-screen leftover - const toRelease = []; - for (const icao of _models.keys()) { - if (icao !== _trackedIcao && !modelEligible.has(icao)) toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); - } - - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao) continue; // tracked entity owns its own motion - - const dr = _deadReckon(icao24, _scratchFleetPos); - // Gate the write — assigning Billboard.position dirties the whole - // collection's vertex buffer, so skip sub-meter moves. - if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { - bb.position = dr; - } - - // Round 6: occlusion-test a LIFTED point for contacts at/below the - // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb - // read "beyond the horizon" and would hide low contacts awaiting floors). - const beyondHorizon = !occluder.isPointVisible(_flightData.get(icao24)?.cullPosition || bb.position); - // A billboard flipping INTO view (horizon reveal while the camera idles) - // gets its rotation refreshed THIS tick even without a pose change — - // otherwise it reappears wearing its stale (often creation-north) nose for - // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false - // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; - if (beyondHorizon) { - // Also hide any 3D model — otherwise a model that crossed the limb would keep - // rendering through the hidden globe at its last matrix. - const m = _models.get(icao24); - if (m && m.show) m.show = false; - continue; - } - - const info = _flightData.get(icao24); - - // One sprite-owned write site composes freshness × focus × limb haze and - // base class/ground scale × limb taper. The locked NearFarScalar remains - // untouched and multiplicative; there is no cull or zero-alpha path. - const cameraDistanceM = Cesium.Cartesian3.distance(camera.positionWC, bb.position); - const distanceScale = nearFarScalarValueAtDistance(bb.scaleByDistance, cameraDistanceM); - const focus = advanceProjectedSpriteFocus( - bb, - bb.position, - scene, - camera, - nowMs, - focusTarget, - undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, - ); - const isCockpitNear = _cockpitContactMode && _cockpitNearContacts.has(icao24); - const treatment = applyAircraftBillboardTreatment({ - billboard: bb, - baseScale: _cockpitContactMode && !isCockpitNear ? 1 : _militaryBillboardScale(icao24), - baseAlpha: _missingPolls.get(icao24) ? 0.45 : 1, - baseColor: MIL_ICON_COLOR, - focusFactor: focus.factor, - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }); - _billboardLimbScale.set(bb, treatment.factors.scale); - // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on - // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so - // focus/limb recession counts) with hysteresis (atlas has no mips). - if (!_cockpitContactMode || isCockpitNear) { - const glyphDevPx = (bb.width || 20) * (bb.scale || 1) - * distanceScale * (globalThis.devicePixelRatio || 1); - const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; - if (wantLarge !== !!bb._gevIconLarge) { - bb._gevIconLarge = wantLarge; - bb.image = aircraftIcon(_iconKind(icao24, _flightData.get(icao24)?.klass), wantLarge ? TRACKED_ICON_PX : undefined); - } - } - - // Smoothed display course: the path direction _deadReckon just reported - // for THIS aircraft (nothing else calls _deadReckon in between), rate- - // limited so segment-boundary course steps glide instead of snapping. - // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering - // aircraft (hold flag) keeps its previous nose direction outright. - const rawCourse = _drCourseDeg != null ? _drCourseDeg : ((info && info.track) || 0); - const prevCourse = _displayCourse.get(icao24); - const course = (_drCourseHold && prevCourse != null) - ? prevCourse - : limitCourseStep( - prevCourse, rawCourse, - courseSlewCapDps(_drSpeedMps != null ? _drSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS), - tickDtSec, - ); - _displayCourse.set(icao24, course); - - // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the - // billboard stays shown until the model is actually READY to render, so a plane is never both - // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every - // tick regardless so it's framed the instant it becomes ready. - // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter - // egg IS the triangle and there is no GLB for it, so the model handoff is - // suppressed. The billboard keeps rendering, so the contact still satisfies - // the getNearby/getDetectableObjects visibility guards. - if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { - _ensureModel(icao24); - const model = _models.get(icao24); - const ownsVisual = _driveFleetModelHandoff( - icao24, - model, - bb, - dr, - course, - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write; boosted - // models also skip the recession fade (mirror of flights.js — - // billboards keep their normal fade, hot MODELS stay full-strength). - baseColor: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - alpha: _irBoost ? 1 : treatment.alpha, - }); - }, - ); - if (ownsVisual) continue; // skip billboard rotation - } - - if ((!_cockpitContactMode || isCockpitNear) && (doRotations || revealed)) { - const rot = screenProjectedRotation(scene, bb.position, course, bb.rotation); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; - } - } - } -} - -/** - * Build a public descriptor for one aircraft using its best current position - * (dead-reckoned when history exists, billboard position otherwise). - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Descriptor with a cloned position, or null if the aircraft is unknown. - */ -function _describeFlight(icao24) { - const info = _flightData.get(icao24); - const bb = _billboards.get(icao24); - const basePos = _deadReckon(icao24) || (bb ? bb.position : null); - if (!basePos) return null; - const displayed = displayedKinematics({ - derivedSpeedMps: _drSpeedMps, - derivedTrackDeg: _drCourseDeg, - reportedSpeedMps: info?.speedMps, - reportedTrackDeg: info?.track, - }); - const carto = Cesium.Cartographic.fromCartesian(basePos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) return null; - return { - icao24, - callsign: _toCleanText(info?.callsign) || null, - // Additive: the label chain's middle link, trimmed like `callsign`, so - // getTrackedSubject and the voice narration can read it straight off the - // descriptor instead of reaching back into `_flightData`. - registration: _toCleanText(info?.registration) || null, - position: Cesium.Cartesian3.clone(basePos), - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - // Keep the cockpit readout on the reported aviation altitude. Render - // terrain height is a separate visual datum and may be below zero. - altitudeM: Number.isFinite(info?.altitudeM) ? info.altitudeM : carto.height, - renderAltitudeM: Number.isFinite(info?.renderAltitudeM) ? info.renderAltitudeM : carto.height, - onGround: info?.onGround === true, - velocityMps: displayed.speedMps, - track: displayed.trackDeg, - stale: Boolean(_missingPolls.get(icao24) || _backoff), - }; -} - -/** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ -const _scratchTrailCarto = new Cesium.Cartographic(); - -/** - * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm - * coarse ground cell — a pure LIFT (above-floor positions pass through - * untouched, unknown floors change nothing). Grounded military billboards - * deliberately render at the pre-datum default height (T7 groundSnap - * invariant), so every position entering the TRAIL subsystem (seed, append, - * per-frame head) goes through this instead — the visible taxi history sits - * on the surface without touching the billboard/model machinery. - * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). - * @returns {Cesium.Cartesian3} The same or a lifted position. - */ -function _trailFloorPosition(position) { - const carto = Cesium.Cartographic.fromCartesian(position, Cesium.Ellipsoid.WGS84, _scratchTrailCarto); - if (!carto) return position; - const latDeg = Cesium.Math.toDegrees(carto.latitude); - const lonDeg = Cesium.Math.toDegrees(carto.longitude); - const floored = floorAltitudeM(carto.height, cachedGroundFloor(latDeg, lonDeg)); - if (floored == null || floored === carto.height) return position; - return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); -} - -/** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {Cesium.Cartesian3} position - New fix position, appended at the head. - */ -function _appendTrailFix(position) { - _trailPositions.push(_trailFloorPosition(position)); - if (_trailPositions.length > TRAIL_MAX_POINTS) _trailPositions.shift(); - _refreshTrailDisplay(); -} - -/** - * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That - * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. - * The cheap per-frame _trailHeadEntity segment bridges the last body point to the - * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll - * cadence), never at motion cadence. - */ -function _refreshTrailDisplay() { - if (!_trail) return; - _trail.setPositions(_trailPositions.length > 1 ? _trailPositions.slice(0, -1) : _trailPositions); -} - -/** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an adsb.lol trace backfill (~24 h of real history). - * @param {string} icao24 - ICAO hex identifier being tracked. - */ -function _startTrail(icao24) { - _trailBackfillToken += 1; - _trailPositions = []; - const history = _positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join via _appendTrailFix as they age. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { - // Floor grounded-history seeds (round 2): a grounded contact's stored - // fixes carry the pre-datum default height — the trail copy sits on - // the surface instead (pure lift; airborne fixes pass through). - _trailPositions.push(_trailFloorPosition(Cesium.Cartesian3.clone(fix.position))); - } - } - if (!_trail && _viewer) { - _trail = createTrail(_viewer, { color: TRAIL_COLOR, width: 2.5 }); - } - _trail?.setVisible(!_cockpitContactMode); - // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated - // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, - // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued - // to the 12 Hz icon instead of lagging ~1 s behind it. - if (!_trailHeadEntity && _viewer) { - _trailHeadEntity = _viewer.entities.add({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick - // owner so a click on the head segment never reads as empty space. - id: `gev-trail:mil-head-${++_trailHeadSeq}`, - show: !_cockpitContactMode, - polyline: { - positions: new Cesium.CallbackProperty(() => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the - // segment would draw IN FRONT of the plane. Likewise during warm-up the icon - // predates all real history, so there is no valid body point behind it. - if (!_trackedIcao || _trailPositions.length < 2 || _isTrackWarmingUp()) return []; - const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = _trailPositions[_trailPositions.length - 2]; - // Floor the head too (round 2): a grounded tracked contact's DR - // display height is the pre-datum default — without this the last - // segment dives underground while taxiing. - const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. Measured against - // the FLOORED endpoint, which is the one actually drawn. - // See trailHeadStart. - const from = trailHeadStart( - start, end, _trackedModelCenterWorld(), _trackedModelEnvelopeM(), _scratchTrailHead, - ); - if (!from) return []; - return [from, end]; - }, false), - width: 2.5, - material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads). - depthFailMaterial: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) - }, - }); - } - _refreshTrailDisplay(); - - const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 - : Infinity; - _backfillTrail(icao24, _trailBackfillToken, oldestFixEpochSec); -} - -/** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are - * stride-thinned to the remaining vertex budget, then spliced strictly older - * than the oldest seeded fix AHEAD of the locally accumulated fine segment, - * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ - * malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO hex identifier being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ -async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let baseEpochSec = null; - let trace = null; - try { - const response = await fetch('/api/adsblol/trace?hex=' + encodeURIComponent(icao24), { - signal: AbortSignal.timeout(8000), - }); - if (!response.ok) return; - const data = await response.json(); - baseEpochSec = Number(data?.timestamp); - trace = Array.isArray(data?.trace) ? data.trace : null; - } catch { - return; // silent fallback to the accumulated trail - } - if (!trace || !Number.isFinite(baseEpochSec)) return; - if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a - // readsb trace point's altitude is barometric feet (same datum as the live - // alt_baro, NOT geometric), so a trail waypoint's render height is the same - // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) - // — geometrically approximate, not exact. - await ensureGeoidReady(); - - // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] - const parsed = []; - for (const point of trace) { - if (!Array.isArray(point)) continue; - const t = baseEpochSec + Number(point[0]); - const lat = Number(point[1]); - const lon = Number(point[2]); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, altFt: point[3] }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse - // ellipsoidal ground along the trace and clamp every waypoint at it. A - // 'ground'/null point sits ON the local surface — the old fixed 50 m - // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m - // ellipsoidal) and dragged the whole pattern-work loop with it. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - let older = []; - let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet - for (const { lat, lon, altFt } of parsed) { - const baroM = (altFt === 'ground' || altFt == null || !Number.isFinite(Number(altFt))) - ? null - : Number(altFt) * 0.3048 + geoidHeight(lat, lon); - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // Ground/no-alt point with an unresolved floor: hold the previous - // waypoint's altitude (continuity — never a dive to a made-up depth). - // Leading points with nothing to carry keep the old low breadcrumb - // sentinel (an arbitrary placeholder, never a reported altitude). - if (altM == null) altM = lastAltM != null ? lastAltM : 50; - lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); - } - - // Stride-thin the backfill so it plus the live fine segment fit the cap. - const budget = Math.max(1, TRAIL_MAX_POINTS - _trailPositions.length); - if (older.length > budget) { - const stride = Math.ceil(older.length / budget); - const thinned = []; - for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); - older = thinned; - } - - _trailPositions = older.concat(_trailPositions); - if (_trailPositions.length > TRAIL_MAX_POINTS) { - _trailPositions = _trailPositions.slice(_trailPositions.length - TRAIL_MAX_POINTS); - } - _refreshTrailDisplay(); -} - -/** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ -function _clearTrail() { - _trailBackfillToken += 1; - _trailPositions = []; - if (_trail) _trail.clear(); - if (_trailHeadEntity && _viewer && !_viewer.isDestroyed()) { - try { _viewer.entities.remove(_trailHeadEntity); } catch { /* already gone */ } - } - _trailHeadEntity = null; -} - -/** - * Destroy the trail primitive entirely (layer disable/teardown). - */ -function _destroyTrail() { - _clearTrail(); - if (_trail) { - _trail.destroy(); - _trail = null; - } -} - -/** - * Stop tracking the current aircraft and clean up all tracking state. - * Restores the original billboard, removes the tracked Entity, invalidates - * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo - * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back - * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms - * way up and loses my context"). The camera stays at its current - * position/orientation, immediately free to orbit/zoom. Applies to every - * deselect path: click-empty-space, Escape, aged-out plane, layer disable, - * and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ -function _clearTracking(skipViewerUntrack = false, { - evicted = false, - origin = 'programmatic', -} = {}) { - _trackedCameraFrameStop?.(); - _trackedCameraFrameStop = null; - if (!_trackedIcao) { - clearFocusTarget('militaryFlights'); - return; - } - const clearedIcao = _trackedIcao; - clearFocusTarget('militaryFlights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (_billboards.has(_trackedIcao)) { - const bb = _billboards.get(_trackedIcao); - const meta = _flightData.get(_trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; tint is full amber on the ground and in the air). - bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; - bb.rotation = _lastTrackedRotation; - } - _lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the - // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). - // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in - // place, so the view stays where the follow left it and the user can immediately orbit/zoom. - if (_viewer && !skipViewerUntrack) { - _viewer.trackedEntity = undefined; - } - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - _trackedIcao = null; - _applyFleetBillboardPresentation(clearedIcao, _billboards.get(clearedIcao)); - clearTrackedSubjectContext('military'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'military', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache so a same-frame re-track cannot read - // the previous aircraft's cached position. - _resetTrackedDisplay(); - _clearTrail(); -} - -function _normalizeTrackedIcao(candidate) { - const normalized = String(candidate ?? '').trim().toLowerCase(); - return normalized || null; -} - -function _isUsableMilitaryAircraft(aircraft) { - if (!aircraft || Array.isArray(aircraft) || typeof aircraft !== 'object') return false; - if (typeof aircraft.hex !== 'string' || !_normalizeTrackedIcao(aircraft.hex)) return false; - return Number.isFinite(_toFiniteNumber(aircraft.lon)) - && Number.isFinite(_toFiniteNumber(aircraft.lat)); -} - -function _applyPendingTrackingRestore() { - const pending = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration) return false; - if (!_billboardCollection?.show || !_billboards.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _pendingTrackingRestore = null; -} - -/** - * Begin tracking a specific aircraft. Clears any previous tracking, hides the - * billboard, creates a new Entity with dead-reckoning CallbackProperties for - * smooth continuous motion, and sets the viewer's trackedEntity so the camera - * follows. - * @param {string} icao24 - ICAO hex identifier of the aircraft to track - */ -function _trackFlight(icao24, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb || !info) return; - - _trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - _cachedDRFrame = -1; - _lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - _releaseModel(icao24); - - // Hide the billboard -- the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/rotation/trail-head callbacks, with - // discontinuity reconciliation). Falls back to the last billboard position when the - // aircraft has no fix. - const getTrackedPosition = () => _trackedDisplayPosition(icao24) || bb.position; - - // Dead-reckoning position property evaluated every render frame. - // isConstant = false so CesiumJS re-evaluates each frame. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - - // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D - // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is - // what makes the follow-camera's bounding sphere always ready (see _trackedModel). - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - // Keep Cesium's EntityView in the same ENU frame as the close-range - // camera guard; AUTO may otherwise alternate with a velocity frame. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon(_iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), TRACKED_ICON_PX), - width: 28, - height: 28, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[_flightData.get(_trackedIcao)?.klass] || 1), - // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: new Cesium.CallbackProperty(() => ( - _modelOwnsVisual(_trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR - ), false), - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the amber icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: new Cesium.CallbackProperty(() => { - const tracked = _flightData.get(_trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !_viewer) return _lastTrackedRotation; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rot = stabilizeScreenRotation(_lastTrackedRotation, projected); - if (rot !== null) _lastTrackedRotation = rot; - return _lastTrackedRotation; - }, false), - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `military:${icao24}`; - _trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(info, icao24), - '#ffd166', - ); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; - const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - _trackedEntity.viewFrom = new Cesium.Cartesian3(0, -followRange * 0.8, followRange * 0.55); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked - // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires - // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. - // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a - // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). - _trackedEntity.gevDisplayPosition = _trackedDisplayCached; - // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the - // follow-camera anti-jitter contract; presentation that must weld to the aircraft you - // can see reads `gevVisualPosition`. - _trackedEntity.gevVisualPosition = _trackedVisualCached; - refreshTrackedReadout(_trackedEntity); - _viewer.camera.cancelFlight(); - // Camera follows the tracked entity - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - - // Track-history trail (PRD F2): seed from local history + async backfill. - // Ground traffic draws NO trail — if the plane takes off while tracked, the - // ground→air transition in update() starts one. - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Trail positions are floored - // at the surface (_trailFloorPosition), so ground legs drape, never dive. - _startTrail(icao24); - - const callsign = _toCleanText(info.callsign) || _toCleanText(info.registration) || icao24; - _publishTrackedSelection(icao24, origin); - console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); -} - -/** - * Map one military aircraft's internal poll record to a plain JSON-safe - * analyst record (analyst query engine seam) — same shape as the flights - * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium - * types, no fetches. Missing/unknown fields are null, never NaN/undefined. - * adsb.lol carries no origin-country field and this layer has no route - * enrichment, so originCountry/routeOrigin/routeDestination are always null. - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @param {Object|null|undefined} info - `_flightData` record for this aircraft. - * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, - * lon: number|null, altitudeM: number|null, speedMps: number|null, - * heading: number|null, verticalRateMps: number|null, onGround: boolean, - * military: boolean, aircraftClass: string|null, originCountry: null, - * operator: string|null, routeOrigin: null, routeDestination: null}} - */ -export function mapAnalystRecord(icao24, info) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const callsign = text(info?.callsign); - return { - id: callsign || text(info?.registration) || icao24, - icao24, - callsign, - lat: num(info?.rawLat), - lon: num(info?.rawLon), - // altitudeFt is the sticky barometric/MSL aviation field — converted to - // meters here for shape parity with the flights layer. - altitudeM: Number.isFinite(info?.altitudeFt) ? info.altitudeFt * 0.3048 : null, - speedMps: num(info?.speedMps), - heading: num(info?.track), - verticalRateMps: num(info?.verticalRateMps), - onGround: info?.onGround === true, - military: true, - // A converted contact reports the class it RENDERS as (mirror of - // flights.js), so an analyst filter/superlative agrees with the triangle. - aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), - originCountry: null, - operator: text(info?.operator), - routeOrigin: null, - routeDestination: null, - }; -} - -/** - * Layer descriptor object for the military flights data layer. - * Conforms to the layer manager contract: init, enable, disable, update, destroy, - * plus optional getNearby, getDetectableObjects, and getStats accessors. - * @type {Object} - */ -const militaryFlightsLayer = { - id: 'military', - name: 'Military Flights', - icon: '🎖️', - source: 'adsb.lol', - /** @type {number} Polling interval in ms between API fetches */ - updateInterval: 15000, - - /** - * Initialize the layer: create the billboard collection, reset all state, - * and install the click-to-track handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - init(viewer) { - clearFocusTarget('militaryFlights'); - _viewer = viewer; - _billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(_billboardCollection); - registerSpriteCollection('military', _billboardCollection); - _modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(_modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!_preloadModel) { - const epoch = _modelEpoch; - Cesium.Model.fromGltfAsync({ url: JET_MODEL_URL, asynchronous: false }) - .then((m) => { - if (epoch === _modelEpoch) { _preloadModel = m; _planeModelLoaded = true; } - else { try { m.destroy(); } catch { /* gone */ } } - }) - .catch(() => { /* tracked plane just stays a billboard a beat longer */ }); - } - _billboards = new Map(); - _detectionObjects = new Map(); - _flightData = new Map(); - _positionHistory = new Map(); - _displayCourse.clear(); - _groundSnap.clear(); - _count = 0; - _lastUpdate = null; - _backoff = false; - _retryAt = 0; - _lastError = null; - _lastStatus = null; - _trackedIcao = null; - _resetTrackedSelectionState(); - _trackedEntity = null; - _cockpitSubjectId = null; - _cockpitContactMode = document.body.classList.contains('cockpit-mode'); - _cockpitNearContacts = new Set(); - if (!_cockpitModeListener) { - _cockpitModeListener = (event) => _applyCockpitState(event?.detail); - window.addEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - } - - _installClickHandler(viewer); - - restoreSpriteOrder(viewer); - - console.log('[Data:Military] Initialized with billboard icons'); +import { createMilitaryFlightLayer } from '../layers/military/index.js'; +import { createAdsbLolSource } from '../sources/live/standalone.js'; +import * as picking from './pickRegistry.js'; +import * as sprites from './spriteOrder.js'; +import * as trails from './trailRenderer.js'; +import * as aircraftPresentation from './tr3bRegistry.js'; +import * as camera from './trackedCamera.js'; +import * as militaryRegistry from './militaryRegistry.js'; +import * as labels from './detectionDraw.js'; +import * as groundFloor from './groundFloor.js'; +import * as meshFloor from './meshFloorSampler.js'; +import * as geoid from './geoid.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as groundSnap from './groundSnap.js'; +import * as recession from './aircraftRecession.js'; + +const militaryFlightsLayer = createMilitaryFlightLayer({ + source: createAdsbLolSource(), + resolveAsset: (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, }, - - /** - * Show the layer and re-install the click handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - enable(viewer) { - if (_billboardCollection) _billboardCollection.show = true; - holdContinuousRender('military'); // per-frame animator (perf wave 2) - if (_modelCollection) _modelCollection.show = true; - _setCockpitContactMode(document.body.classList.contains('cockpit-mode')); - // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop - // only reads geoidHeight() synchronously after this resolves (guarded by - // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. - if (!_geoidReady) { - ensureGeoidReady() - .then(() => { _geoidReady = true; }) - .catch(() => { /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); - } - _installClickHandler(viewer); - registerPickOwner('military', (pickedId) => _billboards.has(pickedId)); - // The flights layer suppresses its military duplicates while we render them - setMilitaryLayerActive(true); - // Force a fresh rotation pass on the first tick after re-enable - _lastCamPoseSig = ''; - if (!_preRenderRemove && viewer?.scene) { - _preRenderRemove = viewer.scene.preRender.addEventListener(_fleetTick); - } - if (!_trackedModelPreUpdateRemove && viewer?.scene) { - _trackedModelPreUpdateRemove = viewer.scene.preUpdate.addEventListener(_updateTrackedModel); - } - if (!_moveEndRemove && viewer?.camera) { - // Arrival polish (mirror of flights.js — see the comment there): a settled - // camera move forces a full rotation pass on the very next frame, because - // the pose-signature gate can eat the settle (final easing frames land - // inside one quantization bucket) and leave stale noses for up to - // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. - _moveEndRemove = viewer.camera.moveEnd.addEventListener(() => { - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; - }); - } - restoreSpriteOrder(viewer); - }, - - /** - * Hide the layer, clear any active tracking, and remove input handlers - * so clicks do not get intercepted while the layer is off. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - if (_billboardCollection) _billboardCollection.show = false; - releaseContinuousRender('military'); - _releaseModels(); - if (_modelCollection) _modelCollection.show = false; - _clearTracking(); - _destroyTrail(); - // Remove click handler + keydown listener while disabled to avoid - // intercepting input when the layer is off - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('military'); - // Flights layer takes over rendering known-military aircraft (amber) - setMilitaryLayerActive(false); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - }, - - /** @deprecated Compatibility alias for {@link enable}. */ - show(viewer) { - this.enable(viewer); - }, - - /** @deprecated Compatibility alias for {@link disable}. */ - hide(viewer) { - this.disable(viewer); - }, - - /** - * Fetch the latest military aircraft positions from adsb.lol and reconcile - * with the billboard collection. Handles error backoff, feed-time-stamped - * position history updates, and grace-period removal of absent aircraft. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - * @returns {Promise} - */ - async update(viewer, { signal = null } = {}) { - const nowMs = Date.now(); - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - ids: new Set(), - source: 'adsb.lol', - }; - if (_retryAt && nowMs < _retryAt) { - _backoff = true; - return; - } - - const resourceController = new AbortController(); - _activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - const response = await fetch(API_URL, { signal: updateSignal }); - _lastStatus = response.status; - - if (!response.ok) { - _backoff = true; - // Parity with flights.js: a 429 gets the LONGER cooldown + a friendly - // rate-limit label instead of the generic transient backoff, so we don't - // hammer the upstream and the UI reads honestly. - if (response.status === 429) { - _retryAt = nowMs + BACKOFF_INTERVAL; - _lastError = 'adsb.lol rate limited'; - return; - } - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - let detail = ''; - try { - const body = await response.json(); - updateSignal.throwIfAborted(); - detail = _toCleanText(body?.error || body?.message); - } catch { - detail = ''; - } - _lastError = detail || `adsb.lol HTTP ${response.status}`; - return; - } - - // adsb.lol returns { ac: [...aircraft], msg: "...", ... } - const data = await response.json(); - updateSignal.throwIfAborted(); - if (!data || !Array.isArray(data.ac)) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed adsb.lol response'; - return; - } - - const usableAircraft = data.ac.filter(_isUsableMilitaryAircraft); - if (data.ac.length > 0 && usableAircraft.length === 0) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed adsb.lol aircraft rows'; - return; - } - - _backoff = false; - _retryAt = 0; - _lastError = null; - const currentIcaos = new Set(); - const cacheAgeMs = Number(response.headers?.get?.('x-ads-b-cache-age-ms')); - const receiptNowMs = Date.now() - (Number.isFinite(cacheAgeMs) && cacheAgeMs > 0 ? cacheAgeMs : 0); - _backoff = response.headers?.get?.('x-ads-b-cache') === 'STALE'; - // Field-test fix (RS46): coarse floor cells to warm for the below-ground - // clamp — collected during the loop (low airborne contacts only), - // batch-resolved once after it. Never a fetch inside the loop. - const _floorWarmPoints = []; - - // -- Parse each aircraft record from the adsb.lol response -- - // Fields used: hex (ICAO), lon, lat, alt_baro (ft, barometric/MSL), - // alt_geom (ft, geometric/WGS84 ellipsoidal — probed live 2026-07-05: - // present on ~45% of records; readsb omits it when the aircraft hasn't - // reported a geometric altitude this cycle), track (deg true), gs - // (ground speed in knots), seen_pos (age of last position, seconds), - // flight (callsign), t (type), r (registration), ownOp (operator). - for (const aircraft of usableAircraft) { - const icao24 = _toCleanText(aircraft?.hex).toLowerCase(); - const lon = _toFiniteNumber(aircraft?.lon); - const lat = _toFiniteNumber(aircraft?.lat); - - currentIcaos.add(icao24); - _missingPolls.delete(icao24); - - const prevMeta = _flightData.get(icao24); - // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no - // separate boolean). Grounded planes fall back to their last known - // altitude (field elevation is unknowable here), else 0 m — never the - // 3 km airborne default (a parked plane must not float). - const rawAltBaro = aircraft?.alt_baro; - const onGround = typeof rawAltBaro === 'string' && rawAltBaro.trim().toLowerCase() === 'ground'; - const altitudeFt = _toFiniteNumber(rawAltBaro); - const altitudeM = Number.isFinite(altitudeFt) - ? altitudeFt * 0.3048 - : (onGround - ? (Number.isFinite(prevMeta?.altitudeFt) ? prevMeta.altitudeFt * 0.3048 : 0) - : 3048); - const track = _toFiniteNumber(aircraft?.track) || 0; - // Convert ground speed from knots to meters/sec for dead reckoning - const speedKt = _toFiniteNumber(aircraft?.gs); - const speedMps = Number.isFinite(speedKt) ? speedKt * 0.514444 : 0; - // Analyst seam (additive): readsb baro_rate is ft/min; keep m/s. - const baroRateFtMin = _toFiniteNumber(aircraft?.baro_rate); - const verticalRateMps = Number.isFinite(baroRateFtMin) ? baroRateFtMin * 0.00508 : null; - - const callsign = _toCleanText(aircraft?.flight); - const type = _toCleanText(aircraft?.t); - const registration = _toCleanText(aircraft?.r); - const operator = _toCleanText(aircraft?.ownOp); - const seenSec = _toFiniteNumber(aircraft?.seen); - - // Height-datum fix (Task 7, mirror of flights.js Task 6): `altitudeM` - // (above) stays the AVIATION field — the sticky barometric/MSL altitude - // read by labels (FL/altitude readout) and the landed-fast-cull - // heuristic. It is NEVER overwritten or renamed. Where the aircraft - // actually RENDERS on the ellipsoidal globe (billboard path only — a - // ground-snapped 3D MODEL uses groundSnap.js's own tileset sample and - // ignores this value entirely) is a SEPARATE value, renderAltitudeM: - // alt_geom when readsb reports it (already WGS84 ellipsoidal), else - // alt_baro+geoid as a visual fallback, else ground surface when parked. - const altGeomFt = _toFiniteNumber(aircraft?.alt_geom); - const geoAltitudeM = Number.isFinite(altGeomFt) ? altGeomFt * 0.3048 : null; - const baroAltitudeM = Number.isFinite(altitudeFt) ? altitudeFt * 0.3048 : null; - - // geoid undulation N: cached per-aircraft (negligible drift — see - // task brief) once the geoid grid has loaded; unavailable pre-load - // just means the baro fallback branch below adds N=0 for a beat. - let geoidN = _geoidNCache.get(icao24); - if (geoidN === undefined && _geoidReady) { - geoidN = geoidHeight(lat, lon); - _geoidNCache.set(icao24, geoidN); - } - - // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a - // grounded plane's MODEL already rides groundSnap.js's one-shot tileset - // sample (_modelDisplayPosition), which is the visual on the ground, and - // its billboard is depth-test-free (_groundDepthDistance) so its exact - // height is cosmetic. Deliberately pass surfaceM=null so - // pickRenderAltitudeM's on-ground surface branch never fires here: - // 1. It would be the SECOND correction of the same grounded plane - // (model tileset-snap is the first) — the exact double-correct the - // brief forbids. - // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the - // grounded billboard sits at 0 m until a surface value warms; letting - // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls - // drags the model's ground-snap input past groundSnap's 50 m - // move-invalidation threshold and forces a re-sample every time the - // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant - // the track regression locks (qa: sampleHeight count must stay flat). - // Grounded planes therefore keep the pre-existing `altitudeM` default - // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what - // matters for AIRBORNE military planes — the actual "renders at MSL" bug. - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: baroAltitudeM, - onGround, - surfaceM: null, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // alt_geom nor alt_baro was ever reported for this aircraft (not even - // stickily) — fall back to the SAME existing default policy `altitudeM` - // already uses (which also carries the on-ground 0 m / last-known-baro - // case), so the two never disagree on the "no data yet" case. - let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; - // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only - // AIRBORNE contact near steep terrain can compute a render height - // BELOW the local surface (no alt_geom; baro+N carries QNH error - // larger than the height above ground). Floor it at the coarse-grid - // ellipsoidal ground (warm-cache read only — the batch warm below - // fills cells for later polls). Grounded contacts are deliberately - // NOT touched: their model rides groundSnap's tileset sample and - // their billboard is depth-test-free (see the surfaceM:null block - // above — same one-shot-invariant reasoning). - if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, cachedGroundFloor(lat, lon)); - _floorWarmPoints.push({ lat, lon }); - } else if (onGround) { - // Grounded contacts: warm the floor cell, and — round 4 — when NO - // 3D model owns this contact's visual, lift the billboard itself - // onto the floor (mesh-first): the R20053 heli sat "straight up in - // the ground" because grounded rows render at the legacy ~0 m. - // With a model present the billboard stays put (it hides behind - // the tileset-snapped model, and moving it would drag groundSnap's - // input past its move-invalidation threshold — the T7 one-shot - // invariant the track regression locks). - _floorWarmPoints.push({ lat, lon }); - const modelOwnsVisual = _modelOwnsVisual(icao24); - if (!modelOwnsVisual) { - const floor = cachedGroundFloor(lat, lon); - if (Number.isFinite(floor)) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); - } - } - } - - const position = Cesium.Cartesian3.fromDegrees(lon, lat, renderAltitudeM); - // Landing/takeoff transition: the ground flip restyles IN PLACE. - const groundFlipped = !!prevMeta && (prevMeta.onGround === true) !== onGround; - // Either flip direction retires the model's ground snap: a departing plane - // flies free of it, a landing plane earns a fresh sample where it rolls out. - if (groundFlipped) _groundSnap.forget(icao24); - - // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold - // last-known-good (bounded by the layer's eviction, which deletes the entry). - const stickyType = stickyText(type, prevMeta?.type); - const meta = { - callsign: stickyText(callsign, prevMeta?.callsign), - type: stickyType, - // Type outranks category automatically inside classifyAircraft. - klass: classifyAircraft({ typeCode: stickyType, category: aircraft?.category }), - registration: stickyText(registration, prevMeta?.registration), - operator: stickyText(operator, prevMeta?.operator), - altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitudeFt` — never rename/replace it (labels, - // the FL readout, and the landed-fast-cull heuristic all still read - // altitudeFt/baro). - geoAltitudeM, - renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), - // Analyst seam (additive): sticky like the other kinematics. - verticalRateMps: stickyNumber(verticalRateMps, prevMeta?.verticalRateMps, null), - lastContactEpochMs: stickyNumber( - Number.isFinite(seenSec) ? receiptNowMs - seenSec * 1000 : null, - prevMeta?.lastContactEpochMs, - null, - ), - turnRateDps: prevMeta?.turnRateDps || 0, - onGround, - // Round 7: sticky airborne history (see _likelyLanded). - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for at/below-ellipsoid - // renders (mirror of flights.js). - cullPosition: renderAltitudeM < 10 ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) : null, - // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded - // re-floor sweep keys floors off these (mirror of flights.js). - rawLat: lat, - rawLon: lon, - }; - _flightData.set(icao24, meta); - - const isTracked = icao24 === _trackedIcao; - - // Append to position history stamped with the FEED's fix epoch. - // adsb.lol's seen_pos is the AGE in seconds of the last position - // report, so the fix epoch is receipt time minus that age. Only - // append when the fix actually advances, so stale repeats don't - // create zero-dt segments. - const seenPos = _toFiniteNumber(aircraft?.seen_pos); - const fixEpochMs = receiptNowMs - (Number.isFinite(seenPos) ? seenPos * 1000 : 0); - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); - if (!_positionHistory.has(icao24)) { - _positionHistory.set(icao24, []); - } - const history = _positionHistory.get(icao24); - const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { - // Per-fix kinematics: the fix's own velocity/track ride along so the - // extrapolation paths use the values that BELONG to the fix they - // extend, not whatever the latest poll reported. - history.push({ - time: fixTime, - epochMs: fixEpochMs, - position: position.clone(), - velocity: meta.speedMps, - track: meta.track, - }); - if (history.length > POSITION_HISTORY_LIMIT) { - history.shift(); - } - // Turn rate from the fix-track history — computed once per new fix - // (≤5 samples), consumed by the extrapolation paths at tick rate. - meta.turnRateDps = turnRateFromFixHistory(history); - // Trail accumulation is separate from the 5-fix DR history (PRD F2) - // so the visible trail keeps growing while tracked. Round 2 - // (owner): ground traffic appends too — taxi history stays live - // after touchdown; _appendTrailFix floors grounded positions at - // the surface so the ground leg drapes instead of diving. - if (isTracked) _appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = _modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - const kinematicsChanged = newest.velocity !== meta.speedMps - || newest.track !== meta.track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.speedMps, - track: meta.track, - turnRateDps: meta.turnRateDps, - }); - if (synthetic) { - history.push(synthetic); - if (history.length > POSITION_HISTORY_LIMIT) history.shift(); - meta.turnRateDps = turnRateFromFixHistory(history); - if (isTracked) _appendTrailFix(synthetic.position.clone()); - } - } - } - - if (_billboards.has(icao24)) { - const bb = _billboards.get(icao24); - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - // Ground flips (landing/takeoff) restyle this SAME billboard in - // place — the transition is never a removal. - if (prevMeta?.klass !== meta.klass || groundFlipped || _cockpitContactMode) { - _applyFleetBillboardPresentation(icao24, bb); - } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) _syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - } else { - const bb = _billboardCollection.add({ - position, - image: aircraftIcon(_iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[meta.klass] || 1) * (meta.onGround ? GROUND_SCALE : 1), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, - sizeInMeters: false, - scaleByDistance: _normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: _groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - _billboards.set(icao24, bb); - _applyFleetBillboardPresentation(icao24, bb); - } - - // Takeoff while TRACKED: ground traffic drew no trail, so start one - // from the fresh airborne history (touchdown needs no action — the - // append gate above simply freezes the existing trail). - if (isTracked && groundFlipped && !meta.onGround) _startTrail(icao24); - - // If this is the tracked aircraft, update label text - // (position updates automatically via dead-reckoning CallbackProperty) - if (isTracked && _trackedEntity) { - const info = _flightData.get(icao24); - _updateTrackedLabelModel(icao24); - } - } - - // Field-test fix (RS46): one batch warm of the low-airborne floor cells - // collected above — fire-and-forget, single-flight, results read - // synchronously by NEXT poll's clamp (same contract as flights.js's - // grounded-surface warm). - warmGroundFloor(_floorWarmPoints); - // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — - // a parked contact whose feed went quiet froze at its pre-warm height. - // The model-ownership gate matches the live grounded-clamp path (a - // tileset-snapped model owns its visual; moving its billboard would - // disturb groundSnap's input — the T7 one-shot invariant). - for (const [icao24, info] of _flightData) { - if (!info?.onGround || currentIcaos.has(icao24)) continue; - if (_modelOwnsVisual(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; - const floor = cachedGroundFloor(info.rawLat, info.rawLon); - if (!Number.isFinite(floor)) continue; - const lifted = floor + GROUND_FLOOR_LIFT_M; - if (Number.isFinite(info.renderAltitudeM) && info.renderAltitudeM >= floor - 1) continue; - info.renderAltitudeM = lifted; - info.cullPosition = null; - const liftedPos = Cesium.Cartesian3.fromDegrees(info.rawLon, info.rawLat, lifted); - const hist = _positionHistory.get(icao24); - const newest = hist?.[hist.length - 1]; - if (newest) newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position); - const bbStale = _billboards.get(icao24); - if (bbStale) bbStale.position = liftedPos; - } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding - // this layer's own billboards/models from the probe. - { - const viewerCarto = _viewer?.camera?.positionCartographic || null; - sampleMeshFloorCells(_viewer?.scene, _floorWarmPoints, { - excludeObjects: [..._billboards.values(), ..._models.values(), _trackedModel].filter(Boolean), - viewerLat: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) : undefined, - viewerLon: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : undefined, - }); - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // adsb.lol routinely drops aircraft for a single poll; immediate removal - // made icons blink and yanked the camera off actively tracked flights. - // EXCEPTION: likely-landed planes (last fix low + slow) get only - // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a - // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of _billboards) { - if (currentIcaos.has(icao24)) continue; - const misses = (_missingPolls.get(icao24) || 0) + 1; - const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; - if (misses < limit) { - _missingPolls.set(icao24, misses); - if (icao24 === _trackedIcao && _trackedEntity) { - // Honest readout: the tracked plane has no faded billboard (its - // entity owns the visual), so refresh the label — with icao24 now - // in _missingPolls, _buildTrackedLabel appends the STALE cue so - // last-known altitude/speed aren't presented as live. - _updateTrackedLabelModel(icao24); - } - continue; - } - _missingPolls.delete(icao24); - - // If the tracked flight is truly gone, clear tracking BEFORE deleting - // its state (M3 ordering, keep it): teardown reads the maps this loop - // is about to delete (billboard restore, DR cache reset), and we must - // never leave the camera mid-follow with stale tracking state. The - // camera is then RELEASED IN PLACE — it stays where the follow left - // it, fully free (owner decision 2026-07-02: no overview flyTo). - if (icao24 === _trackedIcao) { - _clearTracking(false, { evicted: true }); - } - - _billboardCollection.remove(bb); - _billboards.delete(icao24); - _releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - _flightData.delete(icao24); - _positionHistory.delete(icao24); - _displayCourse.delete(icao24); - _groundSnap.forget(icao24); - _geoidNCache.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - _lastCamPoseSig = ''; - - // Feed the shared registry so the flights layer can classify/suppress - registerMilitaryIcaos(currentIcaos); - - _count = _billboards.size; - _lastUpdate = receiptNowMs; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'accepted', - ids: currentIcaos, - source: 'adsb.lol', - }; - console.log(`[Data:Military] Updated: ${_count} aircraft`); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Military update aborted', 'AbortError'); - } - console.warn('[Data:Military] Fetch error:', e); - _backoff = true; - _retryAt = Date.now() + ERROR_BACKOFF_INTERVAL; - _lastError = 'adsb.lol network error'; - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - /** - * Tear down the layer: clear tracking, remove handlers, remove the billboard - * collection from the scene, and release all state. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) - _clearTracking(); - _destroyTrail(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - if (_cockpitModeListener) { - window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - _cockpitModeListener = null; - } - unregisterPickOwner('military'); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - _releaseModels(); - if (_billboardCollection) { - viewer.scene.primitives.remove(_billboardCollection); - _billboardCollection = null; - } - if (_modelCollection) { - viewer.scene.primitives.remove(_modelCollection); // removing destroys it + its models - _modelCollection = null; - } - _modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - _modelPending.clear(); - _modelGen.clear(); - if (_preloadModel) { try { _preloadModel.destroy(); } catch { /* gone */ } _preloadModel = null; } - _planeModelLoaded = false; - _billboards.clear(); - _detectionObjects.clear(); - _flightData.clear(); - _positionHistory.clear(); - _displayCourse.clear(); - _groundSnap.clear(); - _missingPolls.clear(); - _count = 0; - _lastUpdate = null; - _cockpitContactMode = false; - _cockpitNearContacts = new Set(); - _cockpitSubjectId = null; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - ids: new Set(), - source: 'adsb.lol', - }; - _resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - _viewer = null; - }, - - /** - * Live layer params. - * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, - * surrounding military aircraft become 3D models once the camera is zoomed in past - * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by - * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). - * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). - * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params - */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - _cancelPendingTrackingRestore(); - } - if (typeof params.models3d === 'boolean' && params.models3d !== _models3dEnabled) { - _models3dEnabled = params.models3d; - if (!_models3dEnabled) { - _releaseModels(); - _syncTracked2dRotation(); - // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked - // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). - if (_billboardCollection) for (const [icao, bb] of _billboards) { if (icao !== _trackedIcao) bb.show = true; } - } - } - if ((params.models3dMode === 'proximity' || params.models3dMode === 'all') && params.models3dMode !== _models3dMode) { - // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. - _models3dMode = params.models3dMode; - if (_cockpitContactMode) { - _refreshCockpitNearContacts(); - _lastFleetTickMs = 0; - } - } - if (typeof params.irBoost === 'boolean' && params.irBoost !== _irBoost) { - _irBoost = params.irBoost; - _reloadModelsForIrBoost(); - // Sprites don't reload with the models — swap the TR-3B glyph between its - // cold and thermal-reactive variants directly (mirror of flights.js). - _refreshTr3bForStyle(); - } - if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - const requested = _normalizeTrackedIcao(params.selectedMilitaryTrackingId); - if (requested === _trackedIcao) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedIcao) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedIcao) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - getParams() { - return { - models3d: _models3dEnabled, - models3dMode: _models3dMode, - irBoost: _irBoost, - selectedMilitaryTrackingId: _trackedIcao, - }; - }, - - /** - * Re-render a contact whose TR-3B conversion just flipped (Easter egg). - * Callers own the registry write; this only re-derives what renders. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when this layer owns the contact. - */ - refreshTr3b(icao24) { return _refreshTr3bContact(icao24); }, - - /** - * Find military aircraft near a given ECEF position, sorted by distance. - * Used by proximity-based features (e.g. detection overlay, spatial queries). - * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates - * @param {number} range - Maximum distance in meters (Infinity if not specified) - * @param {number} [maxCount=50] - Maximum number of results to return - * @param {object} [options] Query membership options. - * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. - * @returns {Array} Sorted array of nearby aircraft descriptors - */ - getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if (!center || !_billboardCollection || !_billboardCollection.show) return []; - - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 50; - const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - - const now = Cesium.JulianDate.now(); - const nearby = []; - - for (const [icao24, bb] of _billboards) { - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so proximity counts - // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. - if (!aircraftIncludedInNearby({ - isTracked, - billboardShown: bb.show, - modelRendering: _modelOwnsVisual(icao24), - includeHidden, - })) continue; - - const trackedPos = isTracked ? _trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; - if (!pos) continue; - - const distance = Cesium.Cartesian3.distance(center, pos); - if (distance > maxRange) continue; - - const info = _flightData.get(icao24); - nearby.push({ - id: info?.callsign?.trim() || info?.registration?.trim() || icao24, - icao24, - position: pos, - distance, - // Filter surface (mirror of flights.js): the cockpit next/previous path - // matches on this field, so it follows the conversion. - aircraftClass: tr3bAircraftClass(icao24, String(info?.klass || info?.type || '').trim().toLowerCase() || null), - track: info?.track ?? null, - // Display type — converted too, so a Contacts row can't still name the - // airframe the triangle replaced (the filter matcher reads this as a - // fallback candidate as well). - type: tr3bTypeLabel(icao24, info?.type || null), - registration: info?.registration || null, - operator: info?.operator || null, - altitudeFt: info?.altitudeFt ?? null, - }); - } - - nearby.sort((a, b) => a.distance - b.distance); - return nearby.slice(0, limit); - }, - - /** - * Return a subset of aircraft suitable for the detection overlay system. - * Uses a deterministic stride-based sampling to keep the count manageable - * while distributing selections evenly across the collection. - * @param {Object} [options={}] - Options - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) - * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!_billboardCollection || !_billboardCollection.show) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _billboards.size; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Deterministic stride: evenly space selections across the billboard map - const stride = Math.max(1, Math.ceil(_billboards.size / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [icao24, bb] of _billboards) { - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (_cockpitContactMode && icao24.toLowerCase() === _cockpitSubjectId) continue; - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so the detection box - // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. - const model = _models.get(icao24); - const modelOwnsVisual = _modelOwnsVisual(icao24); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; - const info = _flightData.get(icao24); - const callsign = info?.callsign?.trim(); - const registration = info?.registration?.trim(); - let object = _detectionObjects.get(icao24); - if (!object) { - object = { - sourceId: icao24, type: 'AIR', tier: 'military', _weldPos: new Cesium.Cartesian3(), - }; - _detectionObjects.set(icao24, object); - } - // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned - // contacts read the translation the fleet tick already wrote into their - // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket - // are co-located anyway. - const spec = modelOwnsVisual ? _modelSpec(info?.klass) : null; - const pos = isTracked - ? (_trackedVisualCached() || bb.position) - : (modelOwnsVisual - ? modelVisualAnchor( - model.modelMatrix, - spec.visualCenterNative, - Number.isFinite(model.computedScale) ? model.computedScale : spec.scale, - object._weldPos || (object._weldPos = new Cesium.Cartesian3()), - ) - : bb.position); - if (!pos) continue; - object.position = pos; - object.skipLabel = isTracked; - const id = callsign || registration || icao24; - if (object.id !== id) object.id = id; - // Card surface: detectionDraw composes its secondary line from - // `[src.klass, src.metric]`, so a converted contact's card must name the - // TR-3B, not the airframe underneath it. - const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); - if (object.klass !== klass) object.klass = klass; - const altitudeFt = info?.altitudeFt ?? 0; - if (object._altitudeFt !== altitudeFt) { - object._altitudeFt = altitudeFt; - // military metadata stores altitudeFt (feet); the helper takes metres - object.metric = formatFlightLevel(altitudeFt * 0.3048); - } - result.push(object); - if (result.length >= maxCount) break; - } - if (_detectionObjects.size > _billboards.size + 512) { - for (const icao24 of _detectionObjects.keys()) { - if (!_billboards.has(icao24)) _detectionObjects.delete(icao24); - } - } - return result; - }, - - /** - * Find a single aircraft by free-text query. - * Match priority: exact icao24 hex, exact callsign, callsign prefix, - * then callsign substring (all case-insensitive, trimmed). - * @param {string} query - ICAO hex or full/partial callsign. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Best match with a cloned, dead-reckoned position, or null if none. - */ - findByQuery(query) { - if (!_flightData || _flightData.size === 0) return null; - const q = String(query || '').trim().toLowerCase(); - if (!q) return null; - - // Registration is searched alongside callsign because it is what the - // operator SEES: a callsign-less contact reads as its tail number on the - // card, in the analyst's answer, and in the Contacts list. Matching only - // callsigns meant "follow 6606" — and the analyst → track_entity handoff - // the tool instructions prescribe — answered "nothing matched" for the - // very identity the app had just shown (owner field session 2026-08-21, - // 23:48: three failed track_entity retries before a fallback stuck). - // Ranking is shared with the flights layer (contactMatch.js) so the two - // cannot disagree, and it is strictly tiered so a registration can never - // out-rank a real callsign on feed order alone. - let best = null; - for (const [icao24, info] of _flightData) { - const candidate = { - tier: rankContactMatch({ - query: q, - hex: icao24, - callsign: info?.callsign, - registration: info?.registration, - }), - id: icao24, - }; - if (!contactMatchWins(candidate, best)) continue; - best = candidate; - if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; - } - return best ? _describeFlight(best.id) : null; - }, - - /** - * Return id/label/position for up to maxCount currently rendered aircraft. - * Cheap snapshot for voice-tool framing — billboard positions only, no - * dead reckoning and no cloning. - * @param {number} [maxCount=500] - Maximum number of entries to return. - * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} - */ - /** - * Whether this layer still carries a contact, in O(1). - * - * Mirror of `flights.hasContact`: presence consumers must not infer absence - * from the capped `getAllPositions` rows. Id matching follows trackById — - * exact key first, then lowercase. - * A disabled layer keeps its records but hides the collection, so it must - * decline rather than answer from data the user can no longer see. - * @param {string} icao24 Contact identifier. - * @returns {boolean|null} Presence, or null when the layer is disabled or - * holds no data and therefore cannot answer. - */ - hasContact(icao24) { - if (!_billboardCollection || !_billboardCollection.show || _billboards.size === 0) return null; - if (!icao24) return false; - const id = String(icao24).trim(); - return _billboards.has(id) || _billboards.has(id.toLowerCase()); - }, - - getAllPositions(maxCount = 500) { - if (!_billboardCollection || _billboards.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 500; - - const result = []; - for (const [icao24, bb] of _billboards) { - const pos = bb.position; - if (!pos) continue; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) continue; - const info = _flightData.get(icao24); - result.push({ - id: icao24, // identity (trackById/Context resolve this) - // Last surface still reading as the raw hex for a callsign-less - // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. - label: _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24, - position: pos, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - }); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Snapshot the layer's in-memory 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 (!_billboardCollection || !_billboardCollection.show || _flightData.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const [icao24, info] of _flightData) { - result.push(mapAnalystRecord(icao24, info)); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Start camera-tracking an aircraft by ICAO hex identifier. - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {boolean} True if the aircraft exists and tracking started. - */ - trackById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if (!_billboards.has(id)) return false; - if (_isExplicitTrackingOrigin(origin)) _cancelPendingTrackingRestore(); - if (_trackedIcao === id) return _publishTrackedSelection(id, origin); - _trackFlight(id, { origin }); - return true; - }, - - /** Resolve a shared Follow target only against the latest accepted refresh. */ - async resolveTrackingRestoreTarget(icao24, { signal = null, origin = 'share-restore' } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = String(icao24 ?? '').trim().toLowerCase(); - if (!id) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - if (outcome.status !== 'accepted') { - return { - status: 'source-unavailable', - reason: 'adsb.lol snapshot unavailable', - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (!outcome.ids.has(id)) { - return { - status: 'missing', - reason: 'target-absent-from-snapshot', - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const followed = this.trackById(id, { origin }); - return followed - ? { status: 'found', refreshEpoch: outcome.epoch, source: outcome.source } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }, - - /** Reapply the canonical follow frame without recreating the selected flight. */ - refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24 || _cockpitContactMode || !_viewer || !_trackedEntity) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if ( - id !== _trackedIcao - || !_viewer.entities?.contains?.(_trackedEntity) - || _viewer.trackedEntity !== _trackedEntity - ) return false; - _trackedCameraFrameStop?.(); - _viewer.camera.cancelFlight(); - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - _publishTrackedSelection(id, origin); - return true; - }, - - /** - * Stop tracking the currently followed aircraft (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Tracked aircraft info, or null when nothing is tracked. - */ - getTrackedInfo() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described) return null; - const { position, ...rest } = described; - return rest; - }, - - /** - * Return the current aircraft as a Context subject without changing - * tracking or camera ownership. - * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} - * Detached subject descriptor, or null when no aircraft is tracked. - */ - getTrackedSubject() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described?.position) return null; - return { - layerId: 'military', - id: described.icao24, - // Same label chain as getNearby/getDetectableObjects: a callsign-less - // contact reads as its registration, never as the raw ICAO hex. - label: described.callsign || described.registration || described.icao24, - position: Cesium.Cartesian3.clone(described.position), - }; - }, - - /** - * Return current layer health/status for the HUD status chip. - * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} - */ - getStats() { - const retryInSec = _retryAt ? Math.max(0, Math.ceil((_retryAt - Date.now()) / 1000)) : 0; - return { - count: _count, - lastUpdate: _lastUpdate, - stale: _backoff, - error: _lastError, - status: _lastStatus, - retryInSec, - source: 'adsb.lol', - fallback: false, - }; - }, -}; - -/** - * Global keydown handler: pressing Escape clears the active aircraft tracking. - * @param {KeyboardEvent} e - The keyboard event - */ -function _onKeyDown(e) { - if (e.key === 'Escape' && _trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -/** - * Install a left-click handler on the scene canvas for aircraft selection. - * Clicking a military billboard starts tracking that aircraft; clicking empty - * space or a non-military object deselects. Also attaches the Escape key - * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ -function _installClickHandler(viewer) { - if (_clickHandler) return; // already installed - - // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching viewer.trackedEntity (the new owner controls it). - if (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (_trackedIcao && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindTrackingClickGesture(_clickHandler, (click, gesture) => { - // Camera drags never select or deselect, even if they finish over a plane. - // Duration alone is allowed through so a stationary long press can still - // select/switch contacts; the destructive empty-space branch below applies - // the full travel + duration click classifier. - if (!isTrackingSelectionGesture(gesture)) return; - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference. Empty globe clicks are inert until the user - // exits with C, Escape, or the dedicated button. - if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking the tracked entity itself -- ignore (don't deselect) - if (picked.id === _trackedEntity) return; - - // Clicking the plane we're ALREADY tracking (its standalone 3D model or - // any pick carrying its icao) — same no-op as the 2D tracked-entity click - // above. H1: the model used to have no pick id, so this fell through to - // "empty space" and deselected the very plane being tracked. - if (_trackedIcao) { - const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; - if (picked.primitive === _trackedModel || rawPick === _trackedIcao) return; - } - - // BillboardCollection picks: CesiumJS may expose the billboard as - // picked.primitive (with .id = icao24) or directly as picked.id - const billboard = picked.primitive; - if (billboard && billboard.id && _billboards.has(billboard.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Fallback: some CesiumJS versions surface the id string at picked.id - if (picked.id && typeof picked.id === 'string' && _billboards.has(picked.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); - return; - } - } - - // A pick that belongs to a sibling layer (commercial flight, satellite, - // vessel, station, CCTV camera…) is not "empty space" — leave tracking - // alone and let that layer handle it. resolvePickId String()-coerces the - // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the - // registry predicates can recognize them (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; - } - - // Clicked empty space -- deselect only for a clean, short click. A slow - // stationary press may select above, but cannot release existing tracking. - if (!isTrackingClickGesture(gesture)) return; - if (_trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }); - - document.addEventListener('keydown', _onKeyDown); -} - +}); +export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; +export const _setTrackedMilitaryRefreshStateForTest = + militaryFlightsLayer.testing._setTrackedMilitaryRefreshStateForTest; +export const _setMilitaryTrackingRefreshOutcomeForTest = + militaryFlightsLayer.testing._setMilitaryTrackingRefreshOutcomeForTest; +export const _addMilitaryTrackingCandidateForTest = + militaryFlightsLayer.testing._addMilitaryTrackingCandidateForTest; +export const _pendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._pendingMilitaryTrackingRestoreForTest; +export const _applyPendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._applyPendingMilitaryTrackingRestoreForTest; +export const _setCockpitDetectionSubjectForTest = + militaryFlightsLayer.testing._setCockpitDetectionSubjectForTest; +export const _trackedModelRegimeActiveForTest = + militaryFlightsLayer.testing._trackedModelRegimeActiveForTest; +export const _updateTrackedModelForTest = + militaryFlightsLayer.testing._updateTrackedModelForTest; +export const _trackedBillboardColorForTest = + militaryFlightsLayer.testing._trackedBillboardColorForTest; +export const _driveFleetModelHandoffForTest = + militaryFlightsLayer.testing._driveFleetModelHandoffForTest; +export const _ensureFleetModelForTest = + militaryFlightsLayer.testing._ensureFleetModelForTest; +export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord; export default militaryFlightsLayer; diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index 0013146d1..af61d8c91 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,681 +1,23 @@ -import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; import { - clearSelectedEntityContextForLayer, - getSelectedEntityContext, - registerEntityContext, - removeEntityContextsForLayer, - selectEntityContext, -} from './contextStore.js'; -import { - cachedGroundFloor, - floorAltitudeM, - resolveGroundFloorCellsBounded, -} from './groundFloor.js'; -// The shared batched/chunked/session-cached DEM warm chain. The module name is -// historical (it shipped with the FIRMS fire anchors); the mechanism itself is -// generic — cold coarse floor cells for a rendered point set, resolved strictly -// sequentially so overlapping renders cannot stack requests on the proxy. -import { warmFireAnchorFloors } from './fireAnchors.js'; -import { normalizeMilitaryInstallations } from './militaryInstallationData.js'; -import { installationFeedback } from './installationFeedback.js'; -import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; + createInstallationsLayer, + createInstallationSource, +} from '../layers/installations/index.js'; +import * as render from '../renderGovernor.js'; +import * as context from './contextStore.js'; +import * as ground from './groundFloor.js'; +import * as anchors from './fireAnchors.js'; +import * as picking from './pickRegistry.js'; -const LAYER_ID = 'military-installations'; -const REQUEST_DEBOUNCE_MS = 500; -const MAX_VIEWPORT_DEGREES = 10; -const MAX_RENDERED = 700; -const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); -const COLOR_BY_CLASS = { - airfield: '#5aa9ff', - naval_base: '#48c7d5', - range: '#d9a85d', - military_land: '#9ca6b0', - places_candidate: '#c58cff', -}; -const EARTH_MEAN_RADIUS_M = 6371008.8; -const DISTANCE_PREFILTER_MARGIN_M = 5000; -const distanceEndpointScratch = new Cesium.Cartographic(); -const distanceGeodesicScratch = new Cesium.EllipsoidGeodesic(); - -/** - * Allocation-free spherical distance used only as a conservative rejection - * pass before the exact ellipsoidal geodesic calculation. - */ -export function approximateSurfaceDistanceM(latitudeARad, longitudeARad, latitudeBDeg, longitudeBDeg) { - const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg); - const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg); - const latitudeDelta = latitudeBRad - latitudeARad; - const longitudeDelta = Math.atan2( - Math.sin(longitudeBRad - longitudeARad), - Math.cos(longitudeBRad - longitudeARad), - ); - const sinLatitude = Math.sin(latitudeDelta / 2); - const sinLongitude = Math.sin(longitudeDelta / 2); - const haversine = sinLatitude * sinLatitude - + Math.cos(latitudeARad) * Math.cos(latitudeBRad) * sinLongitude * sinLongitude; - return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine))); -} - -const state = { - viewer: null, - dataSource: null, - enabled: false, - records: [], - recordById: new Map(), - selectedId: null, - lastUpdate: null, - error: null, - status: 'idle', - stale: false, - /** Whether the upstream truncated at its element cap for the current view. */ - saturated: false, - loading: false, - abort: null, - /** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */ - retryTimer: null, - /** Current backoff step for that retry; 0 = next failure starts at the minimum. */ - retryDelayMs: 0, - retryAt: 0, - failureReason: null, - moveEndRemove: null, - clickHandler: null, - timer: null, - googleSearchRequested: false, -}; - -function colorFor(record) { - return Cesium.Color.fromCssColorString(COLOR_BY_CLASS[record.class] || '#9ca6b0'); -} - -/** - * Classify a Places text-search result without turning a name match into a - * mapped military-land claim. Google currently has no documented military - * Places type, so ordinary results remain visually distinct candidates; the - * explicit branch is retained for any source response that does carry one. - * @param {object} place Google Places result. - * @returns {string|null} Installation class, or null when not authoritative. - */ -export function classifyGoogleMilitaryPlace(place) { - const types = new Set([ - place?.primaryType, - ...(Array.isArray(place?.types) ? place.types : []), - ].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)); - return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type)) - ? 'military_land' - : 'places_candidate'; -} - -/** @param {object} record @returns {string} Human-readable source attribution. */ -export function installationSourceLabel(record) { - const names = [...new Set((Array.isArray(record?.sources) ? record.sources : []) - .map((source) => String(source?.name || '').trim()) - .filter(Boolean))]; - return names.join(' + ') || 'Unknown mapped source'; -} - -/** - * Shared rendered-surface height for an installation anchor or footprint. - * @param {{latitude:number, longitude:number}} record Installation record. - * @returns {number} Ellipsoidal render height in metres. - */ -export function installationSurfaceHeightM(record) { - return floorAltitudeM( - null, - cachedGroundFloor(record?.latitude, record?.longitude), - ) ?? 0; -} - -/** - * Whether a mapped record belongs to the REQUESTED viewport. - * - * The proxy snaps the request bbox outward onto a shared cache grid, so a - * response is a SUPERSET of what was asked for, and rendering that superset - * would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY" - * context claim. What may be tested depends on how much of a feature's geometry - * we actually hold: - * - * - A NODE is a point: its centre IS its whole geometry, so an exact - * containment test is correct and loses nothing. - * - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox - * queries return features that merely INTERSECT the box, so centre-testing - * these would drop large bases whose centre sits just outside. - * - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on - * their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without - * one, so their true extent is unknown here — and Overpass already proved - * they intersect the queried bbox. Centre-testing them would erase exactly - * the biggest installations. The honest cost is slight over-inclusion, - * bounded by one snap cell (~5.5 km) around the viewport. - * - * @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record - * @param {{south:number, west:number, north:number, east:number}} box Requested viewport. - * @returns {boolean} - */ -export function installationWithinViewport(record, box) { - if (!record || !box) return false; - const { latitude, longitude, footprint } = record; - const centreInside = latitude >= box.south && latitude <= box.north - && longitude >= box.west && longitude <= box.east; - if (centreInside) return true; - if (Array.isArray(footprint) && footprint.length) { - let minLat = Infinity; let maxLat = -Infinity; - let minLon = Infinity; let maxLon = -Infinity; - for (const [lon, lat] of footprint) { - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; - if (lon < minLon) minLon = lon; - if (lon > maxLon) maxLon = lon; - } - return maxLat >= box.south && minLat <= box.north - && maxLon >= box.west && minLon <= box.east; - } - // Unknown extent: inclusive. Only a point feature may be excluded on centre. - return record.osmType !== 'node'; -} - -/** - * Whether a response was truncated at the upstream element cap. - * - * The proxy states this outright, but a `saturated`-less payload is NOT - * evidence of a complete answer: entries cached before the saturation guard - * shipped predate the field and live for 30 days. Fall back to deriving it from - * the element count against the cap the payload itself reports. - * @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload - * @returns {boolean} - */ -export function installationResponseSaturated(payload) { - if (typeof payload?.saturated === 'boolean') return payload.saturated; - const cap = Number(payload?.elementCap); - if (!Number.isFinite(cap) || cap <= 0) return false; - return Array.isArray(payload?.elements) && payload.elements.length >= cap; -} - -/** - * Commit a status/error transition and buy the one frame it needs. - * - * With the render governor idle — Contacts has released its hold and nothing - * else animates — no frame would otherwise arrive to re-read this, so a load - * that fails after the scene went quiet would leave the last healthy readout on - * screen indefinitely. - * @param {string} status @param {?string} error - */ -function setInstallationStatus(status, error = null) { - if (state.status === status && state.error === error) return; - state.status = status; - state.error = error; - governorRequestRender('installations-status'); -} - -function viewportBox(viewer) { - const rectangle = viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid); - if (!rectangle) return null; - const south = Cesium.Math.toDegrees(rectangle.south); - const north = Cesium.Math.toDegrees(rectangle.north); - const west = Cesium.Math.toDegrees(rectangle.west); - const east = Cesium.Math.toDegrees(rectangle.east); - // Cross-dateline/global views require a zoom before a bounded request. - if (!Number.isFinite(south + north + west + east) || east <= west || north - south > MAX_VIEWPORT_DEGREES || east - west > MAX_VIEWPORT_DEGREES) return null; - return { south, west, north, east }; -} - -function clearRendered() { - if (state.dataSource?.entities) state.dataSource.entities.removeAll(); - removeEntityContextsForLayer(LAYER_ID); -} - -/** - * The records that get entities this paint: the nearest `MAX_RENDERED`, plus - * the selected one when it falls outside that window. - * - * Context navigation walks the FULL nearby cohort, which is not bounded by the - * render cap, so selecting item 701+ used to produce no entity at all — the - * camera flew, `getById` returned null, and the selection was silently dropped - * on the floor, leaving the Context subject stale so NEXT offered the same - * installation forever. One extra entity keeps every cohort item selectable - * and the cohort count honest. - * @returns {Array} Records to render this paint. - */ -function renderableRecords() { - const rendered = state.records.slice(0, MAX_RENDERED); - if (!state.selectedId) return rendered; - if (rendered.some((record) => record.id === state.selectedId)) return rendered; - const selected = state.recordById.get(state.selectedId); - return selected ? [...rendered, selected] : rendered; -} - -function renderRecords({ claimSelection = false } = {}) { - // Context navigation can select another layer without a canvas click. - // A delayed floor/data repaint must not steal that newer selection back. - const selectedContext = getSelectedEntityContext(); - if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) { - state.selectedId = null; - } - // Post-moveEnd debounced fetches commit after the camera settles; the - // rebuilt entities need one frame in idle mode. (perf wave 2 fix) - governorRequestRender('installations-render'); - clearRendered(); - for (const record of renderableRecords()) { - const color = colorFor(record); - const surfaceHeightM = installationSurfaceHeightM(record); - const displayPosition = Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - surfaceHeightM, - ); - const entity = state.dataSource.entities.add({ - id: record.id, - position: displayPosition, - point: { - pixelSize: record.id === state.selectedId ? 13 : 9, - color: record.id === state.selectedId ? Cesium.Color.WHITE : color, - outlineColor: Cesium.Color.BLACK.withAlpha(0.8), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - polygon: record.footprint ? { - hierarchy: new Cesium.PolygonHierarchy(record.footprint.map(([longitude, latitude]) => Cesium.Cartesian3.fromDegrees(longitude, latitude))), - material: color.withAlpha(0.12), - outline: true, - outlineColor: color.withAlpha(0.65), - height: surfaceHeightM, - } : undefined, - }); - entity.gevTrackedId = `installations:${record.id}`; - entity.gevDisplayPosition = () => displayPosition; - entity.gevLabelModel = { - title: record.name || 'MAPPED INSTALLATION', - details: [String(record.class || 'installation').replaceAll('_', ' ').toUpperCase()], - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }; - registerEntityContext(entity, { - id: record.id, - layerId: LAYER_ID, - layerName: record.kind === 'place_candidate' - ? 'Military Site Search Candidates' - : 'Mapped Military Installations', - source: installationSourceLabel(record), - label: record.name, - latitude: record.latitude, - longitude: record.longitude, - properties: { - class: record.class, - primaryType: record.primaryType || null, - placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], - validation: record.validation, - retrievedAt: record.retrievedAt, - }, - }); - } - const selectedEntity = state.selectedId - ? state.dataSource.entities.getById(state.selectedId) - : null; - if (selectedEntity) selectEntityContext(selectedEntity); - else state.selectedId = null; -} - -/** - * Second paint for floors that missed the bounded pre-render deadline. - * - * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so - * a cold DEM can never hold the dots hostage — but the resolve keeps running - * and lands seconds later, and without this the records it covers stay pinned - * at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest - * 2026-08-18: "orange dots at the bottom"). - * - * This is the render -> warm -> re-render chain FIRMS already uses, with one - * difference the installations path forces: the trigger is whether a cell that - * was COLD AT PAINT TIME is warm now, not whether this particular batch warmed - * it. The bounded resolve above is still running against the same cells, so - * asking "did MY batch warm anything" would answer false exactly when the other - * resolve won the race — the common case. Still terminating: a set that is - * wholly cold afterwards re-renders zero times and the next camera-driven load - * retries. - * @param {Array} records Records just rendered. - * @returns {void} - */ -function warmInstallationFloors(records) { - const cold = records - .filter((record) => cachedGroundFloor(record.latitude, record.longitude) == null) - .map((record) => ({ lat: record.latitude, lon: record.longitude })); - if (!cold.length) return; - warmFireAnchorFloors(cold).then(() => { - if (!state.enabled || !state.dataSource) return; - if (!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)) return; - renderRecords(); - }); -} - -function selectRecord(id) { - const record = state.recordById.get(id); - if (!record || !state.dataSource) return false; - state.selectedId = id; - renderRecords({ claimSelection: true }); - // renderRecords drops selectedId when the record produced no entity. - return state.selectedId === id; -} - -function installInteraction(viewer) { - if (state.clickHandler) return; - state.clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - state.clickHandler.setInputAction((click) => { - if (!state.enabled) return; - const picked = viewer.scene.pick(click.position); - const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; - if (id && state.recordById.has(id) && id !== state.selectedId) { - selectRecord(id); - } else if (state.selectedId) { - // Clicking the selected site again, empty map, or another contact - // releases this layer's selection. Clear only our shared context so a - // sibling click handler's newly selected aircraft/site stays intact. - state.selectedId = null; - clearSelectedEntityContextForLayer(LAYER_ID); - renderRecords(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); -} - -/** - * Backoff progression for the unavailable-state retry: 30 s, doubling to a - * 240 s ceiling. Pure so the progression is pinnable without booting the layer. - */ -export function installationRetryDelayMs(prevDelayMs) { - const RETRY_MIN_MS = 30000; - const RETRY_CEIL_MS = 240000; - if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS; - return Math.min(prevDelayMs * 2, RETRY_CEIL_MS); -} - -/** - * 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only - * on enable and on camera moveEnd, so a parked camera whose first request died - * (one flaky Overpass mirror is enough) stayed unavailable forever while the - * proxy sat healthy while the layer refused to show its features. While the - * layer is enabled and - * unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load, - * zoom-out, or disable cancels it. - */ -function scheduleUnavailableRetry() { - if (!state.enabled) return; - clearTimeout(state.retryTimer); - state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs); - state.retryAt = Date.now() + state.retryDelayMs; - state.retryTimer = setTimeout(() => { - state.retryTimer = null; - state.retryAt = 0; - if (state.enabled && !state.loading) loadInstallations(); - }, state.retryDelayMs); -} - -function clearUnavailableRetry({ resetBackoff = true } = {}) { - clearTimeout(state.retryTimer); - state.retryTimer = null; - state.retryAt = 0; - if (resetBackoff) state.retryDelayMs = 0; -} - -function scheduleLoad() { - if (!state.enabled) return; - // A user-driven load supersedes any pending retry; the load reschedules on - // failure, so the backoff step is kept rather than reset. - clearUnavailableRetry({ resetBackoff: false }); - clearTimeout(state.timer); - state.timer = setTimeout(() => { loadInstallations(); }, REQUEST_DEBOUNCE_MS); -} - -async function loadInstallations() { - if (!state.enabled || !state.viewer) return; - const box = viewportBox(state.viewer); - // Guidance, not a fault: the layer chose not to query because the view is - // unbounded (a global view, or Cockpit looking at the horizon). Keep it out - // of `error` — the manager derives refresh failures and the global status - // chip derives LOAD FAILED from that field, and a "zoom in" prompt turned - // every Cockpit refresh into a reported failure. The row and toast read the - // prompt from `statusMessage` (installationFeedback) instead. - if (!box) { - state.abort?.abort(); - state.abort = null; - state.loading = false; - clearUnavailableRetry(); - setInstallationStatus('zoom-in'); - return; - } - state.abort?.abort(); - const requestAbort = new AbortController(); - state.abort = requestAbort; - state.loading = true; - clearUnavailableRetry({ resetBackoff: false }); - // The previous attempt's failure is not the outcome of this new attempt. - setInstallationStatus('loading'); - try { - const fetchInstallations = async (exact) => { - const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)])); - if (exact) query.set('exact', '1'); - const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal }); - const body = await response.json(); - if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), { - failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable', - }); - return body; - }; - - let payload = await fetchInstallations(false); - // A SATURATED snapped tile was truncated upstream, so features from the - // snap's extra ring may have crowded out sites actually on screen. Re-ask - // for the exact viewport (separately keyed and cached) before rendering. - let saturated = installationResponseSaturated(payload); - if (saturated) { - payload = await fetchInstallations(true); - saturated = installationResponseSaturated(payload); - } - const normalized = normalizeMilitaryInstallations(payload, payload.retrievedAt || new Date().toISOString()); - // The proxy answers a bbox at least as large as the viewport; keep only what - // was actually asked for so nothing off-screen reaches the map or the - // "current viewport only" context claim. - const records = normalized.records.filter((record) => installationWithinViewport(record, box)); - let placesError = null; - if (state.googleSearchRequested) { - state.googleSearchRequested = false; - const latitude = (box.south + box.north) / 2; - const longitude = (box.west + box.east) / 2; - const radiusM = Math.min(50000, Math.max(1000, Math.round(Math.max(box.north - box.south, box.east - box.west) * 55_000))); - try { - const placesResponse = await fetch(`/api/google/text-search?${new URLSearchParams({ - q: 'military installation', lat: latitude.toFixed(5), lon: longitude.toFixed(5), radiusM: String(radiusM), - })}`, { signal: requestAbort.signal }); - const placesPayload = await placesResponse.json(); - if (!placesResponse.ok) throw new Error(placesPayload?.error || `Google Places HTTP ${placesResponse.status}`); - const seen = new Set(records.map((record) => `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`)); - for (const place of Array.isArray(placesPayload?.places) ? placesPayload.places : []) { - if (!place?.id || !place?.name || !Number.isFinite(place.latitude) || !Number.isFinite(place.longitude)) continue; - const placeClass = classifyGoogleMilitaryPlace(place); - const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`; - if (seen.has(signature)) continue; - seen.add(signature); - const retrievedAt = new Date().toISOString(); - records.push({ - id: `google:${place.id}`, - kind: placeClass === 'military_land' ? 'installation' : 'place_candidate', - class: placeClass, - name: String(place.name).trim(), - latitude: place.latitude, - longitude: place.longitude, - footprint: null, - primaryType: place.primaryType || null, - placeTypes: Array.isArray(place.types) ? place.types : [], - sources: [{ name: 'Google Maps Places', id: place.id, retrievedAt }], - validation: 'unreviewed', - retrievedAt, - }); - } - } catch (error) { - if (error?.name === 'AbortError') return; - placesError = 'Google Places search unavailable; showing mapped sites'; - } - } - await resolveGroundFloorCellsBounded(records.map((record) => ({ - lat: record.latitude, - lon: record.longitude, - }))); - if (requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled) return; - state.records = records; - state.recordById = new Map(state.records.map((record) => [record.id, record])); - state.lastUpdate = Date.now(); - state.stale = payload.status === 'stale'; - // Even the exact-viewport retry can saturate in a dense area. Say so rather - // than implying the view is completely surveyed. - state.saturated = saturated; - state.failureReason = null; - clearUnavailableRetry(); - setInstallationStatus( - state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty', - payload.status === 'stale' - ? 'Serving cached mapped context' - : (saturated ? 'Too many mapped sites in view to list them all' : placesError), - ); - renderRecords(); - warmInstallationFloors(state.records); - } catch (error) { - if (error?.name === 'AbortError') return; - state.failureReason = error?.failureReason || 'unavailable'; - setInstallationStatus('unavailable', error?.message || 'Installation context unavailable'); - scheduleUnavailableRetry(); - } finally { - // An older aborted request must not clear a newer request's busy state. - if (state.abort === requestAbort) { - state.abort = null; - state.loading = false; - } - } -} - -const militaryInstallationsLayer = { - id: LAYER_ID, - name: 'Mapped Installations', - icon: '⌖', - source: 'OpenStreetMap + optional Google Maps Places', - updateInterval: 0, - statsRefreshInterval: 1000, - init(viewer) { - state.viewer = viewer; - state.dataSource = new Cesium.CustomDataSource('military-installations'); - viewer.dataSources.add(state.dataSource); - state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad); - installInteraction(viewer); - }, - enable() { - state.enabled = true; - registerPickOwner(LAYER_ID, (id) => state.recordById.has(id)); - state.dataSource.show = true; - // DataLayerManager invokes update() immediately after enable(), which owns - // the first fetch. Avoid racing it with a second aborting request here. - }, - disable() { - state.enabled = false; - unregisterPickOwner(LAYER_ID); - clearUnavailableRetry(); - clearTimeout(state.timer); - state.abort?.abort(); - state.abort = null; - state.loading = false; - if (state.dataSource) state.dataSource.show = false; - clearSelectedEntityContextForLayer(LAYER_ID); - state.selectedId = null; - state.failureReason = null; - setInstallationStatus('idle'); - }, - update() { return loadInstallations(); }, - /** Request a one-shot Google Maps Places search around the current map view. */ - searchNearby() { - state.googleSearchRequested = true; - return loadInstallations(); - }, - destroy(viewer) { - this.disable(); - state.moveEndRemove?.(); - state.clickHandler?.destroy(); - state.clickHandler = null; - clearRendered(); - if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true); - state.dataSource = null; - }, - getNearby(center, rangeM, maxCount = 50) { - if (!center) return []; - const range = Number.isFinite(rangeM) ? rangeM : Infinity; - const centerCartographic = Cesium.Cartographic.fromCartesian(center); - if (!centerCartographic) return []; - const nearby = []; - const approximateLimit = Number.isFinite(range) - ? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M - : Infinity; - for (const record of state.records) { - if (record.kind !== 'installation') continue; - if (approximateSurfaceDistanceM( - centerCartographic.latitude, - centerCartographic.longitude, - record.latitude, - record.longitude, - ) > approximateLimit) continue; - // The awareness disk is projected onto the ground. Confirm candidates - // with an exact ellipsoidal surface distance and reusable scratch state. - distanceEndpointScratch.longitude = Cesium.Math.toRadians(record.longitude); - distanceEndpointScratch.latitude = Cesium.Math.toRadians(record.latitude); - distanceEndpointScratch.height = 0; - distanceGeodesicScratch.setEndPoints(centerCartographic, distanceEndpointScratch); - const distanceM = distanceGeodesicScratch.surfaceDistance; - if (!Number.isFinite(distanceM) || distanceM > range) continue; - nearby.push({ - ...record, - position: Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - installationSurfaceHeightM(record), - ), - distanceM, - }); - } - nearby.sort((a, b) => a.distanceM - b.distanceM); - return nearby.slice(0, Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50); - }, - /** - * Select and frame a mapped installation from another contextual UI. - * @param {string} id Source-backed installation id. - * @returns {boolean} True when an available installation was focused. - */ - focusById(id) { - const record = state.recordById.get(String(id)); - if (!record || !state.viewer) return false; - // No camera flight without a real selection: a flight plus a stale subject - // reads as success to Context navigation and strands NEXT on this item. - if (!selectRecord(record.id)) return false; - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - installationSurfaceHeightM(record), - ), - 18000, - ), - { duration: 1.4 }, - ); - return true; - }, - getStats() { - return { - count: state.records.length, - lastUpdate: state.lastUpdate, - stale: state.stale, - saturated: state.saturated, - error: state.error, - status: state.status, - loading: state.loading, - retryAt: state.retryAt, - retrying: state.loading && Boolean(state.failureReason), - failureReason: state.failureReason, - statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }), - loadingLabel: state.loading ? 'loading mapped installation context' : '', - }; - }, -}; - -export default militaryInstallationsLayer; +const layer = createInstallationsLayer({ + source: createInstallationSource(), + services: { render, context, ground, anchors, picking }, +}); +export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; +export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace; +export const installationSourceLabel = layer.installationSourceLabel; +export const installationSurfaceHeightM = layer.installationSurfaceHeightM; +export const installationWithinViewport = layer.installationWithinViewport; +export const installationResponseSaturated = + layer.installationResponseSaturated; +export const installationRetryDelayMs = layer.installationRetryDelayMs; +export default layer; diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index fff2c6eef..5825ed221 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui import { installationRetryDelayMs } from './militaryInstallations.js'; import fs from 'node:fs'; -const installationsSource = fs.readFileSync( +const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => { @@ -797,10 +798,10 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', test('the retry is wired to every lifecycle edge, not just declared', () => { assert.match(installationsSource, - /setInstallationStatus\('unavailable',[^]*?\);\n\s*scheduleUnavailableRetry\(\);/, + /setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/, 'a failed load schedules the retry immediately after reporting unavailable'); assert.match(installationsSource, - /clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*state\.records\.length/, + /clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/, 'a successful load clears the pending retry and resets the backoff'); assert.match(installationsSource, /clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/, @@ -811,6 +812,6 @@ test('the retry is wired to every lifecycle edge, not just declared', () => { /function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/, 'a user-driven load supersedes the retry without resetting the backoff step'); assert.match(installationsSource, - /state\.enabled && !state\.loading\) loadInstallations\(\)/, + /layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/, 'the fired retry re-checks enablement and never races an in-flight load'); }); diff --git a/src/data/militaryRegistry.js b/src/data/militaryRegistry.js index d5af68fe4..8cd3e1ed9 100644 --- a/src/data/militaryRegistry.js +++ b/src/data/militaryRegistry.js @@ -1,119 +1,11 @@ -/** - * Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix). - * - * adsb.lol /v2/mil tags aircraft via its database's military flag; OpenSky - * carries no such tag, so military aircraft (e.g. ADAPT91/92) appeared in - * BOTH layers as duplicate icons and tracks. This registry reconciles them: - * - * - The military layer, while enabled, feeds every poll's ICAO set here and - * marks itself active — the commercial flights layer then SUPPRESSES its - * duplicates (military layer wins icon, track, and click). - * - While the military layer is OFF, the flights layer keeps a low-rate - * poll (60s against the dev proxy's cached /api/adsblol/mil) so known - * military aircraft are still classified and styled amber. - */ +import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; +import { createAdsbLolSource } from '../sources/live/standalone.js'; -const MIL_POLL_INTERVAL_MS = 60000; - -/** @type {Set} Lowercase ICAO24 hexes known to be military. */ -const _milIcaos = new Set(); -/** @type {boolean} True while the dedicated military layer is enabled. */ -let _militaryLayerActive = false; -/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ -const _activeChangeListeners = new Set(); -/** @type {number} Epoch ms of the last registry refresh (any source). */ -let _lastRefreshMs = 0; -/** @type {boolean} A self-poll fetch is in flight. */ -let _polling = false; - -/** - * True when the dedicated military layer currently renders these aircraft - * (the flights layer should suppress duplicates rather than restyle them). - * @returns {boolean} - */ -export function isMilitaryLayerActive() { - return _militaryLayerActive; -} - -/** - * Marks the dedicated military layer enabled/disabled. On a TRANSITION - * (value actually changed) the registered change listeners fire so the - * flights layer can reconcile its duplicates immediately instead of waiting - * out its 30 s poll (pre-ship audit M2). - * @param {boolean} active - Whether the military layer renders. - * @returns {void} - */ -export function setMilitaryLayerActive(active) { - const next = !!active; - if (next === _militaryLayerActive) return; - _militaryLayerActive = next; - for (const listener of _activeChangeListeners) { - try { - listener(next); - } catch { - // a broken listener must never break the layer toggle - } - } -} - -/** - * Subscribes to military-layer active-state transitions (fired only when the - * value changes, AFTER the new state is committed). - * @param {(active: boolean) => void} listener - Change callback. - * @returns {() => void} Unsubscribe function. - */ -export function onMilitaryLayerActiveChange(listener) { - if (typeof listener !== 'function') return () => {}; - _activeChangeListeners.add(listener); - return () => _activeChangeListeners.delete(listener); -} - -/** - * Replaces/extends the known-military set from a fresh poll. - * Adds only — transient dropouts from one poll must not declassify an - * aircraft mid-session (the set stays small: a few hundred hexes). - * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. - * @returns {void} - */ -export function registerMilitaryIcaos(icaos) { - for (const icao of icaos || []) { - const hex = String(icao || '').trim().toLowerCase(); - if (hex) _milIcaos.add(hex); - } - _lastRefreshMs = Date.now(); -} - -/** - * Whether an aircraft is known military. - * @param {string} icao24 - ICAO24 hex (any case). - * @returns {boolean} - */ -export function isMilitaryIcao(icao24) { - return _milIcaos.has(String(icao24 || '').toLowerCase()); -} - -/** - * Refreshes the registry from /api/adsblol/mil when stale — used by the - * flights layer so classification works while the military layer is off - * (the military layer's own polls keep it fresh otherwise). The dev proxy - * caches upstream responses, so this is nearly free. - * @returns {void} Fire-and-forget; failures leave the current set intact. - */ -export function refreshMilitaryRegistryIfStale() { - if (_militaryLayerActive) return; // military layer's polls keep us fresh - if (_polling || (Date.now() - _lastRefreshMs) < MIL_POLL_INTERVAL_MS) return; - _polling = true; - (async () => { - try { - const response = await fetch('/api/adsblol/mil', { signal: AbortSignal.timeout(10000) }); - if (!response.ok) return; - const data = await response.json(); - const aircraft = Array.isArray(data?.ac) ? data.ac : []; - registerMilitaryIcaos(aircraft.map((entry) => entry?.hex)); - } catch { - // keep the existing set on any failure - } finally { - _polling = false; - } - })(); -} +const registry = createMilitaryRegistry({ source: createAdsbLolSource() }); +export const isMilitaryLayerActive = registry.isMilitaryLayerActive; +export const setMilitaryLayerActive = registry.setMilitaryLayerActive; +export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange; +export const registerMilitaryIcaos = registry.registerMilitaryIcaos; +export const isMilitaryIcao = registry.isMilitaryIcao; +export const refreshMilitaryRegistryIfStale = + registry.refreshMilitaryRegistryIfStale; diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index aa42f1936..d91914805 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // Regression test for the giant-military-jet bug: MODEL_SCALE must put every // rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is. // @@ -358,7 +359,7 @@ function nativeVisualCenter(file) { // --- per-layer constants, read from source (see header) -------------------- function layerConstants(sourceFile) { - const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8'); + const src = readLayerSource(path.join(ROOT, sourceFile)); const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/); assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`); const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/); @@ -367,8 +368,8 @@ function layerConstants(sourceFile) { } function normalBillboardScaleByDistance(sourceFile) { - const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8'); - const fn = src.match(/function _normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); + const src = readLayerSource(path.join(ROOT, sourceFile)); + const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); @@ -391,7 +392,7 @@ const LAYERS = [ name: 'military', source: 'src/data/militaryFlights.js', asset: (() => { - const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8'); + const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/); assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found'); return m[1]; @@ -491,7 +492,7 @@ for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) { // PLANE_* constants — pin them to the measured meter-scale GLB + flights' // calibration so the copies cannot drift. test('military layer airplane.glb constants match the measured GLB and flights calibration', () => { - const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8'); + const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); const grab = (name) => { const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`)); assert.ok(m, `militaryFlights.js: ${name} not found`); diff --git a/src/data/radio.js b/src/data/radio.js index bc7c57f65..bb454344e 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,2876 +1,99 @@ -/** - * Internet-radio station directory and direct-playback layer. - * - * Radio Browser supplies public-domain directory metadata through the local - * `/api/radio/*` broker. Audio always travels directly from the broadcaster - * to one active HTMLAudioElement after an explicit user action; GEV does not - * proxy, cache, record, or redistribute streams. - * - * @module radio - */ -import * as Cesium from 'cesium'; -import { cachedGroundFloor, warmGroundFloor } from './groundFloor.js'; -import { normalizeRadioCountryInput } from './radioCountry.js'; -import { normalizeRadioFilter } from './layerState.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { - isOwnedByOtherLayer, - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { - earthDiscScreenRadius, - getKeyholeGeometry, - GLOBE_ENTER_CLEARANCE_PX, - isFullGlobeInsideKeyhole, - projectEarthDiscToViewport, -} from '../celestialRing.js'; -import { governorRequestRender } from '../renderGovernor.js'; +import { createRadioSource } from '../layers/radio/source.js'; +import { createRadioLayer } from '../layers/radio/index.js'; +import * as ground from './groundFloor.js'; +import * as picking from './pickRegistry.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as globe from '../celestialRing.js'; +import * as render from '../renderGovernor.js'; -const RADIO_PREFIX = 'radio:'; -const DIRECTORY_ENDPOINT = '/api/radio/stations'; -const HORIZON_TICK_MS = 250; -const HORIZON_CAMERA_MOVE_EPSILON_M = 1; -const MARKER_LIFT_M = 2.5; -const SELECTED_LIFT_M = 5; -const RADIO_PICK_TOLERANCE_PX = 8; -const RADIO_TUNER_DIRECTORY_LIMIT = 750; -const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT; -const RADIO_TUNER_STATIC_MAX_GAIN = 0.018; -const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000; -const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; -const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; -const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export const RADIO_OVERLAY_SOURCE_ID = 'radio'; -export const RADIO_OVERLAY_COHORT_LIMIT = 64; -export const RADIO_SINGLETON_GLOBAL_LIMIT = 16; -export const RADIO_SINGLETON_MID_LIMIT = 32; -export const RADIO_SINGLETON_NEAR_LIMIT = 48; -export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000; -export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000; -export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: RADIO_OVERLAY_COHORT_LIMIT, - collisionCapacity: 96, - moving: false, +const layer = createRadioLayer({ + source: createRadioSource(), + services: { ground, picking, overlays, globe, render }, }); -const RADIO_PICK_OFFSETS = Object.freeze([ - [0, -RADIO_PICK_TOLERANCE_PX], - [RADIO_PICK_TOLERANCE_PX, 0], - [0, RADIO_PICK_TOLERANCE_PX], - [-RADIO_PICK_TOLERANCE_PX, 0], - [6, -6], - [6, 6], - [-6, 6], - [-6, -6], -]); -const _radioEarthScreenCenter = new Cesium.Cartesian2(); -const _radioEarthToCenter = new Cesium.Cartesian3(); -export const DEFAULT_RADIO_FILTER = 'all'; -export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; - -const MUSIC_GENRES = Object.freeze([ - ['alternative', 'Alternative'], - ['ambient', 'Ambient'], - ['blues', 'Blues'], - ['classical', 'Classical'], - ['country', 'Country'], - ['dance', 'Dance'], - ['electronic', 'Electronic'], - ['folk', 'Folk'], - ['funk', 'Funk'], - ['hip hop', 'Hip-Hop'], - ['house', 'House'], - ['indie', 'Indie'], - ['jazz', 'Jazz'], - ['latin', 'Latin'], - ['metal', 'Metal'], - ['oldies', 'Oldies'], - ['pop', 'Pop'], - ['punk', 'Punk'], - ['r&b', 'R&B'], - ['reggae', 'Reggae'], - ['rock', 'Rock'], - ['soul', 'Soul'], - ['techno', 'Techno'], - ['trance', 'Trance'], - ['world', 'World'], -]); - -const CATEGORY_MATCHERS = Object.freeze({ - news: ['news', 'current affairs', 'journalism'], - talk: ['talk', 'spoken word', 'interview', 'podcast'], - weather: ['weather', 'emergency', 'noaa'], - 'public-safety': ['public safety', 'scanner', 'police', 'fire', 'ems', 'dispatch', 'emergency'], - 'aviation-marine': ['aviation', 'air traffic', 'atc', 'airport', 'marine', 'maritime', 'coast guard'], - 'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'], -}); - -const RADIO_CATEGORY_COLORS = Object.freeze({ - all: '#b9fbff', - news: '#44adff', - talk: '#f2b84b', - weather: '#ff5c78', - 'public-safety': '#ff8b4a', - 'aviation-marine': '#a87cff', - 'traffic-transit': '#ffd166', - music: '#54d17a', - other: '#9aa7b3', -}); - -const RADIO_CLUSTER_LABELS = Object.freeze({ - news: 'NEWS', - talk: 'TALK', - weather: 'WEATHER', - 'public-safety': 'SAFETY', - 'aviation-marine': 'AIR / SEA', - 'traffic-transit': 'TRANSIT', - music: 'MUSIC', - other: 'OTHER', -}); - -const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([ - 'news', - 'public-safety', - 'weather', - 'aviation-marine', - 'traffic-transit', - 'talk', - 'music', -]); -const DEFAULT_RADIO_VOLUME = 0.8; -const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({ - instance: null, - generation: null, - updatedAt: null, - stations: Object.freeze([]), - stationIds: Object.freeze([]), -}); - -let _viewer = null; -let _dataSource = null; -let _enabled = false; -let _managerLifecyclePresentation = null; -let _loading = false; -let _stale = false; -let _degraded = false; -let _error = null; -let _updatedAt = null; -let _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; -let _stations = []; -let _stationById = new Map(); -let _categories = []; -let _renderById = new Map(); -let _filter = DEFAULT_RADIO_FILTER; -let _selectedId = null; -let _selectedEntity = null; -let _selectionGeneration = 0; -let _selectionTimer = null; -let _radioCameraNavigationGeneration = 0; -let _radioCameraFlightSequence = 0; -let _activeRadioCameraFlight = null; -let _audio = null; -let _audioStationId = null; -let _audioState = 'stopped'; -let _audioError = null; -let _userVolume = DEFAULT_RADIO_VOLUME; -let _tuningActive = false; -let _tuningStatic = false; -let _tuningAwaitingStationId = null; -let _tuningPreviewId = null; -let _tuningStartStationId = null; -let _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; -let _tuningStationById = new Map(); -let _tuningUnavailableStationId = null; -let _cancelledTuningPresentationStation = null; -let _tuningCameraNavigation = null; -let _tuningNoiseContext = null; -let _tuningNoiseSource = null; -let _tuningNoiseFilter = null; -let _tuningNoiseGain = null; -let _voiceDucked = false; -let _voiceRestoring = false; -let _voiceRestoreTimer = null; -let _volumeFadeFrame = null; -let _volumeTransitionGeneration = 0; -let _playGeneration = 0; -let _playAttemptSequence = 0; -let _activePlaybackAttempt = null; -let _playFallbackId = null; -let _playFallbackFocus = null; -let _playFallbackOrigin = 'programmatic'; -let _playFallbackAttemptId = null; -let _clickHandler = null; -let _horizonTimer = null; -let _lastHorizonCameraPosition = null; -let _horizonScanCount = 0; -let _abortController = null; -let _requestGeneration = 0; -let _sessionGeneration = 0; -let _removeClusterListener = null; -let _overlayPublishTimer = null; -let _clusterOverlayIdentitySequence = 0; -let _clusterOverlayIdentities = []; -let _overlayDiagnostics = { - entryCount: 0, - selectedCount: 0, - singletonTexts: [], - singletonIds: [], - clusterTexts: [], - clusterIds: [], - clusterMemberships: [], -}; -const _listeners = new Set(); -const _playbackControlListeners = new Set(); -const VOICE_RESTORE_DELAY_MS = 650; -const VOICE_RESTORE_DURATION_MS = 1800; - -function isNonGlobalRadioIpv4(hostname) { - const pieces = hostname.split('.'); - if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) return false; - const values = pieces.map(Number); - if (values.some((value) => value > 255)) return true; - const [a, b, c] = values; - return a === 0 || a === 10 || a === 127 || a >= 224 - || (a === 100 && b >= 64 && b <= 127) - || (a === 169 && b === 254) - || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && b === 0) - || (a === 192 && b === 88 && c === 99) - || (a === 192 && b === 168) - || (a === 198 && (b === 18 || b === 19)) - || (a === 198 && b === 51 && c === 100) - || (a === 203 && b === 0 && c === 113); -} - -function isSafeRadioHttpsUrl(value) { - if (typeof value !== 'string' || !value) return false; - try { - const url = new URL(value); - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); - if (url.protocol !== 'https:' || url.username || url.password || !hostname) return false; - return !( - hostname === 'localhost' - || hostname.endsWith('.localhost') - || hostname.endsWith('.local') - || isNonGlobalRadioIpv4(hostname) - || hostname.includes(':') - ); - } catch { - return false; - } -} - -function isValidRadioDirectoryStation(station) { - const cleanText = (value, maxLength, { allowEmpty = true } = {}) => ( - typeof value === 'string' - && value.length <= maxLength - && (allowEmpty || value.trim().length > 0) - && !/[\u0000-\u001f\u007f]/.test(value) - && value === value.trim() - && !/\s{2,}/.test(value) - ); - const textArray = (value, limit, itemMaxLength) => ( - Array.isArray(value) - && value.length <= limit - && value.every((item) => cleanText(item, itemMaxLength, { allowEmpty: false })) - ); - return Boolean( - station - && RADIO_UUID_RE.test(station.id) - && cleanText(station.name, 140, { allowEmpty: false }) - && Number.isFinite(station.lat) && station.lat >= -90 && station.lat <= 90 - && Number.isFinite(station.lon) && station.lon >= -180 && station.lon <= 180 - && isSafeRadioHttpsUrl(station.streamUrl) - && (station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) - && textArray(station.tags, 24, 80) - && textArray(station.languages, 8, 40) - && cleanText(station.state, 80) - && cleanText(station.country, 80) - && cleanText(station.countryCode, 2) - && (station.countryCode === '' || normalizeRadioCountryInput(station.countryCode).valid) - && station.metadataTrust === 'untrusted-community' - && cleanText(station.codec, 16, { allowEmpty: false }) - && /^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) - && (station.bitrate === null - || (Number.isInteger(station.bitrate) && station.bitrate >= 8 && station.bitrate <= 1024)) - ); -} - -function freezeRadioStation(station) { - return Object.freeze({ - id: station.id, - name: station.name, - lat: station.lat, - lon: station.lon, - streamUrl: station.streamUrl, - homepage: station.homepage, - tags: Object.freeze([...station.tags]), - languages: Object.freeze([...station.languages]), - state: station.state, - country: station.country, - countryCode: station.countryCode, - metadataTrust: station.metadataTrust, - codec: station.codec, - bitrate: station.bitrate, - }); -} - -function createAcceptedCatalogSnapshot(instance, generation, updatedAt, stations) { - if (!Number.isSafeInteger(generation) || generation < 1) return null; - if (typeof instance !== 'string' || !instance) return null; - const frozenStations = Object.freeze(stations.map(freezeRadioStation)); - return Object.freeze({ - instance, - generation, - updatedAt, - stations: frozenStations, - stationIds: Object.freeze(frozenStations.map((station) => station.id)), - }); -} - -const RADIO_GLOBE_LABEL_MAX_CHARS = 30; -const RADIO_LABEL_SEGMENTER = typeof Intl?.Segmenter === 'function' - ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) - : null; - -function compactRadioLabelText(value, maxChars) { - const text = String(value || '').replace(/\s+/g, ' ').trim(); - const graphemes = RADIO_LABEL_SEGMENTER - ? [...RADIO_LABEL_SEGMENTER.segment(text)].map((segment) => segment.segment) - : Array.from(text); - if (graphemes.length <= maxChars) return text; - return `${graphemes.slice(0, Math.max(1, maxChars - 1)).join('').trimEnd()}…`; -} - -function cleanRadioLabelName(value) { - return String(value || '') - .replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '') - .replace(/\s+/g, ' ') - .replace(/\s+\([^()]{1,40}\)$/, '') - .replace(/\s+FM$/i, '') - .trim(); -} - -/** Return compact frequency-first text for a Radio globe label. */ -export function radioGlobeLabel(station) { - const fullName = String(station?.name || '').replace(/\s+/g, ' ').trim(); - if (!fullName) return ''; - - let frequency = null; - let labelName = ''; - const explicitMatches = [...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi)]; - const explicit = explicitMatches.find((match) => { - const value = Number(match[1]); - return value >= 64 && value <= 108; - }); - if (explicit) { - frequency = explicit[1]; - const before = cleanRadioLabelName(fullName.slice(0, explicit.index)); - const after = cleanRadioLabelName(fullName.slice(explicit.index + explicit[0].length)); - labelName = before || after; - } else { - // Infer only a leading decimal in the conventional FM band. Integers such - // as "80's" or "100 GREATEST" and domains such as "1.fm" stay names. - const leading = fullName.match(/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/); - const value = Number(leading?.[1]); - if (leading && value >= 87.5 && value <= 108) { - frequency = leading[1]; - labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]); - } - } - - if (!frequency) return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS); - const prefix = `${frequency} FM`; - if (!labelName) return prefix; - const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3; - return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`; -} - -/** Build the protected selected-station text published through WorldOverlay. */ -export function createRadioSelectedOverlayEntry(station, position) { - if (!station?.id || !station?.name || !position) return null; - return { - id: `selected:${station.id}`, - position, - variant: 'selected', - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-label', - priority: Number.MAX_SAFE_INTEGER, - title: radioGlobeLabel(station), - accent: radioCategoryColor(radioStationCategoryId(station)), - interactive: false, - anchorRadiusPx: 20, - minAnchorGapPx: 8, - verticalOnly: true, - placement: 'above', - gapPx: 8, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - }; -} - -/** Build one bounded ambient cluster badge for the shared overlay host. */ -export function createRadioClusterOverlayEntry({ id, position, text, accent, stationCount }) { - if (!id || !position || !text) return null; - return { - id: `cluster:${id}`, - position, - variant: 'label', - paintLane: 'ambient-label', - collisionGroup: 'ambient-label', - priority: Math.max(1, Number(stationCount) || 1), - title: text, - accent: accent || RADIO_CATEGORY_COLORS.other, - interactive: false, - anchorRadiusPx: Math.min(13, 6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8), - minAnchorGapPx: 4, - verticalOnly: true, - placement: 'above', - gapPx: 6, - // Cluster membership/counts update continuously while the camera moves. - // Paint them as hard-opacity incumbents so a replacement count never - // cross-fades with its predecessor or dims merely for leaving the keyhole. - // Collision, viewport rejection, and horizon culling remain host-owned. - stateless: true, - edgeFade: 'none', - horizonCull: true, - terrainOcclusion: false, - // The camera can sit above 24,000 km in the supported full-globe view, and - // horizon clusters are farther from it than the camera's surface altitude. - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - distanceScale: { - near: 100_000, - nearValue: 1.08, - far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - farValue: 0.92, - }, - }; -} - -/** Build one ambient station label while Cesium retains its point and picking. */ -export function createRadioSingletonOverlayEntry({ station, position, priority = 1 }) { - if (!station?.id || !station?.name || !position) return null; - return { - id: `station:${station.id}`, - position, - variant: 'label', - paintLane: 'ambient-label', - collisionGroup: 'ambient-label', - priority: Math.max(1, Number(priority) || 1), - title: radioGlobeLabel(station), - accent: radioCategoryColor(radioStationCategoryId(station)), - interactive: false, - anchorRadiusPx: 9, - minAnchorGapPx: 4, - verticalOnly: true, - placement: 'above', - gapPx: 5, - // Camera/filter changes replace this bounded cohort. Prevent the prior - // cohort from fading over the current visible points. - stateless: true, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - distanceScale: { - near: 100_000, - nearValue: 1, - far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - farValue: 0.86, - }, - }; -} - -/** Return the bounded singleton-label allowance for the current camera scale. */ -export function radioSingletonLabelLimit(cameraHeightM) { - const height = Math.max(0, Number(cameraHeightM) || 0); - if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT; - if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT; - return RADIO_SINGLETON_NEAR_LIMIT; -} - -/** Rank visible singleton stations by camera distance and stable station id. */ -export function selectRadioSingletonCandidates(candidates, limit = RADIO_SINGLETON_NEAR_LIMIT) { - const distance = (candidate) => { - const value = Number(candidate?.distanceM); - return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY; - }; - return [...(candidates || [])] - .sort((a, b) => ( - distance(a) - distance(b) - || String(a?.station?.id || a?.id || '').localeCompare(String(b?.station?.id || b?.id || '')) - )) - .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); -} - -/** Rank and cap ambient Radio clusters before shared-host entry allocation. */ -export function selectRadioClusterCandidates(candidates, limit = RADIO_OVERLAY_COHORT_LIMIT) { - return [...(candidates || [])] - .sort((a, b) => ( - (Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) - || String(a?.id || '').localeCompare(String(b?.id || '')) - )) - .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); -} - -/** - * Preserve one-to-one overlay identity for overlapping clusters. - * Cesium clustering is screen-space, so a small camera move may alter exact - * membership even when the same geographic cluster remains visible. Every - * positive overlap participates in greatest-contributor discovery, while - * inheritance remains mutual-best: a current cluster accepts only one of its - * greatest contributors and a prior identity transfers only to one of its - * strongest split children. - */ -export function reconcileRadioClusterCandidates(candidates, previous = [], createId = null) { - const current = (Array.isArray(candidates) ? candidates : []).map((candidate, index) => { - const stationIds = [...new Set((candidate?.stationIds || []).map((id) => String(id)))].sort(); - const membershipId = String(candidate?.id || `membership:${stationIds.join('|')}`); - return { - ...candidate, - _reconcileIndex: index, - _reconcileMembershipId: membershipId, - _reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`, - stationIds, - }; - }); - const priorMembershipsByIdentity = new Map(); - for (const candidate of Array.isArray(previous) ? previous : []) { - const identityId = String(candidate?.identityId || candidate?.id || ''); - if (!identityId) continue; - if (!priorMembershipsByIdentity.has(identityId)) { - priorMembershipsByIdentity.set(identityId, new Set()); - } - const membership = priorMembershipsByIdentity.get(identityId); - for (const stationId of candidate?.stationIds || []) membership.add(String(stationId)); - } - const prior = [...priorMembershipsByIdentity] - .map(([identityId, stationIds]) => ({ - identityId, - stationIds: [...stationIds].sort(), - })) - .filter((candidate) => candidate.stationIds.length) - .sort((a, b) => a.identityId.localeCompare(b.identityId)); - const priorByStation = new Map(); - for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) { - for (const stationId of prior[priorIndex].stationIds) { - if (!priorByStation.has(stationId)) priorByStation.set(stationId, []); - priorByStation.get(stationId).push(priorIndex); - } - } - - const edges = []; - const greatestOverlapByCurrent = new Map(); - const greatestOverlapByPrior = new Map(); - for (let currentIndex = 0; currentIndex < current.length; currentIndex += 1) { - const overlapByPrior = new Map(); - for (const stationId of current[currentIndex].stationIds) { - for (const priorIndex of priorByStation.get(stationId) || []) { - overlapByPrior.set(priorIndex, (overlapByPrior.get(priorIndex) || 0) + 1); - } - } - for (const [priorIndex, overlap] of overlapByPrior) { - const union = current[currentIndex].stationIds.length + prior[priorIndex].stationIds.length - overlap; - const smaller = Math.min(current[currentIndex].stationIds.length, prior[priorIndex].stationIds.length); - const score = smaller > 0 ? overlap / smaller : 0; - const similarity = union > 0 ? overlap / union : 0; - edges.push({ currentIndex, priorIndex, overlap, score, similarity }); - greatestOverlapByCurrent.set( - currentIndex, - Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap), - ); - greatestOverlapByPrior.set( - priorIndex, - Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap), - ); - } - } - const eligibleEdges = edges.filter((edge) => ( - edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) - && edge.overlap === greatestOverlapByPrior.get(edge.priorIndex) - )); - eligibleEdges.sort((a, b) => ( - // Identity follows the contributor representing the largest share of the - // new cluster. Overlap-coefficient-first incorrectly lets a fully retained - // two-station minority beat a larger partial contributor during a merge. - b.overlap - a.overlap - || b.score - a.score - || b.similarity - a.similarity - || current[a.currentIndex]._reconcileCanonicalKey - .localeCompare(current[b.currentIndex]._reconcileCanonicalKey) - || prior[a.priorIndex].identityId.localeCompare(prior[b.priorIndex].identityId) - )); - - const inheritedByCurrent = new Map(); - const usedPriorIdentities = new Set(); - for (const edge of eligibleEdges) { - const identityId = prior[edge.priorIndex].identityId; - if (inheritedByCurrent.has(edge.currentIndex) || usedPriorIdentities.has(identityId)) continue; - inheritedByCurrent.set(edge.currentIndex, prior[edge.priorIndex].identityId); - usedPriorIdentities.add(identityId); - } - - const generatedByCurrent = new Map(); - if (typeof createId === 'function') { - const freshIndices = current - .map((candidate, index) => ({ candidate, index })) - .filter(({ index }) => !inheritedByCurrent.has(index)) - .sort((a, b) => ( - a.candidate._reconcileCanonicalKey.localeCompare(b.candidate._reconcileCanonicalKey) - )); - for (const { candidate, index } of freshIndices) { - generatedByCurrent.set(index, String(createId(candidate, index) || '')); - } - } - - return current.map((candidate, index) => { - const membershipId = candidate._reconcileMembershipId; - const inherited = inheritedByCurrent.get(index); - const generated = generatedByCurrent.get(index) || ''; - const identityId = inherited || generated || membershipId; - const { - _reconcileIndex, - _reconcileMembershipId, - _reconcileCanonicalKey, - ...rest - } = candidate; - return { ...rest, membershipId, identityId, id: identityId }; - }); -} - -function resetRadioClusterOverlayIdentities() { - _clusterOverlayIdentities = []; -} - -function emptyRadioOverlayDiagnostics() { - return { - entryCount: 0, - selectedCount: 0, - singletonTexts: [], - singletonIds: [], - clusterTexts: [], - clusterIds: [], - clusterMemberships: [], - }; -} - -function clampRadioVolume(value) { - return Math.min(1, Math.max(0, Number(value) || 0)); -} - -/** Return whether camera translation materially changes horizon visibility. */ -export function radioCameraPositionChanged(previous, current, epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M) { - if (!previous || !current) return true; - const dx = Number(current.x) - Number(previous.x); - const dy = Number(current.y) - Number(previous.y); - const dz = Number(current.z) - Number(previous.z); - if (![dx, dy, dz].every(Number.isFinite)) return true; - const threshold = Math.max(0, Number(epsilonM) || 0); - return dx * dx + dy * dy + dz * dz > threshold * threshold; -} - -function cancelRadioVolumeTransition() { - _volumeTransitionGeneration += 1; - if (_voiceRestoreTimer) clearTimeout(_voiceRestoreTimer); - _voiceRestoreTimer = null; - if (_volumeFadeFrame) { - if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(_volumeFadeFrame); - else clearTimeout(_volumeFadeFrame); - } - _volumeFadeFrame = null; -} - -function scheduleVolumeFrame(callback) { - if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback); - return setTimeout(() => callback(Date.now()), 16); -} - -function volumeClock() { - return typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); -} - -/** Normalize one directory tag to a stable, lower-case display token. */ -export function normalizeRadioTag(value) { - return String(value ?? '') - .trim() - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .slice(0, 80); -} - -function stationTags(station) { - if (Array.isArray(station?.tags)) return station.tags.map(normalizeRadioTag).filter(Boolean); - return String(station?.tags ?? '') - .split(',') - .map(normalizeRadioTag) - .filter(Boolean); -} - -function hasTag(station, needles) { - const tags = stationTags(station); - return needles.some((needle) => tags.some((tag) => tag === needle || tag.includes(needle))); -} - -function detectedGenres(station) { - return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(([genre]) => genre); -} - -/** Return whether a station belongs in a station-tag category. */ -export function stationMatchesRadioCategory(station, categoryId) { - if (categoryId === 'all') return true; - if (categoryId.startsWith('genre:')) { - return detectedGenres(station).includes(categoryId.slice('genre:'.length)); - } - if (categoryId === 'music') { - return detectedGenres(station).length > 0 - || hasTag(station, ['music', 'hits', 'songs']); - } - if (categoryId === 'other') { - return !Object.entries(CATEGORY_MATCHERS).some(([id]) => stationMatchesRadioCategory(station, id)) - && !stationMatchesRadioCategory(station, 'music'); - } - return hasTag(station, CATEGORY_MATCHERS[categoryId] || []); -} - -/** Return the shared CSS color for a canonical or detected-genre category. */ -export function radioCategoryColor(categoryId = 'other') { - const normalized = String(categoryId || 'other'); - const canonical = normalized.startsWith('genre:') ? 'music' : normalized; - return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other; -} - -/** Format the concise count/category badge shown above a Radio cluster. */ -export function radioClusterBadgeText(categoryId = 'other', count = 0) { - const normalized = String(categoryId || 'other'); - const canonical = normalized.startsWith('genre:') ? 'music' : normalized; - const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other; - const stationCount = Math.max(0, Math.floor(Number(count) || 0)); - return `${stationCount} ${label}`; -} - -/** Choose the category advertised by a cluster in the active station-tag view. */ -export function radioClusterCategoryId(stations, activeFilter = 'all') { - const filter = String(activeFilter || 'all'); - if (filter !== 'all') { - if (filter.startsWith('genre:') || RADIO_MARKER_CATEGORY_ORDER.includes(filter) || filter === 'other') { - return filter; - } - return 'other'; - } - const categoryCounts = new Map(); - for (const station of Array.isArray(stations) ? stations : []) { - const categoryId = radioStationCategoryId(station); - categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1); - } - let clusterCategory = 'other'; - let clusterCategoryCount = 0; - for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) { - const count = categoryCounts.get(categoryId) || 0; - if (count > clusterCategoryCount) { - clusterCategory = categoryId; - clusterCategoryCount = count; - } - } - if ((categoryCounts.get('other') || 0) > clusterCategoryCount) return 'other'; - return clusterCategory; -} - -/** Build the code-native four-corner bracket used by the selected station. */ -export function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) { - const stroke = /^#[0-9a-f]{6}$/i.test(String(color)) ? String(color) : RADIO_CATEGORY_COLORS.other; - return ``; -} - -/** Choose one stable display category for a station that may match several filters. */ -export function radioStationCategoryId(station) { - return RADIO_MARKER_CATEGORY_ORDER.find((categoryId) => ( - stationMatchesRadioCategory(station, categoryId) - )) || 'other'; -} - -/** Build canonical and detected-genre categories from station-level tags. */ -export function buildRadioCategories(stations) { - const rows = Array.isArray(stations) ? stations : []; - const categories = [ - { id: 'all', label: 'All' }, - { id: 'news', label: 'News' }, - { id: 'talk', label: 'Talk' }, - { id: 'weather', label: 'Weather / Emergency' }, - { id: 'public-safety', label: 'Public Safety' }, - { id: 'aviation-marine', label: 'Aviation / Marine' }, - { id: 'traffic-transit', label: 'Traffic / Transit' }, - { id: 'music', label: 'Music' }, - ]; - - for (const [genre, label] of MUSIC_GENRES) { - const id = `genre:${genre}`; - if (rows.some((station) => stationMatchesRadioCategory(station, id))) { - categories.push({ id, label }); - } - } - categories.push({ id: 'other', label: 'Other' }); - return categories.map((category) => ({ - ...category, - color: radioCategoryColor(category.id), - count: rows.filter((station) => stationMatchesRadioCategory(station, category.id)).length, - })); -} - -/** Filter stations without changing the active stream or selection. */ -export function filterRadioStations(stations, categoryId = 'all') { - return (Array.isArray(stations) ? stations : []) - .filter((station) => stationMatchesRadioCategory(station, categoryId)); -} - -/** Return whether Radio Browser metadata identifies a station as English-language. */ -export function isEnglishRadioStation(station) { - const languages = Array.isArray(station?.languages) ? station.languages : []; - return languages.some((language) => { - const normalized = normalizeRadioTag(language); - return normalized === 'en' || normalized === 'eng' || normalized.startsWith('english'); - }); -} - -function radioAngularDistance(station, anchor) { - const lat1 = Cesium.Math.toRadians(Number(anchor?.lat)); - const lon1 = Cesium.Math.toRadians(Number(anchor?.lon)); - const lat2 = Cesium.Math.toRadians(Number(station?.lat)); - const lon2 = Cesium.Math.toRadians(Number(station?.lon)); - if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return Number.POSITIVE_INFINITY; - const deltaLat = lat2 - lat1; - const deltaLon = lon2 - lon1; - const haversine = Math.sin(deltaLat / 2) ** 2 - + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2; - return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine)))); -} - -/** Rank a copied station list by viewport distance, with an optional English-first tier. */ -export function rankRadioStationsForViewport(stations, anchor, { preferEnglish = false } = {}) { - return (Array.isArray(stations) ? stations : []) - .map((station, index) => ({ - station, - index, - distance: radioAngularDistance(station, anchor), - languageTier: preferEnglish && !isEnglishRadioStation(station) ? 1 : 0, - })) - .sort((a, b) => a.languageTier - b.languageTier || a.distance - b.distance || a.index - b.index) - .map(({ station }) => station); -} - -/** Rank stations for an explicit voice/player request without moving the camera. */ -export function rankRadioStationsForRequest(stations, { - categoryId = 'all', - anchor = null, - country = '', - stationQuery = '', -} = {}) { - const countryFilter = normalizeRadioCountryInput(country); - if (!countryFilter.valid) return []; - const query = normalizeRadioTag(stationQuery); - let matches = filterRadioStations(stations, categoryId); - if (countryFilter.code || countryFilter.name) { - matches = matches.filter((station) => { - const stationCode = String(station?.countryCode || '').trim().toUpperCase(); - const stationCountry = normalizeRadioCountryInput(station?.country); - return (countryFilter.code && stationCode === countryFilter.code) - || (countryFilter.code && stationCountry.valid && stationCountry.code === countryFilter.code); - }); - } - if (query) { - matches = matches.filter((station) => [ - station?.id, - station?.name, - station?.state, - station?.country, - station?.countryCode, - ...(Array.isArray(station?.tags) ? station.tags : []), - ].some((value) => normalizeRadioTag(value).includes(query))); - } - return anchor - ? rankRadioStationsForViewport(matches, anchor) - : matches.slice(); -} - -/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */ -export function radioViewIsGlobal(altitudeM) { - return Number.isFinite(altitudeM) && Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M; -} - -/** Pure stale-response guard shared by the async directory update path. */ -export function radioRequestIsCurrent( - generation, - currentGeneration, - enabled, - sessionGeneration = null, - currentSessionGeneration = sessionGeneration, -) { - return generation === currentGeneration - && Boolean(enabled) - && sessionGeneration === currentSessionGeneration; -} - -/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ -export function radioStationIdFromPick(picked) { - const pending = [picked?.id, picked?.primitive?.id]; - const seen = new Set(); - while (pending.length) { - const value = pending.shift(); - if (typeof value === 'string' || typeof value === 'number') { - const id = String(value); - if (id.startsWith(`${RADIO_PREFIX}selected:`)) return id.slice(`${RADIO_PREFIX}selected:`.length); - if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length); - continue; - } - if (!value || typeof value !== 'object' || seen.has(value)) continue; - seen.add(value); - if (Array.isArray(value)) pending.push(...value); - else { - pending.push(value.id); - if (value.primitive) pending.push(value.primitive.id); - } - } - return null; -} - -/** Map an integer tuner slot directly to one available directory station. */ -export function radioTunerSlot(value, stationCount) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 }; - const max = Math.max(0, count - 1); - const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); - return { - slot, - max, - locked: true, - stationIndex: slot, - leftIndex: slot, - rightIndex: slot, - }; -} - -/** Snap a tuner release to the nearest available directory station. */ -export function radioTunerCommitSlot(value, stationCount) { - return radioTunerSlot(value, stationCount); -} - -/** Map one pointer coordinate to continuous absolute directory progress. */ -export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; - if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); - const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth)); - const coordinate = ratio * (count - 1); - return { - ratio, - coordinate, - stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), - }; -} - -/** Build a bounded virtual tuner tape around one continuous directory coordinate. */ -export function buildRadioTunerTicks(coordinate, stationCount, width, { - insetPx = 7, - minPitchPx = 14, - speedFactor = 5, - overscan = 2, - labelStep = 6, -} = {}) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - const dialWidth = Math.max(0, Number(width) || 0); - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(0, dialWidth - inset * 2); - if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 }; - const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); - if (count === 1) { - return { - ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], - needleX: inset + usableWidth / 2, - pitchPx: Math.max(1, Number(minPitchPx) || 14), - ratio: 0.5, - }; - } - const directoryStep = usableWidth / (count - 1); - const pitchPx = Math.max( - Math.max(1, Number(minPitchPx) || 14), - directoryStep * Math.max(1, Number(speedFactor) || 5), - ); - const needleX = inset + directoryStep * value; - const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); - const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); - const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); - const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); - const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6)); - const labelWidth = Math.max(2, String(count).length); - const ticks = []; - for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { - const channel = stationIndex + 1; - const current = stationIndex === currentIndex; - const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; - ticks.push({ - stationIndex, - channel, - xPx: needleX + pitchPx * (stationIndex - value), - current, - label: labelled ? String(channel).padStart(labelWidth, '0') : '', - }); - } - return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; -} - -/** Plan a station-centered camera move that preserves altitude and view angle. */ -export function radioStationCameraPlan(station, cameraState = {}) { - const targetLat = Number(station?.lat); - const targetLon = Number(station?.lon); - const height = Math.max(1, Number(cameraState.height) || 1); - const heading = Number.isFinite(cameraState.heading) ? cameraState.heading : 0; - const pitch = Number.isFinite(cameraState.pitch) ? cameraState.pitch : -Math.PI / 2; - const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0; - if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null; - - const downAngle = Math.min(Math.PI / 2, Math.max(0.08, Math.abs(Math.min(-0.001, pitch)))); - const groundOffsetM = downAngle > Math.PI / 2 - 1e-6 - ? 0 - : Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle))); - const angularDistance = groundOffsetM / 6_378_137; - const targetLatRad = targetLat * Math.PI / 180; - const cameraBearing = heading + Math.PI; - const cameraLatRad = Math.asin( - Math.sin(targetLatRad) * Math.cos(angularDistance) - + Math.cos(targetLatRad) * Math.sin(angularDistance) * Math.cos(cameraBearing), - ); - const cameraLonRad = targetLon * Math.PI / 180 + Math.atan2( - Math.sin(cameraBearing) * Math.sin(angularDistance) * Math.cos(targetLatRad), - Math.cos(angularDistance) - Math.sin(targetLatRad) * Math.sin(cameraLatRad), - ); - const cameraLon = ((cameraLonRad * 180 / Math.PI + 540) % 360) - 180; - return { - lat: cameraLatRad * 180 / Math.PI, - lon: cameraLon, - height, - heading, - pitch, - roll, - }; -} - -/** - * Decide whether Radio navigation should first restore a centered Earth view. - * Fit-capable clipped discs and closer views whose optical center has reached - * the Earth limb use the staged path; ordinary centered local views stay direct. - */ -export function radioGlobeNeedsRecentering(geometry) { - if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false; - const centeredGeometry = { - ...geometry, - earthCenterX: geometry.keyholeCenterX, - earthCenterY: geometry.keyholeCenterY, - }; - if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true; - const values = [ - geometry.earthCenterX, - geometry.earthCenterY, - geometry.earthRadius, - geometry.keyholeCenterX, - geometry.keyholeCenterY, - ]; - if (!values.every(Number.isFinite) || geometry.earthRadius <= 0) return false; - const centerOffset = Math.hypot( - geometry.earthCenterX - geometry.keyholeCenterX, - geometry.earthCenterY - geometry.keyholeCenterY, - ); - return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX; -} - -/** Preserve closer zoom while capping an extreme full-globe recovery. */ -export function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) { - if (!Number.isFinite(currentHeight) || currentHeight < 0) return null; - return fullGlobeCapable - ? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M) - : currentHeight; -} - -/** Bound one already ordered filtered directory without injecting outside selections. */ -export function buildRadioTunerBand(rankedStations, selected, limit = RADIO_TUNER_STATION_LIMIT) { - const boundedLimit = Math.min( - RADIO_TUNER_DIRECTORY_LIMIT, - Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)), - ); - return (Array.isArray(rankedStations) ? rankedStations : []).slice(0, boundedLimit); -} - -/** Decide whether tuner static should be audible for the current handoff state. */ -export function radioTuningStaticShouldPlay({ - tuningActive = false, - tuningStatic = false, - awaitingStationId = null, - voiceDucked = false, -} = {}) { - return Boolean(tuningStatic && !voiceDucked && (tuningActive || awaitingStationId)); -} - -function markerPosition(station, liftM = MARKER_LIFT_M) { - const floor = cachedGroundFloor(station.lat, station.lon); - return Cesium.Cartesian3.fromDegrees( - station.lon, - station.lat, - (Number.isFinite(floor) ? floor : 0) + liftM, - ); -} - -function selectedStation() { - return _selectedId ? _stationById.get(_selectedId) || null : null; -} - -function selectedPresentationStation() { - if (_tuningActive) return tuningResolutionStation(_tuningPreviewId); - return _cancelledTuningPresentationStation || selectedStation(); -} - -function visibleStations() { - return filterRadioStations(_stations, _filter); -} - -function viewportRadioAnchor() { - const camera = _viewer?.camera; - const scene = _viewer?.scene; - if (!camera) return null; - const altitudeM = Number(camera.positionCartographic?.height); - let cartographic = null; - const canvas = scene?.canvas; - if (canvas && typeof camera.pickEllipsoid === 'function') { - const center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2); - const position = camera.pickEllipsoid(center, scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84); - if (position) cartographic = Cesium.Cartographic.fromCartesian(position); - } - cartographic ||= camera.positionCartographic || null; - if (!cartographic) return null; - return { - lat: Cesium.Math.toDegrees(cartographic.latitude), - lon: Cesium.Math.toDegrees(cartographic.longitude), - altitudeM, - globalView: radioViewIsGlobal(altitudeM), - }; -} - -function rankedVisibleStations() { - const anchor = viewportRadioAnchor(); - return rankRadioStationsForViewport(visibleStations(), anchor, { - preferEnglish: Boolean(anchor?.globalView), - }); -} - -/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */ -export function getRadioAcceptedCatalogSnapshot() { - return _acceptedCatalogSnapshot; -} - -/** Compare every station field that determines tuner presentation and playback. */ -export function radioStationResolutionMatches(frozenStation, currentStation) { - if (!frozenStation || !currentStation) return false; - for (const key of [ - 'id', 'name', 'lat', 'lon', 'streamUrl', 'homepage', 'state', 'country', - 'countryCode', 'metadataTrust', 'codec', 'bitrate', - ]) { - if (frozenStation[key] !== currentStation[key]) return false; - } - return ['tags', 'languages'].every((key) => { - const frozenValues = Array.isArray(frozenStation[key]) ? frozenStation[key] : []; - const currentValues = Array.isArray(currentStation[key]) ? currentStation[key] : []; - return frozenValues.length === currentValues.length - && frozenValues.every((value, index) => value === currentValues[index]); - }); -} - -function captureRadioTuningResolutionSnapshot() { - return _acceptedCatalogSnapshot; -} - -function tuningResolutionStation(id) { - return id ? _tuningStationById.get(String(id)) || null : null; -} - -/** Return an immutable snapshot consumed by the right-rail UI. */ -export function getRadioUIState() { - const visible = visibleStations(); - const selected = selectedStation(); - return Object.freeze({ - enabled: _enabled, - loading: _loading, - stale: _stale, - degraded: _degraded, - error: _error, - updatedAt: _updatedAt, - filter: _filter, - categories: _categories, - acceptedCatalogGeneration: _acceptedCatalogSnapshot.generation, - presentationActive: radioPresentationAllowed(), - stationCount: _stations.length, - filteredCount: visible.length, - selected, - selectedIndex: selected ? visible.findIndex((station) => station.id === selected.id) : -1, - audioState: _audioState, - audioError: _audioError, - playingStationId: _audioStationId, - volume: _userVolume, - effectiveVolume: _audio?.volume ?? (_voiceDucked ? 0 : _userVolume), - voiceDucked: _voiceDucked, - voiceRestoring: _voiceRestoring, - tuningActive: _tuningActive, - tuningStatic: _tuningStatic, - tuningAwaitingStationId: _tuningAwaitingStationId, - tuningPreviewStationId: _tuningPreviewId, - tuningRestoredStationId: _cancelledTuningPresentationStation?.id || null, - tuningCatalogGeneration: _tuningResolutionSnapshot.generation, - tuningUnavailableStationId: _tuningUnavailableStationId, - }); -} - -function emitState() { - const snapshot = getRadioUIState(); - for (const listener of _listeners) { - try { - listener(snapshot); - } catch { - // A broken consumer must not break playback or rendering. - } - } -} - -/** Subscribe to radio state; the current state is delivered immediately. */ -export function subscribeToRadio(listener) { - if (typeof listener !== 'function') return () => {}; - _listeners.add(listener); - listener(getRadioUIState()); - return () => _listeners.delete(listener); -} - -/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */ -export function subscribeToRadioPlaybackControls(listener) { - if (typeof listener !== 'function') return () => {}; - _playbackControlListeners.add(listener); - return () => _playbackControlListeners.delete(listener); -} - -function emitPlaybackControl(action, origin, attemptId = _activePlaybackAttempt?.id || null) { - const event = { action, origin, attemptId }; - for (const listener of _playbackControlListeners) { - try { - listener(event); - } catch { - // Playback controls must remain usable if an observer fails. - } - } -} - -function audioEventBelongsToActiveAttempt(audio) { - const attempt = _activePlaybackAttempt; - return _audio === audio - && Boolean(attempt) - && attempt.generation === _playGeneration - && attempt.stationId === _audioStationId; -} - -function installAudio({ replace = false } = {}) { - if (_audio && !replace) return; - if (typeof Audio === 'undefined') return; - const previousAudio = _audio; - _audio = null; - if (previousAudio) { - try { previousAudio.pause(); } catch { /* already stopped */ } - try { previousAudio.removeAttribute('src'); } catch { /* no source */ } - try { previousAudio.load(); } catch { /* detached media */ } - } - const audio = new Audio(); - _audio = audio; - audio.preload = 'none'; - audio.volume = _voiceDucked ? 0 : _userVolume; - audio.addEventListener('playing', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - _audioState = 'playing'; - _audioError = null; - if (_tuningAwaitingStationId === _audioStationId) clearRadioTuningNoise({ emit: false }); - emitState(); - }); - audio.addEventListener('pause', () => { - if (_audio !== audio) return; - if (_audioState === 'stopped' || _audioState === 'loading') return; - _audioState = 'paused'; - emitState(); - }); - audio.addEventListener('waiting', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - _audioState = 'buffering'; - emitState(); - }); - audio.addEventListener('error', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - const failedId = _audioStationId; - const attempt = _activePlaybackAttempt; - if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return; - _audioState = 'error'; - _audioError = 'Broadcaster stream is unavailable or blocked by the browser.'; - emitState(); - }); -} - -function stopTuningNoiseSource() { - if (_tuningNoiseSource) { - try { _tuningNoiseSource.stop(); } catch { /* already stopped */ } - try { _tuningNoiseSource.disconnect(); } catch { /* already disconnected */ } - } - try { _tuningNoiseFilter?.disconnect(); } catch { /* already disconnected */ } - try { _tuningNoiseGain?.disconnect(); } catch { /* already disconnected */ } - _tuningNoiseSource = null; - _tuningNoiseFilter = null; - _tuningNoiseGain = null; -} - -function syncTuningNoiseGain() { - if (!_tuningNoiseContext || !_tuningNoiseGain) return; - const audible = radioTuningStaticShouldPlay({ - tuningActive: _tuningActive, - tuningStatic: _tuningStatic, - awaitingStationId: _tuningAwaitingStationId, - voiceDucked: _voiceDucked, - }); - const target = audible - ? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, _userVolume * 0.03) - : 0; - const now = _tuningNoiseContext.currentTime; - _tuningNoiseGain.gain.cancelScheduledValues(now); - _tuningNoiseGain.gain.setValueAtTime(_tuningNoiseGain.gain.value, now); - _tuningNoiseGain.gain.linearRampToValueAtTime(target, now + 0.025); -} - -function installTuningNoise() { - const AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext; - if (!AudioContextClass) return false; - if (!_tuningNoiseContext) _tuningNoiseContext = new AudioContextClass(); - const resumed = _tuningNoiseContext.resume?.(); - if (resumed?.catch) void resumed.catch(() => {}); - if (_tuningNoiseSource) return true; - - const frameCount = Math.max(1, Math.floor(_tuningNoiseContext.sampleRate * 0.75)); - const buffer = _tuningNoiseContext.createBuffer(1, frameCount, _tuningNoiseContext.sampleRate); - const channel = buffer.getChannelData(0); - for (let index = 0; index < channel.length; index += 1) channel[index] = Math.random() * 2 - 1; - - _tuningNoiseSource = _tuningNoiseContext.createBufferSource(); - _tuningNoiseFilter = _tuningNoiseContext.createBiquadFilter(); - _tuningNoiseGain = _tuningNoiseContext.createGain(); - _tuningNoiseSource.buffer = buffer; - _tuningNoiseSource.loop = true; - _tuningNoiseFilter.type = 'bandpass'; - _tuningNoiseFilter.frequency.value = 1_650; - _tuningNoiseFilter.Q.value = 0.55; - _tuningNoiseGain.gain.value = 0; - _tuningNoiseSource.connect(_tuningNoiseFilter); - _tuningNoiseFilter.connect(_tuningNoiseGain); - _tuningNoiseGain.connect(_tuningNoiseContext.destination); - _tuningNoiseSource.start(); - return true; -} - -/** Return the complete accepted filtered directory in stable catalog order. */ -export function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) { - if (!radioPresentationAllowed() || !_acceptedCatalogSnapshot.stations.length) return []; - return buildRadioTunerBand(visibleStations(), selectedStation(), limit); -} - -/** Begin a direct-manipulation tuning gesture and pause the current stream. */ -export function beginRadioTuning() { - if (!radioPresentationAllowed() || !visibleStations().length) return false; - const snapshot = captureRadioTuningResolutionSnapshot(); - if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length) return false; - _tuningResolutionSnapshot = snapshot; - _tuningStationById = new Map( - _tuningResolutionSnapshot.stations.map((station) => [station.id, station]), - ); - _tuningUnavailableStationId = null; - _tuningActive = true; - _tuningStatic = false; - _tuningAwaitingStationId = null; - _cancelledTuningPresentationStation = null; - _tuningStartStationId = _selectedId; - _tuningPreviewId = _selectedId; - installTuningNoise(); - pauseRadioPlayback({ origin: 'user' }); - syncTuningNoiseGain(); - emitState(); - return true; -} - -/** Toggle low-volume synthesized static for tuner preview and stream handoff. */ -export function setRadioTuningStatic(active) { - if (!radioPresentationAllowed() || !_tuningActive) return false; - const next = Boolean(active); - if (next === _tuningStatic) return true; - _tuningStatic = next; - syncTuningNoiseGain(); - emitState(); - return true; -} - -function radioCameraState(camera = _viewer?.camera) { - if (!camera) return null; - return { - height: camera.positionCartographic?.height, - heading: camera.heading, - pitch: camera.pitch, - roll: camera.roll, - }; -} - -/** Radio may move the globe only while no tracked entity owns the follow camera. */ -export function radioCameraNavigationAllowed(viewer = _viewer) { - return Boolean(viewer?.camera) && !viewer.trackedEntity; -} - -function radioGlobeRecenterPlan(viewer = _viewer) { - const camera = viewer?.camera; - const canvas = viewer?.scene?.canvas; - const width = canvas?.clientWidth || canvas?.width; - const height = canvas?.clientHeight || canvas?.height; - const cartographic = camera?.positionCartographic; - if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null; - const geometry = projectEarthDiscToViewport( - viewer, - width, - height, - _radioEarthScreenCenter, - _radioEarthToCenter, - ); - let fullGlobeCapable = false; - if (geometry) { - if (!radioGlobeNeedsRecentering(geometry)) return null; - fullGlobeCapable = isFullGlobeInsideKeyhole({ - ...geometry, - earthCenterX: geometry.keyholeCenterX, - earthCenterY: geometry.keyholeCenterY, - }, false); - } else { - const earthRadius = earthDiscScreenRadius( - Cesium.Cartesian3.magnitude(camera.positionWC), - height, - camera.frustum?.fovy, - ); - const facingEarthCenter = Cesium.Cartesian3.dot( - camera.directionWC, - _radioEarthToCenter, - ) > 0; - if (!earthRadius || facingEarthCenter) return null; - const keyhole = getKeyholeGeometry(width, height); - fullGlobeCapable = earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius; - } - const recenterHeight = radioGlobeRecenterHeight( - cartographic.height, - fullGlobeCapable, - ); - if (recenterHeight == null) return null; - return { - destination: Cesium.Cartesian3.fromRadians( - cartographic.longitude, - cartographic.latitude, - recenterHeight, - ), - cameraState: { - ...radioCameraState(camera), - height: recenterHeight, - heading: 0, - pitch: -Cesium.Math.PI_OVER_TWO, - roll: 0, - }, - }; -} - -function radioCameraNavigationIsCurrent(navigation) { - return Boolean( - navigation - && navigation.generation === _radioCameraNavigationGeneration - && _enabled - && radioCameraNavigationAllowed(_viewer) - ); -} - -function cancelActiveRadioCameraFlight() { - if (!_activeRadioCameraFlight || !radioCameraNavigationAllowed(_viewer)) return; - _activeRadioCameraFlight = null; - _viewer.camera.cancelFlight(); -} - -function invalidateRadioCameraNavigation() { - _radioCameraNavigationGeneration += 1; - cancelActiveRadioCameraFlight(); - _activeRadioCameraFlight = null; -} - -function radioCameraNavigationOwnsSelection(navigation, station) { - return Boolean( - navigation - && station - && navigation.generation === _radioCameraNavigationGeneration - && navigation.target?.id === station.id - ); -} - -function startRadioCameraFlight(navigation, options, onComplete) { - if (!radioCameraNavigationIsCurrent(navigation)) return false; - const token = ++_radioCameraFlightSequence; - _activeRadioCameraFlight = { generation: navigation.generation, token }; - const finish = (completed) => { - if (_activeRadioCameraFlight?.token === token) _activeRadioCameraFlight = null; - if (completed && radioCameraNavigationIsCurrent(navigation)) onComplete?.(); - }; - _viewer.camera.flyTo({ - ...options, - complete: () => finish(true), - cancel: () => finish(false), - }); - return true; -} - -function focusRadioNavigationTarget(navigation) { - if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target) return false; - const plan = radioStationCameraPlan(navigation.target, navigation.cameraState); - if (!plan) return false; - navigation.phase = 'focusing'; - return startRadioCameraFlight(navigation, { - destination: Cesium.Cartesian3.fromDegrees(plan.lon, plan.lat, plan.height), - orientation: { heading: plan.heading, pitch: plan.pitch, roll: plan.roll }, - duration: navigation.duration, - }, () => { - navigation.phase = 'settled'; - }); -} - -function beginRadioCameraNavigation(cameraState = null) { - const generation = ++_radioCameraNavigationGeneration; - if (!radioCameraNavigationAllowed(_viewer) || !_enabled) return null; - cancelActiveRadioCameraFlight(); - const recenterPlan = radioGlobeRecenterPlan(_viewer); - return { - generation, - phase: 'idle', - recentered: false, - recenterPlan, - cameraState: recenterPlan?.cameraState || cameraState || radioCameraState(), - target: null, - duration: 0.35, - }; -} - -function rotateRadioStationIntoView( - station, - duration = 0.35, - cameraState = null, - navigation = null, -) { - const activeNavigation = navigation || beginRadioCameraNavigation(cameraState); - if (!station || !radioCameraNavigationIsCurrent(activeNavigation)) return false; - activeNavigation.target = station; - activeNavigation.duration = duration; - if (activeNavigation.phase === 'recentering') return true; - if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight(); - if (activeNavigation.recenterPlan && !activeNavigation.recentered) { - activeNavigation.phase = 'recentering'; - const recenterDuration = Math.min(0.9, Math.max(0.65, duration)); - return startRadioCameraFlight(activeNavigation, { - destination: activeNavigation.recenterPlan.destination, - orientation: { - heading: activeNavigation.cameraState.heading, - pitch: activeNavigation.cameraState.pitch, - roll: activeNavigation.cameraState.roll, - }, - duration: recenterDuration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }, () => { - activeNavigation.recentered = true; - activeNavigation.phase = 'idle'; - focusRadioNavigationTarget(activeNavigation); - }); - } - return focusRadioNavigationTarget(activeNavigation); -} - -/** Preview a tuner station bracket and camera orientation without starting audio. */ -export function previewRadioTuningStation(id, { rotate = true } = {}) { - if (!radioPresentationAllowed() || !_tuningActive) return false; - const station = tuningResolutionStation(id); - const nextId = station?.id || null; - const changed = nextId !== _tuningPreviewId; - _tuningPreviewId = nextId; - _tuningStatic = !station; - if (changed) { - updateSelectionEntity(); - if (station && rotate) { - const cameraState = radioCameraState(); - _tuningCameraNavigation = beginRadioCameraNavigation(cameraState); - rotateRadioStationIntoView(station, 0.35, cameraState, _tuningCameraNavigation); - } - else if (rotate) { - _tuningCameraNavigation = null; - invalidateRadioCameraNavigation(); - } - } - syncTuningNoiseGain(); - emitState(); - return Boolean(station); -} - -function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) { - if (_tuningCameraNavigation) invalidateRadioCameraNavigation(); - _tuningActive = false; - _tuningStatic = false; - _tuningAwaitingStationId = null; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - _tuningCameraNavigation = null; - _cancelledTuningPresentationStation = restoredStation; - updateSelectionEntity(); - syncTuningNoiseGain(); - stopTuningNoiseSource(); - const suspended = _tuningNoiseContext?.suspend?.(); - if (suspended?.catch) void suspended.catch(() => {}); - if (emit) emitState(); -} - -/** Finish a tuning gesture and release its synthesized noise source. */ -export function endRadioTuning() { - if (!_tuningActive && !_tuningStatic && !_tuningAwaitingStationId - && !_tuningNoiseSource && !_cancelledTuningPresentationStation) return; - clearRadioTuningNoise(); -} - -/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */ -export function cancelRadioTuning() { - if (!_tuningActive) return false; - const restoredStation = tuningResolutionStation(_tuningStartStationId); - clearRadioTuningNoise({ restoredStation }); - return true; -} - -/** Commit the exact frozen drag resolution or report that it is unavailable. */ -export function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) { - const frozenStation = tuningResolutionStation(id); - const currentStation = _stationById.get(String(id)) || null; - const generation = _tuningResolutionSnapshot.generation; - if (!radioPresentationAllowed() || !_tuningActive || !frozenStation) { - endRadioTuning(); - return Object.freeze({ ok: false, reason: 'not-tuning', stationId: String(id || ''), generation }); - } - if (!radioStationResolutionMatches(frozenStation, currentStation)) { - const stationId = frozenStation.id; - // A failed exact release owns the visible outcome. Do not let the previous - // selection—or changed metadata under the same ID—reappear between the - // frozen preview and the unavailable state. - _selectedId = null; - _selectionGeneration += 1; - clearRadioTuningNoise({ emit: false }); - _tuningUnavailableStationId = stationId; - emitState(); - return Object.freeze({ ok: false, reason: 'station-unavailable', stationId, generation }); - } - _tuningActive = false; - _tuningStatic = true; - _tuningAwaitingStationId = frozenStation.id; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningUnavailableStationId = null; - const cameraNavigation = _tuningCameraNavigation; - _tuningCameraNavigation = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - // An exact tuner release owns its frozen target. A fallback armed by an - // earlier non-playing selection must never retarget this gesture if the - // broadcaster fails after release. - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - syncTuningNoiseGain(); - emitState(); - const selected = selectRadioStation(frozenStation.id, { - autoplay: true, - focus: false, - origin, - cameraNavigation, - }); - return Object.freeze({ - ok: selected, - reason: selected ? null : 'station-unavailable', - stationId: frozenStation.id, - generation, - }); -} - -function tryRadioFallback( - failedId, - origin = _playFallbackOrigin || 'programmatic', - attemptId = _playFallbackAttemptId, -) { - const fallbackId = _playFallbackId; - const fallbackStation = fallbackId ? _stationById.get(fallbackId) : null; - const fallbackFocusPolicy = _playFallbackFocus; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - if (!fallbackStation || fallbackId === failedId || _selectedId !== failedId) { - return false; - } - const fallbackFocusResult = typeof fallbackFocusPolicy === 'function' - ? fallbackFocusPolicy(fallbackStation) - : fallbackFocusPolicy; - const fallbackCameraNavigation = fallbackFocusResult - && typeof fallbackFocusResult === 'object' - ? fallbackFocusResult - : null; - const fallbackFocus = fallbackCameraNavigation ? false : Boolean(fallbackFocusResult); - selectRadioStation(fallbackId, { - autoplay: true, - focus: fallbackFocus, - origin: origin || 'programmatic', - attemptId, - cameraNavigation: fallbackCameraNavigation, - }); - return true; -} - -function recordDirectoryClick(id) { - fetch(`/api/radio/click/${encodeURIComponent(id)}`, { method: 'POST' }).catch(() => {}); -} - -/** Play the selected broadcaster stream after an explicit user action. */ -export async function playSelectedRadio({ origin = 'programmatic', attemptId = null } = {}) { - const station = selectedStation(); - if (!radioPresentationAllowed() || !station?.streamUrl) return false; - if (_tuningActive) endRadioTuning(); - // A media event carries no reliable attempt identity. Give every explicit - // play/resume/replacement its own element so queued events from the retired - // attempt remain bound to the discarded element and cannot mutate this one. - installAudio({ replace: true }); - if (!_audio) return false; - - const generation = ++_playGeneration; - const ownedAttemptId = attemptId || `radio-play-${++_playAttemptSequence}`; - _activePlaybackAttempt = { - id: ownedAttemptId, - origin, - stationId: station.id, - streamUrl: station.streamUrl, - generation, - }; - if (_playFallbackId) { - _playFallbackOrigin = origin; - _playFallbackAttemptId = ownedAttemptId; - } - _audioError = null; - _audioState = 'loading'; - if (_audioStationId !== station.id || _audio.src !== station.streamUrl) { - _audio.pause(); - _audio.src = station.streamUrl; - _audioStationId = station.id; - } - emitState(); - - try { - const playback = _audio.play(); - if (playback?.then) await playback; - if ( - generation !== _playGeneration - || _audioStationId !== station.id - || _activePlaybackAttempt?.id !== ownedAttemptId - ) return false; - _audioState = 'playing'; - if (_tuningAwaitingStationId === station.id) clearRadioTuningNoise({ emit: false }); - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - recordDirectoryClick(station.id); - emitState(); - if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId); - return true; - } catch (error) { - if (generation !== _playGeneration || _activePlaybackAttempt?.id !== ownedAttemptId) return false; - if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false; - _audioState = 'error'; - _audioError = error?.name === 'NotAllowedError' - ? 'Playback requires a direct click or tap.' - : 'Broadcaster stream could not be started.'; - emitState(); - return false; - } -} - -/** - * Wait for confirmed Radio playback while voice owns a hard mute. - * A fallback station may replace the first stream during this wait, so the - * state subscription—not the first play() promise—is authoritative. - */ -export function confirmRadioPlayback({ - startPlayback, - subscribe, - getState, - timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS, -} = {}) { - if (typeof startPlayback !== 'function' || typeof subscribe !== 'function' || typeof getState !== 'function') { - return Promise.resolve(false); - } - return new Promise((resolve) => { - let settled = false; - let started = false; - let unsubscribe = () => {}; - let timer = null; - const finish = (ok) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - unsubscribe(); - resolve(Boolean(ok)); - }; - const inspect = (state) => { - if (!started || !state) return; - if (!state.voiceDucked && ['loading', 'buffering', 'playing'].includes(state.audioState)) { - finish(false); - return; - } - if (state.audioState === 'playing' && state.playingStationId && state.voiceDucked) { - finish(true); - } else if (state.audioState === 'error') { - finish(false); - } - }; - unsubscribe = subscribe(inspect); - started = true; - timer = setTimeout(() => finish(false), Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS)); - Promise.resolve() - .then(startPlayback) - .then((playStarted) => { - const state = getState(); - inspect(state); - if (!playStarted && !['loading', 'buffering', 'playing'].includes(state?.audioState)) finish(false); - }) - .catch(() => finish(false)); - }); -} - -/** Start and verify a prepared station without ever making it audible under voice. */ -export function playPreparedRadioForVoice(options = {}) { - if (!_voiceDucked) return Promise.resolve(false); - return confirmRadioPlayback({ - startPlayback: () => playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }), - subscribe: subscribeToRadio, - getState: getRadioUIState, - timeoutMs: options.timeoutMs, - }); -} - -/** Stop the shared stream and release its network resource. */ -export function stopRadioPlayback({ origin = 'programmatic', attemptId = null } = {}) { - if (attemptId && _activePlaybackAttempt?.id !== attemptId) return false; - const stoppedAttemptId = _activePlaybackAttempt?.id || null; - endRadioTuning(); - _playGeneration += 1; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - _activePlaybackAttempt = null; - if (_audio) { - _audio.pause(); - _audio.removeAttribute('src'); - _audio.load(); - } - _audioStationId = null; - _audioState = 'stopped'; - _audioError = null; - emitState(); - if (origin === 'user' || origin === 'voice') { - emitPlaybackControl('stop', origin, stoppedAttemptId); - } - return true; -} - -/** Pause or resume the selected stream. Resuming is still click initiated. */ -export function toggleRadioPlayback({ origin = 'programmatic' } = {}) { - if (['loading', 'playing', 'buffering'].includes(_audioState)) { - return Promise.resolve(pauseRadioPlayback({ origin })); - } - if (!radioPresentationAllowed()) return Promise.resolve(false); - if (!_selectedId) { - const ranked = rankedVisibleStations(); - if (!ranked.length) return Promise.resolve(false); - _playFallbackId = ranked[1]?.id || null; - _playFallbackFocus = false; - selectRadioStation(ranked[0].id, { autoplay: false, focus: false }); - } - return playSelectedRadio({ origin }); -} - -/** Pause Radio without toggling a stopped or already-paused stream back on. */ -export function pauseRadioPlayback({ origin = 'programmatic' } = {}) { - if (!['loading', 'playing', 'buffering'].includes(_audioState)) return false; - const pausedAttemptId = _activePlaybackAttempt?.id || null; - if (_tuningAwaitingStationId && !_tuningActive) endRadioTuning(); - _playGeneration += 1; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - _activePlaybackAttempt = null; - _audio?.pause(); - _audioState = 'paused'; - emitState(); - if (origin === 'user' || origin === 'voice') { - emitPlaybackControl('pause', origin, pausedAttemptId); - } - return true; -} - -/** Set shared audio volume, clamped to [0, 1]. */ -export function setRadioVolume(value) { - if (!radioPresentationAllowed()) return false; - const volume = clampRadioVolume(value); - _userVolume = volume; - installAudio(); - if (!_voiceDucked) { - cancelRadioVolumeTransition(); - _voiceRestoring = false; - if (_audio) _audio.volume = volume; - } - syncTuningNoiseGain(); - emitState(); - return true; -} - -/** Durable Radio preferences; this surface never creates or plays audio. */ -export function setRadioParams(params = {}) { - const nextFilter = Object.hasOwn(params, 'filter') - ? normalizeRadioFilter(params.filter) - : null; - if (Object.hasOwn(params, 'filter') && !nextFilter) return false; - const numericVolume = Object.hasOwn(params, 'volume') ? Number(params.volume) : null; - if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume)) return false; - const nextVolume = numericVolume === null ? null : clampRadioVolume(numericVolume); - - let changed = false; - let filterChanged = false; - let clearedCancelledPresentation = false; - if (nextFilter !== null) { - filterChanged = nextFilter !== _filter; - if (filterChanged && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning(); - clearedCancelledPresentation = Boolean(_cancelledTuningPresentationStation); - _cancelledTuningPresentationStation = null; - _filter = nextFilter; - if (clearedCancelledPresentation) updateSelectionEntity(); - if (filterChanged) resetRadioClusterOverlayIdentities(); - changed ||= filterChanged || clearedCancelledPresentation; - } - if (nextVolume !== null && nextVolume !== _userVolume) { - _userVolume = nextVolume; - if (_audio && !_voiceDucked) { - cancelRadioVolumeTransition(); - _voiceRestoring = false; - _audio.volume = nextVolume; - } - syncTuningNoiseGain(); - changed = true; - } - if (changed && radioPresentationAllowed()) { - updateRenderVisibility(); - if (filterChanged && _dataSource?.clustering?.enabled) { - const clusterPoints = _dataSource.clustering.clusterPoints; - _dataSource.clustering.clusterPoints = !clusterPoints; - _dataSource.clustering.clusterPoints = clusterPoints; - _viewer?.scene?.requestRender(); - } - scheduleRadioOverlayPublish(); - } - emitState(); - return true; -} - -export function getRadioParams() { - return { filter: _filter, volume: _userVolume }; -} - -/** - * Mute Radio during a live voice turn, then gently restore the user-owned - * volume after voice returns to standby. Repeated state sync is idempotent. - */ -export function setRadioVoiceDucking(ducked, { - restoreDelayMs = VOICE_RESTORE_DELAY_MS, - restoreDurationMs = VOICE_RESTORE_DURATION_MS, -} = {}) { - const shouldDuck = Boolean(ducked); - if (shouldDuck === _voiceDucked && (shouldDuck || _voiceRestoring)) return; - if (!shouldDuck && !_voiceDucked && !_voiceRestoring - && (!_audio || Math.abs(_audio.volume - _userVolume) < 0.001)) return; - - cancelRadioVolumeTransition(); - _voiceDucked = shouldDuck; - _voiceRestoring = false; - - if (shouldDuck) { - if (_audio) _audio.volume = 0; - syncTuningNoiseGain(); - emitState(); - return; - } - if (!_audio) { - syncTuningNoiseGain(); - _voiceRestoring = false; - emitState(); - return; - } - - const generation = _volumeTransitionGeneration; - syncTuningNoiseGain(); - _voiceRestoring = true; - emitState(); - const beginRestore = () => { - _voiceRestoreTimer = null; - if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return; - const startedAt = volumeClock(); - const initialVolume = _audio.volume; - const duration = Math.max(0, Number(restoreDurationMs) || 0); - const step = (now) => { - if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return; - const progress = duration === 0 ? 1 : Math.min(1, Math.max(0, (now - startedAt) / duration)); - const eased = progress * progress * (3 - 2 * progress); - _audio.volume = clampRadioVolume(initialVolume + (_userVolume - initialVolume) * eased); - if (progress < 1) { - _volumeFadeFrame = scheduleVolumeFrame(step); - return; - } - _volumeFadeFrame = null; - _voiceRestoring = false; - emitState(); - }; - _volumeFadeFrame = scheduleVolumeFrame(step); - }; - const delay = Math.max(0, Number(restoreDelayMs) || 0); - if (delay > 0) _voiceRestoreTimer = setTimeout(beginRestore, delay); - else beginRestore(); -} - -function updateSelectionEntity() { - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - const station = selectedPresentationStation(); - if (!radioPresentationAllowed() || !_viewer || !station) { - scheduleRadioOverlayPublish(); - return; - } - const selectionColor = radioCategoryColor(radioStationCategoryId(station)); - const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(radioSelectionBracketSvg(selectionColor))}`; - - _selectedEntity = _viewer.entities.add({ - id: `${RADIO_PREFIX}selected:${station.id}`, - position: markerPosition(station, SELECTED_LIFT_M), - point: { - pixelSize: 14, - color: Cesium.Color.fromCssColorString(selectionColor), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - billboard: { - image: bracketImage, - width: 40, - height: 40, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72), - }, - }); - scheduleRadioOverlayPublish(); -} - -function clusterPointCollection() { - return _dataSource?.clustering?._clusterPointCollection || null; -} - -function publishRadioOverlayEntries() { - _overlayPublishTimer = null; - if (!radioPresentationAllowed()) { - resetRadioClusterOverlayIdentities(); - _overlayDiagnostics = emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - return; - } - - const entries = []; - const station = selectedPresentationStation(); - if (station) { - const selectedEntry = createRadioSelectedOverlayEntry( - station, - markerPosition(station, SELECTED_LIFT_M), - ); - if (selectedEntry) entries.push(selectedEntry); - } - - const clusterCandidates = []; - const clusteredStationIds = new Set(); - const points = clusterPointCollection(); - for (let index = 0; index < (points?.length || 0); index += 1) { - const point = points.get(index); - if (!point?.show || !point.position || !Array.isArray(point.id) || point.id.length < 3) continue; - const stationIds = point.id - .map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length)) - .filter((id) => { - const station = _stationById.get(id); - return station && stationMatchesRadioCategory(station, _filter); - }) - .sort(); - if (stationIds.length < 3) continue; - for (const stationId of stationIds) clusteredStationIds.add(stationId); - const clusteredStations = stationIds.map((id) => _stationById.get(id)); - const category = radioClusterCategoryId(clusteredStations, _filter); - clusterCandidates.push({ - id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`, - stationIds, - point, - text: radioClusterBadgeText(category, stationIds.length), - accent: radioCategoryColor(category), - stationCount: stationIds.length, - }); - } - const selectedClusterCandidates = selectRadioClusterCandidates(clusterCandidates); - _clusterOverlayIdentities = reconcileRadioClusterCandidates( - selectedClusterCandidates, - _clusterOverlayIdentities, - () => `stable:${++_clusterOverlayIdentitySequence}`, - ); - for (const candidate of _clusterOverlayIdentities) { - const clusterEntry = createRadioClusterOverlayEntry({ - ...candidate, - position: () => candidate.point.position, - }); - if (clusterEntry) entries.push(clusterEntry); - } - - const cameraPosition = _viewer?.camera?.positionWC; - const singletonAllowance = Math.min( - radioSingletonLabelLimit(_viewer?.camera?.positionCartographic?.height), - Math.max(0, RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length), - ); - const singletonCandidates = selectRadioSingletonCandidates( - [..._renderById.values()] - .filter((record) => ( - record.entity?.show - && record.station?.id !== station?.id - && !clusteredStationIds.has(record.station?.id) - )) - .map((record) => ({ - ...record, - distanceM: cameraPosition - ? Cesium.Cartesian3.distance(cameraPosition, record.position) - : Number.POSITIVE_INFINITY, - })), - singletonAllowance, - ); - for (let index = 0; index < singletonCandidates.length; index += 1) { - const candidate = singletonCandidates[index]; - const singletonEntry = createRadioSingletonOverlayEntry({ - station: candidate.station, - position: candidate.position, - // Cluster priorities begin at three. Keep clusters authoritative while - // retaining nearest-first singleton ordering inside the ambient lane. - priority: 2 - (index / Math.max(1, singletonCandidates.length + 1)), - }); - if (singletonEntry) entries.push(singletonEntry); - } - - setOverlayEntries(RADIO_OVERLAY_SOURCE_ID, entries, RADIO_OVERLAY_SOURCE_OPTIONS); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true); - _overlayDiagnostics = { - entryCount: entries.length, - selectedCount: entries.filter((entry) => entry.selected).length, - singletonTexts: entries.filter((entry) => entry.id.startsWith('station:')).map((entry) => entry.title), - singletonIds: entries.filter((entry) => entry.id.startsWith('station:')).map((entry) => entry.id), - clusterTexts: entries.filter((entry) => entry.id.startsWith('cluster:')).map((entry) => entry.title), - clusterIds: entries.filter((entry) => entry.id.startsWith('cluster:')).map((entry) => entry.id), - clusterMemberships: _clusterOverlayIdentities.map((candidate) => ({ - membershipId: candidate.membershipId, - entryId: `cluster:${candidate.id}`, - })), - }; -} - -function scheduleRadioOverlayPublish() { - if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer); - const sessionGeneration = _sessionGeneration; - _overlayPublishTimer = setTimeout(() => { - if (sessionGeneration === _sessionGeneration) publishRadioOverlayEntries(); - }, 0); -} - -function focusStation(station) { - _radioCameraNavigationGeneration += 1; - if (!station || !radioCameraNavigationAllowed(_viewer)) return false; - cancelActiveRadioCameraFlight(); - _viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(station.lon, station.lat, 85_000), - duration: 1.1, - }); - return true; -} - -/** Select a station. Playback occurs only when autoplay is explicitly true. */ -export function selectRadioStation(id, { - autoplay = false, - focus = false, - origin = 'programmatic', - attemptId = null, - cameraNavigation = null, -} = {}) { - if (!radioPresentationAllowed()) return false; - const station = _stationById.get(String(id)); - if (!station) return false; - _tuningUnavailableStationId = null; - if (!radioCameraNavigationOwnsSelection(cameraNavigation, station)) { - invalidateRadioCameraNavigation(); - } - if (_tuningActive || (_tuningAwaitingStationId && _tuningAwaitingStationId !== station.id)) endRadioTuning(); - _cancelledTuningPresentationStation = null; - _selectedId = station.id; - const generation = ++_selectionGeneration; - const sessionGeneration = _sessionGeneration; - updateSelectionEntity(); - warmGroundFloor([{ lat: station.lat, lon: station.lon }]); - if (_selectionTimer) clearTimeout(_selectionTimer); - _selectionTimer = setTimeout(() => { - _selectionTimer = null; - if ( - sessionGeneration === _sessionGeneration - && generation === _selectionGeneration - && _selectedId === station.id - ) updateSelectionEntity(); - }, 1300); - if (focus) focusStation(station); - emitState(); - if (autoplay) void playSelectedRadio({ origin, attemptId }); - return true; -} - -/** Select the previous or next station and optionally retain a UI-owned band order. */ -export function cycleRadioStation(direction = 1, { - rotate = false, - stationIds = null, - autoplay = true, - origin = 'programmatic', -} = {}) { - if (!radioPresentationAllowed()) return false; - const ranked = Array.isArray(stationIds) && stationIds.length - ? stationIds - .slice(0, RADIO_TUNER_DIRECTORY_LIMIT) - .map((id) => _stationById.get(String(id))) - .filter((station) => station && stationMatchesRadioCategory(station, _filter)) - : rankedVisibleStations(); - if (!ranked.length) return false; - const current = ranked.findIndex((station) => station.id === _selectedId); - const nextIndex = current < 0 - ? 0 - : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; - _playFallbackId = ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; - const rotationCameraState = rotate ? radioCameraState() : null; - const rotationNavigation = rotate ? beginRadioCameraNavigation(rotationCameraState) : null; - _playFallbackFocus = rotate - ? (fallbackStation) => { - rotateRadioStationIntoView(fallbackStation, 0.65, rotationCameraState, rotationNavigation); - return rotationNavigation; - } - : false; - const station = ranked[nextIndex]; - if (rotate) rotateRadioStationIntoView(station, 0.65, rotationCameraState, rotationNavigation); - return selectRadioStation(station.id, { - autoplay, - focus: false, - origin, - cameraNavigation: rotationNavigation, - }); -} - -/** Select and optionally play the best station for a location/category request. */ -export function selectRequestedRadioStation(criteria = {}, { autoplay = true, origin = 'programmatic' } = {}) { - if (!radioPresentationAllowed()) return null; - const requestedCategory = String(criteria.categoryId || 'all'); - const categoryId = _categories.some((category) => category.id === requestedCategory) - ? requestedCategory - : 'all'; - setRadioFilter(categoryId); - const ranked = rankRadioStationsForRequest(_stations, { - ...criteria, - categoryId, - anchor: criteria.anchor || viewportRadioAnchor(), - }); - if (!ranked.length) return null; - _playFallbackId = ranked[1]?.id || null; - _playFallbackFocus = false; - selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); - return ranked[0]; -} - -function updateRenderVisibility({ force = true } = {}) { - if (!_viewer || !_dataSource) return; - const cameraPosition = _viewer.camera?.positionWC; - if (!force && !radioCameraPositionChanged(_lastHorizonCameraPosition, cameraPosition)) return; - _lastHorizonCameraPosition = cameraPosition - ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z } - : null; - _horizonScanCount += 1; - const occluder = horizonOccluder(_viewer.camera); - let visibilityChanged = false; - for (const [id, record] of _renderById) { - const matches = stationMatchesRadioCategory(record.station, _filter); - const visible = matches && occluder.isPointVisible(record.position); - if (record.entity.show !== visible) visibilityChanged = true; - record.entity.show = visible; - } - if (_selectedEntity) { - const position = _selectedEntity.position?.getValue?.(Cesium.JulianDate.now()); - const selectedVisible = !position || occluder.isPointVisible(position); - if (_selectedEntity.show !== selectedVisible) visibilityChanged = true; - _selectedEntity.show = selectedVisible; - } - scheduleRadioOverlayPublish(); - // The horizon timer can commit AFTER the camera settles and the governor - // parks the scene — a changed show flag needs one frame. (perf wave 2 fix) - if (visibilityChanged) governorRequestRender('radio-horizon'); -} - -/** Change marker/list category without interrupting an active station. */ -export function setRadioFilter(categoryId) { - if (!radioPresentationAllowed()) return false; - const valid = _categories.some((category) => category.id === categoryId); - const nextFilter = valid ? categoryId : 'all'; - const changed = nextFilter !== _filter; - if (changed && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning(); - const clearsCancelledPresentation = Boolean(_cancelledTuningPresentationStation); - _cancelledTuningPresentationStation = null; - _filter = nextFilter; - if (clearsCancelledPresentation) updateSelectionEntity(); - if (changed) resetRadioClusterOverlayIdentities(); - updateRenderVisibility(); - if (changed && _dataSource?.clustering?.enabled) { - // Entity visibility changes do not invalidate Cesium's existing cluster - // primitives. Toggle a public clustering input twice to mark the current - // cluster set dirty without changing its effective configuration. - const clusterPoints = _dataSource.clustering.clusterPoints; - _dataSource.clustering.clusterPoints = !clusterPoints; - _dataSource.clustering.clusterPoints = clusterPoints; - _viewer?.scene?.requestRender(); - } - scheduleRadioOverlayPublish(); - emitState(); - return true; -} - -/** Retain refresh identities only while every represented station still exists. */ -export function retainRadioClusterIdentitiesForStations(previous, stations) { - const stationIds = new Set((stations || []).map((station) => String(station?.id || '')).filter(Boolean)); - return (previous || []).filter((candidate) => ( - Array.isArray(candidate?.stationIds) - && candidate.stationIds.length > 0 - && candidate.stationIds.every((stationId) => stationIds.has(String(stationId))) - )); -} - -function reconcileStations(stations) { - if (!_tuningActive) _cancelledTuningPresentationStation = null; - _clusterOverlayIdentities = retainRadioClusterIdentitiesForStations( - _clusterOverlayIdentities, - stations, - ); - _stations = Object.freeze([...stations]); - _stationById = new Map(stations.map((station) => [station.id, station])); - _categories = Object.freeze(buildRadioCategories(stations).map((category) => Object.freeze(category))); - if (_filter === DEFAULT_RADIO_FILTER && !filterRadioStations(stations, DEFAULT_RADIO_FILTER).length) { - _filter = 'all'; - } - if (_selectedId && !_stationById.has(_selectedId)) { - if (_audioStationId === _selectedId) stopRadioPlayback(); - _selectedId = null; - } - - if (_dataSource) _dataSource.entities.removeAll(); - _renderById.clear(); - for (const station of stations) { - const position = markerPosition(station); - const markerColor = radioCategoryColor(radioStationCategoryId(station)); - const entity = _dataSource.entities.add({ - id: `${RADIO_PREFIX}${station.id}`, - position, - point: { - pixelSize: 13, - color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86), - outlineColor: Cesium.Color.fromCssColorString('#071b25'), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(100_000, 1.15, 12_000_000, 1), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - }); - _renderById.set(station.id, { station, entity, position }); - } - updateSelectionEntity(); - updateRenderVisibility(); -} - -function installClusterStyling() { - if (!_dataSource || _removeClusterListener) return; - const clustering = _dataSource.clustering; - clustering.enabled = true; - clustering.pixelRange = 42; - clustering.minimumClusterSize = 3; - clustering.clusterPoints = true; - clustering.clusterLabels = false; - clustering.clusterBillboards = false; - _removeClusterListener = clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { - const clusteredStations = []; - for (const entity of clusteredEntities) { - const stationId = String(entity.id || '').slice(RADIO_PREFIX.length); - const station = _renderById.get(stationId)?.station; - if (station) clusteredStations.push(station); - } - const clusterCategory = radioClusterCategoryId(clusteredStations, _filter); - const clusterColor = Cesium.Color.fromCssColorString(radioCategoryColor(clusterCategory)); - // Cesium assigns the entity-id array only to the generated cluster label. - // Mirror it to the visible point so clicking either part of the callout - // resolves the first station and remains a direct playback gesture. - cluster.point.id = clusteredEntities; - cluster.billboard.id = clusteredEntities; - cluster.label.show = false; - cluster.label.text = ''; - cluster.point.show = true; - cluster.point.pixelSize = Math.min(26, 12 + Math.log2(clusteredEntities.length) * 1.6); - cluster.point.color = clusterColor.withAlpha(0.9); - cluster.point.outlineColor = Cesium.Color.BLACK; - cluster.point.outlineWidth = 2; - cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY; - cluster.point.distanceDisplayCondition = new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ); - scheduleRadioOverlayPublish(); - }); -} - -function pickedRadioStationAt(position) { - const scene = _viewer?.scene; - if (!scene || !position) return null; - - const stationFromPick = (picked) => { - const stationId = radioStationIdFromPick(picked); - return stationId && _stationById.has(stationId) ? stationId : null; - }; - const primaryPick = scene.pick(position); - const primaryStationId = stationFromPick(primaryPick); - if (primaryStationId) return primaryStationId; - const primaryId = resolvePickId(primaryPick); - if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; - - if (typeof scene.drillPick === 'function') { - const drilled = scene.drillPick(position, 16) || []; - for (const picked of drilled) { - const stationId = stationFromPick(picked); - if (stationId) return stationId; - } - } - - for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { - const offsetPosition = new Cesium.Cartesian2(position.x + offsetX, position.y + offsetY); - const picked = scene.pick(offsetPosition); - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; - const stationId = stationFromPick(picked); - if (stationId) return stationId; - } - return null; -} - -function radioPresentationAllowed() { - if (!_managerLifecyclePresentation) return _enabled; - return _enabled - && _managerLifecyclePresentation.lifecycleState === 'enabled' - && _managerLifecyclePresentation.enabled - && !_managerLifecyclePresentation.uncertain; -} - -function syncRadioLifecyclePresentation() { - const visible = radioPresentationAllowed(); - if (_dataSource) _dataSource.show = visible; - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); - if (visible) { - installInteraction(); - updateSelectionEntity(); - updateRenderVisibility(); - scheduleRadioOverlayPublish(); - } else { - removeInteraction(); - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - } -} - -function installInteraction() { - if (!_viewer || _clickHandler) return; - registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); - _clickHandler = new Cesium.ScreenSpaceEventHandler(_viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!radioPresentationAllowed()) return; - const stationId = pickedRadioStationAt(click.position); - if (!stationId) return; - _playFallbackId = null; - _playFallbackFocus = null; - selectRadioStation(stationId, { autoplay: true, origin: 'user' }); - if (typeof document !== 'undefined') { - document.dispatchEvent(new CustomEvent('gev:radio-selected', { detail: { stationId } })); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - // Polling remains bounded during flights, but a stationary camera no longer - // rewrites every station's visibility four times per second while voice and - // audio processing share the main thread. - _horizonTimer = setInterval(() => updateRenderVisibility({ force: false }), HORIZON_TICK_MS); -} - -function removeInteraction() { - unregisterPickOwner('radio'); - _clickHandler?.destroy(); - _clickHandler = null; - if (_horizonTimer) clearInterval(_horizonTimer); - _horizonTimer = null; -} - -/** Radio layer lifecycle implementation. */ -export const radioLayer = { - id: 'radio', - name: 'Radio', - icon: '◉', - source: 'Radio Browser', - updateInterval: 45 * 60 * 1000, - - /** Initialize the Cesium data source and the single audio element. */ - init(viewer) { - _sessionGeneration += 1; - _viewer = viewer; - resetRadioClusterOverlayIdentities(); - installAudio(); - if (!_dataSource) { - _dataSource = new Cesium.CustomDataSource('Radio stations'); - viewer.dataSources.add(_dataSource); - installClusterStyling(); - } - _dataSource.show = false; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - }, - - /** Show stations. Enabling or preset restoration never starts audio. */ - enable() { - _enabled = true; - syncRadioLifecyclePresentation(); - emitState(); - }, - - /** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */ - setLifecyclePresentation({ lifecycleState = null, enabled = false, uncertain = false } = {}) { - const settledState = enabled ? 'enabled' : 'disabled'; - const normalizedState = ['enabling', 'enabled', 'disabling', 'disabled'].includes(lifecycleState) - ? lifecycleState - : settledState; - _managerLifecyclePresentation = { - lifecycleState: normalizedState, - enabled: Boolean(enabled), - uncertain: Boolean(uncertain), - }; - syncRadioLifecyclePresentation(); - emitState(); - }, - - /** Hide the layer and stop playback without forgetting the selected station. */ - disable() { - _sessionGeneration += 1; - _enabled = false; - invalidateRadioCameraNavigation(); - _requestGeneration += 1; - _abortController?.abort(); - _abortController = null; - _loading = false; - _selectionGeneration += 1; - if (_selectionTimer) clearTimeout(_selectionTimer); - _selectionTimer = null; - removeInteraction(); - endRadioTuning(); - _cancelledTuningPresentationStation = null; - _tuningUnavailableStationId = null; - stopRadioPlayback({ origin: 'layer-disable' }); - if (_dataSource) _dataSource.show = false; - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer); - _overlayPublishTimer = null; - resetRadioClusterOverlayIdentities(); - _overlayDiagnostics = emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - emitState(); - }, - - /** Refresh directory metadata through the hardened same-origin broker. */ - async update() { - if (!_enabled) return; - const generation = ++_requestGeneration; - const sessionGeneration = _sessionGeneration; - _abortController?.abort(); - _abortController = new AbortController(); - _loading = true; - _error = null; - emitState(); - try { - const response = await fetch(DIRECTORY_ENDPOINT, { signal: _abortController.signal }); - if (!response.ok) throw new Error(`Radio directory returned ${response.status}`); - const body = await response.json(); - if (!radioRequestIsCurrent( - generation, - _requestGeneration, - _enabled, - sessionGeneration, - _sessionGeneration, - )) return; - if (!Array.isArray(body?.stations)) throw new Error('Radio directory response was malformed'); - const updatedAt = typeof body.updatedAt === 'string' && Number.isFinite(Date.parse(body.updatedAt)) - ? body.updatedAt - : null; - const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN; - if ( - !updatedAt - || updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS - || updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS - || typeof body.stale !== 'boolean' - || typeof body.degraded !== 'boolean' - ) throw new Error('Radio directory freshness metadata was malformed'); - const rows = body.stations; - const acceptedRows = rows.filter(isValidRadioDirectoryStation); - if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error('Radio directory response contained malformed stations'); - } - const acceptedGeneration = body.acceptedGeneration; - if ( - acceptedGeneration !== null - && (!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1) - ) throw new Error('Radio directory generation metadata was malformed'); - if (!body.stale && !body.degraded && acceptedGeneration === null) { - throw new Error('Radio directory omitted its accepted generation'); - } - const catalogInstance = body.catalogInstance; - if (!body.stale && !body.degraded && (typeof catalogInstance !== 'string' || !catalogInstance)) { - throw new Error('Radio directory omitted its catalog instance'); - } - const preservingWarmCatalog = _stations.length > 0 && (body.stale || body.degraded); - // Generations are only comparable within one producer instance. A new - // instance token (server restart, different proxy process) starts a fresh - // sequence: never a repeat and never a regression. - const sameCatalogInstance = _acceptedCatalogSnapshot?.instance === catalogInstance; - const currentAcceptedGeneration = sameCatalogInstance - ? _acceptedCatalogSnapshot?.generation - : null; - if ( - !body.stale - && !body.degraded - && Number.isSafeInteger(currentAcceptedGeneration) - && acceptedGeneration < currentAcceptedGeneration - ) throw new Error('Radio directory generation regressed'); - const repeatingAcceptedGeneration = ( - !body.stale - && !body.degraded - && Number.isSafeInteger(currentAcceptedGeneration) - && acceptedGeneration === currentAcceptedGeneration - ); - if (!preservingWarmCatalog) { - const immutableRows = acceptedRows.map(freezeRadioStation); - if (repeatingAcceptedGeneration) { - _updatedAt = _acceptedCatalogSnapshot.updatedAt; - if (!_tuningActive && _cancelledTuningPresentationStation) { - _cancelledTuningPresentationStation = null; - updateSelectionEntity(); - } - } else if (!body.stale && !body.degraded) { - const acceptedSnapshot = createAcceptedCatalogSnapshot( - catalogInstance, - acceptedGeneration, - updatedAt, - immutableRows, - ); - if (!acceptedSnapshot) throw new Error('Radio directory generation metadata was malformed'); - _acceptedCatalogSnapshot = acceptedSnapshot; - reconcileStations(acceptedSnapshot.stations); - } else { - reconcileStations(immutableRows); - } - if (!repeatingAcceptedGeneration) _updatedAt = updatedAt; - } - _degraded = body.degraded; - _stale = body.stale; - _error = preservingWarmCatalog - ? 'Directory refresh degraded; showing the previous station catalog.' - : (_degraded ? 'Radio directory coverage is degraded.' : null); - } catch (error) { - if (error?.name === 'AbortError' || !radioRequestIsCurrent( - generation, - _requestGeneration, - _enabled, - sessionGeneration, - _sessionGeneration, - )) return; - _error = _stations.length - ? 'Directory refresh failed; showing the previous station catalog.' - : 'Radio directory is temporarily unavailable.'; - _stale = _stations.length > 0; - _degraded = _stations.length > 0; - } finally { - if (generation === _requestGeneration && sessionGeneration === _sessionGeneration) { - _loading = false; - _abortController = null; - emitState(); - } - } - }, - - /** Release rendering, event, request, and playback resources. */ - destroy() { - this.disable(); - cancelRadioVolumeTransition(); - _voiceDucked = false; - _voiceRestoring = false; - endRadioTuning(); - const closedTuningNoise = _tuningNoiseContext?.close?.(); - if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {}); - _tuningNoiseContext = null; - if (_audio) _audio.volume = DEFAULT_RADIO_VOLUME; - _audio = null; - _userVolume = DEFAULT_RADIO_VOLUME; - _removeClusterListener?.(); - _removeClusterListener = null; - if (_dataSource && _viewer) _viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - _stations = []; - _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _stationById.clear(); - _categories = Object.freeze([]); - _filter = DEFAULT_RADIO_FILTER; - _renderById.clear(); - resetRadioClusterOverlayIdentities(); - _lastHorizonCameraPosition = null; - _horizonScanCount = 0; - _selectedId = null; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - _tuningUnavailableStationId = null; - _cancelledTuningPresentationStation = null; - _loading = false; - _stale = false; - _degraded = false; - _error = null; - _updatedAt = null; - _managerLifecyclePresentation = null; - _viewer = null; - emitState(); - _listeners.clear(); - _playbackControlListeners.clear(); - }, - - /** Layer statistics for HUD/debug surfaces. */ - getStats() { - return { - count: _stations.length, - filtered: visibleStations().length, - selected: _selectedId, - playing: _audioStationId, - stale: _stale, - degraded: _degraded, - loading: _loading, - error: _error, - lastUpdate: _updatedAt ? Date.parse(_updatedAt) : null, - horizonScans: _horizonScanCount, - overlayEntries: _overlayDiagnostics.entryCount, - }; - }, - - subscribe: subscribeToRadio, - subscribePlaybackControls: subscribeToRadioPlaybackControls, - getAcceptedCatalogSnapshot: getRadioAcceptedCatalogSnapshot, - getUIState: getRadioUIState, - getParams: getRadioParams, - setParams: setRadioParams, - getOverlayDiagnostics: () => ({ - ..._overlayDiagnostics, - singletonTexts: [..._overlayDiagnostics.singletonTexts], - singletonIds: [..._overlayDiagnostics.singletonIds], - clusterTexts: [..._overlayDiagnostics.clusterTexts], - clusterIds: [..._overlayDiagnostics.clusterIds], - clusterMemberships: _overlayDiagnostics.clusterMemberships.map((entry) => ({ ...entry })), - }), - getTunerStations: getRadioTunerStations, - beginTuning: beginRadioTuning, - setTuningStatic: setRadioTuningStatic, - previewTuningStation: previewRadioTuningStation, - commitTuningStation: commitRadioTuningStation, - cancelTuning: cancelRadioTuning, - endTuning: endRadioTuning, - setFilter: setRadioFilter, - selectStation: selectRadioStation, - selectRequestedStation: selectRequestedRadioStation, - cycleStation: cycleRadioStation, - togglePlayback: toggleRadioPlayback, - play: playSelectedRadio, - playForVoice: playPreparedRadioForVoice, - pause: pauseRadioPlayback, - stopPlayback: stopRadioPlayback, - setVolume: setRadioVolume, - setVoiceDucked: setRadioVoiceDucking, -}; - -export default radioLayer; +export const radioGlobeLabel = layer.radioGlobeLabel; +export const createRadioSelectedOverlayEntry = + layer.createRadioSelectedOverlayEntry; +export const createRadioClusterOverlayEntry = + layer.createRadioClusterOverlayEntry; +export const createRadioSingletonOverlayEntry = + layer.createRadioSingletonOverlayEntry; +export const radioSingletonLabelLimit = layer.radioSingletonLabelLimit; +export const selectRadioSingletonCandidates = + layer.selectRadioSingletonCandidates; +export const selectRadioClusterCandidates = layer.selectRadioClusterCandidates; +export const reconcileRadioClusterCandidates = + layer.reconcileRadioClusterCandidates; +export const radioCameraPositionChanged = layer.radioCameraPositionChanged; +export const normalizeRadioTag = layer.normalizeRadioTag; +export const stationMatchesRadioCategory = layer.stationMatchesRadioCategory; +export const radioCategoryColor = layer.radioCategoryColor; +export const radioClusterBadgeText = layer.radioClusterBadgeText; +export const radioClusterCategoryId = layer.radioClusterCategoryId; +export const radioSelectionBracketSvg = layer.radioSelectionBracketSvg; +export const radioStationCategoryId = layer.radioStationCategoryId; +export const buildRadioCategories = layer.buildRadioCategories; +export const filterRadioStations = layer.filterRadioStations; +export const isEnglishRadioStation = layer.isEnglishRadioStation; +export const rankRadioStationsForViewport = layer.rankRadioStationsForViewport; +export const rankRadioStationsForRequest = layer.rankRadioStationsForRequest; +export const radioViewIsGlobal = layer.radioViewIsGlobal; +export const radioRequestIsCurrent = layer.radioRequestIsCurrent; +export const radioStationIdFromPick = layer.radioStationIdFromPick; +export const radioStationCameraPlan = layer.radioStationCameraPlan; +export const radioGlobeNeedsRecentering = layer.radioGlobeNeedsRecentering; +export const radioGlobeRecenterHeight = layer.radioGlobeRecenterHeight; +export const buildRadioTunerBand = layer.buildRadioTunerBand; +export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay; +export const getRadioAcceptedCatalogSnapshot = + layer.getRadioAcceptedCatalogSnapshot; +export const radioStationResolutionMatches = + layer.radioStationResolutionMatches; +export const getRadioUIState = layer.getRadioUIState; +export const subscribeToRadio = layer.subscribeToRadio; +export const subscribeToRadioPlaybackControls = + layer.subscribeToRadioPlaybackControls; +export const getRadioTunerStations = layer.getRadioTunerStations; +export const beginRadioTuning = layer.beginRadioTuning; +export const setRadioTuningStatic = layer.setRadioTuningStatic; +export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed; +export const previewRadioTuningStation = layer.previewRadioTuningStation; +export const endRadioTuning = layer.endRadioTuning; +export const cancelRadioTuning = layer.cancelRadioTuning; +export const commitRadioTuningStation = layer.commitRadioTuningStation; +export const playSelectedRadio = layer.playSelectedRadio; +export const confirmRadioPlayback = layer.confirmRadioPlayback; +export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice; +export const stopRadioPlayback = layer.stopRadioPlayback; +export const toggleRadioPlayback = layer.toggleRadioPlayback; +export const pauseRadioPlayback = layer.pauseRadioPlayback; +export const setRadioVolume = layer.setRadioVolume; +export const setRadioParams = layer.setRadioParams; +export const getRadioParams = layer.getRadioParams; +export const setRadioVoiceDucking = layer.setRadioVoiceDucking; +export const selectRadioStation = layer.selectRadioStation; +export const cycleRadioStation = layer.cycleRadioStation; +export const selectRequestedRadioStation = layer.selectRequestedRadioStation; +export const setRadioFilter = layer.setRadioFilter; +export const retainRadioClusterIdentitiesForStations = + layer.retainRadioClusterIdentitiesForStations; +export { + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, + GLOBAL_RADIO_ALTITUDE_M, +} from '../layers/radio/index.js'; +export default layer; + +export const radioLayer = layer; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from '../ui/radioTunerModel.js'; diff --git a/src/data/radioCountry.test.mjs b/src/data/radioCountry.test.mjs index 3270b46dc..aafe13a07 100644 --- a/src/data/radioCountry.test.mjs +++ b/src/data/radioCountry.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { normalizeRadioCountryInput } from './radioCountry.js'; +import { normalizeRadioCountryInput } from '../layers/radio/index.js'; test('Radio country normalization maps ISO codes and bounded common names', () => { for (const [input, code] of [ diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index c52b9d20d..c63b5e7a9 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,3700 +1,82 @@ -import * as Cesium from 'cesium'; import { - findSatelliteOrbitTrackInTle, - getSatelliteOrbitTrack, - orbitFrameModelMatrix, -} from './satellites.js'; -import { getKeyholeGeometry } from '../celestialRing.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; + createRocketLaunchesLayer, + createLaunchSource, +} from '../layers/launches/index.js'; +import * as satellites from './satellites.js'; +import * as geometry from '../celestialRing.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as render from '../renderGovernor.js'; -const WINDOW_DAYS = 30; -const API_URL = '/api/launches'; - -export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = 'rocket-mission-selected'; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 12, - collisionCapacity: 0, - moving: true, - solveIntervalMs: 0, +const layer = createRocketLaunchesLayer({ + source: createLaunchSource(), + services: { satellites, geometry, overlays, render }, }); - -const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, - moving: false, -}); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); - -let _dataSource = null; -let _count = 0; -let _lastUpdate = null; -let _lastError = null; -let _orbitMatches = 0; -let _clickHandler = null; -let _moveHandler = null; -let _viewer = null; -let _declutterHandler = null; -let _dataManager = null; -let _retryTimer = null; -let _postTleRetryCount = 0; -let _updatePromise = null; -let _updatePromiseToken = 0; -let _updateDirty = false; -let _lifecycleToken = 0; -let _enabled = false; -let _selectedLaunchId = null; -let _explicitSelection = false; -let _launches = []; -let _missionPanel = null; -let _missionRoster = null; -let _missionRosterHoverTimer = null; -let _hoveredRosterLaunchId = null; -let _missionRosterPreviewOwnership = null; -let _missionHoverReticleImage = null; -let _replayVehicleOverlay = null; -let _replayVehicleOverlayText = ''; -const _animationStarts = new Map(); -const _satelliteTelemetry = new Map(); -let _lastPanelTelemetryMs = 0; -let _lastMissionRingRotationMs = 0; -let _activeTleText = null; -let _activeTlePromise = null; -let _activeTlePromiseToken = 0; -let _renderedTleText = null; -let _focusAfterActiveLookup = false; -let _satelliteStateBeforeMission = null; -let _satelliteActivationPromise = null; -let _replayCameraRemover = null; -let _replayCameraLaunchId = null; -let _replayCameraToken = 0; -let _replayPaused = false; -let _replayPausedAtMs = null; -let _replaySpeed = 1; -const _replayTracks = new Map(); -const _missionOverlayRecords = new Map(); -let _missionOverlayHost = DEFAULT_OVERLAY_HOST; -let _selectedMissionOverlayTimeText = null; -let _missionZoomAnchorRemover = null; -let _missionZoomAnchorId = null; -let _launchPadZonePrimitive = null; -let _launchPadZoneRemover = null; -let _launchPadZoneLaunchId = null; -const _missionOrbitPrimitives = new Map(); - -const REPLAY_ASCENT_FALLBACK_SEC = 12; -const REPLAY_ASCENT_MIN_SEC = 8; -const REPLAY_ASCENT_MAX_SEC = 36; -const REPLAY_ORBIT_DURATION_SEC = 28; -const REPLAY_COUNTDOWN_DURATION_SEC = 10; -const REPLAY_TILE_SETTLE_DELAY_SEC = 5; -const REPLAY_INITIAL_RANGE_M = 3500; -const REPLAY_LOCAL_MAX_RANGE_M = 900000; -const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; -const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; -const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; -const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; -const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); -const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); -const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; -const REPLAY_SPEED_MIN = 0.25; -const REPLAY_SPEED_MAX = 4; -const REPLAY_SPEED_STEP = 0.25; -const MAX_POST_TLE_RETRIES = 1; -const POST_TLE_RETRY_DELAY_MS = 1500; -const _declutterTime = new Cesium.JulianDate(); -const _declutterOccluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - new Cesium.Cartesian3(), -); -const _missionRingDate = new Date(0); -const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; -const PROJECTED_ASCENT_ROTATION_SEC = 600; -const STAGE_REENTRY_ALTITUDE_M = 100000; -const MISSION_CLOSE_VIEW_RANGE_M = 180000; -const MISSION_GLOBE_VIEW_RANGE_M = 5000000; -const MISSION_FOCUS_RANGE_M = 12000; -const SATELLITE_STANDALONE_DEFAULTS = { - catalog: 'core', - showPoints: true, - showOrbits: true, -}; - -/** - * Derive the temporary Satellite display mode required by Space Missions. - * @param {object|null} currentParams Complete pre-mission Satellite parameters. - * @returns {object} Temporary mission-specific Satellite parameters. - */ -export function satelliteParamsForSpaceMissions(currentParams) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(currentParams || {}), - catalog: 'dense', - showPoints: false, - showOrbits: false, - }; -} - -/** - * Resolve the complete Satellite parameter set restored after mission mode. - * @param {object|null} snapshot Complete pre-mission Satellite parameters. - * @returns {object} Standalone Satellite parameters. - */ -export function satelliteParamsAfterSpaceMissions(snapshot) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(snapshot || {}), - }; -} - -/** - * Failed launch records may retain a planned orbit in Launch Library, but - * must not be represented as a live or estimated payload in orbit. - * @param {string|null} status Normalized Launch Library status name. - * @returns {boolean} Whether orbital visualization is allowed. - */ -export function launchStatusAllowsOrbit(status) { - return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test(String(status || '')); -} - -/** - * Describe only the mission paths that the selected record can actually show. - * Launch Library may retain a target orbit after a failure, but that target is - * not evidence of orbital insertion and cannot support a reconstructed replay. - * @param {object|null} launch Normalized launch record. - * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. - * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} - */ -export function missionPathPresentation(launch, replayAvailable = false) { - const orbitName = launch?.orbit?.name || (typeof launch?.orbit === 'string' ? launch.orbit : null); - const orbitAllowed = launchStatusAllowsOrbit(launch?.status); - const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) - ? launch.trajectory.filter((point) => ( - Number.isFinite(Number(point?.latitude)) - && Number.isFinite(Number(point?.longitude)) - )).length - : 0; - return { - orbit: orbitName ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` : null, - ascent: suppliedTrajectoryPoints > 1 - ? 'SUPPLIED TRAJECTORY POINTS' - : replayAvailable ? 'RECONSTRUCTED ESTIMATE' : 'UNAVAILABLE', - replayAvailable: Boolean(replayAvailable), - }; -} - -/** - * Decide whether one bounded rebuild is needed after the active TLE lookup. - * @param {object} input Retry state. - * @param {boolean} input.enabled Whether Space Missions is active. - * @param {number} input.retryCount Number of post-TLE retries already used. - * @param {string|null} input.activeTleText Resolved active TLE catalog. - * @param {string|null} input.renderedTleText TLE catalog used for the last build. - * @returns {boolean} Whether to schedule a refresh. - */ -export function shouldRetryAfterActiveTle({ - enabled, - retryCount, - activeTleText, - renderedTleText, -}) { - return Boolean( - enabled - && activeTleText - && activeTleText !== renderedTleText - && retryCount < MAX_POST_TLE_RETRIES, - ); -} - -/** - * Release aircraft follow state through each owning flight layer before a - * mission replay takes over the camera. - * @param {object|null} dataManager DataLayerManager instance. - * @returns {number} Number of owner APIs invoked. - */ -export function releaseAircraftTracking(dataManager) { - let released = 0; - for (const layerId of ['flights', 'military']) { - const module = dataManager?.layers?.get(layerId)?.module; - if (typeof module?.stopTracking !== 'function') continue; - module.stopTracking(); - released++; - } - return released; -} -const MISSION_ORBIT_PATTERN_GROUPS = 4; -const MISSION_ORBIT_DASHES_PER_GROUP = 100; -export const LAUNCH_PAD_ZONE_RADIUS_M = 500; -const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; -const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; - -const TRAJECTORY_STAGE_COLORS = [ - '#ff9f43', '#ff66c4', '#a78bfa', '#7bed9f', '#ffd166', '#60a5fa', -]; - -let _missionOrbitPatternRegistered = false; -const _pathDistanceCache = new WeakMap(); - -/** - * Register the selected-orbit tactical material once. Each group begins with - * one compact round dot followed by one hundred short dashes. - */ -function ensureMissionOrbitPatternRegistered() { - if (_missionOrbitPatternRegistered) return; - new Cesium.Material({ - fabric: { - type: 'GevMissionOrbitTactical', - uniforms: { - color: Cesium.Color.CYAN, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - float groupPosition = fract(materialInput.st.s * groupCount); - float markPosition = groupPosition * (dashCount + 1.0); - float markIndex = floor(markPosition); - float localPosition = fract(markPosition); - float centerDistance = abs(localPosition - 0.5); - float edge = max(fwidth(localPosition) * 1.35, 0.012); - float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); - float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); - float dash = dashAlong * dashAcross; - float dotAlong = (localPosition - 0.5) / 0.32; - float dotAcross = (materialInput.st.t - 0.5) / 0.5; - float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); - float isDot = 1.0 - step(0.5, markIndex); - float visible = mix(dash, dot, isDot); - material.diffuse = color.rgb; - material.emission = color.rgb * mix(0.07, 0.65, isDot); - material.alpha = color.a * visible * mix(0.58, 1.0, isDot); - return material; - }`, - }, - }); - _missionOrbitPatternRegistered = true; -} - -function createMissionOrbitPatternMaterial(color) { - ensureMissionOrbitPatternRegistered(); - return Cesium.Material.fromType('GevMissionOrbitTactical', { - color, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }); -} - -function missionOrbitPrimitiveVisible(launchId) { - return Boolean( - _enabled - && _dataSource?.show - && (!_selectedLaunchId || _selectedLaunchId === launchId), - ); -} - -function syncMissionOrbitPrimitiveVisibility() { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (path.primitive) path.primitive.show = missionOrbitPrimitiveVisible(launchId); - } -} - -function removeMissionOrbitPrimitives() { - for (const path of _missionOrbitPrimitives.values()) { - if (path.primitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(path.primitive); - } - } - _missionOrbitPrimitives.clear(); -} - -function updateMissionOrbitPrimitiveFrames(nowDate) { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - if (path.labelBakePosition && path.labelPosition) { - Cesium.Matrix4.multiplyByPoint( - path.primitive.modelMatrix, - path.labelBakePosition, - path.labelPosition, - ); - } - } -} - -function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { - if (!_viewer || !satelliteTrack || !Number.isFinite(satelliteTrack.gmstAtBake)) return false; - const collection = new Cesium.PolylineCollection(); - collection.add({ - positions: orbitPath, - width: 3, - material: createMissionOrbitPatternMaterial( - Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), - ), - }); - collection.show = missionOrbitPrimitiveVisible(launch.id); - orbitFrameModelMatrix(satelliteTrack.gmstAtBake, new Date(), collection.modelMatrix); - _viewer.scene.primitives.add(collection); - _missionOrbitPrimitives.set(launch.id, { - primitive: collection, - gmstAtBake: satelliteTrack.gmstAtBake, - }); - return true; -} - -function MissionOrbitPatternMaterialProperty(color) { - ensureMissionOrbitPatternRegistered(); - this._color = color; - this._definitionChanged = new Cesium.Event(); -} - -Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { - isConstant: { get() { return true; } }, - definitionChanged: { get() { return this._definitionChanged; } }, -}); - -MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { - return 'GevMissionOrbitTactical'; -}; - -MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(time, result) { - if (!Cesium.defined(result)) result = {}; - result.color = this._color; - result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; - result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; - return result; -}; - -MissionOrbitPatternMaterialProperty.prototype.equals = function equals(other) { - return this === other; -}; - -/** - * Decide whether the selected launch-pad zone belongs in the current view. - * Both altitude and direct camera range are bounded so an oblique close-up can - * show the effect without leaking it into regional or globe views. - * @param {object} input Visibility inputs. - * @param {boolean} input.layerActive Whether Space Missions is active. - * @param {string|null} input.selectedLaunchId Selected mission identifier. - * @param {string} input.launchId Candidate mission identifier. - * @param {number} input.cameraHeightM Camera height above the ellipsoid. - * @param {number} input.cameraDistanceM Direct camera range to the launch pad. - * @returns {boolean} - */ -export function launchPadZoneVisible({ - layerActive, - selectedLaunchId, - launchId, - cameraHeightM, - cameraDistanceM, -}) { - return Boolean( - layerActive - && selectedLaunchId - && selectedLaunchId === launchId - && Number.isFinite(cameraHeightM) - && cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M - && Number.isFinite(cameraDistanceM) - && cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, - ); -} - -/** - * Resolve whether a surface mission anchor is safely on the camera-facing - * side of Earth. The small positive limb margin prevents labels anchored just - * beyond the horizon from leaking through the hidden Cesium globe. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {number} [limbMargin] Additional normalized horizon clearance. - * @returns {boolean} Whether the marker belongs on the visible hemisphere. - */ -export function missionAnchorHorizonVisible(cameraPosition, markerPosition, limbMargin = 0.012) { - if (!cameraPosition || !markerPosition) return false; - const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); - if (!Number.isFinite(cameraMagnitude) || cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius) { - return false; - } - const cameraDirection = Cesium.Cartesian3.normalize( - cameraPosition, - new Cesium.Cartesian3(), - ); - const markerDirection = Cesium.Cartesian3.normalize( - markerPosition, - new Cesium.Cartesian3(), - ); - const limbThreshold = Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; - return Cesium.Cartesian3.dot(cameraDirection, markerDirection) - > limbThreshold + limbMargin; -} - -/** - * Resolve launch-anchor visibility for overview and selected-mission views. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {string} markerId Mission represented by this anchor. - * @param {string|null} selectedLaunchId Explicitly selected mission. - * @returns {boolean} Whether the launch anchor should render. - */ -export function missionAnchorVisible( - cameraPosition, - markerPosition, - markerId, - selectedLaunchId = null, -) { - if (selectedLaunchId && markerId !== selectedLaunchId) return false; - return missionAnchorHorizonVisible(cameraPosition, markerPosition); -} - -function focusFullGlobe(viewer, duration = 2.4) { - const canvas = viewer?.scene?.canvas; - const height = canvas?.clientHeight || canvas?.height; - const width = canvas?.clientWidth || canvas?.width; - const cartographic = viewer?.camera?.positionCartographic; - const fovy = viewer?.camera?.frustum?.fovy; - if (!(width > 0) || !(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) return; - const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; - const keyholeRadius = getKeyholeGeometry(width, height).radius; - const targetScreenRadius = keyholeRadius * 0.61; - const angularRadius = Math.atan((targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)); - const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); - const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, altitude), - orientation: { heading: viewer.camera.heading, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - -function shortMissionLabel(name, maxLength = 24) { - const text = String(name || 'Unnamed mission').replace(/\s+/g, ' ').trim().split(' | ')[0]; - const compact = text.split(' — ')[0].trim(); - return compact.length > maxLength ? `${compact.slice(0, maxLength - 1).trimEnd()}…` : compact; -} - -/** - * Reduce generic launch-complex names to their identifying pad or area suffix. - * @param {string|null} launchSite Launch Library pad name. - * @returns {string|null} Compact launch-site identifier. - */ -export function compactLaunchSiteName(launchSite) { - const text = String(launchSite || '').replace(/\s+/g, ' ').trim(); - if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; - const genericPrefix = /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; - const compact = text.replace(genericPrefix, '').trim(); - return compact || null; -} - -/** - * Build the source-owned launch-site marker presentation. Overview markers - * compete in the bounded ambient-label domain; the selected mission moves to - * the protected selected lane and gains the former launch-site detail line. - * @param {object} launch Normalized Launch Library mission. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. - * @param {boolean} [selected=false] Whether the mission owns the selected view. - * @returns {object} - */ -export function createRocketMissionMarkerOverlayEntry(launch, position, selected = false) { - const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); - const siteName = compactLaunchSiteName(launch?.launchSite); - const launchTimeMs = Date.parse(launch?.launchTime); - const details = selected - ? [siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'] - : []; - return { - id: `launch:${launch?.id}`, - position, - variant: 'label', - title: mission, - details, - accent: '#22e6e6', - priority: selected - ? Number.MAX_SAFE_INTEGER - : Number.isFinite(launchTimeMs) ? Math.floor(launchTimeMs / 1000) : 0, - selected, - protected: selected, - paintLane: selected ? 'selected' : 'ambient-label', - collisionGroup: 'ambient-label', - interactive: false, - distanceScale: { - near: 1000, - nearValue: 1.08, - far: 20_000_000, - farValue: 0.78, - }, - gapPx: 12, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** - * Build one protected label belonging to the selected mission's trajectory, - * live payload position, or orbit. Copy is already source-formatted by the - * caller; newline semantics become explicit host detail rows. - * @param {object} input - * @param {string} input.id Stable mission-element identity. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. - * @param {string} input.text Former native-label text, including newlines. - * @param {string} input.accent Source-owned label color. - * @param {number} [input.priority=0] Protected placement order. - * @param {number} [input.gapPx=8] Anchor-to-label gap. - * @returns {object} - */ -export function createRocketMissionElementOverlayEntry({ - id, - position, - text, - accent, - priority = 0, - gapPx = 8, -}) { - const [title, ...details] = String(text || '').split('\n'); - return { - id: String(id), - position, - variant: 'label', - title, - details, - accent, - priority, - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-label', - interactive: false, - gapPx, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** Keep the newest ambient mission markers with stable identity tie-breaking. */ -export function selectRocketMissionMarkerOverlayCohort( - entries, - limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, -) { - const cap = Math.max(0, Math.min( - ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - Math.floor(Number(limit) || 0), - )); - if (!Array.isArray(entries) || cap === 0) return []; - return entries.slice().sort((a, b) => ( - b.priority - a.priority || String(a.id).localeCompare(String(b.id)) - )).slice(0, cap); -} - -function missionHoverReticleImage() { - if (_missionHoverReticleImage) return _missionHoverReticleImage; - const canvas = document.createElement('canvas'); - const size = 48; - const inset = 6; - const arm = 12; - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); - context.strokeStyle = '#22e6e6'; - context.lineWidth = 2; - context.lineCap = 'square'; - context.shadowColor = 'rgba(34, 230, 230, .72)'; - context.shadowBlur = 5; - context.beginPath(); - context.moveTo(inset, inset + arm); - context.lineTo(inset, inset); - context.lineTo(inset + arm, inset); - context.moveTo(size - inset - arm, inset); - context.lineTo(size - inset, inset); - context.lineTo(size - inset, inset + arm); - context.moveTo(inset, size - inset - arm); - context.lineTo(inset, size - inset); - context.lineTo(inset + arm, size - inset); - context.moveTo(size - inset - arm, size - inset); - context.lineTo(size - inset, size - inset); - context.lineTo(size - inset, size - inset - arm); - context.stroke(); - _missionHoverReticleImage = canvas; - return canvas; -} - -/** - * Resolve the one permitted screen-space replay overlay state. - * @param {object} input Overlay state. - * @param {boolean} input.replayActive Whether replay owns the camera. - * @param {boolean} input.ascending Whether the replay marker is on ascent. - * @param {boolean} input.countdownActive Whether the T-minus hold is active. - * @returns {'countdown'|'ascent'|'orbit'|null} - */ -export function replayOverlayMode({ - replayActive, - ascending, - countdownActive, - preCountdownActive = false, -}) { - if (!replayActive) return null; - if (preCountdownActive) return null; - if (countdownActive) return 'countdown'; - return ascending ? 'ascent' : 'orbit'; -} - -/** - * Rotate an upright screen-space rocket so its nose follows a projected path. - * @param {{x: number, y: number}} from Current screen point. - * @param {{x: number, y: number}} to Forward screen point. - * @returns {number} Clockwise CSS rotation in radians. - */ -export function replayVehicleScreenRotation(from, to) { - const dx = Number(to?.x) - Number(from?.x); - const dy = Number(to?.y) - Number(from?.y); - if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 0.01) return 0; - return Math.atan2(dx, -dy); -} - -/** - * Reduce small screen-space reprojection jitter without allowing the marker - * to lag behind a camera jump or a phase transition. - * @param {{x:number,y:number}|null} previous Previous rendered position. - * @param {{x:number,y:number}} next Current path projection. - * @param {number} [alpha] Interpolation amount. - * @param {number} [snapDistance] Maximum distance to smooth. - * @returns {{x:number,y:number}} - */ -export function smoothReplayWindowPosition( - previous, - next, - alpha = 0.55, - snapDistance = 24, -) { - if (!previous || !next) return next; - const distance = Math.hypot(next.x - previous.x, next.y - previous.y); - if (!Number.isFinite(distance) || distance > snapDistance) return next; - const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); - return { - x: Cesium.Math.lerp(previous.x, next.x, amount), - y: Cesium.Math.lerp(previous.y, next.y, amount), - }; -} - -function createReplayVehicleOverlay() { - if (_replayVehicleOverlay || typeof document === 'undefined') return; - const host = document.getElementById('cesiumContainer') || document.body; - _replayVehicleOverlay = document.createElement('div'); - _replayVehicleOverlay.className = 'mission-replay-vehicle-overlay'; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.setAttribute('aria-hidden', 'true'); - _replayVehicleOverlay.innerHTML = ` - - - - - - - - - - - - - - - - - - - `; - host.appendChild(_replayVehicleOverlay); -} - -function hideReplayVehicleOverlay() { - if (!_replayVehicleOverlay) return; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.classList.remove('is-thrusting', 'is-paused'); -} - -function destroyReplayVehicleOverlay() { - _replayVehicleOverlay?.remove(); - _replayVehicleOverlay = null; - _replayVehicleOverlayText = ''; -} - -/** - * Score the amount of useful mission context available for roster triage. - * @param {object} launch Normalized launch record. - * @returns {number} Completeness score. - */ -export function missionDataCompleteness(launch = {}) { - let score = 0; - const present = (value) => value !== null && value !== undefined && value !== ''; - score += present(launch.provider) ? 1 : 0; - score += present(launch.mission) ? 2 : 0; - score += present(launch.missionName) ? 1 : 0; - score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; - score += Array.isArray(launch.payloads) ? Math.min(launch.payloads.length, 5) * 2 : 0; - score += Array.isArray(launch.recoveryStages) ? Math.min(launch.recoveryStages.length, 4) * 2 : 0; - score += Array.isArray(launch.trajectory) ? Math.min(launch.trajectory.length, 12) : 0; - score += Array.isArray(launch.timeline) ? Math.min(launch.timeline.length, 6) : 0; - return score; -} - -/** - * Build a data-rich roster while preserving the source-array index - * used by mission selection and Previous/Next navigation. - * @param {Array} launches Normalized launch records. - * @returns {Array<{launch: object, index: number}>} - */ -export function missionRosterEntries(launches) { - return (launches || []) - .map((launch, index) => ({ launch, index })) - .sort((a, b) => { - const completeness = missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); - if (completeness !== 0) return completeness; - const aTime = Date.parse(a.launch?.launchTime); - const bTime = Date.parse(b.launch?.launchTime); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime; - return b.index - a.index; - }); -} - -/** - * Preserve the user's globe scale for roster previews while avoiding an - * accidental surface-level fly-to when the list is opened from a close view. - * @param {number} cameraHeight Current camera height above the ellipsoid. - * @returns {number} Preview range in metres. - */ -export function missionHoverPreviewRange(cameraHeight) { - const height = Number(cameraHeight); - return Math.max( - MISSION_CLOSE_VIEW_RANGE_M, - Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, - ); -} - -/** - * Coordinate pointer and keyboard ownership of the temporary roster preview. - * The most recent input owns the preview until it leaves, then any remaining - * input resumes ownership. This lets keyboard focus take over from a pointer - * resting on another row without losing that pointer preview on blur. - * @param {{preview: (index: number) => void, clear: () => void}} handlers - * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, - * reset: () => void}} - */ -export function createMissionRosterPreviewOwnership({ preview, clear }) { - let pointerIndex = null; - let focusIndex = null; - let latestOwner = null; - let activeIndex = null; - - const sync = () => { - const nextIndex = latestOwner === 'pointer' - ? pointerIndex ?? focusIndex - : focusIndex ?? pointerIndex; - if (nextIndex === activeIndex) return; - activeIndex = nextIndex; - if (Number.isInteger(nextIndex)) preview(nextIndex); - else clear(); - }; - - return { - pointerEnter(index) { - pointerIndex = index; - latestOwner = 'pointer'; - sync(); - }, - pointerLeave() { - pointerIndex = null; - if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus'; - sync(); - }, - focus(index) { - focusIndex = index; - latestOwner = 'focus'; - sync(); - }, - blur(nextIndex = null) { - focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; - if (latestOwner === 'focus') latestOwner = focusIndex === null - ? (pointerIndex === null ? null : 'pointer') - : 'focus'; - sync(); - }, - reset() { - pointerIndex = null; - focusIndex = null; - latestOwner = null; - activeIndex = null; - clear(); - }, - }; -} - -/** - * Bind native keyboard focus events for one rendered roster row. - * @param {Element} button Mission roster button. - * @param {number} index Source-array mission index. - * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership - * @param {Element} roster Roster root used to reject focus targets outside the list. - */ -export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) { - button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); - button.addEventListener('mouseleave', () => ownership.pointerLeave()); - button.addEventListener('focus', () => ownership.focus(index)); - button.addEventListener('blur', (event) => { - const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]'); - const nextIndex = nextButton && roster.contains(nextButton) - ? Number(nextButton.dataset.missionRosterIndex) - : null; - ownership.blur(nextIndex); - }); -} - -/** Capture the exact mission identity owned by keyboard focus before a refresh. */ -export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) { - const button = activeElement?.closest?.('[data-mission-roster-id]'); - if (!button || !list?.contains(button)) return null; - const launchId = button.dataset.missionRosterId; - return launchId ? { launchId } : null; -} - -/** Restore focus by mission identity, or continue after the list if it departed. */ -export function restoreMissionRosterFocus(list, snapshot, continuation) { - if (!snapshot?.launchId) return 'none'; - const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || []) - .find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId); - if (button) { - button.focus({ preventScroll: true }); - return 'restored'; - } - if (continuation?.focus) { - continuation.focus({ preventScroll: true }); - return 'continued'; - } - return 'none'; -} - -/** Resolve a pending preview against the current refresh, never a stale object. */ -export function resolveMissionRosterPreviewLaunch(launches, launchId) { - return (launches || []).find((candidate) => candidate.id === launchId) || null; -} - -/** - * Format a mission epoch for compact on-globe replay labels. - * @param {string|Date|null} launchTime ISO-8601 mission time or Date. - * @returns {string} UTC timestamp or a clear unavailable state. - */ -export function formatMissionEventTime(launchTime) { - const date = new Date(launchTime); - if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; - return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; -} - -/** - * Parse the ISO-8601 durations supplied by Launch Library timeline events. - * @param {string|null} value ISO duration such as PT8M40S or -PT35M. - * @returns {number|null} Signed duration in seconds. - */ -export function parseMissionDurationSeconds(value) { - const match = String(value || '').trim().match( - /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, - ); - if (!match) return null; - const seconds = (Number(match[2] || 0) * 86400) - + (Number(match[3] || 0) * 3600) - + (Number(match[4] || 0) * 60) - + Number(match[5] || 0); - return match[1] ? -seconds : seconds; -} - -function orbitInsertionOffsetSeconds(launch) { - const events = (launch.timeline || []).filter((event) => Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0); - if (!events.length) return null; - const deployment = events.filter((event) => /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test(event.name)); - if (deployment.length) return Math.max(...deployment.map((event) => event.offsetSeconds)); - const engineCutoff = events.filter((event) => /seco|second engine cutoff/i.test(event.name)); - if (engineCutoff.length) return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); - return Math.max(...events.map((event) => event.offsetSeconds)); -} - -function estimatedOrbitPeriodSeconds(orbitPath) { - if (!orbitPath?.length) return 5400; - const meanRadius = orbitPath.reduce((total, point) => total + Cesium.Cartesian3.magnitude(point), 0) / orbitPath.length; - return 2 * Math.PI * Math.sqrt((meanRadius ** 3) / 3.986004418e14); -} - -/** - * Derive a compressed but mission-specific ascent replay duration. - * Launch Library timelines are authoritative when they expose insertion, - * SECO, or separation timing. Sparse records fall back to the reconstructed - * path length and a conservative ascent velocity estimate. - * @param {object} launch Normalized launch record. - * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. - * @returns {number} Replay duration in seconds. - */ -export function replayAscentDurationSeconds(launch, ascentPath = []) { - const disclosedSeconds = orbitInsertionOffsetSeconds(launch); - let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; - if (!realAscentSeconds && ascentPath.length > 1) { - const pathLength = ascentPath.slice(1).reduce( - (total, point, index) => total + Cesium.Cartesian3.distance(ascentPath[index], point), - 0, - ); - realAscentSeconds = Math.max(180, pathLength / 9000); - } - if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; - return Cesium.Math.clamp( - REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), - REPLAY_ASCENT_MIN_SEC, - REPLAY_ASCENT_MAX_SEC, - ); -} - -/** - * Clamp and snap a replay speed multiplier to the supported slider range. - * @param {number|string} value Requested playback multiplier. - * @returns {number} Supported multiplier between 0.25x and 4x. - */ -export function normalizeReplaySpeed(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 1; - const clamped = Cesium.Math.clamp(numeric, REPLAY_SPEED_MIN, REPLAY_SPEED_MAX); - return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; -} - -/** - * Preserve replay elapsed time when resuming after a pause. - * @param {number} startedAt Original replay start epoch in milliseconds. - * @param {number} pausedAt Pause epoch in milliseconds. - * @param {number} resumedAt Resume epoch in milliseconds. - * @returns {number} Shifted start epoch. - */ -export function replayStartAfterPause(startedAt, pausedAt, resumedAt) { - if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) return startedAt; - return startedAt + Math.max(0, resumedAt - pausedAt); -} - -export function replayState( - launch, - startedAt, - ascentDurationSec, - orbitDurationSec, - orbitPeriodSec, - speed = 1, - nowMs = Date.now(), - preCountdownDurationSec = 0, - loop = true, -) { - const animationDurationSec = ascentDurationSec + orbitDurationSec; - const realSecondsSinceStart = (nowMs - startedAt) / 1000; - const preCountdownDuration = Math.max(0, Number(preCountdownDurationSec) || 0); - const preCountdownActive = preCountdownDuration > 0 - && realSecondsSinceStart < -preCountdownDuration; - const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; - const countdownSeconds = countdownActive ? Math.ceil(-realSecondsSinceStart) : 0; - const elapsedSinceStart = Math.max(0, realSecondsSinceStart * normalizeReplaySpeed(speed)); - const elapsed = loop - ? elapsedSinceStart % animationDurationSec - : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); - const insertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const ascending = elapsed < ascentDurationSec; - const phaseProgress = ascending - ? elapsed / ascentDurationSec - : (elapsed - ascentDurationSec) / orbitDurationSec; - const missionOffsetSec = insertionOffsetSec === null - ? null - : ascending - ? phaseProgress * insertionOffsetSec - : insertionOffsetSec + phaseProgress * orbitPeriodSec; - const launchEpoch = Date.parse(launch.launchTime); - const eventTime = Number.isFinite(launchEpoch) && missionOffsetSec !== null - ? new Date(launchEpoch + missionOffsetSec * 1000) - : null; - return { - ascending, - phaseProgress, - eventTime, - elapsedSinceStart, - countdownActive, - preCountdownActive, - countdownSeconds, - }; -} - -export function approximateOrbitPath(launch) { - if (!launch.orbit?.name || !Number.isFinite(launch.lat) || !Number.isFinite(launch.lon)) return null; - const orbitName = launch.orbit.name.toLowerCase(); - const altitude = orbitName.includes('geostationary') || orbitName.includes('transfer') ? 35786000 - : orbitName.includes('medium') ? 20200000 : 550000; - const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; - const longitude = Cesium.Math.toRadians(launch.lon); - const latitude = Cesium.Math.toRadians(launch.lat); - const up = new Cesium.Cartesian3( - Math.cos(latitude) * Math.cos(longitude), - Math.cos(latitude) * Math.sin(longitude), - Math.sin(latitude), - ); - const east = new Cesium.Cartesian3( - -Math.sin(longitude), - Math.cos(longitude), - 0, - ); - const north = new Cesium.Cartesian3( - -Math.sin(latitude) * Math.cos(longitude), - -Math.sin(latitude) * Math.sin(longitude), - Math.cos(latitude), - ); - const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); - const isWesternNorthAmerica = launch.lat > 20 && launch.lat < 60 - && launch.lon > -140 && launch.lon < -105; - const launchAzimuthDeg = isPolar - ? (launch.lat >= 0 ? 180 : 0) - : isWesternNorthAmerica ? 190 : 90; - const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); - const forward = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(launchAzimuth), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(launchAzimuth), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - // A reconstructed orbit is an estimate, not a claim that the vehicle was - // inserted directly above the pad. Offset the orbital plane downrange by a - // small launch-to-insertion arc so a top-down view does not draw the entire - // orbit on top of the launch site. The ascent still joins this ring at its - // propagated insertion reference. - const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); - const orbitAnchor = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(up, Math.cos(insertionArc), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(forward, Math.sin(insertionArc), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const planeNormal = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const crossTrack = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(planeNormal, orbitAnchor, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return Array.from({ length: 97 }, (_, index) => { - const angle = (index / 96) * Math.PI * 2; - return new Cesium.Cartesian3( - radius * (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), - radius * (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), - radius * (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), - ); - }); -} - -function entityLaunchId(entity) { - if (!entity?.id || typeof entity.id !== 'string') return null; - const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); - return match?.[1] || null; -} - -function clearMissionOverlaySources() { - _selectedMissionOverlayTimeText = null; - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); -} - -function syncMissionOverlayEntries() { - if (!_enabled || !_dataSource?.show) { - clearMissionOverlaySources(); - return; - } - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (selectedRecord) { - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - const entries = selectedRecord.elementEntryFactories.map((createEntry) => createEntry()); - if (_replayCameraLaunchId !== selectedRecord.launch.id) { - entries.unshift(createRocketMissionMarkerOverlayEntry( - selectedRecord.launch, - selectedRecord.anchorPosition, - true, - )); - } - _missionOverlayHost.setEntries( - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, true); - _selectedMissionOverlayTimeText = selectedRecord.liveEventTime - ? formatMissionEventTime(selectedRecord.liveEventTime()) - : null; - return; - } - - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); - _selectedMissionOverlayTimeText = null; - const entries = selectRocketMissionMarkerOverlayCohort( - Array.from(_missionOverlayRecords.values(), (record) => { - const entry = createRocketMissionMarkerOverlayEntry( - record.launch, - record.anchorPosition, - ); - if (record.launch.id === _hoveredRosterLaunchId) { - entry.pinned = true; - entry.priority = Number.MAX_SAFE_INTEGER - 1; - } - return entry; - }), - ); - _missionOverlayHost.setEntries( - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, true); -} - -function refreshSelectedMissionOverlayText() { - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (!selectedRecord?.liveEventTime) return; - const nextText = formatMissionEventTime(selectedRecord.liveEventTime()); - if (nextText !== _selectedMissionOverlayTimeText) syncMissionOverlayEntries(); -} - -function setSelectedMission(launchId, isolate = true) { - if (launchId) clearMissionRosterHover(); - if (_replayCameraLaunchId && _replayCameraLaunchId !== launchId) stopMissionReplay(); - if (_missionZoomAnchorId && _missionZoomAnchorId !== launchId) stopMissionZoomAnchor(); - _selectedLaunchId = launchId; - _explicitSelection = Boolean(launchId && isolate); - if (launchId) _animationStarts.set(launchId, Date.now()); - if (!launchId && _viewer) { - stopMissionZoomAnchor(); - _viewer.selectedEntity = undefined; - } - if (!_dataSource) return; - for (const entity of _dataSource.entities.values) { - const relatedId = entityLaunchId(entity); - entity.show = launchId - ? relatedId === launchId - : entity.id.startsWith('rocket-launch:'); - } - syncMissionOrbitPrimitiveVisibility(); - syncMissionOverlayEntries(); - renderMissionPanel(); -} - -function hideLaunchPadZone() { - if (_launchPadZonePrimitive) _launchPadZonePrimitive.show = false; -} - -function removeLaunchPadZonePrimitive() { - if (_launchPadZonePrimitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(_launchPadZonePrimitive); - } - _launchPadZonePrimitive = null; - _launchPadZoneLaunchId = null; -} - -function createLaunchPadZonePrimitive(launch) { - removeLaunchPadZonePrimitive(); - const material = new Cesium.Material({ - fabric: { - type: 'GevLaunchPadZone', - uniforms: { - color: Cesium.Color.fromCssColorString('#22e6e6'), - fillAlpha: 0.105, - rimAlpha: 0.72, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; - float radius = length(centered); - float inside = 1.0 - smoothstep(0.985, 1.0, radius); - float rim = smoothstep(0.952, 0.985, radius) * inside; - material.diffuse = color.rgb; - material.emission = color.rgb * rim * 0.35; - material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); - return material; - }`, - }, - }); - const geometry = new Cesium.EllipseGeometry({ - center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), - semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - granularity: Cesium.Math.toRadians(0.08), - vertexFormat: Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, - }); - _launchPadZonePrimitive = _viewer.scene.primitives.add(new Cesium.GroundPrimitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry, - id: `rocket-pad-zone:${launch.id}`, - }), - appearance: new Cesium.MaterialAppearance({ - material, - translucent: true, - closed: false, - faceForward: true, - flat: true, - // Keep the zone classified onto the photoreal surface, but bias only its - // rasterized depth toward the camera. This avoids coplanar fragments - // being intermittently buried by the launch-pad mesh at oblique angles - // without adding a world-space height that would make the ring float. - renderState: { - depthTest: { - enabled: true, - }, - depthMask: false, - polygonOffset: { - enabled: true, - factor: -1, - units: -4, - }, - blending: Cesium.BlendingState.ALPHA_BLEND, - }, - }), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - show: true, - })); - _launchPadZoneLaunchId = launch.id; -} - -function initLaunchPadZonePrimitive() { - if (!_viewer || _launchPadZoneRemover) return; - _launchPadZoneRemover = _viewer.scene.preRender.addEventListener(() => { - if (!_enabled || !_dataSource?.show) { - hideLaunchPadZone(); - return; - } - const nowMs = Date.now(); - if (nowMs - _lastMissionRingRotationMs >= 1000) { - _missionRingDate.setTime(nowMs); - updateMissionOrbitPrimitiveFrames(_missionRingDate); - _lastMissionRingRotationMs = nowMs; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const camera = _viewer?.camera; - if (!launch || !camera) { - hideLaunchPadZone(); - return; - } - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const visible = launchPadZoneVisible({ - layerActive: Boolean(_dataSource?.show), - selectedLaunchId: _selectedLaunchId, - launchId: launch.id, - cameraHeightM: camera.positionCartographic?.height, - cameraDistanceM: Cesium.Cartesian3.distance(camera.positionWC, launchPosition), - }); - if (!visible) { - hideLaunchPadZone(); - return; - } - if (_launchPadZoneLaunchId !== launch.id) createLaunchPadZonePrimitive(launch); - _launchPadZonePrimitive.show = true; - }); -} - -function destroyLaunchPadZonePrimitive() { - if (_launchPadZoneRemover) _launchPadZoneRemover(); - _launchPadZoneRemover = null; - removeLaunchPadZonePrimitive(); -} - -export function samplePath(path, progress, result) { - if (!path?.length) return undefined; - // Degenerate returns clone into `result` when provided — handing back a - // path vertex would let an in-place caller mutate the path geometry. - if (path.length === 1) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - let distances = _pathDistanceCache.get(path); - if (!distances) { - distances = new Float64Array(path.length); - for (let index = 1; index < path.length; index++) { - distances[index] = distances[index - 1] - + Cesium.Cartesian3.distance(path[index - 1], path[index]); - } - _pathDistanceCache.set(path, distances); - } - const totalDistance = distances.at(-1); - if (!(totalDistance > 0)) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; - let low = 1; - let high = distances.length - 1; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (distances[middle] < targetDistance) low = middle + 1; - else high = middle; - } - const index = Math.max(0, low - 1); - const segmentDistance = distances[index + 1] - distances[index]; - const segmentProgress = segmentDistance > 0 - ? (targetDistance - distances[index]) / segmentDistance - : 0; - return Cesium.Cartesian3.lerp( - path[index], - path[index + 1], - segmentProgress, - result || new Cesium.Cartesian3(), - ); -} - -/** - * Resolve a continuously advancing orbit fraction from a wall-clock epoch. - * Fractional seconds prevent the selected satellite marker from jumping at - * each second boundary and forcing a one-frame photoreal globe LOD pulse. - * @param {number} nowMs Wall-clock epoch in milliseconds. - * @param {number} periodSec Orbital period in seconds. - * @returns {number} Normalized progress in the range [0, 1). - */ -export function orbitProgressAtTime(nowMs, periodSec) { - const period = Math.max(1, Number(periodSec) || 1); - return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; -} - -/** - * Construct one continuous estimated climb from the pad to orbit insertion. - * Horizontal movement begins much more slowly than altitude gain, preserving - * the near-vertical launch appearance without a hard corner at 120 km. - * @param {Cesium.Cartesian3} launchPosition Launch-pad position. - * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. - * @param {number} [samples] Number of curve intervals. - * @returns {Cesium.Cartesian3[]} - */ -export function reconstructedAscentPath(launchPosition, insertionPosition, samples = 512) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const origin = ellipsoid.cartesianToCartographic(launchPosition); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!origin || !insertion) return [launchPosition, insertionPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(origin.longitude, origin.latitude), - new Cesium.Cartographic(insertion.longitude, insertion.latitude), - ellipsoid, - ); - return Array.from({ length: samples + 1 }, (_, index) => { - const progress = index / samples; - if (index === 0) return launchPosition; - if (index === samples) return insertionPosition; - const horizontalProgress = progress ** 4; - const cartographic = geodesic.interpolateUsingFraction( - horizontalProgress, - new Cesium.Cartographic(), - ); - // Approximate the inertial eastward lead accumulated during a ten-minute - // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the - // upper climb, and returns to the fixed insertion endpoint. - const rotationalLead = EARTH_ROTATION_RAD_PER_SEC - * PROJECTED_ASCENT_ROTATION_SEC - * Math.sin(Math.PI * progress ** 3); - cartographic.longitude = Cesium.Math.negativePiToPi( - cartographic.longitude + rotationalLead, - ); - cartographic.height = Cesium.Math.lerp( - Math.max(0, origin.height), - Math.max(0, insertion.height), - Math.sin(progress * Cesium.Math.PI_OVER_TWO), - ); - return ellipsoid.cartographicToCartesian(cartographic); - }); -} - -function nearestOrbitIndex(orbitPath, referencePosition) { - if (!orbitPath?.length || !referencePosition) return 0; - const referenceDirection = Cesium.Cartesian3.normalize(referencePosition, new Cesium.Cartesian3()); - let bestIndex = 0; - let bestDot = -Number.MAX_VALUE; - orbitPath.forEach((candidate, index) => { - const direction = Cesium.Cartesian3.normalize(candidate, new Cesium.Cartesian3()); - const dot = Cesium.Cartesian3.dot(referenceDirection, direction); - if (dot > bestDot) { - bestDot = dot; - bestIndex = index; - } - }); - return bestIndex; -} - -function orbitPathFromInsertion(orbitPath, insertionIndex) { - if (!orbitPath?.length) return []; - const first = orbitPath[0]; - const last = orbitPath.at(-1); - const isClosed = orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; - const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); - if (!core.length) return orbitPath.slice(); - const index = Cesium.Math.mod(insertionIndex, core.length); - const rotated = [...core.slice(index), ...core.slice(0, index)]; - rotated.push(rotated[0]); - return rotated; -} - -function surfaceSafeSegment(startPosition, endPosition) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = ellipsoid.cartesianToCartographic(startPosition); - const end = ellipsoid.cartesianToCartographic(endPosition); - if (!start || !end) return [startPosition, endPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(start.longitude, start.latitude), - new Cesium.Cartographic(end.longitude, end.latitude), - ellipsoid, - ); - // Replay advances in real time across this path. Keep the geometry - // surface-safe, but give the animated marker and chase camera enough - // samples that they do not visibly pause at long segment boundaries. - const steps = Cesium.Math.clamp(Math.ceil(geodesic.surfaceDistance / 75000), 2, 256); - const positions = []; - for (let index = 0; index <= steps; index++) { - const fraction = index / steps; - const eased = fraction * fraction * (3 - 2 * fraction); - const cartographic = geodesic.interpolateUsingFraction(fraction, new Cesium.Cartographic()); - cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); - positions.push(ellipsoid.cartographicToCartesian(cartographic)); - } - positions[0] = startPosition; - positions[positions.length - 1] = endPosition; - return positions; -} - -function surfaceSafePath(controlPositions) { - if (!controlPositions?.length) return []; - if (controlPositions.length === 1) return controlPositions.slice(); - const path = []; - for (let index = 0; index < controlPositions.length - 1; index++) { - const segment = surfaceSafeSegment(controlPositions[index], controlPositions[index + 1]); - path.push(...(index === 0 ? segment : segment.slice(1))); - } - return path; -} - -function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { - if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) return ascentPath; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const transferEnd = orbitPath[insertionIndex]; - const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; - const orbitTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(nextOrbit, transferEnd, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.equalsEpsilon(orbitTangent, Cesium.Cartesian3.ZERO, 1e-8)) return ascentPath; - - // Replace a substantial final section with one cubic Bézier transition. - // Matching both endpoint tangents avoids the short corrective hook produced - // by locally pulling only the last few ascent samples toward the orbit. - const blendCount = Math.min(52, ascentPath.length - 2); - const blendStart = ascentPath.length - 1 - blendCount; - const start = ascentPath[blendStart]; - const previous = ascentPath[Math.max(0, blendStart - 1)]; - const altitudeEnvelope = ascentPath.slice(blendStart).map((position) => Math.max( - 0, - ellipsoid.cartesianToCartographic(position)?.height || 0, - )); - const ascentTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const chordLength = Math.max(Cesium.Cartesian3.distance(start, transferEnd), 1000); - const handleLength = chordLength * 0.28; - const controlA = Cesium.Cartesian3.add( - start, - Cesium.Cartesian3.multiplyByScalar(ascentTangent, handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const controlB = Cesium.Cartesian3.add( - transferEnd, - Cesium.Cartesian3.multiplyByScalar(orbitTangent, -handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - for (let index = 0; index <= blendCount; index++) { - const progress = index / blendCount; - const inverse = 1 - progress; - const point = new Cesium.Cartesian3(); - Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlA, 3 * inverse ** 2 * progress, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlB, 3 * inverse * progress ** 2, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(transferEnd, progress ** 3, new Cesium.Cartesian3()), - point, - ); - // A Cartesian Bézier is a chord in world space and can pass through the - // ellipsoid when its orbit-tangent handle is long. Preserve the original - // climb's smooth altitude envelope while retaining the Bézier's horizontal - // curvature and tangent-aligned insertion. - const cartographic = ellipsoid.cartesianToCartographic(point); - const minimumHeight = altitudeEnvelope[index] ?? 0; - if (cartographic && cartographic.height < minimumHeight) { - cartographic.height = minimumHeight; - ascentPath[blendStart + index] = ellipsoid.cartographicToCartesian(cartographic); - } else { - ascentPath[blendStart + index] = point; - } - } - ascentPath[ascentPath.length - 1] = transferEnd; - return ascentPath; -} - -/** - * Build a globe-safe ascent ending at the nearest point on the selected orbit. - * The returned orbit is rotated to begin at that same insertion point so the - * animated marker cannot jump between ascent and orbital phases. - * @param {Cesium.Cartesian3} launchPosition Launch-site position. - * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. - * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. - * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. - * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} - */ -export function buildMissionPaths( - launchPosition, - trajectoryPositions, - orbitPath, - insertionReference = null, -) { - const suppliedTrajectory = trajectoryPositions || []; - const controls = [launchPosition, ...suppliedTrajectory]; - const insertionIndex = nearestOrbitIndex( - orbitPath, - insertionReference || controls.at(-1), - ); - const transferEnd = orbitPath[insertionIndex]; - const ascentPath = suppliedTrajectory.length - ? surfaceSafePath([...controls, transferEnd]) - : reconstructedAscentPath(launchPosition, transferEnd); - blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); - return { - ascentPath, - animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), - insertionIndex, - }; -} - -export function cameraHeadingForPath(path, progress, fallback = Math.PI) { - if (!path?.length) return fallback; - const current = samplePath(path, progress); - const currentCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); - if (!currentCartographic) return fallback; - for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { - const next = samplePath(path, Math.min(1, progress + step)); - const nextCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); - if (!nextCartographic) continue; - const geodesic = new Cesium.EllipsoidGeodesic(currentCartographic, nextCartographic); - if (geodesic.surfaceDistance > 10 && Number.isFinite(geodesic.startHeading)) { - // HeadingPitchRange positions the camera opposite its heading vector. - // Passing the forward path heading therefore keeps the camera behind - // the vehicle, with the remaining ascent receding into the scene. - return Cesium.Math.zeroToTwoPi(geodesic.startHeading); - } - } - return fallback; -} - -/** - * Resolve the initial replay heading as a profile view of the path. - * @param {Cesium.Cartesian3[]} path Replay path. - * @returns {number} Heading perpendicular to the initial path direction. - */ -export function replayInitialCameraHeading(path) { - return Cesium.Math.zeroToTwoPi( - cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, - ); -} - -/** - * Keep ascent framing behind and slightly to one side of the vehicle, then - * widen that rear-quarter angle as the orbit becomes visible. - * @param {number} pathHeading Forward path bearing in radians. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {number} Cesium HeadingPitchRange heading. - */ -export function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - return Cesium.Math.zeroToTwoPi( - pathHeading + Cesium.Math.lerp( - REPLAY_ASCENT_CAMERA_OFFSET_RAD, - REPLAY_ORBIT_CAMERA_OFFSET_RAD, - blend, - ), - ); -} - -/** - * Limit replay-camera yaw changes so a path heading wrap or insertion turn - * cannot swing the chase view through the front of the vehicle. - * @param {number} previous Previous Cesium heading in radians. - * @param {number} desired Desired Cesium heading in radians. - * @param {number} [maxStepRad] Maximum angular change for one rendered frame. - * @returns {number} - */ -export function smoothReplayCameraHeading( - previous, - desired, - maxStepRad = Cesium.Math.toRadians(2), -) { - if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); - if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); - const delta = Cesium.Math.negativePiToPi(desired - previous); - const step = Cesium.Math.clamp(delta, -Math.abs(maxStepRad), Math.abs(maxStepRad)); - return Cesium.Math.zeroToTwoPi(previous + step); -} - -/** - * Blend from a global nadir view into an oblique local 3D view as the user - * approaches a selected launch site. - * @param {number} rangeM Camera distance from the launch-site anchor. - * @returns {number} Cesium camera pitch in radians. - */ -export function missionZoomPitch(rangeM) { - const range = Math.max(0, Number(rangeM) || 0); - const blend = Cesium.Math.clamp( - (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)) - / (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)), - 0, - 1, - ); - return Cesium.Math.lerp(Cesium.Math.toRadians(-42), -Cesium.Math.PI_OVER_TWO, blend); -} - -/** - * Resolve the replay camera offset for either close ascent tracking or the - * orbital globe pullback. - * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. - * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. - * @returns {{range: number, pitch: number}} - */ -export function replayCameraView(state, altitudeM) { - const altitude = Math.max(0, Number(altitudeM) || 0); - const localRange = Cesium.Math.clamp( - REPLAY_INITIAL_RANGE_M + altitude * 0.7, - REPLAY_INITIAL_RANGE_M, - REPLAY_LOCAL_MAX_RANGE_M, - ); - const rawContextBlend = Cesium.Math.clamp( - (altitude - 20000) - / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), - 0, - 1, - ); - const contextBlend = rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); - const contextRange = Cesium.Math.clamp( - 180000 + altitude * 3.8, - MISSION_CLOSE_VIEW_RANGE_M, - REPLAY_CONTEXT_MAX_RANGE_M, - ); - const ascentRange = Cesium.Math.lerp(localRange, contextRange, contextBlend); - const ascentPitch = Cesium.Math.lerp( - Cesium.Math.toRadians(-20), - Cesium.Math.toRadians(-34), - contextBlend, - ); - if (state?.ascending) { - return { range: ascentRange, pitch: ascentPitch }; - } - const rawBlend = Cesium.Math.clamp( - (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ); - const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); - return { - range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), - // Keep an oblique tactical view of the complete orbit rather than ending - // in a nadir view. This also prevents the camera from appearing to - // return toward the launch site after insertion. - pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), - }; -} - -/** - * Move the orbit-follow target from the vehicle toward its sub-satellite - * globe anchor, keeping Earth centered while the vehicle remains in frame. - * @param {Cesium.Cartesian3} position Animated orbital position. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Camera look-at target. - */ -export function replayOrbitGlobeAnchor(position, orbitBlend = 0) { - if (!position) return position; - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - if (blend <= 0) return Cesium.Cartesian3.clone(position); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - if (!cartographic) return Cesium.Cartesian3.clone(position); - const altitude = Math.max(0, cartographic.height || 0); - return Cesium.Cartesian3.fromRadians( - cartographic.longitude, - cartographic.latitude, - Cesium.Math.lerp(altitude, altitude * 0.1, blend), - ); -} - -/** - * Keep the orbital camera's look-at frame biased toward the moving vehicle. - * Blending completely to a whole-orbit bounding-sphere center can place the - * target at (or numerically close to) Earth's center. That frame is singular - * for a heading/pitch camera and lets compact-orbit vehicles leave the view as - * the camera rotates. Retaining a radial vehicle bias keeps the local frame - * stable while the wider camera range still contains Earth and the full orbit. - * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. - * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Stable camera look-at target. - */ -export function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { - if (!vehicleAnchor) return vehicleAnchor; - if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) - * REPLAY_ORBIT_FRAME_CENTER_BLEND; - return Cesium.Cartesian3.lerp( - vehicleAnchor, - frameCenter, - blend, - new Cesium.Cartesian3(), - ); -} - -/** - * Build a stable orbit-relative camera pose. The camera remains on one side - * of the orbital plane and uses the vehicle radial as its visual up axis. - * Consequently the forward orbit tangent always projects toward screen-left - * instead of changing direction when local compass headings wrap near a pole. - * @param {Cesium.Cartesian3} position Current vehicle position. - * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. - * @param {Cesium.Cartesian3} target Camera look-at target. - * @param {number} range Camera distance from the target. - * @param {number} pitch Camera elevation below the local horizon. - * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} - */ -export function replayOrbitCameraPose( - position, - tangentPosition, - target, - range, - pitch, -) { - if (!position || !tangentPosition || !target) return null; - const radial = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.clone(position), - new Cesium.Cartesian3(), - ); - const tangent = Cesium.Cartesian3.subtract( - tangentPosition, - position, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; - Cesium.Cartesian3.normalize(tangent, tangent); - const orbitNormal = Cesium.Cartesian3.cross( - radial, - tangent, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; - Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); - - const distance = Math.max(1, Number(range) || 1); - const elevation = Cesium.Math.clamp( - Math.abs(Number(pitch) || 0), - Cesium.Math.toRadians(5), - Cesium.Math.toRadians(80), - ); - const planeOffset = Cesium.Cartesian3.multiplyByScalar( - orbitNormal, - Math.cos(elevation) * distance, - new Cesium.Cartesian3(), - ); - const radialOffset = Cesium.Cartesian3.multiplyByScalar( - radial, - Math.sin(elevation) * distance, - new Cesium.Cartesian3(), - ); - const destination = Cesium.Cartesian3.add( - target, - planeOffset, - new Cesium.Cartesian3(), - ); - Cesium.Cartesian3.add(destination, radialOffset, destination); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const radialAlongView = Cesium.Cartesian3.multiplyByScalar( - direction, - Cesium.Cartesian3.dot(radial, direction), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(radial, radialAlongView, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return { destination, direction, up }; -} - -/** - * Build one conservative frame that contains both Earth and the complete - * selected orbit. High-apogee missions cannot be composed from the vehicle's - * instantaneous altitude alone because the opposite side of the orbit may - * extend much farther from the globe. - * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. - * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. - */ -export function replayOrbitFrameSphere(orbitPath = []) { - const earth = new Cesium.BoundingSphere( - Cesium.Cartesian3.ZERO, - Cesium.Ellipsoid.WGS84.maximumRadius, - ); - if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; - const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); - return Cesium.BoundingSphere.union(earth, orbit, new Cesium.BoundingSphere()); -} - -/** - * Ensure high-altitude missions frame both the globe and selected vehicle. - * @param {number} baseRange Range from the normal replay camera transition. - * @param {number} altitudeM Vehicle altitude above the ellipsoid. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. - * @returns {number} Camera range in metres. - */ -export function replayOrbitGlobeRange( - baseRange, - altitudeM, - orbitBlend = 0, - frameRadiusM = 0, -) { - const range = Math.max(0, Number(baseRange) || 0); - const altitude = Math.max(0, Number(altitudeM) || 0); - const frameRadius = Math.max(0, Number(frameRadiusM) || 0); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - const globeAndVehicleRange = Math.max( - range, - altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, - frameRadius * 3, - ); - return Cesium.Math.lerp(range, globeAndVehicleRange, blend); -} - -function stopMissionZoomAnchor() { - if (_missionZoomAnchorRemover) _missionZoomAnchorRemover(); - _missionZoomAnchorRemover = null; - _missionZoomAnchorId = null; - if (_viewer?.camera && !_replayCameraLaunchId) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - } -} - -function startMissionZoomAnchor(launch, initialRange) { - if (!_viewer || !launch || _replayCameraLaunchId) return; - stopMissionZoomAnchor(); - const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const range = Math.max(2000, Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M); - _missionZoomAnchorId = launch.id; - _viewer.camera.lookAt( - target, - new Cesium.HeadingPitchRange(0, missionZoomPitch(range), range), - ); -} - -function syncReplayButton() { - const button = _missionPanel?.querySelector('[data-mission-replay]'); - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - const speedControl = _missionPanel?.querySelector('.mission-replay-speed-control'); - if (!button) return; - const active = Boolean(_replayCameraLaunchId && _replayCameraLaunchId === _selectedLaunchId); - const replayAvailable = Boolean(_selectedLaunchId && _replayTracks.has(_selectedLaunchId)); - if (speedControl) speedControl.hidden = !replayAvailable; - button.hidden = active || !replayAvailable; - button.disabled = !replayAvailable; - button.textContent = 'REPLAY ASCENT'; - button.classList.remove('active'); - button.setAttribute('aria-pressed', String(active)); - button.title = 'Replay the estimated ascent with a following camera'; - if (transport) { - transport.hidden = !active; - transport.classList.toggle('is-paused', active && _replayPaused); - const toggleButton = transport.querySelector('[data-mission-replay-toggle]'); - if (toggleButton) { - toggleButton.disabled = !active; - toggleButton.textContent = _replayPaused ? '▶' : 'Ⅱ'; - toggleButton.setAttribute('aria-label', _replayPaused ? 'Resume replay' : 'Pause replay'); - toggleButton.title = _replayPaused ? 'Resume replay' : 'Pause replay'; - } - } -} - -function syncReplayCountdownButton(state) { - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - if (!transport || !_replayCameraLaunchId) return; - const phase = state.countdownActive - ? `T minus ${state.countdownSeconds}` - : state.preCountdownActive - ? 'Preparing launch site' - : state.elapsedSinceStart < 1 - ? 'Liftoff' - : state.ascending ? 'Ascent replay' : 'Orbit replay'; - transport.setAttribute('aria-label', `${phase}${_replayPaused ? ', paused' : ''}`); -} - -function syncReplaySpeedControl() { - const input = _missionPanel?.querySelector('[data-mission-replay-speed]'); - const output = _missionPanel?.querySelector('[data-mission-replay-speed-output]'); - if (input) { - input.value = String(_replaySpeed); - const progress = ((_replaySpeed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100; - input.style.setProperty('--replay-speed-progress', `${progress}%`); - } - if (output) output.textContent = `${_replaySpeed.toFixed(_replaySpeed % 1 ? 2 : 0)}×`; -} - -function setReplaySpeed(value) { - const nextSpeed = normalizeReplaySpeed(value); - const previousSpeed = _replaySpeed; - if (nextSpeed === previousSpeed) { - syncReplaySpeedControl(); - return; - } - const now = _replayPaused && Number.isFinite(_replayPausedAtMs) - ? _replayPausedAtMs - : Date.now(); - for (const [launchId, startedAt] of _animationStarts) { - if (!Number.isFinite(startedAt)) continue; - const elapsedMissionMs = (now - startedAt) * previousSpeed; - _animationStarts.set(launchId, now - elapsedMissionMs / nextSpeed); - } - _replaySpeed = nextSpeed; - syncReplaySpeedControl(); -} - -function replayClockNow(launchId) { - if ( - _replayPaused - && _replayCameraLaunchId === launchId - && Number.isFinite(_replayPausedAtMs) - ) { - return _replayPausedAtMs; - } - return Date.now(); -} - -function pauseMissionReplay() { - if (!_replayCameraLaunchId || _replayPaused) return false; - _replayPausedAtMs = Date.now(); - _replayPaused = true; - syncReplayButton(); - return true; -} - -function resumeMissionReplay() { - if (!_replayCameraLaunchId || !_replayPaused || !Number.isFinite(_replayPausedAtMs)) { - return false; - } - const resumedAt = Date.now(); - const startedAt = _animationStarts.get(_replayCameraLaunchId); - if (Number.isFinite(startedAt)) { - _animationStarts.set( - _replayCameraLaunchId, - replayStartAfterPause(startedAt, _replayPausedAtMs, resumedAt), - ); - } - _replayPaused = false; - _replayPausedAtMs = null; - syncReplayButton(); - return true; -} - -function stopMissionReplay() { - const stoppedLaunchId = _replayCameraLaunchId; - _replayCameraToken++; - if (_replayCameraRemover) _replayCameraRemover(); - _replayCameraRemover = null; - _viewer?.camera?.cancelFlight(); - if (_viewer?.camera) _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - _replayCameraLaunchId = null; - _replayPaused = false; - _replayPausedAtMs = null; - if (stoppedLaunchId) _animationStarts.set(stoppedLaunchId, Date.now()); - syncReplayButton(); - syncMissionOverlayEntries(); -} - -function startMissionReplay(launchId) { - const launch = _launches.find((item) => item.id === launchId); - const track = _replayTracks.get(launchId); - if (!_viewer || !launch || !track) return false; - if (_replayCameraLaunchId === launchId) { - stopMissionReplay(); - return false; - } - - stopMissionZoomAnchor(); - stopMissionReplay(); - _replayCameraLaunchId = launchId; - _replayPaused = false; - _replayPausedAtMs = null; - const token = ++_replayCameraToken; - const ascentDurationSec = track.ascentDurationSec; - // Start broadside to the ascent/orbit direction so the launch profile is - // visible. The chase heading then eases toward the path tangent after the - // camera is established. - const initialHeading = replayInitialCameraHeading(track.ascentPath); - track.lastCameraHeading = initialHeading; - syncReplayButton(); - syncMissionOverlayEntries(); - releaseAircraftTracking(_dataManager); - _viewer.trackedEntity = undefined; - _viewer.camera.cancelFlight(); - _animationStarts.set( - launchId, - Date.now() + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, - ); - let cameraReady = false; - let lastCameraUpdateMs = null; - - // Move the chase camera before scene traversal. Mutating it in preRender - // makes Cesium discover a new view after 3D-tile refinement, which can cause - // a self-sustaining refinement loop and visible stutter during slow replay. - _replayCameraRemover = _viewer.scene.preUpdate.addEventListener(() => { - if (token !== _replayCameraToken || _replayCameraLaunchId !== launchId) return; - const state = track.beginReplayFrame(); - syncReplayCountdownButton(state); - if (!cameraReady) return; - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - const position = samplePath(path, state.phaseProgress); - if (!position) return; - const pathHeading = cameraHeadingForPath( - path, - state.phaseProgress, - track.lastCameraHeading, - ); - const orbitBlend = !state.ascending - ? Cesium.Math.clamp( - (Number(state.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ) - : 0; - const desiredHeading = replayChaseCameraHeading(pathHeading, orbitBlend); - if (state.ascending) track.orbitCameraWorldFrame = false; - const nowMs = performance.now(); - const frameDurationMs = Number.isFinite(lastCameraUpdateMs) - ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) - : 1000 / 60; - lastCameraUpdateMs = nowMs; - track.lastCameraHeading = smoothReplayCameraHeading( - track.lastCameraHeading, - desiredHeading, - Cesium.Math.toRadians(2) * frameDurationMs / (1000 / 60), - ); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - const altitude = Math.max(0, cartographic?.height || 0); - const cameraView = replayCameraView(state, altitude); - const defaultOrbitTarget = !state.ascending - ? replayOrbitGlobeAnchor(position, orbitBlend) - : position; - const cameraTarget = !state.ascending - ? replayOrbitCameraTarget( - defaultOrbitTarget, - track.orbitFrameSphere?.center, - orbitBlend, - ) - : defaultOrbitTarget; - const cameraRange = !state.ascending - ? replayOrbitGlobeRange( - cameraView.range, - altitude, - orbitBlend, - track.orbitFrameSphere?.radius, - ) - : cameraView.range; - if (state.ascending) { - _viewer.camera.lookAt( - cameraTarget, - new Cesium.HeadingPitchRange( - track.lastCameraHeading, - cameraView.pitch, - cameraRange, - ), - ); - } else { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const tangentProgress = Math.min(1, state.phaseProgress + tangentStep); - const tangentPosition = samplePath( - path, - tangentProgress > state.phaseProgress - ? tangentProgress - : Math.max(0, state.phaseProgress - tangentStep), - ); - const orbitPose = replayOrbitCameraPose( - position, - tangentPosition, - cameraTarget, - cameraRange, - cameraView.pitch, - ); - if (orbitPose) { - if (!track.orbitCameraWorldFrame) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - track.orbitCameraWorldFrame = true; - } - _viewer.camera.setView({ - destination: orbitPose.destination, - orientation: { - direction: orbitPose.direction, - up: orbitPose.up, - }, - }); - } - } - if (state.elapsedSinceStart >= ascentDurationSec - + REPLAY_ORBIT_DURATION_SEC) { - stopMissionReplay(); - } - }); - - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(track.ascentPath[0], 0), - { - offset: new Cesium.HeadingPitchRange( - initialHeading, - Cesium.Math.toRadians(-20), - REPLAY_INITIAL_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - cameraReady = true; - }, - cancel: () => { - if (token === _replayCameraToken && _replayCameraLaunchId === launchId) stopMissionReplay(); - }, - }, - ); - return true; -} - -function finiteCoordinate(value) { - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function normalizePayloadFlights(launch) { - const flights = launch.rocket?.payloads || launch.payloads || launch.mission?.payloads || []; - if (!Array.isArray(flights)) return []; - return flights.map((flight, index) => { - const payload = flight.payload || flight; - return { - id: String(flight.id || payload.id || `payload-${index}`), - name: payload.name || flight.name || 'Unnamed payload', - type: payload.type?.name || flight.type?.name || null, - manufacturer: payload.manufacturer?.name || null, - operator: payload.operator?.name || null, - destination: flight.destination || payload.destination || null, - amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1, - massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== '')) - && Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0 - ? Number(payload.mass) : null, - }; - }); -} - -function normalizeLanding(stage, fallbackName, category, index) { - const landing = stage?.landing; - const location = landing?.landing_location || {}; - const launcher = stage?.launcher || stage?.spacecraft || {}; - const serial = launcher.serial_number || stage?.serial_number || null; - const stageType = stage?.type?.name || stage?.type || category; - const attempted = landing?.attempt === true; - const success = landing?.success; - const recoveryType = landing?.type?.name || null; - const launcherStatus = launcher.status?.name || null; - const status = success === true ? 'RECOVERED' - : success === false ? 'LOST' - : attempted ? 'RECOVERY ATTEMPT' - : recoveryType ? recoveryType.toUpperCase() - : launcherStatus ? launcherStatus.toUpperCase() - : 'NO RECOVERY DATA'; - return { - id: String(stage?.id || landing?.id || `${category}-${index}`), - category, - name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, - serial, - reused: stage?.reused === true, - flightNumber: finiteCoordinate(stage?.launcher_flight_number), - status, - attempted, - success: success === true ? true : success === false ? false : null, - recoveryType, - destination: location.name || landing?.destination || landing?.type?.name || null, - description: landing?.description || null, - downrangeKm: finiteCoordinate(landing?.downrange_distance), - lat: finiteCoordinate(location.latitude ?? landing?.latitude), - lon: finiteCoordinate(location.longitude ?? landing?.longitude), - }; -} - -function normalizeRecoveryStages(launch, payloads) { - const rocket = launch.rocket || {}; - const launcherStages = Array.isArray(rocket.launcher_stage) ? rocket.launcher_stage : []; - const spacecraftStages = Array.isArray(rocket.spacecraft_stage) ? rocket.spacecraft_stage : []; - const stages = [ - ...launcherStages.map((stage, index) => normalizeLanding(stage, `Launcher stage ${index + 1}`, 'LAUNCHER', index)), - ...spacecraftStages.map((stage, index) => normalizeLanding(stage, `Spacecraft stage ${index + 1}`, 'SPACECRAFT', index)), - ]; - const payloadFlights = rocket.payloads || launch.payloads || []; - if (Array.isArray(payloadFlights)) { - payloadFlights.forEach((flight, index) => { - if (!flight?.landing) return; - stages.push(normalizeLanding( - { ...flight, type: payloads[index]?.type || 'Payload', serial_number: payloads[index]?.name }, - payloads[index]?.name || `Payload ${index + 1}`, - 'PAYLOAD', - index, - )); - }); - } - return stages; -} - -function landingEndpoint(stage, launch, insertionPosition) { - if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { - return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; - } - const recoveryIdentity = `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); - if (/return to launch site|rtls|launch site|landing zone/.test(recoveryIdentity)) { - return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; - } - if (!(stage.downrangeKm > 0) || !insertionPosition) return null; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!insertion) return null; - const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); - const bearing = geodesic.startHeading; - const angularDistance = (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; - const lat1 = start.latitude; - const lon1 = start.longitude; - const lat = Math.asin( - Math.sin(lat1) * Math.cos(angularDistance) - + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), - ); - const lon = lon1 + Math.atan2( - Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), - Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), - ); - return { - lat: Cesium.Math.toDegrees(lat), - lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), - accuracy: 'EST. DOWNRANGE', - }; -} - -function stageReentryRecoveryPath(ascentPath, endpoint, stageIndex, stageCount) { - if (!ascentPath?.length || !endpoint) return []; - const progress = Cesium.Math.clamp(0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, 0.28, 0.68); - const separation = samplePath(ascentPath, progress); - const destination = Cesium.Cartesian3.fromDegrees(endpoint.lon, endpoint.lat, 12); - return surfaceSafeSegment(separation, destination); -} - -function atmosphericReentryIndex(path) { - if (!path?.length) return 0; - const ellipsoid = Cesium.Ellipsoid.WGS84; - for (let index = 1; index < path.length; index++) { - const previousHeight = ellipsoid.cartesianToCartographic(path[index - 1])?.height; - const height = ellipsoid.cartesianToCartographic(path[index])?.height; - if ( - Number.isFinite(previousHeight) - && Number.isFinite(height) - && previousHeight > STAGE_REENTRY_ALTITUDE_M - && height <= STAGE_REENTRY_ALTITUDE_M - ) { - return index; - } - } - return Math.min(Math.max(Math.round(path.length * 0.55), 0), path.length - 1); -} - -function missionTableRows(items, columns, emptyText) { - if (!items.length) return `${emptyText}`; - return items.map((item) => item).join(''); -} - -function setMissionPanelField(selector, value, title = '') { - const output = _missionPanel?.querySelector(selector); - if (!output) return; - const row = output.closest('[data-mission-field]'); - const available = value !== null && value !== undefined && String(value).trim() !== ''; - if (row) row.hidden = !available; - if (!available) { - output.textContent = ''; - output.removeAttribute('title'); - return; - } - output.textContent = value; - if (title) output.title = title; - else output.removeAttribute('title'); -} - -function renderMissionPanel() { - if (!_missionPanel) return; - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const index = launch ? _launches.indexOf(launch) : -1; - _missionPanel.hidden = !launch; - if (!launch) return; - _missionPanel.querySelector('[data-mission-title]').textContent = shortMissionLabel(launch.name, 32).toUpperCase(); - setMissionPanelField('[data-mission-provider]', launch.provider); - setMissionPanelField('[data-mission-status]', launch.status); - setMissionPanelField( - '[data-mission-site]', - launch.launchSite && launch.launchSite !== 'Unknown launch site' ? launch.launchSite : null, - ); - setMissionPanelField('[data-mission-time]', launch.launchTime); - const pathPresentation = missionPathPresentation(launch, _replayTracks.has(launch.id)); - setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); - _missionPanel.querySelector('[data-mission-ascent-source]').textContent = pathPresentation.ascent; - const payloadRows = launch.payloads.length - ? launch.payloads.slice(0, 5).map((payload) => { - const detail = [ - payload.manufacturer, - payload.operator && payload.operator !== payload.manufacturer ? payload.operator : null, - Number.isFinite(payload.massKg) ? `${payload.massKg.toLocaleString()} KG` : null, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; - }) - : []; - if (launch.payloads.length > 5) { - payloadRows.push(`+${launch.payloads.length - 5} additional payload records`); - } - _missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows( - payloadRows, - 3, - 'PAYLOAD DATA UNAVAILABLE', - ); - const stageRows = launch.recoveryStages.map((stage) => { - const endpoint = stage.endpoint; - const destination = stage.destination || (endpoint?.accuracy === 'PAD / RTLS' ? launch.launchSite : 'UNAVAILABLE'); - const position = endpoint - ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` - : stage.downrangeKm > 0 ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` : 'POSITION UNAVAILABLE'; - const stageDetail = [ - Number.isFinite(stage.flightNumber) ? `FLIGHT ${stage.flightNumber}` : null, - stage.reused ? 'REUSED' : null, - stage.recoveryType, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; - }); - _missionPanel.querySelector('[data-mission-stages]').innerHTML = missionTableRows( - stageRows, - 3, - 'NO STAGE RE-ENTRY / RECOVERY DATA', - ); - const stageSection = _missionPanel.querySelector('[data-mission-stages-section]'); - if (stageSection) stageSection.hidden = stageRows.length === 0; - updateMissionTelemetry(true); - _missionPanel.querySelector('[data-mission-index]').textContent = `${index + 1} / ${_launches.length}`; - _missionPanel.querySelector('[data-mission-prev]').disabled = index <= 0; - _missionPanel.querySelector('[data-mission-next]').disabled = index < 0 || index >= _launches.length - 1; - syncReplayButton(); - const panelScroller = _missionPanel.closest('.global-context-panel-inner'); - if (panelScroller) panelScroller.scrollTop = 0; -} - -function renderMissionRoster() { - if (!_missionRoster) return; - const list = _missionRoster.querySelector('[data-mission-roster-list]'); - const count = _missionRoster.querySelector('[data-mission-roster-count]'); - if (count) count.textContent = `${_launches.length} / 30D`; - if (!list) return; - const focusSnapshot = captureMissionRosterFocus(list); - _missionRosterPreviewOwnership?.reset(); - const entries = missionRosterEntries(_launches); - if (!entries.length) { - list.innerHTML = 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'; - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); - return; - } - list.innerHTML = entries.map(({ launch, index }) => { - const color = missionMarkerColor(launch).toCssColorString(); - const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; - const provider = launch.provider || 'UNSPECIFIED OPERATOR'; - const label = shortMissionLabel(launch.name, 27).toUpperCase(); - return `${escapeMissionText(label)}${escapeMissionText(provider)} · ${escapeMissionText(date)}›`; - }).join(''); - list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { - const index = Number(button.dataset.missionRosterIndex); - bindMissionRosterItemKeyboardPreview( - button, - index, - _missionRosterPreviewOwnership, - _missionRoster, - ); - }); - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); -} - -function escapeMissionText(value) { - return String(value ?? '').replace(/[&<>"']/g, (character) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - })[character]); -} - -function updateMissionTelemetry(force = false) { - if (!_missionPanel || !_selectedLaunchId || !_dataSource) return; - const now = performance.now(); - if (!force && now - _lastPanelTelemetryMs < 250) return; - _lastPanelTelemetryMs = now; - const satellite = _dataSource.entities.getById(`rocket-satellite:${_selectedLaunchId}`); - const position = satellite?.position?.getValue(Cesium.JulianDate.now(_declutterTime)); - const altitudeM = position ? Cesium.Cartographic.fromCartesian(position)?.height : null; - setMissionPanelField( - '[data-mission-distance]', - Number.isFinite(altitudeM) - ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` - : null, - ); - const speedMps = _satelliteTelemetry.get(_selectedLaunchId)?.speedMps; - setMissionPanelField( - '[data-mission-speed]', - Number.isFinite(speedMps) - ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` - : null, - Number.isFinite(speedMps) - ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` - : '', - ); -} - -function selectMissionAt(index) { - const launch = _launches[index]; - if (!launch) return; - setSelectedMission(launch.id, true); - focusMission(launch); -} - -function clearMissionRosterPreviewState() { - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _missionRosterHoverTimer = null; - const changed = _hoveredRosterLaunchId !== null; - _hoveredRosterLaunchId = null; - if (changed) syncMissionOverlayEntries(); -} - -function clearMissionRosterHover() { - if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset(); - else clearMissionRosterPreviewState(); -} - -function previewMissionFromRoster(launch) { - if (!_viewer || !launch || _selectedLaunchId) return; - const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height); - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(position, 0), - { - offset: new Cesium.HeadingPitchRange(0, -Cesium.Math.PI_OVER_TWO, range), - duration: 0.8, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }, - ); -} - -function scheduleMissionRosterPreview(index) { - const launch = _launches[index]; - if (!launch || _selectedLaunchId) return; - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _hoveredRosterLaunchId = launch.id; - syncMissionOverlayEntries(); - _missionRosterHoverTimer = setTimeout(() => { - _missionRosterHoverTimer = null; - if (_hoveredRosterLaunchId !== launch.id) return; - const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id); - if (currentLaunch) previewMissionFromRoster(currentLaunch); - }, 140); -} - -function focusMission(launch) { - if (!_viewer || !launch) return; - stopMissionZoomAnchor(); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - const orbitEntity = _dataSource.entities.getById(`rocket-orbit:${launch.id}`); - const orbitPositions = orbitEntity?.polyline?.positions?.getValue(Cesium.JulianDate.now()); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - let range = 18000000; - if (orbitPositions?.length > 1) { - const canvas = _viewer.scene.canvas; - const fovy = _viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); - const aspect = Math.max(0.1, (canvas.clientWidth || canvas.width) / Math.max(1, canvas.clientHeight || canvas.height)); - const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); - const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; - const orbitExtent = orbitPositions.reduce( - (largest, point) => Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), - 0, - ); - range = Math.max( - orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), - 18000000, - ); - } - const targetNormal = Cesium.Cartesian3.normalize(launchPosition, new Cesium.Cartesian3()); - const destination = Cesium.Cartesian3.add( - launchPosition, - Cesium.Cartesian3.multiplyByScalar(targetNormal, range, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const direction = Cesium.Cartesian3.negate(targetNormal, new Cesium.Cartesian3()); - let right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3()); - if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { - right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Y, right); - } - Cesium.Cartesian3.normalize(right, right); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - _viewer.camera.flyTo({ - destination, - orientation: { direction, up }, - duration: 1.1, - complete: () => { - if (_selectedLaunchId === launch.id) startMissionZoomAnchor(launch, range); - }, - }); -} - -function focusLaunchSite(launch) { - if (!_viewer || !launch) return; - stopMissionReplay(); - stopMissionZoomAnchor(); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(launchPosition, 0), - { - offset: new Cesium.HeadingPitchRange( - 0, - Cesium.Math.toRadians(-42), - MISSION_FOCUS_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - if (_selectedLaunchId === launch.id) { - startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); - } - }, - }, - ); -} - -function createMissionPanel() { - if (_missionPanel || typeof document === 'undefined') return; - const host = document.getElementById('space-mission-panel-host') - || document.getElementById('right-context-rail'); - if (!host) return; - _missionRoster = document.getElementById('space-mission-roster'); - if (_missionRoster) { - _missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({ - preview: scheduleMissionRosterPreview, - clear: clearMissionRosterPreviewState, - }); - _missionRoster.onclick = (event) => { - const button = event.target instanceof Element - ? event.target.closest('[data-mission-roster-index]') - : null; - if (!button) return; - selectMissionAt(Number(button.dataset.missionRosterIndex)); - }; - } - _missionPanel = document.createElement('aside'); - _missionPanel.id = 'space-mission-panel'; - _missionPanel.className = 'context-space-mission-detail'; - _missionPanel.setAttribute('aria-label', 'Selected Space Mission'); - _missionPanel.innerHTML = `SELECTED SPACE MISSION×MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED · PAYLOADNAMETYPEDESTINATIONSTAGE / RE-ENTRY / RECOVERYSTAGESTATUSFINAL POSITIONREPLAY SPEED1×0.25×1×4×FOCUSREPLAY ASCENT‹ PREV—NEXT ›SHOW ALL / DESELECT`; - _missionPanel.querySelector('.mission-action-row').insertAdjacentHTML( - 'beforeend', - ` - Ⅱ - × - `, - ); - host.appendChild(_missionPanel); - _missionPanel.querySelector('[data-mission-prev]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) - 1)); - _missionPanel.querySelector('[data-mission-next]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) + 1)); - _missionPanel.querySelector('[data-mission-close]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-show-all]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-focus]').addEventListener('click', () => { - const launch = _launches.find((item) => item.id === _selectedLaunchId); - if (launch) focusLaunchSite(launch); - }); - _missionPanel.querySelector('[data-mission-replay]').addEventListener('click', () => { - if (_selectedLaunchId) startMissionReplay(_selectedLaunchId); - }); - _missionPanel.querySelector('[data-mission-replay-toggle]').addEventListener('click', () => { - if (_replayPaused) resumeMissionReplay(); - else pauseMissionReplay(); - }); - _missionPanel.querySelector('[data-mission-replay-cancel]').addEventListener('click', stopMissionReplay); - _missionPanel.querySelector('[data-mission-replay-speed]').addEventListener('input', (event) => { - setReplaySpeed(event.currentTarget.value); - }); - syncReplaySpeedControl(); -} - -function updateReplayVehicleOverlay(occluder) { - if (!_viewer || !_replayVehicleOverlay || !_selectedLaunchId) { - hideReplayVehicleOverlay(); - return null; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const track = _replayTracks.get(_selectedLaunchId); - if (!launch || !track) { - hideReplayVehicleOverlay(); - return null; - } - const replayActive = _replayCameraLaunchId === launch.id; - const state = track.getReplayState(); - const mode = replayOverlayMode({ - replayActive, - ascending: state.ascending, - countdownActive: state.countdownActive, - preCountdownActive: state.preCountdownActive, - }); - if (!mode) { - track.lastOverlayWindowPosition = null; - track.lastOverlayMode = null; - hideReplayVehicleOverlay(); - return null; - } - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - // Do not call scene.sampleHeight() from this post-render path. A selected - // mission can remain visible from globe range, and a periodic remote height - // probe forces Google Photorealistic 3D Tiles to reconsider its refinement - // set even though the camera is stationary. That presented as a one-second - // shrink/expand pulse across the globe. Replay follows its already-built, - // surface-safe Cartesian path and therefore needs no runtime pad sampling. - const position = samplePath(path, state.phaseProgress); - if (!position || !occluder.isPointVisible(position)) { - hideReplayVehicleOverlay(); - return null; - } - const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( - _viewer.scene, - position, - ); - if (!windowPosition) { - hideReplayVehicleOverlay(); - return null; - } - const canvas = _viewer.scene.canvas; - if ( - windowPosition.x < -80 - || windowPosition.y < -100 - || windowPosition.x > canvas.clientWidth + 80 - || windowPosition.y > canvas.clientHeight + 100 - ) { - hideReplayVehicleOverlay(); - return null; - } - // The replay camera already targets this same frame-cached world position. - // Applying a second temporal filter here made the DOM vehicle trail the - // camera until the snap threshold was crossed, producing a repeating - // forward/back jump. Keep smoothing only for the non-tracked close-up pad - // marker, where the user can move the camera independently. - const renderedWindowPosition = !replayActive && track.lastOverlayMode === mode - ? smoothReplayWindowPosition(track.lastOverlayWindowPosition, windowPosition) - : windowPosition; - track.lastOverlayWindowPosition = renderedWindowPosition; - track.lastOverlayMode = mode; - _replayVehicleOverlay.hidden = false; - _replayVehicleOverlay.dataset.mode = mode; - _replayVehicleOverlay.classList.toggle( - 'is-thrusting', - replayActive && state.ascending && !state.countdownActive, - ); - _replayVehicleOverlay.classList.toggle('is-paused', replayActive && _replayPaused); - _replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; - let vehicleRotation = 0; - if (replayActive && !state.countdownActive) { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); - const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); - const useForward = forwardProgress > state.phaseProgress; - const tangentPosition = samplePath( - path, - useForward ? forwardProgress : backwardProgress, - ); - const tangentWindowPosition = tangentPosition - ? Cesium.SceneTransforms.worldToWindowCoordinates(_viewer.scene, tangentPosition) - : null; - if (tangentWindowPosition) { - const screenDelta = Math.hypot( - tangentWindowPosition.x - windowPosition.x, - tangentWindowPosition.y - windowPosition.y, - ); - // When the path points almost directly into/out of the camera, its - // screen projection has no trustworthy direction. Keep the last valid - // path-facing pose instead of snapping the rocket to a camera-facing - // upright orientation. - if (screenDelta >= 2) { - vehicleRotation = useForward - ? replayVehicleScreenRotation(windowPosition, tangentWindowPosition) - : replayVehicleScreenRotation(tangentWindowPosition, windowPosition); - track.lastVehicleRotation = vehicleRotation; - } else if (Number.isFinite(track.lastVehicleRotation)) { - vehicleRotation = track.lastVehicleRotation; - } - } - } - _replayVehicleOverlay.style.setProperty( - '--mission-replay-rotation', - `${Cesium.Math.toDegrees(vehicleRotation)}deg`, - ); - - const mission = shortMissionLabel(launch.name, 22).toUpperCase(); - const siteName = compactLaunchSiteName(launch.launchSite); - const siteCallout = siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'; - let title = mission; - let detail = siteCallout; - if (mode === 'countdown') { - title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; - detail = `LAUNCH STANDBY\n${siteCallout}`; - } else if (mode === 'ascent') { - title = state.elapsedSinceStart < 1 - ? `LIFTOFF · ${mission}` - : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'recovery') { - title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'orbit') { - title = `ORBIT REPLAY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } - if (_replayPaused) title = `PAUSED · ${title}`; - const nextText = `${title}\n${detail}`; - if (nextText !== _replayVehicleOverlayText) { - _replayVehicleOverlayText = nextText; - _replayVehicleOverlay.querySelector('[data-replay-overlay-title]').textContent = title; - _replayVehicleOverlay.querySelector('[data-replay-overlay-detail]').textContent = detail; - } - return mode; -} - -function setGraphicVisibility(graphic, visible, time) { - if (!graphic) return; - const next = Boolean(visible); - const current = graphic.show?.getValue?.(time); - if (current !== next) graphic.show = next; -} - -function updateMissionFrame() { - if (!_enabled || !_viewer || !_dataSource?.show) { - hideReplayVehicleOverlay(); - return; - } - updateMissionTelemetry(); - const time = Cesium.JulianDate.now(_declutterTime); - _declutterOccluder.cameraPosition = _viewer.scene.camera.positionWC; - updateReplayVehicleOverlay(_declutterOccluder); - refreshSelectedMissionOverlayText(); - for (const entity of _dataSource.entities.values) { - if (!entity.show) continue; - if (!entity.position) continue; - const id = entityLaunchId(entity); - if (!id) continue; - const position = entity.position.getValue(time); - let horizonVisible = Boolean(position && _declutterOccluder.isPointVisible(position)); - const launchAnchor = entity.id.startsWith('rocket-launch:'); - if (launchAnchor) { - horizonVisible = missionAnchorVisible( - _viewer.scene.camera.positionWC, - position, - id, - _selectedLaunchId, - ); - // Keep entity.show reserved for mission-selection isolation. Horizon - // state is applied to the individual graphics below so a rear-side - // anchor remains eligible for reevaluation as the camera moves. - } - if (entity.point) { - setGraphicVisibility(entity.point, horizonVisible, time); - } - if (launchAnchor && entity.billboard) { - setGraphicVisibility( - entity.billboard, - horizonVisible && id === _hoveredRosterLaunchId, - time, - ); - } - } -} - -/** - * Select a stable marker color from the mission operator and payload name. - * Labels stay cyan so the layer remains visually coherent. - * @param {object} launch Normalized launch record. - * @returns {Cesium.Color} - */ -export function missionMarkerColor(launch) { - const identity = `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); - if (/nasa|national aeronautics/.test(identity)) return Cesium.Color.fromCssColorString('#ff9f43'); - if (/starlink|spacex|space exploration/.test(identity)) return Cesium.Color.fromCssColorString('#4cc9f0'); - if (/rocket lab/.test(identity)) return Cesium.Color.fromCssColorString('#7bed9f'); - if (/isro|indian space/.test(identity)) return Cesium.Color.fromCssColorString('#ff66c4'); - if (/cnsa|china national|long march/.test(identity)) return Cesium.Color.fromCssColorString('#ffd166'); - if (/blue origin/.test(identity)) return Cesium.Color.fromCssColorString('#a78bfa'); - if (/ula|united launch alliance/.test(identity)) return Cesium.Color.fromCssColorString('#f97316'); - if (/arianespace|esa|european space/.test(identity)) return Cesium.Color.fromCssColorString('#60a5fa'); - if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); - return Cesium.Color.fromCssColorString('#22e6e6'); -} - -/** - * Normalize a Launch Library 2 response into records suitable for the layer. - * Trajectory points are retained only when the upstream explicitly supplies - * them; orbital tracks must not be reconstructed from launch metadata. - * @param {object} payload Launch Library 2-compatible response. - * @param {Date} [now] Reference time used for the rolling window. - * @returns {Array} - */ -export function normalizeRocketLaunches(payload, now = new Date()) { - const launches = Array.isArray(payload) ? payload : payload?.results; - if (!Array.isArray(launches)) return []; - const cutoff = now.getTime() - WINDOW_DAYS * 86400000; - return launches.map((launch) => { - const launchTime = launch.net || launch.window_start || launch.pad?.location?.name; - const date = Date.parse(launchTime); - const pad = launch.pad || {}; - const location = pad.location || {}; - const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates).split(',').map(Number); - const lat = Number.isFinite(Number(pad.latitude)) ? Number(pad.latitude) : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) ? Number(pad.longitude) : coordinateLon; - const payloads = normalizePayloadFlights(launch); - return { - id: String(launch.id || launch.slug || launch.name || `launch-${date}`), - name: launch.name || 'Unnamed launch', - status: launch.status?.name || 'Unknown', - launchTime: Number.isFinite(date) ? new Date(date).toISOString() : null, - launchSite: pad.name || location.name || 'Unknown launch site', - lat: Number.isFinite(lat) ? lat : null, - lon: Number.isFinite(lon) ? lon : null, - provider: launch.launch_service_provider?.name || null, - mission: launch.mission?.description || null, - missionName: launch.mission?.name || null, - satelliteQuery: launch.mission?.name || launch.name || null, - payloads, - recoveryStages: normalizeRecoveryStages(launch, payloads), - trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], - timeline: Array.isArray(launch.timeline) - ? launch.timeline.map((event) => ({ - name: event.type?.abbrev || event.type?.name || event.name || 'Mission event', - relativeTime: event.relative_time || event.relativeTime || null, - offsetSeconds: parseMissionDurationSeconds(event.relative_time || event.relativeTime), - })) - : [], - orbit: launch.mission?.orbit || launch.orbit || null, - source: 'Launch Library 2', - inWindow: Number.isFinite(date) && date >= cutoff && date <= now.getTime(), - }; - }).filter((launch) => launch.inWindow && launch.lat !== null && launch.lon !== null); -} - -function addLaunchEntity(launch, activeTleText = _activeTleText) { - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const overlayRecord = { - launch, - anchorPosition: position, - elementEntryFactories: [], - liveEventTime: null, - }; - _missionOverlayRecords.set(launch.id, overlayRecord); - const orbitAllowed = launchStatusAllowsOrbit(launch.status); - const coreTrack = orbitAllowed && launch.satelliteQuery - ? getSatelliteOrbitTrack(launch.satelliteQuery, { launchTime: launch.launchTime }) - : null; - const satelliteTrack = coreTrack || (orbitAllowed && activeTleText && launch.satelliteQuery - ? findSatelliteOrbitTrackInTle( - activeTleText, - launch.satelliteQuery, - { launchTime: launch.launchTime }, - ) - : null); - const orbitPath = !orbitAllowed - ? null - : satelliteTrack?.orbitPath?.length > 1 - ? satelliteTrack.orbitPath - : approximateOrbitPath(launch); - launch.recoveryStages.forEach((stage) => { - stage.endpoint = landingEndpoint(stage, launch, orbitPath?.[0] || null); - }); - if (satelliteTrack) _orbitMatches++; - const entity = _dataSource.entities.add({ - id: `rocket-launch:${launch.id}`, - position, - point: { - pixelSize: 5, - color: missionMarkerColor(launch), - outlineColor: missionMarkerColor(launch), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - billboard: { - image: missionHoverReticleImage(), - width: 24, - height: 24, - show: false, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - launchName: launch.name, - launchTime: launch.launchTime, - launchSite: launch.launchSite, - provider: launch.provider, - mission: launch.mission, - status: launch.status, - source: launch.source, - orbit: JSON.stringify(launch.orbit || {}), - satelliteName: satelliteTrack?.name || '', - noradId: satelliteTrack?.noradId || '', - }, - }); - const points = launch.trajectory - .filter((point) => Number.isFinite(Number(point.latitude)) && Number.isFinite(Number(point.longitude))) - .map((point) => ({ - stage: String(point.stage || point.stage_name || point.phase || point.stageName || 'trajectory'), - position: Cesium.Cartesian3.fromDegrees(Number(point.longitude), Number(point.latitude), Number(point.altitude || 0)), - })); - if (points.length > 1) { - const segments = []; - points.forEach((point) => { - const previous = segments.at(-1); - if (!previous || previous.stage !== point.stage) segments.push({ stage: point.stage, positions: [] }); - segments.at(-1).positions.push(point.position); - }); - segments.forEach((segment, index) => { - if (segment.positions.length < 2) return; - _dataSource.entities.add({ - id: `rocket-trajectory:${launch.id}:${index}`, - polyline: { - positions: surfaceSafePath(segment.positions), - width: 2, - material: Cesium.Color.fromCssColorString(TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length]).withAlpha(0.8), - clampToGround: false, - arcType: Cesium.ArcType.NONE, - }, - properties: { launchId: launch.id, stage: segment.stage, source: launch.source }, - }); - }); - } - if (orbitPath?.length > 1) { - const orbitCurrent = satelliteTrack?.current || { longitude: launch.lon, latitude: launch.lat, altitude: 0 }; - const orbitPeriodSec = satelliteTrack?.periodSec || estimatedOrbitPeriodSeconds(orbitPath); - const launchEpochMs = Date.parse(launch.launchTime); - const disclosedInsertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) - ? Math.max(0, disclosedInsertionOffsetSec) - : 600; - const insertionEpochMs = Number.isFinite(launchEpochMs) - ? launchEpochMs + insertionDurationSec * 1000 - : Number.NaN; - let insertionReference = points.at(-1)?.position || null; - if (!insertionReference && satelliteTrack?.positionAt && Number.isFinite(insertionEpochMs)) { - const propagatedInsertion = satelliteTrack.positionAt(new Date(insertionEpochMs)); - if (propagatedInsertion) { - insertionReference = Cesium.Cartesian3.fromDegrees( - propagatedInsertion.longitude, - propagatedInsertion.latitude, - propagatedInsertion.altitude, - ); - } - } - if (!insertionReference && !satelliteTrack) { - // A projected orbit has no authoritative historical phase. Start its - // plane over the launch site, advance only by the estimated powered - // ascent duration, and join the forward orbit tangent. Using the UTC - // epoch as phase sent some ascents toward the far side of Earth and - // forced a visible 180-degree corrective hook before insertion. - const insertionProgress = Cesium.Math.clamp( - insertionDurationSec / orbitPeriodSec, - 1 / 96, - 0.16, - ); - insertionReference = samplePath(orbitPath, insertionProgress); - } - const { ascentPath, animatedOrbitPath } = buildMissionPaths( - position, - points.map((point) => point.position), - orbitPath, - insertionReference, - ); - const ascentDurationSec = replayAscentDurationSeconds(launch, ascentPath); - // Cesium can evaluate CallbackProperty values multiple times while it - // traverses one frame. Camera tracking and the HTML replay overlay must - // use the exact same replay epoch as those callbacks; sampling Date.now() - // independently made the vehicle advance relative to the camera and then - // snap back on the next frame. - let replayStateForFrame = null; - const sampleReplayState = () => replayState( - launch, - _animationStarts.get(launch.id) || Date.now(), - ascentDurationSec, - REPLAY_ORBIT_DURATION_SEC, - orbitPeriodSec, - _replaySpeed, - replayClockNow(launch.id), - REPLAY_TILE_SETTLE_DELAY_SEC, - _replayCameraLaunchId !== launch.id, - ); - // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed - // one state there and retain it through postRender so every consumer in - // that traversal sees the same timestamp. scene.frameState.frameNumber is - // advanced between some of those phases, so it is not a reliable cache key. - const beginReplayFrame = () => { - replayStateForFrame = sampleReplayState(); - return replayStateForFrame; - }; - const getReplayState = () => { - if (!replayStateForFrame) { - replayStateForFrame = sampleReplayState(); - } - return replayStateForFrame; - }; - launch.recoveryStages.forEach((stage, stageIndex) => { - stage.endpoint = landingEndpoint(stage, launch, ascentPath.at(-1)); - const path = stageReentryRecoveryPath(ascentPath, stage.endpoint, stageIndex, launch.recoveryStages.length); - if (path.length < 2) return; - const reentryIndex = atmosphericReentryIndex(path); - const reentryPath = path.slice(reentryIndex); - const color = Cesium.Color.fromCssColorString( - TRAJECTORY_STAGE_COLORS[(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length], - ); - _dataSource.entities.add({ - id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, - polyline: { - positions: path, - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ - color: color.withAlpha(0.82), - dashLength: 12, - dashPattern: 0xAAAA, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'STAGE_REENTRY_RECOVERY', - accuracy: stage.endpoint.accuracy, - source: launch.source, - }, - }); - if (reentryPath.length > 1) { - _dataSource.entities.add({ - id: `rocket-reentry:${launch.id}:${stageIndex}`, - polyline: { - positions: reentryPath, - width: 2.5, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), - dashLength: 8, - dashPattern: 0xF0F0, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'ATMOSPHERIC_REENTRY', - altitudeM: STAGE_REENTRY_ALTITUDE_M, - source: 'Estimated 100 km atmospheric interface', - }, - }); - const reentryPosition = path[reentryIndex]; - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `reentry:${launch.id}:${stageIndex}`, - position: reentryPosition, - text: 'STAGE RE-ENTRY', - accent: '#ffd166', - priority: 700_000 - stageIndex, - gapPx: 8, - }) - )); - } - _dataSource.entities.add({ - id: `rocket-recovery-end:${launch.id}:${stageIndex}`, - position: path.at(-1), - point: { - pixelSize: 4, - color, - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'RECOVERY_ENDPOINT', - source: launch.source, - }, - }); - }); - _replayTracks.set(launch.id, { - ascentPath, - animatedOrbitPath, - orbitFrameSphere: replayOrbitFrameSphere(animatedOrbitPath), - ascentDurationSec, - beginReplayFrame, - getReplayState, - lastCameraHeading: Math.PI, - orbitCameraWorldFrame: false, - lastVehicleRotation: 0, - lastOverlayWindowPosition: null, - lastOverlayMode: null, - }); - let livePosition = Cesium.Cartesian3.fromDegrees( - orbitCurrent.longitude, - orbitCurrent.latitude, - orbitCurrent.altitude, - ); - const liveTime = new Date(); - const liveTelemetry = { speedMps: null }; - _satelliteTelemetry.set(launch.id, liveTelemetry); - let liveFrameNumber = -1; - const fallbackPeriodSec = estimatedOrbitPeriodSeconds(orbitPath); - const updateLiveState = () => { - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; - liveFrameNumber = frameNumber; - const nowMs = Date.now(); - liveTime.setTime(nowMs); - if (satelliteTrack) { - const propagated = satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; - if (propagated) { - Cesium.Cartesian3.fromDegrees( - propagated.longitude, - propagated.latitude, - propagated.altitude, - undefined, - livePosition, - ); - liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) - ? propagated.speedMps - : null; - } - } else { - // In-place write keeps one Cartesian3 for the mission's lifetime — - // the estimated branch previously reallocated per propagation. - livePosition = samplePath( - orbitPath, - orbitProgressAtTime(nowMs, fallbackPeriodSec), - livePosition, - ) || livePosition; - } - }; - _dataSource.entities.add({ - id: `rocket-satellite:${launch.id}`, - position: new Cesium.CallbackProperty(() => { - updateLiveState(); - return livePosition; - }, false), - point: { - pixelSize: 6, - color: Cesium.Color.fromCssColorString(satelliteTrack ? '#7bed9f' : '#ffd166'), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - noradId: satelliteTrack?.noradId || '', - phase: satelliteTrack ? 'CURRENT_SATELLITE_POSITION' : 'ESTIMATED_ORBIT_POSITION', - source: satelliteTrack ? 'Satellites layer' : 'Approximate mission orbit', - }, - }); - overlayRecord.liveEventTime = () => liveTime; - overlayRecord.elementEntryFactories.push(() => { - const name = satelliteTrack - ? shortMissionLabel(satelliteTrack.name, 22).toUpperCase() - : 'EST. ORBIT POSITION'; - return createRocketMissionElementOverlayEntry({ - id: `payload-position:${launch.id}`, - position: () => livePosition, - text: `${name}\n${formatMissionEventTime(liveTime)}`, - accent: satelliteTrack ? '#7bed9f' : '#ffd166', - priority: 900_000, - gapPx: 12, - }); - }); - // Live satellite rings use a primitive collection so the core Satellite - // GMST transform can rotate their baked ECEF geometry without rebuilding - // it. Estimated launch-site-relative rings remain ordinary entities. - const primitiveOrbitAdded = addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack); - if (!primitiveOrbitAdded) { - _dataSource.entities.add({ - id: `rocket-orbit:${launch.id}`, - polyline: { - positions: orbitPath, - // The 3 px canvas gives the periodic dot room to read as round; the - // shader masks the ten intervening dashes down to a thin center stroke. - width: 3, - material: new MissionOrbitPatternMaterialProperty( - Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), - ), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - satelliteName: '', - noradId: '', - source: 'Approximate mission orbit', - }, - }); - } - const orbitLabelBakePosition = ascentPath.at(-1); - let orbitLabelPosition = orbitLabelBakePosition; - const primitivePath = _missionOrbitPrimitives.get(launch.id); - if (primitivePath) { - primitivePath.labelBakePosition = orbitLabelBakePosition; - primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( - primitivePath.primitive.modelMatrix, - orbitLabelBakePosition, - new Cesium.Cartesian3(), - ); - orbitLabelPosition = () => primitivePath.labelPosition; - } - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `orbit:${launch.id}`, - position: orbitLabelPosition, - text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', - accent: satelliteTrack ? '#22e6e6' : '#c084fc', - priority: 800_000, - gapPx: 8, - }) - )); - const replayPosition = new Cesium.CallbackProperty(() => { - const state = getReplayState(); - return state.ascending - ? samplePath(ascentPath, state.phaseProgress) - : samplePath(animatedOrbitPath, state.phaseProgress); - }, false); - _dataSource.entities.add({ - id: `rocket-vehicle:${launch.id}`, - position: replayPosition, - properties: { launchId: launch.id, noradId: satelliteTrack?.noradId || '', phase: 'ASCENT_THEN_ORBIT', source: satelliteTrack ? 'Satellite trajectory animation' : 'Approximate trajectory animation' }, - }); - _dataSource.entities.add({ - id: `rocket-transfer:${launch.id}`, - polyline: { - positions: ascentPath, - width: 1.5, - // The animated vehicle uses Cartesian interpolation between these - // exact samples. Prevent Cesium from replacing each segment with a - // geodesic arc, which would visually separate the dot from the line. - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), - dashLength: 24, - dashPattern: 0xF0F0, - }), - }, - properties: { - launchId: launch.id, - phase: 'APPROXIMATE_TRANSFER', - source: satelliteTrack - ? 'Launch site to propagated insertion' - : 'Launch site to forward projected insertion', - }, - }); - } -} - -function clearPostTleRetry() { - if (_retryTimer) clearTimeout(_retryTimer); - _retryTimer = null; -} - -function schedulePostTleRetry(token) { - if (_retryTimer || token !== _lifecycleToken || !shouldRetryAfterActiveTle({ - enabled: _enabled, - retryCount: _postTleRetryCount, - activeTleText: _activeTleText, - renderedTleText: _renderedTleText, - })) return; - _postTleRetryCount++; - _retryTimer = setTimeout(() => { - _retryTimer = null; - if (_enabled && token === _lifecycleToken) requestMissionUpdate(); - }, POST_TLE_RETRY_DELAY_MS); -} - -function ensureActiveTleLookup(token) { - if (_activeTleText) return Promise.resolve(_activeTleText); - if (_activeTlePromise && _activeTlePromiseToken === token) return _activeTlePromise; - const request = fetch('/api/celestrak/active') - .then((activeResponse) => { - if (!activeResponse.ok) throw new Error(`HTTP ${activeResponse.status}`); - return activeResponse.text(); - }) - .then((text) => { - if (!_enabled || token !== _lifecycleToken) return null; - _activeTleText = text; - _focusAfterActiveLookup = Boolean(_selectedLaunchId); - schedulePostTleRetry(token); - return text; - }) - .catch((error) => { - if (_enabled && token === _lifecycleToken) { - console.warn('[Data:RocketLaunches] Active satellite lookup unavailable:', error.message); - } - return null; - }) - .finally(() => { - if (_activeTlePromise === request) { - _activeTlePromise = null; - _activeTlePromiseToken = 0; - } - }); - _activeTlePromise = request; - _activeTlePromiseToken = token; - return request; -} - -async function captureSatelliteDependency() { - if (!_dataManager || _satelliteStateBeforeMission) return; - _satelliteStateBeforeMission = { - // Effective visibility: a user enable still mid-activation is intent ON — - // capturing settled false would restore the user's enable away on exit. - enabled: _dataManager.isEffectivelyEnabled?.('satellites') - ?? _dataManager.isEnabled('satellites'), - params: satelliteParamsAfterSpaceMissions( - _dataManager.getLayerParams('satellites'), - ), - }; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsForSpaceMissions(_satelliteStateBeforeMission.params), - ); - const token = _lifecycleToken; - const activation = Promise.resolve(_dataManager.setEnabled('satellites', true)); - _satelliteActivationPromise = activation; - try { - const activated = await activation; - // Space Missions without its satellite dependency is a broken replay - // surface; fail the mission enable so the manager's fail-closed path and - // the Context rollback see an honest failure instead of a silent success. - if ( - token === _lifecycleToken - && _enabled - && (activated === false || !_dataManager.isEnabled('satellites')) - ) { - throw new Error('Space Missions requires the satellites layer, which failed to start'); - } - } finally { - if (token === _lifecycleToken && _satelliteActivationPromise === activation) { - _satelliteActivationPromise = null; - } - } -} - -async function restoreSatelliteDependency() { - const snapshot = _satelliteStateBeforeMission; - if (!snapshot || !_dataManager) return; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsAfterSpaceMissions(snapshot.params), - ); - const restored = await _dataManager.setEnabled('satellites', snapshot.enabled); - if ( - restored === false - || _dataManager.isEnabled('satellites') !== snapshot.enabled - ) { - throw new Error('Space Missions could not restore the satellites layer'); - } -} - -async function performMissionUpdate(token) { - try { - ensureActiveTleLookup(token); - const response = await fetch(API_URL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const launches = normalizeRocketLaunches(await response.json()); - if (!_enabled || token !== _lifecycleToken || !_dataSource) return; - const activeTleText = _activeTleText; - if (_replayCameraLaunchId) stopMissionReplay(); - _launches = launches; - removeMissionOrbitPrimitives(); - _dataSource.entities.removeAll(); - _missionOverlayRecords.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _orbitMatches = 0; - launches.forEach((launch) => addLaunchEntity(launch, activeTleText)); - _renderedTleText = activeTleText; - if (_renderedTleText === _activeTleText) clearPostTleRetry(); - _count = launches.length; - renderMissionRoster(); - if (!_selectedLaunchId || !launches.some((launch) => launch.id === _selectedLaunchId)) { - setSelectedMission(null, false); - } else { - setSelectedMission(_selectedLaunchId, true); - if (_focusAfterActiveLookup) { - focusMission(launches.find((launch) => launch.id === _selectedLaunchId)); - } - } - _focusAfterActiveLookup = false; - renderMissionPanel(); - _lastUpdate = Date.now(); - _lastError = null; - } catch (error) { - if (_enabled && token === _lifecycleToken) { - _lastError = error.message; - console.warn('[Data:RocketLaunches] Fetch error:', error); - } - } -} - -function requestMissionUpdate() { - if (!_enabled) return Promise.resolve(); - const token = _lifecycleToken; - _updateDirty = true; - if (_updatePromise && _updatePromiseToken === token) return _updatePromise; - let request; - request = (async () => { - while (_enabled && token === _lifecycleToken && _updateDirty) { - _updateDirty = false; - await performMissionUpdate(token); - } - })().finally(() => { - if (_updatePromise === request) { - _updatePromise = null; - _updatePromiseToken = 0; - } - }); - _updatePromise = request; - _updatePromiseToken = token; - return request; -} - -const rocketLaunchesLayer = { - id: 'rocket-launches', - name: 'Space Missions (30d)', - icon: '🚀', - source: 'Launch Library 2', - updateInterval: 300000, - - init(viewer) { - _viewer = viewer; - _enabled = false; - createMissionPanel(); - createReplayVehicleOverlay(); - _dataSource = new Cesium.CustomDataSource('rocket-launches'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); - _count = 0; - _lastUpdate = null; - _lastError = null; - _orbitMatches = 0; - clearMissionOverlaySources(); - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const entity = viewer.scene.drillPick(movement.position, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - const launchId = entityLaunchId(entity); - if (!launchId) return; - setSelectedMission(launchId); - focusMission(_launches.find((launch) => launch.id === launchId)); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - _moveHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _moveHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const missionEntity = viewer.scene.drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - // Apply horizon visibility before Cesium traverses and picks the scene. - // A postRender write is one frame late and can remain visibly stale when - // request-on-demand rendering stops after a globe camera move. - _declutterHandler = viewer.scene.preRender.addEventListener(updateMissionFrame); - initLaunchPadZonePrimitive(); - }, - - async enable() { - _enabled = true; - holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) - _lifecycleToken++; - _postTleRetryCount = 0; - try { - await this._enableBody(); - } catch (error) { - // Enable is a transaction: a failed dependency must not leave mission - // UI/datasource visible or the satellite snapshot retained (a retained - // snapshot makes the next enable skip dependency capture entirely). - try { - await this.disable(); - } catch (cleanupError) { - console.warn('[Missions] enable rollback failed:', cleanupError); - } - throw error; - } - }, - async _enableBody() { - _updateDirty = false; - if (_dataSource) _dataSource.show = true; - syncMissionOrbitPrimitiveVisibility(); - focusFullGlobe(_viewer); - document.getElementById('cockpit-context')?.setAttribute('hidden', ''); - if (_selectedLaunchId) setSelectedMission(_selectedLaunchId, _explicitSelection); - else setSelectedMission(null, false); - await captureSatelliteDependency(); - }, - async disable() { - _enabled = false; - releaseContinuousRender('rocket-launches'); - _lifecycleToken++; - _updateDirty = false; - clearPostTleRetry(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - if (_dataSource) _dataSource.show = false; - syncMissionOrbitPrimitiveVisibility(); - if (_viewer?.scene?.canvas) _viewer.scene.canvas.style.cursor = ''; - hideLaunchPadZone(); - hideReplayVehicleOverlay(); - _selectedLaunchId = null; - clearMissionOverlaySources(); - await restoreSatelliteDependency(); - renderMissionPanel(); - }, - - update() { - return requestMissionUpdate(); - }, - - /** Release only Space Mission camera ownership, preserving layer and selection state. */ - releaseCameraOwnership() { - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - }, - - async destroy(viewer) { - releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _lifecycleToken++; - _updateDirty = false; - await restoreSatelliteDependency(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - clearPostTleRetry(); - if (_clickHandler) _clickHandler.destroy(); - _clickHandler = null; - if (_moveHandler) _moveHandler.destroy(); - _moveHandler = null; - if (_declutterHandler) _declutterHandler(); - _declutterHandler = null; - destroyLaunchPadZonePrimitive(); - removeMissionOrbitPrimitives(); - clearMissionOverlaySources(); - destroyReplayVehicleOverlay(); - _launches = []; - _missionOverlayRecords.clear(); - _animationStarts.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _missionPanel?.remove(); - _missionPanel = null; - if (_missionRoster) { - _missionRoster.onclick = null; - } - _missionRosterPreviewOwnership = null; - _missionRoster = null; - _viewer = null; - if (_dataSource) viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - _count = 0; - _orbitMatches = 0; - _lastUpdate = null; - _activeTleText = null; - _activeTlePromise = null; - _activeTlePromiseToken = 0; - _renderedTleText = null; - _postTleRetryCount = 0; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager = null; - }, - - getStats() { return { count: _count, orbitMatches: _orbitMatches, lastUpdate: _lastUpdate, error: _lastError }; }, - attachDataManager(dataManager) { _dataManager = dataManager; }, -}; - -/** Test seam for real layer lifecycle coverage with a recording host. */ -export function _setRocketMissionOverlayHostForTest(host = null) { - _missionOverlayHost = host ? { ...DEFAULT_OVERLAY_HOST, ...host } : DEFAULT_OVERLAY_HOST; -} - -/** Test seam that exercises the real selection/deselection path. */ -export function _setSelectedRocketMissionForTest(launchId = null) { - setSelectedMission(launchId, Boolean(launchId)); -} - -export default rocketLaunchesLayer; +export const satelliteParamsForSpaceMissions = + layer.satelliteParamsForSpaceMissions; +export const satelliteParamsAfterSpaceMissions = + layer.satelliteParamsAfterSpaceMissions; +export const launchStatusAllowsOrbit = layer.launchStatusAllowsOrbit; +export const missionPathPresentation = layer.missionPathPresentation; +export const shouldRetryAfterActiveTle = layer.shouldRetryAfterActiveTle; +export const releaseAircraftTracking = layer.releaseAircraftTracking; +export const launchPadZoneVisible = layer.launchPadZoneVisible; +export const missionAnchorHorizonVisible = layer.missionAnchorHorizonVisible; +export const missionAnchorVisible = layer.missionAnchorVisible; +export const compactLaunchSiteName = layer.compactLaunchSiteName; +export const createRocketMissionMarkerOverlayEntry = + layer.createRocketMissionMarkerOverlayEntry; +export const createRocketMissionElementOverlayEntry = + layer.createRocketMissionElementOverlayEntry; +export const selectRocketMissionMarkerOverlayCohort = + layer.selectRocketMissionMarkerOverlayCohort; +export const replayOverlayMode = layer.replayOverlayMode; +export const replayVehicleScreenRotation = layer.replayVehicleScreenRotation; +export const smoothReplayWindowPosition = layer.smoothReplayWindowPosition; +export const missionDataCompleteness = layer.missionDataCompleteness; +export const missionRosterEntries = layer.missionRosterEntries; +export const missionHoverPreviewRange = layer.missionHoverPreviewRange; +export const createMissionRosterPreviewOwnership = + layer.createMissionRosterPreviewOwnership; +export const bindMissionRosterItemKeyboardPreview = + layer.bindMissionRosterItemKeyboardPreview; +export const captureMissionRosterFocus = layer.captureMissionRosterFocus; +export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus; +export const resolveMissionRosterPreviewLaunch = + layer.resolveMissionRosterPreviewLaunch; +export const formatMissionEventTime = layer.formatMissionEventTime; +export const parseMissionDurationSeconds = layer.parseMissionDurationSeconds; +export const replayAscentDurationSeconds = layer.replayAscentDurationSeconds; +export const normalizeReplaySpeed = layer.normalizeReplaySpeed; +export const replayStartAfterPause = layer.replayStartAfterPause; +export const replayState = layer.replayState; +export const approximateOrbitPath = layer.approximateOrbitPath; +export const samplePath = layer.samplePath; +export const orbitProgressAtTime = layer.orbitProgressAtTime; +export const reconstructedAscentPath = layer.reconstructedAscentPath; +export const buildMissionPaths = layer.buildMissionPaths; +export const cameraHeadingForPath = layer.cameraHeadingForPath; +export const replayInitialCameraHeading = layer.replayInitialCameraHeading; +export const replayChaseCameraHeading = layer.replayChaseCameraHeading; +export const smoothReplayCameraHeading = layer.smoothReplayCameraHeading; +export const missionZoomPitch = layer.missionZoomPitch; +export const replayCameraView = layer.replayCameraView; +export const replayOrbitGlobeAnchor = layer.replayOrbitGlobeAnchor; +export const replayOrbitCameraTarget = layer.replayOrbitCameraTarget; +export const replayOrbitCameraPose = layer.replayOrbitCameraPose; +export const replayOrbitFrameSphere = layer.replayOrbitFrameSphere; +export const replayOrbitGlobeRange = layer.replayOrbitGlobeRange; +export const missionMarkerColor = layer.missionMarkerColor; +export const normalizeRocketLaunches = layer.normalizeRocketLaunches; +export const _setRocketMissionOverlayHostForTest = + layer._setRocketMissionOverlayHostForTest; +export const _setSelectedRocketMissionForTest = + layer._setSelectedRocketMissionForTest; +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from '../layers/launches/index.js'; +export default layer; diff --git a/src/data/satellites.js b/src/data/satellites.js index 0fe8b6d02..ecad3df45 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,2397 +1,54 @@ -import * as Cesium from 'cesium'; -import { twoline2satrec, propagate, gstime, eciToGeodetic, degreesLong, degreesLat } from 'satellite.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { findNextIssPass } from './issPass.js'; import { - advanceSpriteFocus, - clearFocusTarget, - focusAlphaNeedsWrite, - focusNowMs, - focusPassIsNeeded, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, -} from './focusDeemphasis.js'; -import { refreshTrackedReadout } from './trackedReadout.js'; -import { - satelliteClassColor, - satelliteClassLabel, - satelliteClassLegend, - tallySatelliteClasses, -} from './satelliteClass.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { - clearTrackedSubjectContext, - getContextStore, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; + createSatellitesLayer, + createSatelliteSource, +} from '../layers/satellites/index.js'; +import * as picking from './pickRegistry.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as layerState from './layerState.js'; -/** - * Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation. - * - * Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS, - * Galileo, and the geosynchronous belt. Optional dense mode (setParams - * catalog:'dense') adds the Starlink shell as points-only extras. - * Renders positions via PointPrimitiveCollection, orbital paths as polylines. - * Click any satellite to track it with camera follow + orbital path. - * - * ISS gets special treatment: larger point, persistent host label, path shown by default. - */ - -const ISS_NORAD = 25544; -export const ISS_OVERLAY_SOURCE_ID = 'satellites-iss'; -export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 1, - collisionCapacity: 1, - moving: true, - solveIntervalMs: 125, +const layer = createSatellitesLayer({ + source: createSatelliteSource(), + services: { picking, focus, readout, overlays, context, render, layerState }, }); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); -let _overlayHost = DEFAULT_OVERLAY_HOST; -const ORBIT_PATH_STEPS = 180; // points per orbital path -const POSITION_UPDATE_MS = 1000; // re-propagate every 1s (SGP4 is smooth at this rate) -const RING_ROTATION_MS = 1000; // re-align baked orbit rings to current GMST every 1s - -/** - * CelesTrak groups loaded as the core catalog, in dedupe-priority order: - * a satellite that appears in multiple groups keeps the FIRST (most specific) - * tag. `path` is the upstream GROUP name forwarded by the /api/celestrak - * proxy; `tag` is the internal group key used for POINT_STYLES lookup. - * Note: CelesTrak's GLONASS group is named 'glo-ops' (not - * 'glonass-operational' — that name 404s upstream). - */ -const CATALOG_GROUPS = [ - { tag: 'stations', path: 'stations' }, - { tag: 'visual', path: 'visual' }, - { tag: 'gps-ops', path: 'gps-ops' }, - { tag: 'glonass', path: 'glo-ops' }, - { tag: 'galileo', path: 'galileo' }, - { tag: 'geo', path: 'geo' }, -]; - -// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as -// points-only extras — no labels, no detection-overlay participation, and a -// relaxed propagation budget (round-robin, ~1/5 of core cadence per sat). -const DENSE_GROUP_PATH = 'starlink'; -const DENSE_REFRESH_FRAMES = 300; // full dense pass spread over ~300 frames (~5s @ 60fps) -const DENSE_CREATE_CHUNK = 1500; // satrec builds per macro-task while loading - -/** - * Tracked-entity camera offset, east-north-up meters (Entity.viewFrom). - * Magnitude ≈ 726 km — the user-validated "slightly zoomed out" framing for - * LEO: far enough that per-frame satellite motion doesn't stutter the camera - * or smear the label, with +Z biasing the view down onto the satellite. - * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. - */ -const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3(-450000, -450000, 350000); -const HIGH_ORBIT_ALTITUDE_M = 2000000; -const TRACK_VIEW_FROM_HIGH_SCALE = 4; // ≈ 2900 km back for MEO/GEO - -/** - * Shared per-group point styling — single source of truth used by BOTH the - * creation site (update) and tracking restore (_clearTracking) so deselecting - * a satellite never loses the original palette (WS-D3). - * - * Colors come from `satelliteClass.js` so the dot, the class label on the - * card, and the legend swatch on the layer row can never disagree. Only - * pixelSize/outline live here — those encode per-group prominence, not class. - * Converted once at module load; per-point color is a plain primitive - * attribute, so classification costs nothing per frame. - */ -const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); - -const POINT_STYLES = { - // The ISS keeps its own long-standing red hero styling rather than the - // STATION class color: it is the object most users open this layer for, it - // carries a permanent name label, and its size/outline already set it apart. - // Its card still reads "STATION · ISS", so the class stays legible. - iss: { - pixelSize: 12, - color: Cesium.Color.fromCssColorString('#ff4444'), - outlineColor: POINT_OUTLINE, - outlineWidth: 2, - }, - stations: { - pixelSize: 8, - color: _classColor('stations'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - visual: { - pixelSize: 6, - color: _classColor('visual'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - // Nav constellations (GPS / GLONASS / Galileo) resolve to one shared NAV - // color; 6px so the MEO shells read as clearly as the old visual group did. - 'gps-ops': { - pixelSize: 6, - color: _classColor('gps-ops'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - glonass: { - pixelSize: 6, - color: _classColor('glonass'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - galileo: { - pixelSize: 6, - color: _classColor('galileo'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - geo: { - pixelSize: 5, - color: _classColor('geo'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - // Dense-mode extras (Starlink): dim, small, points-only. - dense: { - pixelSize: 3, - color: _classColor('dense').withAlpha(0.9), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, -}; - -/** - * Resolve the canonical point style for a satellite. - * @param {number} noradId NORAD catalog number. - * @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense'). - * @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }} - */ -function _pointStyleFor(noradId, group) { - if (noradId === ISS_NORAD) return POINT_STYLES.iss; - return POINT_STYLES[group] || POINT_STYLES.visual; -} - -// Satellite catalog: { noradId → { name, satrec, group } } -let _catalog = new Map(); -let _pointCollection = null; -let _points = new Map(); // noradId → point primitive -/** Stable lightweight records reused by the detection overlay between updates. */ -let _detectionObjects = new Map(); -// noradId → { primitive, gmstAtBake } -// primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF -// at gmstAtBake; the preRender tick re-aligns it to current GMST by updating -// its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1). -let _orbitPaths = new Map(); -let _count = 0; -let _lastUpdate = null; -/** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */ -let _lastError = null; -const _activeUpdateControllers = new Set(); -let _denseLoadController = null; - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); - _denseLoadController?.abort(); - _denseLoadController = null; -} -let _viewer = null; -let _preRenderListener = null; -let _lastPropagation = 0; -let _lastRingRotation = 0; -let _lastFocusUpdate = 0; -/** Points whose animated emphasis remains outside the 1.0 deadband. */ -let _activeFocusCount = 0; -const _scratchFocusScreen = new Cesium.Cartesian2(); -let _enabled = false; - -// Click-to-track state -let _trackedNorad = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - failedGroups: [], -}; -let _trackedEntity = null; -let _clickHandler = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; - -// Runtime params (DataLayerManager.setLayerParams path) -let _params = { catalog: 'core', showPoints: true, showOrbits: true }; // 'core' | 'dense' -let _denseIds = []; // norad ids of dense extras, round-robin order -let _denseCursor = 0; // next dense id to re-propagate -let _denseLoadToken = 0; // invalidates in-flight dense loads on mode flip/reload -let _denseLoadPromise = null; - -/** - * Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'. - * The catalog param flips synchronously but the Starlink shell arrives over - * seconds and can fail outright (CelesTrak 502s this feed regularly), so the - * row chip reports THIS, not the param. An active chip must mean dense points - * are actually on screen. - */ -let _denseStatus = 'idle'; -/** @type {string|null} Why the last dense load failed, for the chip tooltip. */ -let _denseError = null; -/** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */ -let _catalogRevision = 0; -let _classTallyCache = { revision: -1, counts: null }; -/** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */ -let _rowControlsListener = null; - -/** Tell the manager to re-render this layer's row (chip state / legend counts). */ -function _notifyRowControls() { - try { - _rowControlsListener?.(); - } catch (error) { - console.warn('[Data:Satellites] row-controls listener failed:', error); - } -} - -/** - * Per-class tally for the row legend, cached against the catalog revision. - * Without the cache this scans ~10.7k entries in dense mode on every panel - * refresh, and any layer polling on the 1s stats timer refreshes the panel. - * @returns {Record} Class key → count. - */ -function _classTally() { - if (_classTallyCache.counts && _classTallyCache.revision === _catalogRevision) { - return _classTallyCache.counts; - } - const entries = []; - // Pass the ISS flag, not just the group: during a stations-feed outage the - // ISS is ingested as `visual`, and the legend must file it exactly where its - // card does (STATION) rather than letting STATION vanish from the legend. - for (const [noradId, sat] of _catalog) { - entries.push({ group: sat.group, isIss: noradId === ISS_NORAD }); - } - const counts = tallySatelliteClasses(entries); - _classTallyCache = { revision: _catalogRevision, counts }; - return counts; -} - -/** - * Satellite preferences may be restored while the layer is disabled (for - * example, when Space Missions releases its dependency). Preferences must not - * revive render primitives until the layer is explicitly enabled again. - * @param {boolean} layerEnabled Whether the Satellite layer is enabled. - * @param {boolean} requestedVisible Whether the current presentation requests visibility. - * @returns {boolean} Whether a primitive should be visible now. - */ -export function satelliteVisualsVisible(layerEnabled, requestedVisible) { - return Boolean(layerEnabled) && Boolean(requestedVisible); -} - -/** - * Decide whether a valid catalog request represents a real mode transition. - * Reapplying an already-active mode must not restart the dense TLE load. - * @param {'core'|'dense'} currentCatalog Current catalog mode. - * @param {string|undefined} requestedCatalog Requested catalog mode. - * @returns {boolean} Whether the catalog mode should change. - */ -export function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) { - return (requestedCatalog === 'core' || requestedCatalog === 'dense') - && requestedCatalog !== currentCatalog; -} - -/** Build the persistent ISS ambient label from the cached point position. */ -export function createIssOverlayEntry(position) { - return { - id: String(ISS_NORAD), - position, - variant: 'label', - title: 'ISS', - accent: '#ff4444', - priority: 1000, - collisionGroup: 'ambient-label', - paintLane: 'ambient-label', - interactive: false, - distanceScale: { - near: 1_000_000, - nearValue: 1, - far: 30_000_000, - farValue: 0.4, - }, - gapPx: 14, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** Cached ISS point only; never performs a fresh SGP4 propagation. */ -function _issDisplayCached() { - return _points.get(ISS_NORAD)?.position || null; -} - -/** - * Physically DOCKED vehicles are separate real tracks sharing one position: the - * station and everything berthed to it sit within metres of each other. Their - * ambient labels therefore stack underneath the tracked card, which is what the - * owner saw. This radius is deliberately tight — it must catch a docked stack - * and nothing else, so an unrelated satellite in a similar orbit is never - * suppressed. Formation-flying pairs are km apart; a docked stack is ~100 m. - */ -const DOCKED_COMPANION_RADIUS_M = 2000; -/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */ -const DOCKED_SCAN_INTERVAL_MS = 1000; -/** @type {Set} NORAD ids co-located with the tracked satellite. */ -let _dockedCompanions = new Set(); -let _lastDockedScanMs = Number.NEGATIVE_INFINITY; - -/** - * Rebuild the docked-companion set for the tracked satellite. - * @returns {boolean} true when membership changed (callers resync presentation). - */ -function _refreshDockedCompanions(nowMs) { - const trackedPosition = _trackedNorad === null ? null : _trackedDisplayCached(); - if (!trackedPosition) { - if (_dockedCompanions.size === 0) return false; - _dockedCompanions = new Set(); - return true; - } - if (nowMs - _lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS) return false; - _lastDockedScanMs = nowMs; - const radiusSq = DOCKED_COMPANION_RADIUS_M * DOCKED_COMPANION_RADIUS_M; - let changed = false; - let found = 0; - const next = new Set(); - for (const [noradId, point] of _points) { - if (noradId === _trackedNorad || !point?.position) continue; - const dx = point.position.x - trackedPosition.x; - const dy = point.position.y - trackedPosition.y; - const dz = point.position.z - trackedPosition.z; - if (dx * dx + dy * dy + dz * dz > radiusSq) continue; - next.add(noradId); - found++; - if (!_dockedCompanions.has(noradId)) changed = true; - } - if (found !== _dockedCompanions.size) changed = true; - if (changed) _dockedCompanions = next; - return changed; -} - -/** Names of the docked companions, stable-sorted so the card text never churns. */ -function _dockedCompanionNames() { - const names = []; - for (const noradId of _dockedCompanions) { - names.push(_catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`); - } - return names.sort(); -} - -/** Keep persistent ISS text mutually exclusive with the tracked host card. */ -function _syncIssOverlay() { - // Hidden when ISS is the tracked subject, and equally when ISS is DOCKED to - // whatever is tracked: its ambient label would otherwise sit underneath the - // tracked card at the same position. - const visible = _enabled && _params.showOrbits && _trackedNorad !== ISS_NORAD - && !_dockedCompanions.has(ISS_NORAD) - && _catalog.has(ISS_NORAD) && _issDisplayCached(); - if (!visible) { - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - return; - } - _overlayHost.setEntries( - ISS_OVERLAY_SOURCE_ID, - [createIssOverlayEntry(_issDisplayCached)], - ISS_OVERLAY_SOURCE_OPTIONS, - ); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true); -} - -// Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and -// getTrackedInfo all read one SGP4 sample per rendered frame, keyed on -// scene.frameState.frameNumber, so they never diverge in epoch. -let _trackedFrameNumber = -1; -let _trackedFrameGeo = null; // { longitude, latitude, altitude } or null -const _trackedFrameCartesian = new Cesium.Cartesian3(); -/** Optional deterministic clock used only by the production-frame test seam. */ -let _trackedFrameNowForTest = null; - -// Scratch variables -const _scratchCartesian = new Cesium.Cartesian3(); -const _scratchRingRotation = new Cesium.Matrix3(); - -/** - * Build the rigid ECEF transform that keeps an orbit path baked at one GMST - * aligned with live SGP4 positions propagated at another epoch. - * @param {number} gmstAtBake GMST used when the path positions were baked. - * @param {Date} nowDate Epoch whose rotating-Earth frame should be displayed. - * @param {Cesium.Matrix4} [result] Optional matrix to update in place. - * @returns {Cesium.Matrix4} Z-rotation from bake-time ECEF to current ECEF. - */ -export function orbitFrameModelMatrix( - gmstAtBake, - nowDate, - result = new Cesium.Matrix4(), -) { - const deltaGmst = gstime(nowDate) - gmstAtBake; - const rotation = Cesium.Matrix3.fromRotationZ(-deltaGmst, _scratchRingRotation); - return Cesium.Matrix4.fromRotationTranslation( - rotation, - Cesium.Cartesian3.ZERO, - result, - ); -} - -/** - * Parse TLE text into array of { name, line1, line2 } objects. - */ -function parseTLE(text) { - const lines = text.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0); - const result = []; - for (let i = 0; i < lines.length - 2; i += 3) { - const name = lines[i]; - const line1 = lines[i + 1]; - const line2 = lines[i + 2]; - if (line1.startsWith('1 ') && line2.startsWith('2 ')) { - result.push({ name, line1, line2 }); - } - } - return result; -} - -/** - * Propagate satellite position at a given JS Date. - * Returns geodetic position plus inertial speed from the same SGP4 propagation - * epoch, or null on error. - */ -function propagatePosition(satrec, date) { - try { - const posVel = propagate(satrec, date); - if (!posVel.position || typeof posVel.position === 'boolean') return null; - - const gmst = gstime(date); - const geo = eciToGeodetic(posVel.position, gmst); - const velocity = posVel.velocity && typeof posVel.velocity !== 'boolean' - ? posVel.velocity - : null; - const speedMps = velocity - ? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000 - : null; - - return { - longitude: degreesLong(geo.longitude), - latitude: degreesLat(geo.latitude), - altitude: geo.height * 1000, // km → meters - speedMps: Number.isFinite(speedMps) ? speedMps : null, - }; - } catch { - return null; - } -} - -function orbitalPeriodSeconds(satrec) { - const meanMotion = satrec.no * (1440 / (2 * Math.PI)); - return 86400 / Math.max(meanMotion, 0.1); -} - -/** - * Compute full orbital path as array of Cartesian3 positions. - * Steps around one full orbit based on the satellite's mean motion. - */ -function computeOrbitPath(satrec, referenceDate) { - const periodSec = orbitalPeriodSeconds(satrec); - const stepSec = periodSec / ORBIT_PATH_STEPS; - - const positions = []; - const baseTime = referenceDate.getTime(); - // Fix GMST to reference time so the orbital ring closes. - // Without this, Earth rotation during the orbit period (~24° for LEO) - // shifts the end point west of the start, leaving a visible gap. - const fixedGmst = gstime(referenceDate); - - for (let i = 0; i <= ORBIT_PATH_STEPS; i++) { - const t = new Date(baseTime + i * stepSec * 1000); - try { - const posVel = propagate(satrec, t); - if (!posVel.position || typeof posVel.position === 'boolean') continue; - const geo = eciToGeodetic(posVel.position, fixedGmst); - positions.push(Cesium.Cartesian3.fromDegrees( - degreesLong(geo.longitude), - degreesLat(geo.latitude), - geo.height * 1000 - )); - } catch { continue; } - } - - return positions; -} - -/** - * Show orbital path for a satellite. - * - * RING FLICKER FIX: this is a one-instance Cesium.Primitive built - * synchronously ONCE (asynchronous: false), then re-aligned to current GMST - * each tick via its modelMatrix. The previous Entity-polyline approach - * rebuilt geometry ASYNCHRONOUSLY on every positions assignment, which made - * both the selected ring and the ISS ring blink once per second while the - * rebuild was in flight. A rigid Z-rotation needs no rebuild at all. - * - * Why not a CallbackProperty entity (dynamic mode)? Verified in Cesium - * 1.138 source: the dynamic polyline updater renders through a shared - * PolylineCollection and applies only the fill material — - * `depthFailMaterial` is silently dropped, losing the dimmed behind-Earth - * segment. The Primitive keeps it via depthFailAppearance + the per-instance - * depthFailColor attribute (same mechanism the entity STATIC batch uses): - * bright where above the horizon, dimmed where behind the globe. - */ -function _showOrbitPath(noradId, color) { - if (_orbitPaths.has(noradId)) return; // already showing - - const sat = _catalog.get(noradId); - if (!sat || !_viewer) return; - - const bakeDate = new Date(); - const basePositions = computeOrbitPath(sat.satrec, bakeDate); - if (basePositions.length < 2) return; - - const pathColor = color || Cesium.Color.CYAN; - - const primitive = new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry({ - positions: basePositions, - width: noradId === ISS_NORAD ? 2.5 : 2.0, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: Cesium.ColorGeometryInstanceAttribute.fromColor(pathColor.withAlpha(0.6)), - depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(pathColor.withAlpha(0.35)), - }, - }), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: false, // build this frame — no async-rebuild blink window - allowPicking: false, // ring clicks fall through to satellites/deselect - }); - _viewer.scene.primitives.add(primitive); - - _orbitPaths.set(noradId, { - primitive, - // Same Date object as computeOrbitPath's internal fixedGmst → identical - // GMST value (gstime is pure), so delta starts at exactly 0 and the - // initial identity modelMatrix is correct until the first ring tick. - gmstAtBake: gstime(bakeDate), - }); -} - -/** - * Rotate every baked orbit ring from its bake-time ECEF snapshot to current - * GMST (WS-D1). The baked ring is an inertial-frame snapshot frozen at - * gmstAtBake; the live dot lives in true rotating-frame ECEF, so without this - * the dot slides west off the ring at Earth-rotation rate (~0.25°/min). - * - * Sign derivation: a point fixed in inertial space keeps its right ascension - * α, so its ECEF longitude λ = α − gmst DECREASES by ΔGMST as time advances - * (it drifts WEST). Cesium.Matrix3.fromRotationZ(θ) rotates +X toward +Y, - * i.e. INCREASES longitude by θ — so we rotate the baked points by −ΔGMST. - * Mental check: ISS baked over Austin at t0; 10 min later Austin has rotated - * east under the (inertial) ring, so the ring must sit further WEST in ECEF. - * - * Wraparound: gstime returns radians in [0, 2π), so the raw difference can be - * off from the continuous ΔGMST — but only by exact multiples of 2π, which a - * rotation cannot distinguish. Tiny negative deltas right after bake are - * likewise harmless. No unwrapping needed. - * - * No SGP4 here — the rotation is applied as the ring primitive's modelMatrix - * (exact compensation; WGS84 is rotationally symmetric about Z, so rotating - * the baked curve rigidly equals rebaking from rotated points). modelMatrix - * updates are synchronous uniforms — no geometry rebuild, no flicker. Post- - * creation modelMatrix changes are supported for one-instance primitives in - * 3D mode, which these rings are. In-place mutation is safe: Primitive.update - * diffs modelMatrix against an internal clone. - * @param {Date} nowDate Epoch used for current GMST. - */ -function _updateOrbitPathRotations(nowDate) { - if (_orbitPaths.size === 0) return; - - for (const path of _orbitPaths.values()) { - if (!path.primitive) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - } -} - -/** - * Remove orbital path for a satellite. - */ -function _hideOrbitPath(noradId) { - const path = _orbitPaths.get(noradId); - if (path) { - if (_viewer) _viewer.scene.primitives.remove(path.primitive); // remove() destroys - _orbitPaths.delete(noradId); - } -} - -function _normalizeTrackedNorad(candidate) { - const numeric = Number(candidate); - if (!Number.isFinite(numeric)) return null; - const rounded = Math.trunc(numeric); - return rounded > 0 ? rounded : null; -} - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - -function _applyPendingTrackingRestore() { - const pending = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration || !_enabled) return false; - if (!_viewer || !_catalog.has(pending.id) || !_points.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackSatellite(pending.id, { origin: pending.origin }); - return _trackedNorad === pending.id; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _pendingTrackingRestore = null; -} - -/** - * Stop tracking the currently followed satellite. - * @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed - * the follow-camera (viewer.trackedEntityChanged), tear down our own state - * but do NOT clear viewer.trackedEntity — the new owner controls it now, - * and clearing it would yank the camera off their target (mirror of flights). - */ -function _clearTracking(skipViewerUntrack = false, { origin = 'programmatic' } = {}) { - // Untracking dissolves the cluster: every companion returns to its own - // ambient label on the next collection. - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - if (!_trackedNorad) { - clearFocusTarget('satellites'); - _syncIssOverlay(); - return; - } - const clearedNorad = _trackedNorad; - clearFocusTarget('satellites', _trackedNorad); - - const lastPos = _points.get(_trackedNorad); - - // Re-show the primitive (hidden while the tracked entity rendered the dot) - // and restore the original group palette from the shared style table (WS-D3) - if (lastPos) { - const style = _pointStyleFor(_trackedNorad, _catalog.get(_trackedNorad)?.group); - lastPos.show = true; - lastPos.pixelSize = style.pixelSize; - lastPos.color = style.color; - lastPos.outlineColor = style.outlineColor; - lastPos.outlineWidth = style.outlineWidth; - lastPos.disableDepthTestDistance = 0; - } - - // Invalidate the per-frame tracked-position cache (WS-D2) - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - - // Remove tracked entity and orbit path (unless ISS — keep its path) - if (_trackedNorad !== ISS_NORAD) { - _hideOrbitPath(_trackedNorad); - } - if (_viewer && !skipViewerUntrack) _viewer.trackedEntity = undefined; - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _trackedNorad = null; - _syncIssOverlay(); - clearTrackedSubjectContext('satellites'); - _contextRefreshedAtMs = 0; - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'satellites', id: clearedNorad, origin, - }); -} - -/** - * Get the tracked satellite's geodetic position, propagated at most once per - * rendered frame (WS-D2). All tracked-satellite consumers (entity position - * callback → camera, host model, point primitive, getTrackedInfo) share this - * single `new Date()` epoch per frame, so they can never diverge by the old - * 200ms throttle. The matching ECEF position is left in - * `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap. - * @returns {{ longitude: number, latitude: number, altitude: number }|null} - */ -function _getTrackedFramePosition() { - if (_trackedNorad === null) return null; - const sat = _catalog.get(_trackedNorad); - if (!sat) return null; - - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber === -1 || frameNumber !== _trackedFrameNumber || _trackedFrameGeo === null) { - const pos = propagatePosition( - sat.satrec, - _trackedFrameNowForTest ? new Date(_trackedFrameNowForTest()) : new Date(), - ); - if (!pos) return _trackedFrameGeo; // propagation hiccup — keep last good sample - _trackedFrameGeo = pos; - Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude, undefined, _trackedFrameCartesian); - _trackedFrameNumber = frameNumber; - // Throttled inside; membership changes are rare, so resync the ISS ambient - // gate only when the cluster actually changed. - const clusterChanged = _refreshDockedCompanions( - _trackedFrameNowForTest ? _trackedFrameNowForTest() : Date.now(), - ); - _updateTrackedSatelliteLabelModel(); - // The card and the context slot describe the same satellite — keep them - // together so voice never narrates a fix the card has already replaced. - _refreshTrackedSubjectContext(); - if (clusterChanged) _syncIssOverlay(); - const name = sat.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${_trackedNorad}`; - const labelWidthPx = Math.max(name.length, altitudeText.length) * 7.8 + 20; - const labelHeightPx = 2 * 13 + 12; - const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides - // Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all - // share one epoch, avoiding a second propagation phase and its old jitter. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'satellites', - id: _trackedNorad, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _trackedFrameCartesian, - widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)), - // Union of the 14 px point and the two-line label shifted 18 px upward. - heightPx: trackedPointDiameterPx + 18 + labelHeightPx, - }); - } - return _trackedFrameGeo; -} - -/** Cached ECEF display point only; never propagates a fresh SGP4 sample. */ -function _trackedDisplayCached() { - return _trackedFrameGeo ? _trackedFrameCartesian : null; -} - -/** Update the explicit model only when the rounded altitude line changes. */ -/** Epoch of the last shared-context refresh for the tracked satellite. */ -let _contextRefreshedAtMs = 0; -/** - * Shared-context refresh interval. The tracked satellite is re-propagated - * every rendered frame; the voice context only needs to be current to about - * the propagation cadence, so this refreshes on the same 1 s beat instead of - * allocating a record 60 times a second. - */ -const CONTEXT_REFRESH_INTERVAL_MS = 1000; - -/** - * Describe the tracked satellite for the shared context slot the voice tools - * read. Values come from the live per-frame propagation, not a selection-time - * snapshot, so a long follow never narrates a position the satellite has left. - * @param {number} noradId Catalog identity. - * @param {{latitude: number, longitude: number, altitude: number}|null} [position] - * Explicit position; defaults to the current per-frame propagation. - * @returns {object|null} Context metadata, or null when the satellite is gone. - */ -function _contextSubjectMetadata(noradId, position = null) { - const sat = _catalog.get(noradId); - if (!sat) return null; - const pos = position || _getTrackedFramePosition(); - if (!pos) return null; - const name = sat.name?.trim() || `SAT-${noradId}`; - const altitudeKm = Number.isFinite(pos.altitude) ? Math.round(pos.altitude / 1000) : null; - return { - id: String(noradId), - layerId: 'satellites', - layerName: 'Satellites', - source: 'CelesTrak', - label: name, - latitude: pos.latitude, - longitude: pos.longitude, - // Flat text only: the voice payload compacts properties through a string - // cleaner that drops nested objects. - properties: { - name, - operator: '', - noradId: String(noradId), - class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }), - altitude: altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`, - }, - }; -} - -/** - * Reconcile the published subject with a freshly rebuilt catalog. - * - * A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the - * catalog, so the tracked satellite's entry is a NEW object with a new satrec. - * A surviving subject is simply re-resolved against it. A subject that is GONE - * must release the slot: the per-frame refresh cannot do this itself, because - * `_getTrackedFramePosition` returns early once the satellite has no catalog - * entry — so without this the record would linger and voice would narrate a - * satellite the catalog no longer carries, frozen at its last position. - * - * Releasing is gated on PROOF. The subject is preserved unless it is absent - * from a catalog that is both complete (`accepted`, no failed CelesTrak group) - * and applicable (dense settled, when dense is the requested catalog). A - * partial refresh, a failed dense load, or an empty catalog is unproven - * absence, and unproven absence is not absence — the same honesty rule the - * tracking-restore path already applies. - * @returns {Promise} Resolves once the applicable catalog has settled. - */ -async function _reconcileTrackedSubjectContext() { - const subjectAtStart = _trackedNorad; - if (subjectAtStart === null) return; - // Dense extras land AFTER the core rebuild resolves. Deciding before they - // settle called a dense subject missing and deleted its record; the record - // then stayed gone, because a refresh can update an existing record but - // cannot recreate one. - const denseSettlement = _params.catalog === 'dense' ? _denseLoadPromise : null; - if (denseSettlement) { - try { - await denseSettlement; - } catch { - // A failed dense load proves nothing about the subject; fall through and - // let the outcome check below preserve it. - } - } - // The operator may have moved on while we waited. - if (_trackedNorad !== subjectAtStart) return; - - const metadata = _contextSubjectMetadata(subjectAtStart); - if (metadata) { - const store = getContextStore(); - const key = String(subjectAtStart); - if (store.entities.has(key)) { - refreshTrackedSubjectContext(metadata); - } else if (!store.selectedEntityId) { - // The record was dropped while the subject was briefly unresolvable. - // Restore it — but only into an EMPTY slot: a satellite reappearing must - // never yank the subject away from something the operator selected since. - selectTrackedSubjectContext(metadata); - } - _contextRefreshedAtMs = Date.now(); - return; - } - - // Absence only counts when EVERY catalog that could carry the subject - // actually loaded. Unproven absence is not absence — the same honesty rule - // the tracking-restore path applies. - // - // Deliberately NOT scoped to the group the subject was last seen in: - // CelesTrak reclassifies satellites between groups, so a subject missing - // from its old group may simply have moved to one that failed this refresh. - // Believing the old group alone would drop it. Any failed or empty group is - // therefore a reason to wait — and `accepted` already means every group - // returned entries. - if (_catalog.size === 0) return; - if (_lastTrackingRefreshOutcome?.status !== 'accepted') return; - // Dense is a potential carrier too whenever it was REQUESTED — and the - // request is read from `denseSettlement`, captured before the await, not - // from `_params.catalog` now. A failed dense load reverts the mode to 'core' - // as part of settling (an ACTIVE chip over an empty sky would be a lie), so - // by the time we get here the intent that made dense a carrier has been - // erased. Re-reading it would skip this guard on exactly the runs that need - // it and delete a subject the dense catalog might have carried. - if (denseSettlement && _denseStatus !== 'ready') return; - clearTrackedSubjectContext('satellites'); - _contextRefreshedAtMs = 0; -} - -/** - * Keep the shared context slot current with the tracked satellite, on the - * propagation beat rather than the frame clock. - * @returns {void} - */ -function _refreshTrackedSubjectContext() { - if (_trackedNorad === null) return; - const now = Date.now(); - if (now - _contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS) return; - _contextRefreshedAtMs = now; - refreshTrackedSubjectContext(_contextSubjectMetadata(_trackedNorad)); -} - -function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) { - if (!_trackedEntity || _trackedNorad === null) return; - const sat = _catalog.get(_trackedNorad); - const title = sat?.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeM = _trackedFrameGeo?.altitude ?? fallbackAltitudeM; - const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${_trackedNorad}`; - // Class leads the detail block: it is what tells the operator WHAT they are - // looking at, and it stays readable under the IR styles that flatten the - // dot colors to a single channel (the card is painted above post-FX). - const details = [satelliteClassLabel(sat?.group, { isIss: _trackedNorad === ISS_NORAD }), detail]; - // Docked companions are consolidated onto the tracked card as SECONDARY info - // instead of competing with it as separate ambient labels. Identities are - // preserved: the catalog is untouched and every companion returns to its own - // label the moment the cluster is no longer tracked. - const companions = _dockedCompanionNames(); - if (companions.length > 0) { - const extra = companions.length - 1; - details.push(`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`); - } - const current = _trackedEntity.gevLabelModel; - // Compare the WHOLE detail array: comparing only `details[0]` swallowed any - // change confined to the companions line, so the card would never republish. - const unchanged = current?.title === title - && current?.details?.length === details.length - && details.every((line, index) => current.details[index] === line); - if (unchanged) return; - _trackedEntity.gevLabelModel = { title, details, accent: '#ffd84d' }; - refreshTrackedReadout(_trackedEntity); -} - -function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); - - const point = _points.get(noradId); - const sat = _catalog.get(noradId); - if (!point || !sat) return; - - _trackedNorad = noradId; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _syncIssOverlay(); - - // Hide the primitive — the tracked ENTITY renders the dot below. The - // entity must own a point graphic so the Viewer's tracking camera can - // resolve a bounding sphere and engage viewFrom (a label-only entity - // left the camera stranded; this mirrors the proven flights pattern). - point.show = false; - - // Show orbital path - _showOrbitPath(noradId, Cesium.Color.YELLOW); - - // Tracked entity position propagates per evaluation through the per-frame - // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. - // Falls back to the point primitive's 1s-throttled position if SGP4 fails. - const positionProperty = new Cesium.CallbackProperty(() => { - const pos = _getTrackedFramePosition(); - return pos ? _trackedFrameCartesian : point.position; - }, false); - - const name = sat.name.trim(); - - // Comfortable tracking landing: ~726 km back for LEO (user-validated - // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled - // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = propagatePosition(sat.satrec, new Date()); - const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M - ? TRACK_VIEW_FROM_HIGH_SCALE - : 1; - const viewFrom = Cesium.Cartesian3.multiplyByScalar( - TRACK_VIEW_FROM_LEO, viewScale, new Cesium.Cartesian3() - ); - - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - viewFrom, - point: { - pixelSize: 14, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `satellites:${noradId}`; - _trackedEntity.gevDisplayPosition = _trackedDisplayCached; - _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); - - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'satellites', - id: noradId, - label: name, - position: Cesium.Cartesian3.clone(point.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); - _contextRefreshedAtMs = Date.now(); - - _viewer.trackedEntity = _trackedEntity; - console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); -} - -/** - * Propagate all CORE satellite positions and update point primitives. - * (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are - * excluded: they refresh on the round-robin budget in _propagateDenseChunk. - */ -function _propagateAll() { - const now = new Date(); - let updated = 0; - - for (const [noradId, sat] of _catalog) { - if (sat.group === 'dense') continue; - const pos = propagatePosition(sat.satrec, now); - if (!pos) continue; - - const cartesian = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - const point = _points.get(noradId); - if (point) { - point.position = cartesian; - updated++; - } - } - - return updated; -} - -/** - * Re-propagate a small per-frame slice of the dense extras (round-robin). - * Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES - * frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays - * ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the - * work per frame avoids the once-per-second spike a tick-sized chunk - * (~2K props ≈ 4ms) would cause. - */ -function _propagateDenseChunk() { - if (_denseIds.length === 0) return; - const perFrame = Math.max(1, Math.ceil(_denseIds.length / DENSE_REFRESH_FRAMES)); - const now = new Date(); - for (let i = 0; i < perFrame; i++) { - if (_denseCursor >= _denseIds.length) _denseCursor = 0; - const noradId = _denseIds[_denseCursor++]; - if (noradId === _trackedNorad) continue; // per-frame tracked path owns it - const sat = _catalog.get(noradId); - const point = _points.get(noradId); - if (!sat || !point) continue; - const pos = propagatePosition(sat.satrec, now); - if (pos) { - point.position = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - } - } -} - -/** - * Load the dense catalog extras (Starlink) as points-only satellites. - * Chunked so ~10K twoline2satrec builds + initial propagations never block a - * frame; a token guards against mode flips / catalog rebuilds mid-load. - */ -async function _loadDenseCatalog({ signal = null } = {}) { - if (!_viewer || !_pointCollection) return { status: 'source-unavailable', reason: 'layer-unavailable' }; - _denseLoadController?.abort(); - const resourceController = new AbortController(); - _denseLoadController = resourceController; - const loadSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - const token = ++_denseLoadToken; - _denseStatus = 'loading'; - _denseError = null; - _notifyRowControls(); - try { - loadSignal.throwIfAborted(); - const res = await fetch(`/api/celestrak/${DENSE_GROUP_PATH}`, { signal: loadSignal }); - if (!res.ok) { - console.warn(`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' fetch failed (${res.status})`); - _denseLoadFailed(token, `feed unavailable (${res.status})`); - return { status: 'source-unavailable', reason: `feed unavailable (${res.status})` }; - } - const text = await res.text(); - loadSignal.throwIfAborted(); - if (token !== _denseLoadToken || _params.catalog !== 'dense') { - return { status: 'superseded', reason: 'dense-load-superseded' }; - } - - const entries = parseTLE(text); - const style = POINT_STYLES.dense; - const now = new Date(); - let added = 0; - - for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { - loadSignal.throwIfAborted(); - if (token !== _denseLoadToken || _params.catalog !== 'dense' || !_pointCollection) { - return { status: 'superseded', reason: 'dense-load-superseded' }; - } - const end = Math.min(start + DENSE_CREATE_CHUNK, entries.length); - for (let i = start; i < end; i++) { - const entry = entries[i]; - const satrec = twoline2satrec(entry.line1, entry.line2); - if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); - if (_catalog.has(noradId)) continue; // core catalog keeps priority - const pos = propagatePosition(satrec, now); - if (!pos) continue; - - _catalog.set(noradId, { name: entry.name, satrec, group: 'dense' }); - const point = _pointCollection.add({ - position: Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude), - pixelSize: style.pixelSize, - color: style.color, - outlineColor: style.outlineColor, - outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), - id: noradId, - }); - _points.set(noradId, point); - _denseIds.push(noradId); - added++; - } - // Yield to the event loop between chunks. - await new Promise(resolve => setTimeout(resolve, 0)); - loadSignal.throwIfAborted(); - } - - // A 200 that yields nothing usable is still a failed load — an empty body, - // an HTML error page the proxy passed through, or a feed of TLEs the core - // catalog already owns. Treating it as success is the same lie as treating - // a 502 as success, just through a different door. - if (added === 0) { - console.warn(`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' returned no usable satellites`); - _denseLoadFailed(token, 'feed returned no satellites'); - return { status: 'source-unavailable', reason: 'feed returned no satellites' }; - } - - _count = _points.size; - _catalogRevision++; - _denseStatus = 'ready'; - console.log(`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`); - // The panel would otherwise keep the pre-load count and legend until the - // next natural refresh — up to the 5-minute catalog interval. - _notifyRowControls(); - _applyPendingTrackingRestore(); - return { status: 'ready', added }; - } catch (e) { - if (loadSignal.aborted || e?.name === 'AbortError') { - return { status: 'cancelled', reason: String(loadSignal.reason || 'aborted') }; - } - console.warn('[Data:Satellites] Dense catalog load failed:', e); - _denseLoadFailed(token, 'feed unreachable'); - return { status: 'source-unavailable', reason: 'feed unreachable' }; - } finally { - if (_denseLoadController === resourceController) _denseLoadController = null; - } -} - -/** - * Settle a failed dense load: drop any partial chunk, return the layer to the - * core catalog, and leave the reason on the chip. Reverting the param is the - * point — a chip that reads ACTIVE over an empty sky is a lie. - * @param {number} token The load token that failed. - * @param {string} reason Short operator-facing cause. - */ -function _denseLoadFailed(token, reason) { - // A newer load (or a mode flip) already owns the state — say nothing. - if (token !== _denseLoadToken) return; - _params.catalog = 'core'; - _removeDenseCatalog(); - _denseStatus = 'failed'; - _denseError = reason; - _notifyRowControls(); -} - -/** Remove all dense extras (catalog entries, points, tracking if needed). */ -function _removeDenseCatalog() { - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load - if (_trackedNorad !== null && _catalog.get(_trackedNorad)?.group === 'dense') { - _clearTracking(); - } - for (const noradId of _denseIds) { - const point = _points.get(noradId); - if (point && _pointCollection) _pointCollection.remove(point); - _points.delete(noradId); - _catalog.delete(noradId); - } - _denseIds = []; - _denseCursor = 0; - _count = _points.size; - _catalogRevision++; -} - -/** - * Shared scene.preRender tick (single definition for init + enable): - * - core fleet propagation at 200ms-tracked / 1s-idle cadence, - * - dense extras on a per-frame round-robin budget, - * - tracked satellite's point primitive per frame (WS-D2), - * - orbit ring GMST re-alignment every ~1s (WS-D1). - */ -function _preRenderTick() { - if (!_enabled) return; - const now = focusNowMs(Date.now()); - - const interval = _trackedNorad ? 200 : POSITION_UPDATE_MS; - // Space Missions keeps this layer enabled for TLE lookup while deliberately - // hiding its standalone fleet. Do not rebuild hidden point buffers on the - // one-second propagation cadence: that GPU upload presented as a periodic - // whole-globe pulse even though the camera remained stationary. - if (_params.showPoints && now - _lastPropagation >= interval) { - _propagateAll(); - _lastPropagation = now; - } - - if (_params.showPoints) _propagateDenseChunk(); - - // Keep the tracked dot on the per-frame epoch shared with label + camera — - // runs after _propagateAll so the per-frame sample wins over the 200ms one. - if (_trackedNorad !== null) { - const pos = _getTrackedFramePosition(); - const point = _points.get(_trackedNorad); - if (pos && point) { - point.position = _trackedFrameCartesian; // primitive setter clones - } - } - - _updatePointFocus(now); - - // Hidden standalone orbit primitives do not need GMST matrix writes while - // Space Missions draws the selected mission orbit itself. - if (_params.showOrbits && now - _lastRingRotation >= RING_ROTATION_MS) { - _updateOrbitPathRotations(new Date(now)); - _lastRingRotation = now; - } -} - -/** - * Seed a single tracked satellite so tests can invoke the exact production - * pre-render callback without constructing a WebGL viewer. - * @param {object} state - * @param {number} state.noradId - * @param {string} state.name - * @param {object} state.satrec - * @param {object} state.entity - * @param {object} state.point - * @param {object} state.viewer - * @param {() => (number|Date)} state.now - */ -export function _setTrackedSatelliteRefreshStateForTest({ - noradId, - name, - satrec, - entity, - point, - viewer, - now, - // Extra catalog/point rows so a test can exercise a docked cluster (and a - // control satellite that must NOT be treated as part of it). - neighbours = [], -}) { - _viewer = viewer; - _catalog = new Map([[noradId, { name, satrec, group: 'stations' }]]); - _points = new Map([[noradId, point]]); - for (const neighbour of neighbours) { - _catalog.set(neighbour.noradId, { - name: neighbour.name, - satrec: neighbour.satrec || satrec, - group: neighbour.group || 'stations', - }); - _points.set(neighbour.noradId, neighbour.point); - } - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - _trackedNorad = noradId; - _trackedEntity = entity; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = now; - _params = { catalog: 'core', showPoints: false, showOrbits: false }; - _enabled = true; -} - -/** Seed catalog authority and optional dense settlement for share-Follow tests. */ -export function _setSatelliteTrackingRefreshOutcomeForTest({ - status = 'accepted', - failedGroups = [], - catalog = 'core', - densePromise = null, -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { epoch, status, failedGroups: [...failedGroups] }; - _params.catalog = catalog; - _denseLoadPromise = densePromise || Promise.resolve({ status: 'not-requested' }); -} - -/** The tracked satellite's current ECEF sample — the value the docked-cluster - * scan measures against. Exposed so a test can place neighbours around it. */ -export function _trackedFrameCartesianForTest() { - return _trackedFrameCartesian; -} - -/** Invoke the same callback registered on `scene.preRender` in production. */ -export function _runSatellitePreRenderForTest() { - _preRenderTick(); -} - -/** - * Seed the minimum render state a dense-catalog load needs, so a test can - * exercise the real async load/settle/fail path (and the row-control states it - * drives) without constructing a WebGL viewer. - * @param {{ catalog?: 'core'|'dense', showPoints?: boolean }} [options] - */ -export function _setDenseCatalogStateForTest({ catalog = 'core', showPoints = true } = {}) { - _viewer = { scene: { primitives: { add: (p) => p, remove() {} } } }; - // Neutralize the shared world-overlay host: these tests exercise catalog and - // row-control logic, not the ISS callout. - _overlayHost = { setEntries() {}, setVisible() {}, clearSource() {} }; - _pointCollection = { - show: true, - add: (opts) => ({ ...opts }), - remove() {}, - removeAll() {}, - }; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _params = { catalog, showPoints, showOrbits: false }; - _enabled = true; -} - -/** Tear the dense seam back down so ordering cannot leak into other tests. */ -export function _clearDenseCatalogStateForTest() { - _rowControlsListener = null; - _overlayHost = DEFAULT_OVERLAY_HOST; - _orbitPaths = new Map(); - _viewer = null; - _pointCollection = null; - _catalog = new Map(); - _points = new Map(); - _denseIds = []; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _cancelPendingTrackingRestore(); - _enabled = false; -} - -/** Catalog group tag recorded for a satellite, for ingestion-path assertions. */ -export function _catalogGroupForTest(noradId) { - return _catalog.get(Number(noradId))?.group; -} - -/** Seed ISS/tracking state while retaining the production track and host paths. */ -export function _setSatelliteLabelLifecycleStateForTest({ - viewer, - satrec, - point, - overlayHost, - preservePending = false, -}) { - _viewer = viewer; - _catalog = new Map([[ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }]]); - _points = new Map([[ISS_NORAD, point]]); - _orbitPaths = new Map([[ - ISS_NORAD, - { primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, gmstAtBake: 0 }, - ]]); - _trackedNorad = null; - _trackedEntity = null; - if (!preservePending) _cancelPendingTrackingRestore(); - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _enabled = true; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _overlayHost = overlayHost || DEFAULT_OVERLAY_HOST; - _syncIssOverlay(); -} - -/** Exercise production ISS tracking from the cached catalog and point. */ -export function _trackIssForTest() { - _trackSatellite(ISS_NORAD); - return _trackedEntity; -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingSatelliteTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated catalog refresh. */ -export function _applyPendingSatelliteTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Remove a cached catalog row so tests can model a target arriving later. */ -export function _removeSatelliteTrackingCandidateForTest(noradId) { - const id = Number(noradId); - _catalog.delete(id); - _points.delete(id); -} - -/** Exercise production untrack and restore the default host seam. */ -export function _clearSatelliteLabelLifecycleForTest() { - _clearTracking(); - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - _overlayHost = DEFAULT_OVERLAY_HOST; -} - -/** Focus alpha for satellite points, inside the existing shared preRender tick. */ -function _updatePointFocus(nowMs) { - const target = getFocusTarget(); - if (!_params.showPoints || !focusPassIsNeeded(target, _activeFocusCount)) return; - if (nowMs - _lastFocusUpdate < 80) return; - _lastFocusUpdate = nowMs; - const scene = _viewer.scene; - const camera = _viewer.camera; - const result = applySatellitePointFocusDeemphasis({ - points: _points, - trackedId: _trackedNorad, - target, - previousActiveCount: _activeFocusCount, - nowMs, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchFocusScreen) - ), - cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - baseColorFor: (noradId) => _pointStyleFor(noradId, _catalog.get(noradId)?.group).color, - }); - _activeFocusCount = result.activeCount; -} - -/** - * Apply the gated satellite-point focus pass through the production color path. - * @param {object} input - * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} - */ -export function applySatellitePointFocusDeemphasis({ - points, - trackedId, - target, - previousActiveCount = 0, - nowMs, - screenPositionFor, - cameraDistanceFor, - baseColorFor, - 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 [noradId, point] of points || []) { - if (noradId === trackedId || !point?.position) continue; - const cameraDistance = cameraDistanceFor(point.position); - const distanceScale = nearFarScalarValueAtDistance(point.scaleByDistance, cameraDistance); - const halfExtentPx = (point.pixelSize || 5) * distanceScale * 0.5; - const focus = advanceSpriteFocus(point, { - // Hidden points still release toward identity, preventing stale dim - // alpha if a catalog/presentation toggle later makes them visible. - screenPosition: point.show === false ? null : screenPositionFor(point.position), - cameraDistance, - nowMs, - target, - params, - spriteHalfWidthPx: halfExtentPx, - spriteHalfHeightPx: halfExtentPx, - }); - transitioning ||= focus.transitioning; - if (focus.active) activeCount += 1; - const base = baseColorFor(noradId); - const alpha = base.alpha * focus.factor; - if (focusAlphaNeedsWrite(point.color?.alpha, alpha, params)) { - // Point stays continuously present at the non-zero emphasis floor; its - // own alpha yields around the tracked target, independent of draw order. - point.color = base.withAlpha(alpha); - writes += 1; - } - } - return { writes, transitioning, activeCount, ran: true }; -} - - -const satellitesLayer = { - id: 'satellites', - name: 'Satellites', - icon: '🛰️', - source: 'CelesTrak', - updateInterval: 0, // We use preRender for real-time updates, not interval polling - refreshInterval: 5 * 60 * 1000, // Catalog data refresh; propagation remains preRender-owned. - - async init(viewer) { - _abortActiveUpdates(); - clearFocusTarget('satellites'); - _viewer = viewer; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _count = 0; - _lastUpdate = null; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _trackedEntity = null; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - // Dense extras rebuild via update() when _params.catalog === 'dense' - // (the catalog-mode preference itself is sticky across init/destroy). - _denseIds = []; - _denseCursor = 0; - _denseLoadPromise = null; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - - // Point primitives for satellite dots - _pointCollection = new Cesium.PointPrimitiveCollection(); - viewer.scene.primitives.add(_pointCollection); - - _installClickHandler(viewer); - - // Pre-render listener for real-time position updates - // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - - console.log('[Data:Satellites] Initialized'); - }, - - enable(viewer) { - _enabled = true; - holdContinuousRender('satellites'); // per-frame animator (perf wave 2) - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - // Orbit ring primitives + persistent ISS host label — show them - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - // Re-attach input handlers and preRender propagation - _installClickHandler(viewer); - // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; - // the registry hands predicates String()-coerced ids, so match via Number(). - registerPickOwner('satellites', (pickedId) => { - const norad = Number(pickedId); - return Number.isFinite(norad) && _points.has(norad); - }); - if (!_preRenderListener && viewer) { - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - } - _applyPendingTrackingRestore(); - }, - - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - _enabled = false; - releaseContinuousRender('satellites'); - if (_pointCollection) _pointCollection.show = false; - for (const path of _orbitPaths.values()) path.primitive.show = false; - _clearTracking(); - _syncIssOverlay(); - // Remove click handler + keydown listener + preRender propagation while disabled - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - }, - - async update(viewer, { signal = null } = {}) { - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - failedGroups: [], - }; - const resourceController = new AbortController(); - _activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - // Load all core groups in parallel; a failed/empty group degrades - // gracefully (parseTLE of an upstream error body yields []). - const results = await Promise.all(CATALOG_GROUPS.map(async (groupDef) => { - try { - const res = await fetch(`/api/celestrak/${groupDef.path}`, { signal: updateSignal }); - if (!res.ok) return { ...groupDef, entries: [], ok: false }; - const entries = parseTLE(await res.text()); - updateSignal.throwIfAborted(); - return { ...groupDef, entries, ok: entries.length > 0 }; - } catch (error) { - if (updateSignal.aborted || error?.name === 'AbortError') throw error; - return { ...groupDef, entries: [], ok: false }; - } - })); - updateSignal.throwIfAborted(); - - const failed = results.filter(r => !r.ok).map(r => r.path); - if (failed.length > 0) { - console.warn(`[Data:Satellites] Groups failed or empty: ${failed.join(', ')}`); - } - console.log(`[Data:Satellites] Loaded ${results.map(r => `${r.tag}:${r.entries.length}`).join(' ')}`); - - // CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing. - // Wiping the collection + catalog here would blank all 838 satellites while - // the chip still read "just now". Keep the existing (stale) catalog on - // screen and surface the outage instead — do NOT stamp _lastUpdate. - if (results.every(r => !r.ok)) { - _lastError = 'CelesTrak unreachable'; - console.warn('[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage'); - // Re-apply dense mode is skipped (no fresh core catalog); tracking untouched. - return; - } - - // At least one group loaded — proceed with a fresh rebuild, but keep the - // partial outage visible at the layer control instead of presenting the - // reduced catalog as a fully healthy refresh. - _lastError = failed.length - ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` - : null; - - // Clear existing - _pointCollection.removeAll(); - _points.clear(); - for (const path of _orbitPaths.values()) viewer.scene.primitives.remove(path.primitive); - _orbitPaths.clear(); - _catalog.clear(); - // The detection overlay caches one record per satellite and stamps its - // id/class at creation only. A rebuild can re-tag a satellite (a failed - // group shifts which one wins dedupe), so the cache must go with the - // catalog or those labels stay stale for the life of the session. - _detectionObjects.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load against the old catalog - _syncIssOverlay(); - - const now = new Date(); - - // Process all TLE entries in CATALOG_GROUPS order - const allEntries = []; - for (const r of results) { - for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); - } - - // Deduplicate by NORAD ID — first (most specific) group tag wins - const seen = new Set(); - - for (const entry of allEntries) { - const satrec = twoline2satrec(entry.line1, entry.line2); - if (!satrec || satrec.error !== 0) continue; - - const noradId = Number(satrec.satnum); - if (seen.has(noradId)) continue; - seen.add(noradId); - - // Store in catalog - _catalog.set(noradId, { - name: entry.name, - satrec, - group: entry.group, - }); - - // Propagate initial position - const pos = propagatePosition(satrec, now); - if (!pos) continue; - - const cartesian = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - - // Add point primitive (styling from the shared table, WS-D3) - const style = _pointStyleFor(noradId, entry.group); - const point = _pointCollection.add({ - position: cartesian, - pixelSize: style.pixelSize, - color: style.color, - outlineColor: style.outlineColor, - outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), - id: noradId, - }); - - _points.set(noradId, point); - } - - // Show ISS orbital path by default - if (_catalog.has(ISS_NORAD)) { - _showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color); - const issPath = _orbitPaths.get(ISS_NORAD); - if (issPath) issPath.primitive.show = _params.showOrbits; - - _syncIssOverlay(); - } - - _count = _points.size; - _catalogRevision++; - _lastUpdate = Date.now(); - _lastPropagation = Date.now(); - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: failed.length ? 'partial' : 'accepted', - failedGroups: [...failed], - }; - console.log(`[Data:Satellites] ${_count} satellites active, ISS path shown`); - - // Re-apply dense mode after a full catalog rebuild (fire-and-forget — - // _loadDenseCatalog handles its own errors and token invalidation). - _denseLoadPromise = _params.catalog === 'dense' - ? _loadDenseCatalog({ signal: updateSignal }) - : Promise.resolve({ status: 'not-requested' }); - // The catalog the published voice subject was resolved against is gone. - // Re-resolve it, or release the slot if the subject provably did not - // survive. Deliberately not awaited: it waits on dense settlement, and - // the rebuild must not block on that. - void _reconcileTrackedSubjectContext(); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Satellite update aborted', 'AbortError'); - } - console.warn('[Data:Satellites] Fetch error:', e); - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _clearTracking(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - // Orbit ring primitives are removed (and destroyed) here - for (const path of _orbitPaths.values()) { - viewer.scene.primitives.remove(path.primitive); - } - _points.clear(); - _detectionObjects.clear(); - _orbitPaths.clear(); - _catalog.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _rowControlsListener = null; - _count = 0; - _lastUpdate = null; - _lastError = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - failedGroups: [], - }; - _viewer = null; - }, - - getDetectableObjects(options = {}) { - if (!_pointCollection || !_pointCollection.show) return []; - // Dense extras are points-only: excluded from the detection overlay. - const eligibleCount = Math.max(1, _points.size - _denseIds.length); - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : eligibleCount; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max(1, Math.ceil(eligibleCount / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [noradId, point] of _points) { - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (!point.position) continue; - const isTracked = noradId === _trackedNorad; - // A docked companion sits at the tracked subject's own position, so its - // mark and label would stack underneath the tracked card. It is listed on - // that card instead. Only members of the tracked cluster are affected — - // unrelated nearby satellites are never suppressed. - if (!isTracked && _dockedCompanions.has(noradId)) continue; - const cat = _catalog.get(noradId); - let object = _detectionObjects.get(noradId); - if (!object) { - object = { - sourceId: noradId, - id: cat?.name || `SAT-${noradId}`, - type: 'SAT', - // Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS"). - // The detection canvas composites ABOVE the post-FX chain, so this - // is how class survives NVG/FLIR once the dot colors are collapsed. - klass: satelliteClassLabel(cat?.group, { isIss: noradId === ISS_NORAD }), - }; - _detectionObjects.set(noradId, object); - } - object.position = point.position; - object.skipLabel = isTracked; - result.push(object); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * Find a satellite by exact NORAD id (numeric string) or case-insensitive - * name substring. Position is freshly propagated via SGP4. - * @param {string|number} query NORAD id or partial name. - * @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null} - */ - findByQuery(query) { - if (query === null || query === undefined || !_catalog || _catalog.size === 0) return null; - const q = String(query).trim(); - if (!q) return null; - - let noradId = null; - if (/^\d+$/.test(q) && _catalog.has(Number(q))) { - noradId = Number(q); - } else { - const lower = q.toLowerCase(); - for (const [id, sat] of _catalog) { - if (sat.name.toLowerCase().includes(lower)) { - noradId = id; - break; - } - } - } - if (noradId === null) return null; - - const sat = _catalog.get(noradId); - const pos = propagatePosition(sat.satrec, new Date()); - if (!pos) return null; - - return { - noradId, - name: sat.name.trim(), - position: Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude), - latitude: pos.latitude, - longitude: pos.longitude, - altitudeM: pos.altitude, - }; - }, - - /** - * Get positions of currently rendered satellites from per-point state - * (no SGP4 re-propagation). - * @param {number} [maxCount=300] Maximum entries to return. - * @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>} - */ - getAllPositions(maxCount = 300) { - const result = []; - if (!_points || _points.size === 0) return result; - const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300; - - for (const [noradId, point] of _points) { - if (result.length >= cap) break; - if (!point.position) continue; - // Dense extras are points-only — keep voice/framing lists to the core catalog. - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const carto = Cesium.Cartographic.fromCartesian(point.position); - if (!carto) continue; - const sat = _catalog.get(noradId); - result.push({ - id: noradId, - label: sat ? sat.name.trim() : String(noradId), - position: point.position, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - }); - } - return result; - }, - - /** - * Track a satellite by NORAD id (camera follow + orbit path + highlight), - * same path as clicking its point. - * @param {string|number} noradId NORAD catalog number. - * @returns {boolean} True if tracking started. - */ - trackById(noradId, { origin = 'programmatic' } = {}) { - const id = Number(noradId); - if (!Number.isFinite(id) || !_viewer || !_catalog.has(id) || !_points.has(id)) return false; - _cancelPendingTrackingRestore(); - _trackSatellite(id, { origin }); - return _trackedNorad === id; - }, - - /** - * Resolve a shared Follow target only after the applicable CelesTrak - * catalog has settled. A partial catalog can prove presence, never absence. - */ - async resolveTrackingRestoreTarget(noradId, { - signal = null, - origin = 'share-restore', - } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = _normalizeTrackedNorad(noradId); - if (id === null) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - const found = () => _catalog.has(id) && _points.has(id); - const follow = () => { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - return this.trackById(id, { origin }) - ? { status: 'found', refreshEpoch: outcome.epoch } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }; - - if (found()) return follow(); - if (outcome.status !== 'accepted' && outcome.status !== 'partial') { - return { - status: 'source-unavailable', - reason: 'CelesTrak catalog unavailable', - refreshEpoch: outcome.epoch, - }; - } - - if (_params.catalog === 'dense') { - const dense = await (_denseLoadPromise || Promise.resolve({ status: 'source-unavailable' })); - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - if (_lastTrackingRefreshOutcome.epoch !== outcome.epoch) { - return { status: 'superseded', reason: 'newer-catalog-refresh' }; - } - if (found()) return follow(); - if (dense?.status !== 'ready') { - return { - status: 'source-unavailable', - reason: dense?.reason || 'dense catalog unavailable', - refreshEpoch: outcome.epoch, - }; - } - } - - if (outcome.status === 'partial') { - return { - status: 'source-unavailable', - reason: 'partial CelesTrak catalog cannot prove absence', - refreshEpoch: outcome.epoch, - failedGroups: [...outcome.failedGroups], - }; - } - return { - status: 'missing', - reason: 'target-absent-from-catalog', - refreshEpoch: outcome.epoch, - }; - }, - - /** - * Stop tracking the currently tracked satellite (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Get info about the currently tracked satellite. - * @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null} - */ - getTrackedInfo() { - if (_trackedNorad === null || !_catalog.has(_trackedNorad)) return null; - const sat = _catalog.get(_trackedNorad); - // Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame. - const pos = _getTrackedFramePosition(); - if (!pos) return null; - return { - noradId: _trackedNorad, - name: sat.name.trim(), - latitude: pos.latitude, - longitude: pos.longitude, - altitudeM: pos.altitude, - }; - }, - - /** - * Runtime params (DataLayerManager.setLayerParams path). - * catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell - * as points-only extras on a relaxed propagation budget). - * @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params] - */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedSatTrackingId')) { - _cancelPendingTrackingRestore(); - } - const catalog = params.catalog; - if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense') return false; - const catalogChanged = satelliteCatalogModeChanged(_params.catalog, catalog); - if (catalogChanged) { - _params.catalog = catalog; - } - if (params.showPoints !== undefined) { - _params.showPoints = params.showPoints !== false; - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - } - if (params.showOrbits !== undefined) { - _params.showOrbits = params.showOrbits !== false; - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - } - if (catalogChanged && catalog === 'dense') { - _denseLoadPromise = _loadDenseCatalog(); - } else if (catalog === 'core') { - if (catalogChanged) _removeDenseCatalog(); - // Any explicit request for core clears the error, even when the mode did - // NOT change: a failed dense load already reverted the param to core, so - // a Space Missions restore of an already-core snapshot would otherwise - // leave the user staring at a DENSE ✕ they never caused. - _denseStatus = 'idle'; - _denseError = null; - } - if (catalogChanged) console.log(`[Data:Satellites] Catalog mode: ${catalog}`); - if (Object.hasOwn(params, 'selectedSatTrackingId')) { - const requested = _normalizeTrackedNorad(params.selectedSatTrackingId); - if (requested === _trackedNorad) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedNorad !== null) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedNorad !== null) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - - /** @returns {{ catalog: string }} Current runtime params. */ - getParams() { - return { - catalog: _params.catalog, - showPoints: _params.showPoints, - showOrbits: _params.showOrbits, - selectedSatTrackingId: _trackedNorad, - }; - }, - - /** - * Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE - * catalog chip plus a class legend so the point colors are learnable without - * a new panel. - * - * The chip is stateless — it declares the params to apply and the manager - * owns the write, so the Space Missions snapshot/restore path (which drives - * the same `catalog` param) stays the single source of truth and the chip - * always renders whatever the layer actually has. - * - * The chip reports the dense LOAD state, not the catalog param: the param - * flips synchronously while the Starlink shell takes seconds to arrive and - * frequently 502s, so ACTIVE means "dense points are on screen" and nothing - * less. The legend tally is cached against the catalog revision. - * @returns {{ chips: Array, legend: Array }} Row controls. - */ - getRowControls() { - // A dependency owner (Space Missions) borrows this layer for TLE lookup - // with showPoints:false. Nothing is rendered, so a legend would describe an - // empty sky and a chip write would be silently reverted by that owner's - // restore. Surrender the row rather than lie about it. - if (!_params.showPoints) return { chips: [], legend: [] }; - - const loading = _denseStatus === 'loading'; - const failed = _denseStatus === 'failed'; - const active = _params.catalog === 'dense' && _denseStatus === 'ready'; - let title = 'Add the full Starlink broadband shell (thousands of extra points)'; - if (loading) title = 'Loading the Starlink shell…'; - else if (failed) title = `Starlink ${_denseError || 'load failed'} — click to retry`; - else if (active) title = 'Showing the full Starlink shell — click for the core catalog only'; - return { - chips: [{ - id: 'catalog', - label: loading ? 'DENSE ···' : (failed ? 'DENSE ✕' : 'DENSE'), - active, - busy: loading, - disabled: loading, - state: loading ? 'loading' : (failed ? 'error' : (active ? 'active' : 'idle')), - title, - params: { catalog: active ? 'core' : 'dense' }, - }], - legend: satelliteClassLegend(_classTally()), - }; - }, - - /** - * Install the manager's "row controls changed" callback. The dense load is - * asynchronous, so completion and failure have to push a re-render — nothing - * else would repaint this row before the 5-minute catalog refresh. - * @param {(() => void)|null} listener Callback, or null to detach. - */ - setRowControlsListener(listener) { - _rowControlsListener = typeof listener === 'function' ? listener : null; - }, - - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - stale: false, - status: _lastError === 'CelesTrak unreachable' - ? 'unavailable' - : (_lastError ? 'degraded' : 'nominal'), - error: _lastError, - }; - }, -}; - -function _onKeyDown(e) { - if (_enabled && e.key === 'Escape' && _trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -function _installClickHandler(viewer) { - if (_clickHandler) return; // already installed - - // Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights, - // military, …) grabs the follow-camera, drop our tracking so the orbit ring / - // tracked entity don't orphan — without touching viewer.trackedEntity (the - // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity - // briefly undefined mid-_trackSatellite) doesn't self-clear. - if (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (!_enabled) return; - if (_trackedNorad && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking tracked entity itself — ignore - if (picked.id === _trackedEntity) return; - - // Check if it's a satellite point (id is NORAD catalog number) - const prim = picked.primitive; - if (prim && prim.id != null) { - const noradId = Number(prim.id); - if (!isNaN(noradId) && _catalog.has(noradId)) { - _cancelPendingTrackingRestore(); - _trackSatellite(noradId, { origin: 'user' }); - return; - } - } - } - - // A pick that belongs to a sibling layer (plane, vessel, station, CCTV - // camera…) is not "empty space" — leave OUR tracking (and crucially - // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; - } - - // Clicked empty space — deselect - if (_trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - document.addEventListener('keydown', _onKeyDown); -} - -/** - * Next ISS pass for an observer. Requires the catalog to have loaded (the - * satellites layer enabled at least once this session). - * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} - */ -export function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { - const sat = _catalog.get(ISS_NORAD); - if (!sat || !sat.satrec) return { status: 'no-tle' }; - const pass = findNextIssPass({ - satrec: sat.satrec, latDeg, lonDeg, fromMs: Date.now(), minElevDeg, - }); - return pass ? { status: 'ok', pass } : { status: 'none' }; -} - -/** - * Score a mission-to-catalog name match. Compact identifier containment handles - * names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as - * "Starlink Group 17-40" → an arbitrary "STARLINK-1008" remain below the - * acceptance threshold. - * @param {string} query Mission or payload name. - * @param {string} catalogName Satellite catalog name. - * @returns {number} Match score; 0 means no useful relationship. - */ -export function scoreSatelliteNameMatch(query, catalogName) { - const q = String(query || '').trim().toLowerCase(); - const name = String(catalogName || '').trim().toLowerCase(); - if (!q || !name) return 0; - const qCompact = q.replace(/[^a-z0-9]/g, ''); - const nameCompact = name.replace(/[^a-z0-9]/g, ''); - if (qCompact === nameCompact) return 1000; - let score = 0; - if ( - Math.min(qCompact.length, nameCompact.length) >= 5 - && (qCompact.includes(nameCompact) || nameCompact.includes(qCompact)) - ) { - score += 200 + Math.min(qCompact.length, nameCompact.length); - } - const ignored = new Set(['group', 'block', 'mission', 'launch', 'falcon', 'rocket']); - const tokens = q.split(/[^a-z0-9]+/).filter((token) => token.length >= 4 && !ignored.has(token)); - score += tokens.reduce((total, token) => total + (name.includes(token) ? token.length : 0), 0); - return score; -} - -function internationalDesignatorYear(satrec) { - const match = String(satrec?.intldesg || '').match(/^(\d{2})/); - if (!match) return null; - const shortYear = Number(match[1]); - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; -} - -let _lookupTleText = null; -let _lookupTleEntries = []; - -function lookupTleEntries(tleText) { - if (tleText !== _lookupTleText) { - _lookupTleText = tleText; - _lookupTleEntries = parseTLE(tleText); - } - return _lookupTleEntries; -} - -function tleLineLaunchYear(line1) { - const shortYear = Number(String(line1 || '').slice(9, 11)); - if (!Number.isFinite(shortYear)) return null; - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; -} - -function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); - const current = propagatePosition(satrec, referenceDate); - if (!current) return null; - return { - noradId: Number(satrec.satnum), - name: String(name || '').trim(), - current, - periodSec: orbitalPeriodSeconds(satrec), - orbitPath: computeOrbitPath(satrec, referenceDate), - gmstAtBake: gstime(referenceDate), - positionAt: (date) => propagatePosition(satrec, date), - }; -} - -/** - * Find and propagate a mission payload directly from a TLE catalog. - * This supports newly launched payloads that are present in CelesTrak's active - * feed but have not yet moved into a narrower operational group. - * @param {string} tleText Three-line-element catalog text. - * @param {string} query Mission or payload name. - * @param {{launchTime?: string|null}} [options] Optional launch epoch for namesake rejection. - * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} - */ -export function findSatelliteOrbitTrackInTle(tleText, query, options = {}) { - const launchYear = Number.isFinite(Date.parse(options.launchTime)) - ? new Date(options.launchTime).getUTCFullYear() - : null; - let bestEntry = null; - let bestScore = 0; - const catalogText = String(tleText || ''); - for (const entry of lookupTleEntries(catalogText)) { - const designatorYear = tleLineLaunchYear(entry.line1); - if (launchYear !== null && designatorYear !== null && designatorYear !== launchYear) continue; - const score = scoreSatelliteNameMatch(query, entry.name); - if (score > bestScore) { - bestEntry = entry; - bestScore = score; - } - } - if (!bestEntry || bestScore < 12) return null; - const satrec = twoline2satrec(bestEntry.line1, bestEntry.line2); - if (!satrec || satrec.error !== 0) return null; - return orbitTrackFromRecord(bestEntry.name, satrec); -} - -/** - * Return the current propagated position and one-orbit path for a catalog satellite. - * @param {string|number} query NORAD id or mission/payload name. - * @param {{launchTime?: string|null}} [options] Optional launch epoch used to reject namesakes from another launch year. - * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} - */ -export function getSatelliteOrbitTrack(query, options = {}) { - if (query === null || query === undefined || !_catalog?.size) return null; - const q = String(query).trim().toLowerCase(); - let noradId = /^\d+$/.test(q) ? Number(q) : null; - if (noradId === null || !_catalog.has(noradId)) { - noradId = null; - const launchYear = Number.isFinite(Date.parse(options.launchTime)) - ? new Date(options.launchTime).getUTCFullYear() - : null; - let bestScore = 0; - for (const [id, sat] of _catalog) { - const designatorYear = internationalDesignatorYear(sat.satrec); - if (launchYear !== null && designatorYear !== null && designatorYear !== launchYear) continue; - const score = scoreSatelliteNameMatch(q, sat.name); - if (score > bestScore) { bestScore = score; noradId = id; } - } - if (bestScore < 12) noradId = null; - } - if (noradId === null) return null; - const sat = _catalog.get(noradId); - return orbitTrackFromRecord(sat.name, sat.satrec); -} - -export default satellitesLayer; +export const satelliteVisualsVisible = layer.satelliteVisualsVisible; +export const satelliteCatalogModeChanged = layer.satelliteCatalogModeChanged; +export const createIssOverlayEntry = layer.createIssOverlayEntry; +export const orbitFrameModelMatrix = layer.orbitFrameModelMatrix; +export const _setTrackedSatelliteRefreshStateForTest = + layer._setTrackedSatelliteRefreshStateForTest; +export const _setSatelliteTrackingRefreshOutcomeForTest = + layer._setSatelliteTrackingRefreshOutcomeForTest; +export const _trackedFrameCartesianForTest = + layer._trackedFrameCartesianForTest; +export const _runSatellitePreRenderForTest = + layer._runSatellitePreRenderForTest; +export const _setDenseCatalogStateForTest = layer._setDenseCatalogStateForTest; +export const _clearDenseCatalogStateForTest = + layer._clearDenseCatalogStateForTest; +export const _catalogGroupForTest = layer._catalogGroupForTest; +export const _setSatelliteLabelLifecycleStateForTest = + layer._setSatelliteLabelLifecycleStateForTest; +export const _trackIssForTest = layer._trackIssForTest; +export const _pendingSatelliteTrackingRestoreForTest = + layer._pendingSatelliteTrackingRestoreForTest; +export const _applyPendingSatelliteTrackingRestoreForTest = + layer._applyPendingSatelliteTrackingRestoreForTest; +export const _removeSatelliteTrackingCandidateForTest = + layer._removeSatelliteTrackingCandidateForTest; +export const _clearSatelliteLabelLifecycleForTest = + layer._clearSatelliteLabelLifecycleForTest; +export const applySatellitePointFocusDeemphasis = + layer.applySatellitePointFocusDeemphasis; +export const getNextIssPass = layer.getNextIssPass; +export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch; +export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle; +export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack; +export { + ISS_OVERLAY_SOURCE_ID, + ISS_OVERLAY_SOURCE_OPTIONS, +} from '../layers/satellites/index.js'; +export default layer; diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 1225e462e..e5ea59d6c 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -1,3 +1,4 @@ +import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/); assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/); assert.match( - createFirmsHeatmapLayer.toString(), - /registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/, + createFirmsRendering.toString(), + /registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/, 'lazy FIRMS registration must restore order immediately', ); }); diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 53d1a664e..20d6e02ff 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -496,6 +496,14 @@ export function createTeleGeographySubmarineCableLayer({ let _cableDataSource = null; let _landingDataSource = null; let _referenceDataSource = null; + /** + * Fetched cable/landing JSON kept across disable/enable. A disable RELEASES + * the three data sources (see releaseDataSources) instead of hiding them, + * so a re-enable has to rebuild the entities; the network/parse half of + * that rebuild is the expensive part users would notice, so it is cached. + */ + let _cachedCableJson = null; + let _cachedLandingJson = null; let _referenceRecords = []; let _surfaceRecords = []; let _clickHandler = null; @@ -553,11 +561,17 @@ export function createTeleGeographySubmarineCableLayer({ const owns = () => generation === _loadGeneration && !abort.signal.aborted; try { - const [cableJson, landingJson] = await Promise.all([ - fetchJson(cableUrl, abort.signal), - fetchJson(landingPointUrl, abort.signal), - ]); - if (!owns()) return; + let cableJson = _cachedCableJson; + let landingJson = _cachedLandingJson; + if (!cableJson || !landingJson) { + [cableJson, landingJson] = await Promise.all([ + fetchJson(cableUrl, abort.signal), + fetchJson(landingPointUrl, abort.signal), + ]); + if (!owns()) return; + _cachedCableJson = cableJson; + _cachedLandingJson = landingJson; + } const cableFeatures = normalizeFeatures(cableJson, 'cable'); const landingFeatures = normalizeFeatures(landingJson, 'landing'); @@ -706,6 +720,37 @@ export function createTeleGeographySubmarineCableLayer({ } } + /** + * Remove the three data sources from the viewer and forget the built + * entities. Hiding them (`show = false`) is NOT enough: Cesium's + * DataSourceDisplay still walks every visualizer of a hidden source on + * every frame, and the thousands of hidden polylines/billboards keep + * costing CPU and GC pressure long after the user toggled the layer off — + * that is the "everything gets sluggish after I turn layers off" report. + * The fetched JSON stays cached, so the next enable rebuilds without a + * network round trip or a re-parse. + */ + function releaseDataSources(viewer) { + const sources = [_cableDataSource, _landingDataSource, _referenceDataSource]; + _cableDataSource = null; + _landingDataSource = null; + _referenceDataSource = null; + _referenceRecords = []; + _surfaceRecords = []; + _pickByEntity = new WeakMap(); + _referenceLabelCount = 0; + _publishScratch.length = 0; + // The blend pass targets the collections being dropped; the rebuilt + // sources need their own pass. + _markerBlendDone = false; + _loaded = false; + _referenceSweepGate.reset(); + for (const source of sources) { + if (!source) continue; + try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ } + } + } + function updateVisibility() { if (_cableDataSource) _cableDataSource.show = _enabled; if (_landingDataSource) _landingDataSource.show = _enabled; @@ -1093,7 +1138,6 @@ export function createTeleGeographySubmarineCableLayer({ disable() { _enabled = false; - updateVisibility(); _overlayPublisher.hide(); resetPublishSignature(); if (_loading && _abort) { @@ -1101,6 +1145,10 @@ export function createTeleGeographySubmarineCableLayer({ _loading = false; _loadingLabel = ''; } + // Free the entities rather than hide them; enable() rebuilds from the + // cached JSON. An in-flight load's own post-await ownership check + // removes whatever that stale generation adds later. + releaseDataSources(_viewer); }, update(viewer) { @@ -1109,9 +1157,9 @@ export function createTeleGeographySubmarineCableLayer({ destroy(viewer) { if (_abort) _abort.abort(); - if (_cableDataSource && viewer) viewer.dataSources.remove(_cableDataSource, true); - if (_landingDataSource && viewer) viewer.dataSources.remove(_landingDataSource, true); - if (_referenceDataSource && viewer) viewer.dataSources.remove(_referenceDataSource, true); + releaseDataSources(viewer || _viewer); + _cachedCableJson = null; + _cachedLandingJson = null; // hide() clears every published host entry and goes invisible while // keeping the publisher reusable — the legacy layer supported re-init // after destroy, and hidden publishers already drop late publishes. diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 11550561e..58bfaa87d 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -743,7 +743,15 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b env.layer.disable(); env.layer.enable(env.viewer); await env.settle(); - assert.equal(geojson.calls.length, 1, 'stale load A must not start the landing GeoJson build'); + // A already cached the fetched JSON, so load B skips the network and goes + // straight to its own CABLE build; the only new GeoJson call must be that + // one — a landing build here would mean stale A ran on past its check. + assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching'); + assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build'); + assert.ok( + Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke), + 'the second GeoJson call is load B\'s cable build, not A\'s landing build', + ); env.releaseAll(); await env.settle(); @@ -1254,3 +1262,36 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i env.layer.destroy(env.viewer); }); + +test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => { + const env = await createRealCableLayerHarness(); + assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on'); + const before = env.layer.getStats().count; + assert.equal(before, 2); + + // Any fetch after this point is a bug: the rebuild must come from the cache. + const originalFetch = globalThis.fetch; + let fetches = 0; + globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); }; + try { + env.layer.disable(); + assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources'); + + env.layer.enable(env.viewer); + const deadline = Date.now() + 2_000; + while (Date.now() < deadline && env.dataSources.length < 3) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(fetches, 0, 'the rebuild must not touch the network'); + assert.equal(env.layer.getStats().error, null); + assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio'); + assert.equal(env.layer.getStats().count, before); + const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); + assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt'); + + env.layer.destroy(env.viewer); + assert.equal(env.dataSources.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index f7f00b0bd..484768337 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/tr3bRegistry.test.mjs // TR-3B conversion Easter egg: registry state, sprite-variant selection, // class-label override, and the render-path invariants that keep a converted @@ -227,7 +228,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke test('both flight layers keep a converted contact 2D and visible (render invariants)', async () => { for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); // 1. The 3D model handoff is SUPPRESSED for a converted contact — there is // no TR-3B GLB, so the triangle billboard stays the visual. @@ -237,13 +238,13 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // (2026-08-19), so the suppression moved from a conjunct on // `_modelRegimeActive()` to an explicit early return. The invariant is // unchanged: a converted contact never reaches the model handoff. - assert.match(source, /if \(!_trackedIcao \|\| _cockpitContactMode \|\| isTr3b\(_trackedIcao\)\) \{/, + assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/, `${name}: the standalone tracked model is suppressed for a converted contact`); // 2. The billboard is never hidden by that suppression — it must keep // satisfying the getNearby/getDetectableObjects visibility guards, so a // converted contact still works in Contacts and Cockpit. - assert.match(source, /if \(bb && id !== _trackedIcao\) bb\.show = true;|if \(modelled && id !== _trackedIcao\) modelled\.show = true;/, + assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/, `${name}: converting restores the billboard the model handoff had hidden`); // 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no @@ -253,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || []; assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`); for (const call of callSites) { - assert.match(call, /aircraftIcon\(\s*_iconKind\(/, + assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/, `${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`); } @@ -270,8 +271,8 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( // personally clicked, and re-tracking the same aircraft after a layer restart // should still show the triangle. Only a page reload resets it — so no // production code may clear the registry. - for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) { + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); assert.doesNotMatch(source, /clearTr3bRegistry/, `${name}: teardown must not clear session conversions`); } @@ -406,10 +407,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => { // in the fleet tick (no seam to drive headlessly), so this asserts the guard's // POSITION rather than replaying the four-pass selection. for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); // Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated // ambient-enrichment candidate loop that also builds a `cand`. - const loop = /const cand = \[\];\s*\n\s*for \(const \[icao, bb\] of _billboards\)[\s\S]*?cand\.push\(/.exec(source)?.[0]; + const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0]; assert.ok(loop, `${name}: the model-eligibility candidate loop is present`); assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 4e453647a..1b02df1d5 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/trackedModelRegime.test.mjs // // Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19). @@ -931,15 +932,15 @@ for (const fixture of LAYERS) { test('both layers gate the tracked-model load and record its failures', async () => { const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); - assert.match(source, /!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed\(\)/, + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); + assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, `${name}: the driver asks permission before starting another tracked-model load`); - assert.match(source, /_noteTrackedModelLoadFailure\(trackedSpec\.url, err\)/, + assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/, `${name}: a rejected load is recorded against this selection, not silently retried`); // The reset must be reachable from the real teardown, not only a test seam. - assert.match(source, /_releaseTrackedModel\(\);\n _resetTrackedSelectionState\(\);/, + assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: deselect clears the per-selection latches in the production path`); - assert.match(source, /_trackedIcao = icao24;\n _resetTrackedSelectionState\(\);/, + assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: selecting a contact starts from a clean per-selection state`); } }); @@ -947,16 +948,16 @@ test('both layers gate the tracked-model load and record its failures', async () test('the tracked regime never consults the fleet models3d toggle', async () => { const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); - const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); + const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`); - assert.doesNotMatch(regime, /_models3dEnabled|_modelRegimeActive\(\)/, + assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/, `${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`); assert.match(regime, /trackedModelZoomActive\(/, `${name}: the tracked contact uses the shared hysteretic zoom policy`); // The FLEET must still obey the toggle — the budget decision stays the operator's. - const fleet = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; - assert.match(fleet, /if \(!_models3dEnabled\) return false;/, + const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, `${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`); } }); diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 1cf3873c6..48e484aca 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; @@ -244,14 +245,14 @@ test('tracking layers write gevLabelModel and expose only their cached display p 'militaryFlights.js', 'satellites.js', 'militaryInstallations.js', - ].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')])); + ].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))])); const sources = Object.fromEntries(files); for (const [name, source] of files) { assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`); assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`); } - assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached')); - assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached')); + assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); + assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached')); assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false); assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false); @@ -260,11 +261,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( - [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')] + [name, readLayerSource(new URL(`./${name}`, import.meta.url))] ))); for (const [name, source] of files) { assert.ok( - source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'), + (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), `${name} trail head uses the dedicated lower-centre model anchor`, ); assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'), diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index f19200b68..a9a918fef 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -110,16 +111,16 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno test('civilian and military click handlers apply duration only at the deselect branch', () => { const sources = [ - readFileSync(new URL('./flights.js', import.meta.url), 'utf8'), - readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'), + readLayerSource(new URL('./flights.js', import.meta.url)), + readLayerSource(new URL('./militaryFlights.js', import.meta.url)), ]; for (const source of sources) { assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/); - assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+_clearTracking\([^)]*\{ origin: 'user' \}\)/); + assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/); } assert.doesNotMatch( sources[0], - /_trackedEntity = _viewer\.entities\.add\(\{\s*id:/, + /(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/, 'civilian tracked entities must retain Cesium-generated GUIDs', ); }); diff --git a/src/data/traffic.js b/src/data/traffic.js index b5fe47cbe..05fed3bf1 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,2506 +1,13 @@ -import * as Cesium from 'cesium'; -import { deriveFetchCenter, clampBoundsAroundCenter } from './trafficBounds.js'; -import { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } from './flowTiles.js'; -import { matchFlowToRoads } from './flowMatch.js'; -import { flowBucket, flowSpeedScale, flowDensityMult } from './trafficFlowStyle.js'; -import { - trafficStyleProfile, - presetDotRgba, - presetSizeDelta, - presetDotOutline, - trafficBucketTier, -} from './trafficPresetStyle.js'; -import { queuePlatoons, locateAlongRoad } from './trafficQueue.js'; -import { registerDynamicCredit, TOMTOM_CREDIT } from './dataCredits.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -/** - * @file Street Traffic — animated dots along OSM road polylines, colored by - * live TomTom congestion when a key is configured. - * - * Road geometry: OSM Overpass API (free, no auth). Fetches road polylines for - * the camera viewport, spawns PointPrimitives that lerp along pre-computed - * Cartesian3 waypoints. Camera-gated: only active below ~8 km altitude. - * - * Two modes (decided once per session via `/api/tomtom/status`): - * - `sim` (keyless default): white dots at hardcoded per-road-class speeds — - * the original simulation, byte-identical behavior. - * - `live`: TomTom flow tiles (`flowTiles.js`) are matched onto the same - * Overpass roads (`flowMatch.js`); matched roads color/slow/densify their - * dots by real congestion (`trafficFlowStyle.js`), closed roads spawn no - * dots, and unmatched roads keep the simulated white. - * - * Architecture overview: - * - Camera-change listener triggers debounced road fetching per viewport tile. - * - Fetch bounds center on the camera's look-at point (`trafficBounds.js`, C4). - * - Roads are fetched in two passes: major-only (fast) then full graph (detailed). - * - Fetched tiles are cached by clamped bounding-box key to avoid re-fetching. - * - Dot budget allocation distributes a hard cap fairly across visible roads. - * - Each dot lerps along pre-computed Cartesian3 waypoints every preRender frame. - * - * @module data/traffic - */ - -/** @const {string} Proxy endpoint for Overpass API queries */ -const OVERPASS_URL = '/api/overpass'; -/** @const {number} Meters — hide all traffic dots above this camera altitude */ -const ACTIVATION_ALTITUDE = 8000; -/** @const {number} Meters — above this altitude, only major roads are fetched */ -const FAST_FETCH_ALTITUDE = 4500; -/** @const {number} Milliseconds — debounce delay before fetching after camera settles */ -const FETCH_DEBOUNCE = 320; -/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */ -const DOT_HEIGHT_OFFSET = 3.0; -/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */ -const OVERLAP_THRESHOLD = 0.6; -/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */ -const MAX_DOTS = 6000; -/** @const {number} Polylines longer than this are simplified by sub-sampling */ -const MAX_WAYPOINTS_PER_ROAD = 80; -/** @const {number} Km — minimum viewport center shift before allowing refresh */ -const MIN_CENTER_SHIFT_KM = 0.35; -/** - * @const {number} Km — max great-circle distance the fetch center may sit from - * the camera nadir. Traffic only activates below ACTIVATION_ALTITUDE (8 km), - * so a look-at ground point farther than this is horizon-gazing and gets - * pulled back toward nadir (C4 oblique-bounds fix). - */ -const MAX_LOOKAT_PULL_KM = 12; -/** - * Development-only causal timing. Vite folds `import.meta.env.DEV` to false - * in production, so the query-string read, nullable trace branches, and every - * debug helper are removed from production builds. The remaining load-path - * selectors point directly at the original functions: no marks, listeners, - * observers, timers, counters, or per-road timing checks are installed. - */ -const TRAFFIC_TIMING_ENABLED = import.meta.env?.DEV - && typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('trafficDebug') === '1'; - -/** @const {Object} Speed in meters per second by highway tag (approximate real-world values) */ -const SPEED_MPS = { - motorway: 25, // ~90 km/h - trunk: 20, // ~72 km/h - primary: 14, // ~50 km/h - secondary: 11, // ~40 km/h - tertiary: 8, // ~30 km/h - residential: 5, // ~18 km/h - unclassified: 5, -}; - -/** @const {Object} Density multiplier — higher values spawn more dots on important roads */ -const DENSITY_MULT = { - motorway: 3.0, trunk: 2.5, primary: 2.0, secondary: 1.5, - tertiary: 1.0, residential: 0.5, unclassified: 0.4, -}; - -/** @const {Object} Pixel size per road type (scaled up 25% for screen-recording visibility) */ -const SIZE_BY_TYPE = { - motorway: 6, trunk: 6, primary: 5, secondary: 5, - tertiary: 4, residential: 4, unclassified: 4, -}; - -/** - * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): - * green free flow / amber slow / red jam, all at 0.9 alpha. - * Roads without flow data (`road.flow == null`) keep the sim's white. - * @const {Object} - */ -const FLOW_BUCKET_COLORS = { - free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9), - slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9), - jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), -}; - -// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── -/** @const {number} Max congestion heat-line polylines per render (jam first). */ -const HEAT_LINE_CAP = 400; -/** @const {number} Px — glowing jam corridor line width. */ -const HEAT_LINE_JAM_WIDTH = 9; -/** @const {number} Px — flat slow corridor line width. */ -const HEAT_LINE_SLOW_WIDTH = 4; -/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */ -const HEAT_JAM_BASE_ALPHA = 0.55; -/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */ -const HEAT_JAM_PULSE_ALPHA = 0.2; -/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ -const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); -/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ -const HEAT_SLOW_COLOR = Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2); -/** - * @const {number} Meters — jam dots depth-test-punch through the 3D tiles out - * to this camera distance so queues stay visible at city scale. The single - * start-of-road terrain sample puts much of a road below the rendered mesh - * at oblique city views (first A/B capture: 396 jam dots, zero visible), so - * the shipped 2 km window hides exactly the congestion this prototype is - * meant to surface. Live jam dots only; sim dots keep the shipped 2 km. - */ -const JAM_DOT_DEPTH_PUNCH = 15000; -/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ -const JAM_DOT_FAR_SCALE = 0.55; -/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */ -const CREEP_BURST = 2.2; -/** @const {number[]} Ms range a jam dot creeps forward before stopping. */ -const CREEP_MOVE_MS = [1200, 3000]; -/** @const {number[]} Ms range a jam dot sits stopped between creeps. */ -const CREEP_STOP_MS = [1500, 5000]; - -// ─── Module State ────────────────────────────────────────── -/** @type {Cesium.Viewer|null} */ -let _viewer = null; -/** @type {Cesium.PointPrimitiveCollection|null} */ -let _pointCollection = null; -/** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ -let _dots = []; -/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ -let _roads = []; -/** @type {boolean} Whether the layer is currently enabled */ -let _enabled = false; -/** @type {Function|null} Disposer returned by preRender event subscription */ -let _preRenderRemover = null; -/** @type {Function|null} Disposer returned by camera.changed event subscription */ -let _cameraRemover = null; -/** @type {ReturnType|null} Debounce timer for camera-change fetch */ -let _fetchTimeout = null; -/** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */ -let _lastBounds = null; -/** @type {boolean} True while an Overpass fetch is in flight */ -let _fetching = false; -/** @type {number} Current count of rendered dots */ -let _count = 0; -/** @type {number|null} Timestamp of last successful render */ -let _lastUpdate = null; -/** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */ -let _loadGeneration = 0; -/** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */ -let _activeFetchAbort = null; -/** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */ -let _densityScale = 1.0; -/** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */ -let _speedScale = 1.0; -/** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */ -let _lastViewCenter = null; -/** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */ -let _prevPercentageChanged = null; -/** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */ -let _liveMode = false; -/** - * Short user-facing reason live flow is currently unavailable, or null while - * healthy. Only ever set in live mode: keyless simulation is a designed - * fallback, not a fault, and must never read as an error. - * @type {string|null} - */ -let _flowError = null; -/** - * True when `/api/tomtom/status` itself could not be reached, so the layer is - * simulating because it could not ask — not because the server said "no key". - * @type {boolean} - */ -let _flowStatusUnavailable = false; -/** - * Flow requests this layer still owns. The 250 ms paint race lets a flow - * fetch outlive the road load that started it (cached roads settle - * instantly), so `_fetching` alone under-reports the work in flight: the - * loading batch would close with LOAD COMPLETE and a failure landing after - * it could never be announced. Counted, not boolean — recolor-after-timeout - * means two loads can overlap. - * @type {number} - */ -let _flowPending = 0; -/** - * Rendered-dot counts per flow bucket (sim = white ambient, no flow data). - * Reset with the dots in clearDots; drives the data-panel diagnostics and - * the qa-traffic harness color assertions. - * @type {{free:number, slow:number, jam:number, sim:number}} - */ -let _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; -/** @type {number} Roads in the current render skipped entirely as closed. */ -let _closedRoads = 0; -/** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */ -let _uncoveredMode = 'sim'; -/** - * Jam-viz prototype mode: 'density' = deep-jam density boost + platoon queues - * + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both'; - * 'none' = shipped main behavior. Live mode only — the keyless simulation - * never has `road.flow`, so every jamViz path is unreachable there. - * Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available - * via setParams). - * @type {'none'|'density'|'heatline'|'both'} - */ -let _jamViz = 'density'; -/** - * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the - * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE) — polylines at the - * dots' single-sample heights depth-fail under the mesh across an oblique - * city view (first A/B capture: 198 lines rendered, zero visible). One - * primitive per bucket so the jam batch can pulse via a single shared - * material uniform. - * @type {Cesium.GroundPolylinePrimitive|null} - */ -let _heatJamPrim = null; -/** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ -let _heatSlowPrim = null; -/** @type {number} Heat-lines in the current render (stats). */ -let _heatLineCount = 0; -/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ -let _heatSupported = null; -/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ -let _lastRenderAltitude = 0; -/** - * Active post-FX style (StyleManager preset name), synced from - * `document.documentElement.dataset.gevStyle` at init and the - * `gev:style-change` window event thereafter. Drives the preset-aware dot - * styling (`trafficPresetStyle.js`): NVG/FLIR/noir re-encode congestion in - * luminance + size (their shaders discard hue), retro/CRT gets saturated - * hues + a size boost to survive pixelation. 'normal' → shipped palette. - * @type {string} - */ -let _stylePreset = 'normal'; -/** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */ -let _presetDots = 'on'; -/** @type {boolean} gev:style-change listener bound (bind once per page). */ -let _styleListenerBound = false; -/** - * Effective per-bucket dot colors: preset override when one applies, else - * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — - * spawn/recolor/restyle all read from here, no per-dot allocation. - * @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} - */ -let _activeBucketColors = { ...FLOW_BUCKET_COLORS }; -/** - * @const {number} Minimum base pixel size for COLORED dots while a styled - * preset is active — residential-road dots spawn at 4 px and vanish into - * post-FX pixelation; presence is the dots' whole job there (owner round - * 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched. - */ -const STYLED_MIN_BASE_PX = 5; - -/** @returns {boolean} A non-normal preset profile is active and enabled. */ -function presetProfileActive() { - return _presetDots === 'on' && trafficStyleProfile(_stylePreset) !== 'normal'; -} - -/** - * Pixel-size delta the active preset adds for a bucket (0 when the kill - * switch is off or the profile is normal). - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket. - * @returns {number} Pixels to add on top of the shipped sizing. - */ -function activeSizeDelta(bucket) { - return _presetDots === 'on' ? presetSizeDelta(_stylePreset, bucket) : 0; -} - -/** - * Base pixel size for a dot: shipped SIZE_BY_TYPE, floored at - * STYLED_MIN_BASE_PX for colored dots while a styled preset is active. - * @param {string} roadType - OSM highway class. - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). - * @returns {number} Base pixel size before jam/preset deltas. - */ -function baseDotSize(roadType, bucket) { - const base = SIZE_BY_TYPE[roadType] || 4; - return (bucket && presetProfileActive()) ? Math.max(base, STYLED_MIN_BASE_PX) : base; -} - -/** Recompute `_activeBucketColors` from the active style + kill switch. */ -function refreshBucketColors() { - for (const bucket of ['free', 'slow', 'jam']) { - const rgba = _presetDots === 'on' ? presetDotRgba(_stylePreset, bucket) : null; - _activeBucketColors[bucket] = rgba - ? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) - : FLOW_BUCKET_COLORS[bucket]; - } -} - -/** - * Apply the active preset's dark-halo outline to a colored dot (or clear - * it back to the shipped no-outline state). NVG's auto-gain saturates the - * scene, so brightness alone cannot separate a dot from a bright road — - * the dark ring restores local contrast through every luma-mapping shader. - * @param {Cesium.PointPrimitive} point - The dot primitive. - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). - */ -function applyOutline(point, bucket) { - const spec = _presetDots === 'on' ? presetDotOutline(_stylePreset, bucket) : null; - if (spec) { - point.outlineColor = new Cesium.Color( - spec.rgba[0] / 255, spec.rgba[1] / 255, spec.rgba[2] / 255, spec.rgba[3], - ); - point.outlineWidth = spec.width; - } else { - point.outlineWidth = 0; - } -} - -/** - * Re-apply dot styling in place after a style/param change: colored dots - * get the (new) effective bucket color and size; sim (white) dots are - * never touched. No refetch, no respawn — heat-lines rebuild for their - * per-preset colors. - */ -function restyleDotsInPlace() { - refreshBucketColors(); - if (!_dots.length && !_heatLineCount) return; - for (const dot of _dots) { - const bucket = dot.bucket; - if (!bucket) continue; // sim/uncovered dots stay byte-identical - dot.point.color = _activeBucketColors[bucket]; - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - applyOutline(dot.point, bucket); - } - rebuildHeatLines(visibleRoadsForAltitude(_roads, _lastRenderAltitude)); -} - -/** - * Adopt a new active style preset (from the gev:style-change event or the - * dataset read at init) and restyle live dots immediately. - * @param {string|null|undefined} name - StyleManager preset name. - */ -function setStylePreset(name) { - const next = (typeof name === 'string' && name) ? name : 'normal'; - if (next === _stylePreset) return; - _stylePreset = next; - restyleDotsInPlace(); -} - -/** @returns {boolean} Density/queue/creep prototype active. */ -const jamDensityOn = () => _jamViz === 'density' || _jamViz === 'both'; -/** @returns {boolean} Heat-line prototype active. */ -const heatlineOn = () => _jamViz === 'heatline' || _jamViz === 'both'; -/** - * Dot fade-out distances, recomputed per render from the camera-to-area - * distance. The original constants (8 km scale / 10 km translucency) - * assumed a nadir view; at oblique pitch the loaded area legitimately sits - * 7–12+ km from the CAMERA and every dot faded to invisible (field-test - * round 1: "the screen itself was empty"). - */ -let _fadeScaleFar = 8000; -let _fadeTransFar = 10000; -/** @type {Promise|null} Session-cached status check (one fetch per session) */ -let _flowStatusPromise = null; -/** @type {ReturnType|null} Enable-time retry until the first load commits. */ -let _enableKickTimer = null; -/** @type {number} 0–100 int — matched roads / roads with any flow candidates */ -let _flowCoveragePct = 0; -/** @type {Function|null} Development-only camera moveEnd timing disposer. */ -let _trafficTimingMoveEndRemover = null; -/** @type {Set|null} Development-only one-shot postRender disposers. */ -let _trafficTimingPostRenderRemovers = null; -/** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */ -let _trafficTimingCurrentAnchor = null; -/** @type {number} Development-only unique mark/trace sequence. */ -let _trafficTimingSequence = 0; -/** @type {number} Development-only count of correlated trace objects created. */ -let _trafficTimingTracesCreated = 0; -/** @type {number} Development-only count of loads dropped for missing/stale anchors. */ -let _trafficTimingDroppedTraces = 0; - -/** - * Return development timing counters for the capture harness and inertness test. - * This named export is unused by the application and removed from production. - * @returns {{enabled:boolean, marksInstalled:number, traceObjectsCreated:number, - * uncorrelatedTracesDropped:number}} - */ -export function getTrafficTimingDiagnostics() { - return { - enabled: Boolean(TRAFFIC_TIMING_ENABLED), - marksInstalled: _trafficTimingMoveEndRemover ? 1 : 0, - traceObjectsCreated: _trafficTimingTracesCreated, - uncorrelatedTracesDropped: _trafficTimingDroppedTraces, - }; -} - -/** - * Tile cache keyed by "s,w,n,e" string. - * Each entry stores separately fetched major-only and full road sets - * so the major pass can be served from cache while a full fetch continues. - * @type {Map} - */ -const _tileCache = new Map(); -/** @const {number} Maximum tile cache entries before LRU eviction */ -const TILE_CACHE_MAX_ENTRIES = 64; - -/** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ -const _scratchLerp = new Cesium.Cartesian3(); - -// ─── Overpass API ────────────────────────────────────────── - -/** - * Build an Overpass QL query string to fetch road ways within a bounding box. - * - * The query uses a highway tag regex filter. When `majorOnly` is true, only - * motorway/trunk/primary/secondary are included; otherwise all drivable road - * classes are fetched. The `out geom qt;` suffix returns inline geometry - * (lat/lon per node) sorted by quadtile for faster server response. - * - * @param {number} south - Southern latitude bound (degrees). - * @param {number} west - Western longitude bound (degrees). - * @param {number} north - Northern latitude bound (degrees). - * @param {number} east - Eastern longitude bound (degrees). - * @param {Object} [opts] - * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only. - * @param {number} [opts.timeoutSec=25] - Overpass server-side timeout. - * @returns {string} Overpass QL query body. - */ -function buildOverpassQuery(south, west, north, east, { majorOnly = false, timeoutSec = 25 } = {}) { - // Regex matches the OSM `highway` tag value against allowed road types - const regex = majorOnly - ? '^(motorway|trunk|primary|secondary)$' - : '^(motorway|trunk|primary|secondary|tertiary|residential|unclassified)$'; - return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`; -} - -/** - * Fetch road geometries from the Overpass API via the local proxy. - * - * Sends a POST with the query as form-encoded `data`. Supports - * AbortController signals so in-flight requests can be cancelled - * when the camera moves before the response arrives. - * - * @param {number} south - Southern latitude bound (degrees). - * @param {number} west - Western longitude bound (degrees). - * @param {number} north - Northern latitude bound (degrees). - * @param {number} east - Eastern longitude bound (degrees). - * @param {Object} [opts] - * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes. - * @param {number} [opts.timeoutSec=25] - Server-side Overpass timeout. - * @param {AbortSignal} [opts.signal] - Abort signal for cancellation. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} Parsed JSON response from Overpass. - * @throws {Error} If the HTTP response status is not OK. - */ -async function fetchRoads( - south, - west, - north, - east, - { majorOnly = false, timeoutSec = 25, signal } = {}, - trace = null, -) { - const query = buildOverpassQuery(south, west, north, east, { majorOnly, timeoutSec }); - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingPass(trace, majorOnly ? 'major' : 'full', 'proxy') - : null; - if (state) trace.currentPass = state.pass; - const fetchStart = state ? trafficTimingMark(state, 'fetch-start') : null; - if (state) { - trafficTimingMeasure( - 'last-camera-change-to-fetch-start', state, trace.cameraChangeMark, fetchStart, - ); - } - const response = await fetch(OVERPASS_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - signal, - }); - - if (!response.ok) { - throw new Error(`Overpass API returned ${response.status}`); - } - - if (!state) return response.json(); - - if (state) { - state.proxyCache = response.headers.get('x-overpass-cache'); - state.proxyUpstream = response.headers.get('x-overpass-upstream'); - } - const responseStart = state ? trafficTimingMark(state, 'response-json-start', { - responseStatus: response.status, - }) : null; - if (state) { - trafficTimingMeasure('fetch-to-response', state, fetchStart, responseStart, { - responseStatus: response.status, - }); - } - const data = await response.json(); - if (state) { - const responseEnd = trafficTimingMark(state, 'response-json-end', { - responseStatus: response.status, - }); - trafficTimingMeasure('response-json', state, responseStart, responseEnd, { - responseStatus: response.status, - }); - } - return data; -} - -/** - * Parse an Overpass `out geom;` JSON response into internal road objects. - * - * Each OSM `way` element carries an inline `geometry` array of `{lat, lon}` - * objects, so no separate node look-up or osmtogeojson conversion is needed. - * - * Processing per way: - * 1. Extract [lon, lat] coordinate pairs. - * 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices. - * 3. Sample terrain height once at the first vertex (avoids per-vertex cost). - * 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances. - * - * @param {Object} overpassData - Raw JSON response from the Overpass API. - * @param {Array} overpassData.elements - Array of OSM elements. - * @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} - * Parsed road objects ready for dot spawning. - */ -function parseRoads(overpassData) { - if (!overpassData || !overpassData.elements) return []; - - const roads = []; - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; - - const rawCoords = el.geometry.map(g => [g.lon, g.lat]); - - // Sub-sample long polylines: keep every Nth vertex to stay within budget - const simplifyStep = rawCoords.length > MAX_WAYPOINTS_PER_ROAD - ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) - : 1; - const coords = []; - for (let i = 0; i < rawCoords.length; i += simplifyStep) { - coords.push(rawCoords[i]); - } - - // Ensure the original endpoint is always preserved - const last = rawCoords[rawCoords.length - 1]; - const tail = coords[coords.length - 1]; - if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { - coords.push(last); - } - - if (coords.length < 2) continue; - - const type = el.tags?.highway || 'unclassified'; - // One-way capture (field-test round 1: "a car would never go in - // reverse"): dots on one-way roads all travel the legal direction. - // OSM: oneway=yes/1/true → digitization order; '-1' → reversed; - // roundabouts are one-way by definition. 0 = two-way (alternate). - const onewayTag = el.tags?.oneway; - const oneway = (onewayTag === 'yes' || onewayTag === '1' || onewayTag === 'true' || el.tags?.junction === 'roundabout') - ? 1 - : (onewayTag === '-1' ? -1 : 0); - - // Sample terrain height once at the road start to avoid per-vertex cost - let baseHeight = 0; - const firstCoord = coords[0]; - if (_viewer?.scene?.sampleHeightSupported && firstCoord) { - const carto = Cesium.Cartographic.fromDegrees(firstCoord[0], firstCoord[1]); - const sampled = _viewer.scene.sampleHeight(carto); - if (Number.isFinite(sampled)) baseHeight = sampled; - } - - // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation - const waypoints = coords.map(([lng, lat]) => { - const h = baseHeight + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); - }); - - // Pre-compute segment distances in meters for speed-to-t conversion - const segmentDist = []; - for (let i = 0; i < waypoints.length - 1; i++) { - segmentDist.push(Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1])); - } - - roads.push({ coords, type, oneway, waypoints, segmentDist }); - } - - return roads; -} - -// ─── Road Length Estimation ──────────────────────────────── - -/** - * Estimate the total length of a road in meters from its degree-based coordinates. - * - * Uses Euclidean distance in degree-space then multiplies by the equatorial - * approximation of 111 km per degree. Accurate enough for dot density spacing - * but not for navigation. - * - * @param {number[][]} coords - Array of [lon, lat] pairs. - * @returns {number} Approximate road length in meters. - */ -function estimateRoadLengthDeg(coords) { - let len = 0; - for (let i = 0; i < coords.length - 1; i++) { - const dx = coords[i + 1][0] - coords[i][0]; - const dy = coords[i + 1][1] - coords[i][1]; - len += Math.sqrt(dx * dx + dy * dy); - } - // Rough conversion: 1 degree ~ 111,000 meters at equator - return len * 111000; -} - -// ─── Dot Spawning ────────────────────────────────────────── - -/** - * Compute the ideal number of dots for a single road at a given camera altitude. - * - * Spacing increases with altitude so fewer dots are rendered when zoomed out. - * The result is further scaled by the road-type density multiplier and the - * user-adjustable `_densityScale`. - * - * @param {{coords:number[][], type:string}} road - Parsed road object. - * @param {number} altitude - Current camera altitude in meters. - * @returns {number} Ideal dot count (minimum 1). - */ -function computeDotCount(road, altitude) { - // Live flow: closed roads carry zero traffic; congestion packs more dots. - // `road.flow` is only ever set in live mode, so the keyless path is - // untouched (flow stays undefined → multiplier 1, identical output). - const flow = _liveMode ? road.flow : null; - if (flow?.closure) return 0; - // Strict data-integrity view: uncovered roads spawn nothing when hidden. - if (_liveMode && !flow && _uncoveredMode === 'hide') return 0; - - const lengthM = estimateRoadLengthDeg(road.coords); - - // Altitude-adaptive spacing: closer camera = denser dots - let spacing; - if (altitude < 1000) spacing = 30; - else if (altitude < 3000) spacing = 80; - else if (altitude < 5000) spacing = 150; - else spacing = 250; - - const mult = (DENSITY_MULT[road.type] || 1) - * _densityScale - * (flow ? flowDensityMult(flow.level, { jamBoost: jamDensityOn() }) : 1); - return Math.max(1, Math.floor((lengthM / spacing) * mult)); -} - -/** - * Distribute a fixed dot budget fairly across all visible roads. - * - * Algorithm: - * 1. Compute ideal dot count per road via `computeDotCount`. - * 2. Seed one dot to every road that wants at least one (fairness pass). - * 3. Distribute remaining budget proportionally to each road's ideal count. - * 4. Assign leftover dots (from floor rounding) to roads with the highest - * fractional residuals (largest-remainder method). - * - * This prevents high-density motorways from starving smaller residential roads - * when the global MAX_DOTS cap is reached. - * - * @param {Array} roads - Parsed road objects. - * @param {number} altitude - Camera altitude in meters (affects spacing). - * @param {number} dotCap - Maximum total dots to allocate. - * @returns {number[]} Per-road dot budgets, same length as `roads`. - */ -function allocateRoadDotBudgets(roads, altitude, dotCap) { - const planned = roads.map((road) => computeDotCount(road, altitude)); - const budgets = new Array(roads.length).fill(0); - let remaining = Math.max(0, dotCap); - - // Pass 1 — fairness seed: give one dot to every road (highest-demand first) - const firstPassOrder = planned - .map((count, index) => ({ count, index })) - .sort((a, b) => b.count - a.count); - - for (const entry of firstPassOrder) { - if (remaining <= 0) break; - if (entry.count <= 0) continue; - budgets[entry.index] = 1; - remaining -= 1; - } - - if (remaining <= 0) return budgets; - - // Pass 2 — proportional distribution of the remaining budget - let totalRemainder = 0; - for (let i = 0; i < planned.length; i++) { - totalRemainder += Math.max(0, planned[i] - budgets[i]); - } - if (totalRemainder <= 0) return budgets; - - const residuals = []; - let assigned = 0; - for (let i = 0; i < planned.length; i++) { - const cap = Math.max(0, planned[i] - budgets[i]); - if (cap <= 0) continue; - const ideal = (cap / totalRemainder) * remaining; - const add = Math.min(cap, Math.floor(ideal)); - budgets[i] += add; - assigned += add; - residuals.push({ index: i, residual: ideal - add }); - } - - // Pass 3 — largest-remainder: hand out leftover dots from floor rounding - let leftover = remaining - assigned; - if (leftover > 0 && residuals.length > 0) { - residuals.sort((a, b) => b.residual - a.residual); - let cursor = 0; - while (leftover > 0 && residuals.length > 0) { - const idx = residuals[cursor % residuals.length].index; - if (budgets[idx] < planned[idx]) { - budgets[idx] += 1; - leftover -= 1; - } - cursor += 1; - // Safety valve: avoid infinite loop if all roads are already at their ideal - if (cursor > residuals.length * 3 && leftover > 0) break; - } - } - - return budgets; -} - -/** - * Spawn animated dot primitives along a single road. - * - * Each dot is placed at a random position along the road, assigned a - * randomized speed (base +/-30%), and given a direction (alternating - * forward/backward to simulate two-way traffic). - * - * @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road - * Parsed road object with pre-computed waypoints. - * @param {number} altitude - Camera altitude (used if budgetCount is null). - * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back - * to `computeDotCount` when null. - */ -function spawnDotsForRoad(road, altitude, budgetCount = null) { - // Live flow styling (`road.flow` only exists in live mode; keyless path is - // byte-identical): closures spawn nothing, congestion colors/slows dots. - const flow = _liveMode ? road.flow : null; - if (flow?.closure) return; - if (_liveMode && !flow && _uncoveredMode === 'hide') return; - - const count = Number.isFinite(budgetCount) - ? Math.max(0, Math.floor(budgetCount)) - : computeDotCount(road, altitude); - const numSegments = road.waypoints.length - 1; - if (numSegments < 1 || count <= 0) return; - - const baseMps = SPEED_MPS[road.type] || 5; - const bucket = flow ? flowBucket(flow.level) : null; - // Jam dots get +1px: a red queue should read as a queue at a glance. - // Preset-aware styling adds its own size delta and floors the base (0 / - // no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT. - const pixelSize = baseDotSize(road.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - const flowColor = bucket ? _activeBucketColors[bucket] : null; - // Dark-halo outline under styled presets (null = shipped no-outline). - const outlineSpec = (bucket && _presetDots === 'on') ? presetDotOutline(_stylePreset, bucket) : null; - const outlineColor = outlineSpec - ? new Cesium.Color(outlineSpec.rgba[0] / 255, outlineSpec.rgba[1] / 255, outlineSpec.rgba[2] / 255, outlineSpec.rgba[3]) - : null; - const flowSpeed = flow ? flowSpeedScale(flow.level) : 1; - const now = Date.now(); - - // Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper - // platoons (one shared direction per queue) instead of uniform scatter. - // Unreachable in sim mode — `bucket` requires flow. - let placements = null; - if (bucket === 'jam' && jamDensityOn()) { - let totalLen = 0; - for (const d of road.segmentDist) totalLen += d; - const platoons = queuePlatoons(totalLen, count); - if (platoons.length) { - placements = []; - for (let p = 0; p < platoons.length; p++) { - const dir = road.oneway ? road.oneway : ((p % 2 === 0) ? 1 : -1); - for (const s of platoons[p]) { - const { segIdx, t } = locateAlongRoad(road.segmentDist, s); - placements.push({ segIdx, t, direction: dir }); - } - } - } - } - - for (let i = 0; i < count; i++) { - if (_dots.length >= MAX_DOTS) return; - - // Random start position: pick a random segment and offset within it — - // unless this is a queued jam dot with a platoon placement. - const segIdx = placements ? placements[i].segIdx : Math.floor(Math.random() * numSegments); - const t = placements ? placements[i].t : Math.random(); - - // Speed noise: base speed +/-30% for organic variation. baseMps (noise - // included, flow excluded) is kept on the dot so a late-arriving flow - // match can rescale speed in place (recolorDotsInPlace). - const noisedMps = baseMps * _speedScale * (0.7 + Math.random() * 0.6); - const mps = noisedMps * flowSpeed; - - // One-way roads flow only their legal direction; two-way alternates - // (per platoon in queue mode — a queue moves as one). - const direction = placements - ? placements[i].direction - : (road.oneway ? road.oneway : ((i % 2 === 0) ? 1 : -1)); - - // Compute initial Cartesian3 position via linear interpolation - Cesium.Cartesian3.lerp(road.waypoints[segIdx], road.waypoints[segIdx + 1], t, _scratchLerp); - - // Jam-viz density prototype: jam dots stay visible at city scale — a - // longer depth-test punch-through (single-sample road heights sit under - // the mesh at oblique views) and a higher far-scale floor. Sim dots and - // other buckets keep the shipped values. - const jamProminent = bucket === 'jam' && jamDensityOn(); - const point = _pointCollection.add({ - position: Cesium.Cartesian3.clone(_scratchLerp), - pixelSize, - // No flow data → today's exact simulated white. - color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), - scaleByDistance: new Cesium.NearFarScalar(100, 1.5, _fadeScaleFar, jamProminent ? JAM_DOT_FAR_SCALE : 0.3), - translucencyByDistance: new Cesium.NearFarScalar(100, 1.0, _fadeTransFar, 0.0), - // visible through tiles only when very close (jam: city-scale punch) - disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000, - // Preset dark halo (spread only when present — the keyless/normal - // path passes the exact shipped option set). - ...(outlineSpec ? { outlineColor, outlineWidth: outlineSpec.width } : {}), - }); - _bucketCounts[bucket || 'sim'] += 1; - - _dots.push({ - point, - road, - bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse - waypoints: road.waypoints, - segmentDist: road.segmentDist, - numSegments, - segIdx, - t, - mps, // meters per second (flow-scaled) - baseMps: noisedMps, // pre-flow speed, for in-place flow rescale - direction, - stoppedUntil: 0, - // Stop-and-go creep state (jam-viz density prototype): jam dots - // alternate move-bursts and stops. Null in sim mode and for non-jam. - creep: (bucket === 'jam' && jamDensityOn()) - ? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 } - : null, - }); - } -} - -// ─── Animation ───────────────────────────────────────────── - -/** @type {number} Timestamp of the last animation tick (ms) */ -let _lastAnimTime = 0; -/** @type {number} Running frame counter (for diagnostics) */ -let _animFrame = 0; - -/** - * Per-frame animation callback registered on `scene.preRender`. - * - * For every active dot: - * 1. Skip if currently paused by a simulated stop-light. - * 2. Convert speed (m/s) to a parametric t-delta relative to the current - * segment's Cartesian distance. - * 3. Advance t in the dot's travel direction, handling segment boundary - * crossings and end-of-road reversals. - * 4. Linearly interpolate between the two bounding waypoints and update the - * point primitive's position. - * - * Delta time is capped at 100 ms to prevent large jumps after background tabs. - */ -function animate() { - const now = Date.now(); - // Delta time in seconds, capped to avoid jumps when returning from background tab - const dt = _lastAnimTime ? Math.min((now - _lastAnimTime) / 1000, 0.1) : 0.016; - _lastAnimTime = now; - - for (let i = 0; i < _dots.length; i++) { - const dot = _dots[i]; - - // Simulated stop-light pause — skip movement while timer is active - if (now < dot.stoppedUntil) continue; - - // Stop-and-go creep (jam-viz density prototype, live jam dots only): - // alternate short forward bursts with stops. The burst multiplier keeps - // the long-run average near the honest TomTom crawl speed. - let burst = 1; - if (dot.creep) { - if (now >= dot.creep.until) { - dot.creep.moving = !dot.creep.moving; - const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS; - dot.creep.until = now + lo + Math.random() * (hi - lo); - } - if (!dot.creep.moving) continue; - burst = CREEP_BURST; - } - - // Convert m/s speed to parametric t-delta for the current segment length - const segLen = dot.segmentDist[dot.segIdx] || 1; - const tDelta = (dot.mps * burst * dt) / segLen; - - // Advance parametric position along the road in the current direction - dot.t += tDelta * dot.direction; - - // Handle forward segment boundary crossing (t >= 1.0) - if (dot.t >= 1.0) { - dot.t -= 1.0; - dot.segIdx++; - if (dot.segIdx >= dot.numSegments) { - // End of road: recycle to the road's entry with a small stagger — - // cars don't reverse at the end of a street (field-test round 1). - // Direction is preserved, so one-way flow stays legal. - dot.segIdx = 0; - dot.t = Math.random() * 0.3; - } - maybeStopLight(dot, now); - } else if (dot.t <= 0.0) { - // Handle backward segment boundary crossing (t <= 0.0) - dot.t += 1.0; - dot.segIdx--; - if (dot.segIdx < 0) { - // Start of road (traveling backward): recycle to the far end. - dot.segIdx = dot.numSegments - 1; - dot.t = 1.0 - Math.random() * 0.3; - } - maybeStopLight(dot, now); - } - - // Lerp between pre-computed Cartesian3 waypoints (no trig needed). - // Pass the scratch directly: PointPrimitive's position setter clones the - // value into its own storage (and skips the VBO dirty flag when equal), so - // the extra defensive clone here allocated 360–720k Cartesian3/s of pure - // garbage across 6000 dots. (perf item 5) - const a = dot.waypoints[dot.segIdx]; - const b = dot.waypoints[dot.segIdx + 1]; - Cesium.Cartesian3.lerp(a, b, dot.t, _scratchLerp); - dot.point.position = _scratchLerp; - } - - // Jam heat-lines throb (~1.6 s period) — one shared material uniform for - // the whole jam batch, no geometry rebuild. Null outside live heatline mode. - if (_heatJamPrim?.appearance) { - _heatJamPrim.appearance.material.uniforms.color.alpha = - HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); - } - - _animFrame++; -} - -/** - * Randomly pause a dot near road endpoints to simulate stop-light behaviour. - * - * Only triggers within the first 2 or last 2 segments of the road, and only - * with a very low per-frame probability (0.8%) to keep traffic flowing. - * - * @param {Object} dot - The dot state object. - * @param {number} now - Current timestamp in milliseconds. - */ -function maybeStopLight(dot, now) { - const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2; - if (nearEnd && Math.random() < 0.008) { - // Pause for 2–6 seconds - dot.stoppedUntil = now + 2000 + Math.random() * 4000; - } -} - -// ─── Camera Monitoring ───────────────────────────────────── - -/** - * Get the current camera altitude in meters above the ellipsoid. - * @returns {number} Camera height in meters, or Infinity if unavailable. - */ -function getCameraAltitude() { - const carto = _viewer.camera.positionCartographic; - return carto ? carto.height : Infinity; -} - -/** - * Compute the current camera view rectangle in degrees. - * @returns {{south:number, west:number, north:number, east:number}|null} - * Bounding box in degrees, or null if the rectangle cannot be computed. - */ -function getViewBounds() { - const rect = _viewer.camera.computeViewRectangle(); - if (!rect) return null; - return { - south: Cesium.Math.toDegrees(rect.south), - west: Cesium.Math.toDegrees(rect.west), - north: Cesium.Math.toDegrees(rect.north), - east: Cesium.Math.toDegrees(rect.east), - }; -} - -/** - * Derive the road-fetch center from the camera's look-at ground point. - * - * C4 fix: at oblique pitch `computeViewRectangle()` spans toward the horizon, - * so its midpoint can sit tens of km from what the user is looking at. Instead - * we pick the ellipsoid under the canvas center (`camera.pickEllipsoid` — this - * works with the globe hidden under Google 3D tiles, where `scene.globe.pick` - * is NOT reliable), fall back to the camera nadir on a sky/horizon look, and - * pull horizon-gaze hits back to MAX_LOOKAT_PULL_KM from nadir. - * - * @returns {{lat:number, lon:number, source:string}|null} Fetch center in - * degrees, or null when the camera position is unavailable. - */ -function getFetchCenter() { - const carto = _viewer.camera.positionCartographic; - if (!carto) return null; - const nadirLat = Cesium.Math.toDegrees(carto.latitude); - const nadirLon = Cesium.Math.toDegrees(carto.longitude); - - let hitLat; - let hitLon; - const canvas = _viewer.scene.canvas; - const width = canvas.clientWidth || canvas.width; - const height = canvas.clientHeight || canvas.height; - if (width > 0 && height > 0) { - const hit = _viewer.camera.pickEllipsoid( - new Cesium.Cartesian2(width / 2, height / 2), - Cesium.Ellipsoid.WGS84 - ); - if (hit) { - const hitCarto = Cesium.Cartographic.fromCartesian(hit); - hitLat = Cesium.Math.toDegrees(hitCarto.latitude); - hitLon = Cesium.Math.toDegrees(hitCarto.longitude); - } - } - - return deriveFetchCenter({ - nadirLat, nadirLon, hitLat, hitLon, maxPullKm: MAX_LOOKAT_PULL_KM, - }); -} - -/** - * Compute the geographic center of a bounding box. - * @param {{south:number, west:number, north:number, east:number}} bounds - * @returns {{lat:number, lon:number}} Center point in degrees. - */ -function getBoundsCenter(bounds) { - return { - lat: (bounds.south + bounds.north) / 2, - lon: (bounds.west + bounds.east) / 2, - }; -} - -/** - * Approximate great-circle distance between two points in kilometres. - * - * Uses an equirectangular projection (cosine correction on longitude) - * with the 111 km/degree approximation. Sufficient for the small - * viewport-center shifts being compared. - * - * @param {{lat:number, lon:number}} a - First point. - * @param {{lat:number, lon:number}} b - Second point. - * @returns {number} Distance in kilometres. - */ -function distanceKm(a, b) { - const dLat = (a.lat - b.lat) * 111; - const avgLat = ((a.lat + b.lat) / 2) * (Math.PI / 180); - const dLon = (a.lon - b.lon) * 111 * Math.cos(avgLat); - return Math.sqrt((dLat * dLat) + (dLon * dLon)); -} - -/** - * Check whether two bounding boxes overlap by at least a given fraction of - * the first box's area. Used to decide if a camera move is large enough to - * warrant a new road fetch. - * - * @param {{south:number, west:number, north:number, east:number}} a - Reference bounds. - * @param {{south:number, west:number, north:number, east:number}} b - Bounds to compare. - * @param {number} threshold - Minimum overlap fraction (0-1) relative to `a`. - * @returns {boolean} True if overlap area / a area >= threshold. - */ -function boundsOverlap(a, b, threshold) { - // Compute the intersection rectangle - const overlapS = Math.max(a.south, b.south); - const overlapN = Math.min(a.north, b.north); - const overlapW = Math.max(a.west, b.west); - const overlapE = Math.min(a.east, b.east); - - // No intersection if the rectangle is degenerate - if (overlapN <= overlapS || overlapE <= overlapW) return false; - - const overlapArea = (overlapN - overlapS) * (overlapE - overlapW); - const aArea = (a.north - a.south) * (a.east - a.west); - - return aArea > 0 && (overlapArea / aArea) >= threshold; -} - -/** - * Clamp a bounding box to a maximum span to avoid overloading the Overpass API. - * - * The box is centered on the input's midpoint with each axis capped at 0.05 - * degrees (~5.5 km at the equator). This keeps query area and response size - * manageable while still covering the visible neighbourhood. - * - * NOTE (C4): the camera-driven path in `onCameraChanged` centers on the - * derived look-at point via `clampBoundsAroundCenter` instead; this - * midpoint-centered variant remains as the internal re-clamp guard in - * `loadRoadsForBounds` (idempotent on already-clamped bounds). - * - * @param {{south:number, west:number, north:number, east:number}} bounds - * @returns {{south:number, west:number, north:number, east:number}} Clamped bounds. - */ -function clampBounds(bounds) { - return clampBoundsAroundCenter(bounds, getBoundsCenter(bounds)); -} - -/** - * Camera-change handler — the main entry point for viewport-driven road loading. - * - * Gating logic: - * 1. If the camera is above ACTIVATION_ALTITUDE, clear all dots and bail. - * 2. Clamp the view bounds and compute the viewport center. - * 3. Skip the fetch if the new viewport significantly overlaps the last-fetched - * bounds AND the center has shifted less than MIN_CENTER_SHIFT_KM. This - * prevents redundant fetches during small pans. - * 4. Otherwise, debounce and schedule `loadRoadsForBounds`. - */ -function onCameraChanged() { - if (!_enabled) return; - - const alt = getCameraAltitude(); - - // Above activation altitude — remove all traffic and stop. - // Also null the last-fetch gate: otherwise zooming back down to the SAME - // viewport hits the overlap/center-shift skip in step 3 and the dots - // (cleared here) never reload (H5). Clearing the gate forces a fresh fetch. - if (alt > ACTIVATION_ALTITUDE) { - clearDots(); - _lastBounds = null; - _lastViewCenter = null; - return; - } - - const bounds = getViewBounds(); - if (!bounds) return; - // C4 fix: center the fetch box on the camera's look-at ground point (with - // nadir fallback + 12 km horizon-gaze pull-back), NOT the view rectangle's - // midpoint — at oblique pitch that midpoint drifts toward the horizon. - const fetchCenter = getFetchCenter(); - const clamped = fetchCenter - ? clampBoundsAroundCenter(bounds, fetchCenter) - : clampBounds(bounds); - const center = getBoundsCenter(clamped); - - // Skip re-fetch when viewport overlap is high and center shift is negligible - if ( - _lastBounds - && _lastViewCenter - && boundsOverlap(clamped, _lastBounds, OVERLAP_THRESHOLD) - && distanceKm(center, _lastViewCenter) < MIN_CENTER_SHIFT_KM - ) { - return; - } - - // Debounce: wait for camera to settle before triggering a fetch. In debug - // captures the final changed event that arms this exact timeout is its - // causal anchor; Cesium's later moveEnd notification is diagnostic only. - const interactionAnchor = TRAFFIC_TIMING_ENABLED ? markTrafficTimingCameraChange() : null; - clearTimeout(_fetchTimeout); - _fetchTimeout = setTimeout( - () => _loadRoadsForBounds(clamped, alt, interactionAnchor), - FETCH_DEBOUNCE, - ); -} - -/** Abort any in-flight Overpass fetch and clear the controller reference. */ -function cancelActiveFetch() { - if (_activeFetchAbort) { - _activeFetchAbort.abort(); - _activeFetchAbort = null; - } -} - -// ─── Live Flow (TomTom) ──────────────────────────────────── - -/** - * Map a failed flow fetch onto one short, honest user-facing reason. - * - * `fetchFlowForBounds` only rejects when EVERY covering tile failed, so a - * non-null result here always means "there is no live flow to show right - * now" — the dots fall back to simulated white. Mirrors the - * `deriveAisFeedError` honesty helper. - * - * @param {Error|{name?:string, message?:string}|null|undefined} error - Rejection from the flow fetch. - * @returns {string|null} Short reason, or null for an aborted (superseded) fetch. - */ -export function deriveTrafficFlowError(error) { - if (!error || error.name === 'AbortError') return null; - const message = String(error.message || error); - const status = Number(message.match(/HTTP (\d{3})/)?.[1]); - if (status === 503) return 'TomTom key unavailable'; - if (status === 429) return 'TomTom daily budget reached'; - if (status === 502 || status === 504) return 'TomTom upstream unreachable'; - if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`; - return 'TomTom flow unavailable'; -} - -/** - * Derive the layer's honest feed presentation from its live-flow state. - * - * The three states a user can be in, and what each must read as: - * - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a - * label that never claims live data; - * - live and healthy → LIVE with real coverage; - * - live but flow-down → an `error` string, so the chip degrades and says - * the colors on screen are simulated. Never a stale "LIVE · N% cov". - * - * @param {Object} [input] - * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. - * @param {boolean} [input.fetching] - A viewport load is in flight. - * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. - * @param {number} [input.coveragePct] - Matched-road coverage, 0–100. - * @param {boolean} [input.statusUnavailable] - The status probe itself failed. - * @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}} - */ -export function trafficFeedPresentation({ - liveMode = false, - fetching = false, - flowError = null, - coveragePct = 0, - statusUnavailable = false, -} = {}) { - // `mode` is the CONFIGURED source (live key present vs keyless), not this - // instant's health — health rides on `error`. The qa-traffic harness pins - // that meaning. - const mode = liveMode ? 'live' : 'sim'; - if (liveMode && flowError) { - // One string for both fields. The manager's meta line renders `error` and - // drops `loadingLabel` in its error branch, so the owner's SIMULATED copy - // has to BE the error text or the steady state reverts to a bare - // "TomTom daily budget reached" that never says what is on screen. - const degraded = `SIMULATED — ${flowError}`; - return { mode, error: degraded, loadingLabel: degraded }; - } - if (liveMode) { - return { - mode, - error: null, - loadingLabel: fetching - ? 'syncing LIVE traffic flow' - : `LIVE · TomTom flow · ${coveragePct}% cov`, - }; - } - // Keyless simulation — one terse line that names the mode and the remedy - // (owner's copy shape). The chip's own progress text carries "working"; - // this line must never imply a live feed. - return { - mode, - error: null, - loadingLabel: statusUnavailable - ? 'SIMULATED — traffic service unreachable' - : 'SIMULATED — add TomTom key for live', - }; -} - -/** - * Check `/api/tomtom/status` once per session and cache the result. - * Live mode iff the server holds a TomTom key; the TomTom attribution credit - * registers the first time live mode activates. Keyless or unreachable → - * simulation mode, exactly today's behavior. - * - * @returns {Promise} Resolves when `_liveMode` is settled. - */ -function ensureFlowStatus() { - if (!_flowStatusPromise) { - _flowStatusPromise = fetch('/api/tomtom/status') - .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))) - .then((status) => { - _liveMode = Boolean(status?.hasKey); - _flowStatusUnavailable = false; - if (_liveMode) { - console.log('[Data:Traffic] TomTom key present — live flow mode'); - registerDynamicCredit(_viewer, TOMTOM_CREDIT); - } - }) - .catch((e) => { - // Simulating because we could not ask, which is NOT the same as - // "server says no key" — getStats() distinguishes the two. - _liveMode = false; - _flowStatusUnavailable = true; - console.warn('[Data:Traffic] TomTom status unreachable — simulated traffic:', e?.message || e); - }); - } - return _flowStatusPromise; -} - -/** - * Live mode only: fetch TomTom flow for the clamped bounds, match it onto the - * parsed roads, and attach `road.flow` (`{level, closure}` or null). - * - * Reuses the load-generation guard: stale flow responses are discarded, and - * the shared AbortController lets `cancelActiveFetch()` (next load / disable) - * cancel an in-flight flow fetch. Any failure leaves roads unmatched — the - * dots then render in today's simulated white, never a phantom color — and is - * recorded in `_flowError` so `getStats()` degrades honestly instead of - * reporting a stale "LIVE · N% cov" over simulated dots. - * - * @param {Array} roads - Parsed road objects (mutated: `road.flow`). - * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. - * @param {number} generation - `_loadGeneration` at call time. - * @returns {Promise} - */ -async function applyFlowToRoads(roads, clamped, generation) { - // Claim the work synchronously, before the first await, so `stats.loading` - // covers this request from the same tick the caller started it — the - // loading batch must not be able to close underneath an in-flight fetch. - _flowPending += 1; - try { - if (!_flowStatusPromise) return; // status check not started — sim mode - await _flowStatusPromise; - if (!_liveMode || !_enabled) return; - if (generation !== _loadGeneration) return; - if (!Array.isArray(roads) || roads.length === 0) return; - try { - // Cached paths reach here without a live controller; the fetch paths - // reuse theirs so one cancel covers both roads and flow. - if (!_activeFetchAbort) _activeFetchAbort = new AbortController(); - const segments = await fetchFlowForBounds(clamped, { signal: _activeFetchAbort.signal }); - if (generation !== _loadGeneration) return; - const { matches, matchedCount, candidateCount } = matchFlowToRoads(roads, segments); - for (let i = 0; i < roads.length; i++) { - roads[i].flow = matches[i]; - } - _flowCoveragePct = candidateCount > 0 - ? Math.round((matchedCount / candidateCount) * 100) - : 0; - _flowError = null; - } catch (e) { - if (e?.name === 'AbortError') return; - // Same guard the success path gets: a superseded request rejecting late - // (or after disable() cleared the state) must not restore a stale - // outage over newer good data. - if (generation !== _loadGeneration || !_enabled) return; - // Every covering tile failed: there is no live flow on screen. Drop the - // now-false coverage number and surface the reason through getStats(). - _flowError = deriveTrafficFlowError(e); - _flowCoveragePct = 0; - console.warn('[Data:Traffic] Flow fetch failed (sim colors remain):', e?.message || e); - } - } finally { - _flowPending -= 1; - } -} - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ -const FLOW_RENDER_RACE_MS = 250; - -/** - * Race flow application against the paint deadline, render, and schedule an - * in-place recolor if flow lost the race. - * @param {Array} roads - Parsed road objects. - * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. - * @param {number} generation - `_loadGeneration` at call time. - * @param {number} altitude - Camera altitude in meters. - * @param {string} label - Render log label. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} True if this generation rendered. - */ -async function applyFlowThenRender(roads, clamped, generation, altitude, label, trace = null) { - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingRenderState(trace, label) - : null; - const flowRaceStart = state ? trafficTimingMark(state, 'flow-render-race-start', { - deadlineMs: FLOW_RENDER_RACE_MS, - }) : null; - const flowJob = applyFlowToRoads(roads, clamped, generation); - const outcome = await Promise.race([ - flowJob.then(() => 'flow'), - new Promise((resolve) => setTimeout(() => resolve('timeout'), FLOW_RENDER_RACE_MS)), - ]); - if (state) { - const flowRaceEnd = trafficTimingMark(state, 'flow-render-race-end', { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - trafficTimingMeasure('flow-render-race', state, flowRaceStart, flowRaceEnd, { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - } - if (generation !== _loadGeneration) return false; - renderRoadsForAltitude(roads, altitude, label, trace); - if (outcome === 'timeout') { - flowJob.then(() => { - if (generation !== _loadGeneration) return; - recolorDotsInPlace(label); - }).catch(() => { /* applyFlowToRoads settles its own failures */ }); - } - return true; -} - -/** - * Apply late-arriving flow data to already-rendered dots without a respawn: - * color, jam size, and speed update in place; closed roads' dots hide. - * Density bunching intentionally waits for the next natural re-render — - * color and speed are the live signal, dot count is a refinement. - * @param {string} label - Render log label (for the console trace). - */ -function recolorDotsInPlace(label) { - if (!_liveMode || !_dots.length) return; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - let closedDots = 0; - const now = Date.now(); - for (const dot of _dots) { - const flow = dot.road ? dot.road.flow : null; - if (flow?.closure) { - dot.point.show = false; - closedDots += 1; - continue; - } - const bucket = flow ? flowBucket(flow.level) : null; - dot.bucket = bucket; - dot.point.color = bucket ? _activeBucketColors[bucket] : Cesium.Color.WHITE.withAlpha(0.85); - if (bucket === 'jam') { - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + 1 + activeSizeDelta('jam'); - } else if (bucket && presetProfileActive()) { - // Preset profiles size-floor every bucket; the shipped normal path - // keeps its jam-only size touch (byte-identical behavior). - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + activeSizeDelta(bucket); - } - // Late flow can move a dot between buckets — keep the preset halo in - // step (no-op writes under the normal profile, whose dots have none). - if (presetProfileActive()) applyOutline(dot.point, bucket); - dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1); - // Late flow tags/untags stop-and-go creep + city-scale prominence the - // same way it rescales speed. Queue *positions* wait for the next - // natural re-render, like density bunching. - if (bucket === 'jam' && jamDensityOn()) { - if (!dot.creep) dot.creep = { moving: Math.random() < 0.4, until: now + Math.random() * 2000 }; - dot.point.scaleByDistance = new Cesium.NearFarScalar(100, 1.5, _fadeScaleFar, JAM_DOT_FAR_SCALE); - dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH; - } else { - dot.creep = null; - } - _bucketCounts[bucket || 'sim'] += 1; - } - _closedRoads = _roads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0); - rebuildHeatLines(visibleRoadsForAltitude(_roads, _lastRenderAltitude)); - console.log(`[Data:Traffic] Flow recolor (${label}): ${_dots.length} dots, closedDots=${closedDots}`); -} - -/** - * At high altitude only major roads render — shared by the render pass and - * the late-flow heat-line rebuild so lines never mark roads without dots. - * @param {Array} roads - Parsed road objects. - * @param {number} altitude - Camera altitude in meters. - * @returns {Array} The roads visible at this altitude. - */ -function visibleRoadsForAltitude(roads, altitude) { - return altitude > 5000 - ? roads.filter(r => r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary') - : roads; -} - -/** Remove both heat-line ground primitives from the scene. */ -function removeHeatLines() { - if (_heatJamPrim) { - _viewer?.scene.groundPrimitives.remove(_heatJamPrim); - _heatJamPrim = null; - } - if (_heatSlowPrim) { - _viewer?.scene.groundPrimitives.remove(_heatSlowPrim); - _heatSlowPrim = null; - } - _heatLineCount = 0; -} - -/** - * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): - * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing - * pulsing red for jam, faint flat amber for slow — with the dots animating - * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam - * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam - * first, longest first); overflow is logged. No-op in sim mode, when the - * heatline mode is off, or without ground-primitive support. - * - * @param {Array} roads - Road objects visible in the current render. - */ -function rebuildHeatLines(roads) { - removeHeatLines(); - if (!_viewer || !_liveMode || !heatlineOn()) return; - if (_heatSupported === null) { - _heatSupported = Cesium.GroundPolylinePrimitive.isSupported(_viewer.scene); - if (!_heatSupported) console.warn('[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled'); - } - if (!_heatSupported) return; - - const candidates = []; - for (const road of roads) { - const flow = road.flow; - if (!flow || flow.closure) continue; - const bucket = flowBucket(flow.level); - if (bucket === 'free') continue; - let len = 0; - for (const d of road.segmentDist) len += d; - candidates.push({ road, bucket, len }); - } - candidates.sort((a, b) => (a.bucket === b.bucket - ? b.len - a.len - : (a.bucket === 'jam' ? -1 : 1))); - const kept = candidates.slice(0, HEAT_LINE_CAP); - - const instancesFor = (bucket, width) => kept - .filter((c) => c.bucket === bucket) - .map((c) => new Cesium.GeometryInstance({ - geometry: new Cesium.GroundPolylineGeometry({ positions: c.road.waypoints, width }), - })); - - // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in - // luminance like the dots: jam = white glow, slow = faint gray. - const monoHeat = _presetDots === 'on' && trafficStyleProfile(_stylePreset) === 'mono'; - const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; - const slowLineColor = monoHeat - ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) - : HEAT_SLOW_COLOR; - - const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); - if (jamInstances.length) { - _heatJamPrim = _viewer.scene.groundPrimitives.add(new Cesium.GroundPolylinePrimitive({ - geometryInstances: jamInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), - glowPower: 0.25, - }), - }), - })); - } - const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); - if (slowInstances.length) { - _heatSlowPrim = _viewer.scene.groundPrimitives.add(new Cesium.GroundPolylinePrimitive({ - geometryInstances: slowInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('Color', { color: slowLineColor }), - }), - })); - } - - _heatLineCount = kept.length; - if (candidates.length > kept.length) { - console.log(`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`); - } -} - -/** - * Clear existing dots and re-spawn them for the given road set and altitude. - * - * When zoomed out (>5 km), only major road types are rendered to reduce clutter. - * Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`. - * - * @param {Array} roads - Parsed road objects to render. - * @param {number} altitude - Camera altitude in meters. - * @param {string} label - Logging label (e.g. "Cache full", "Loaded major"). - * @param {Object|null} [trace=null] - Development-only correlated load trace. - */ -function renderRoadsForAltitude(roads, altitude, label, trace = null) { - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingRenderState(trace, label) - : null; - const renderId = state ? ++trace.renderSequence : null; - clearDots(); - _roads = roads; - _lastRenderAltitude = altitude; - - // At high altitude, drop minor roads to reduce visual noise - const filteredRoads = visibleRoadsForAltitude(roads, altitude); - - // Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count - // them here so the closure signal is visible in stats even at zero dots. - _closedRoads = _liveMode - ? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0) - : 0; - - // Fade distances must track the camera-to-AREA distance, not assume a - // nadir view: oblique pitches put the loaded roads many km away even at - // low altitude. Probe three roads and stretch the curves accordingly. - let areaDist = altitude; - if (_viewer && filteredRoads.length) { - const probes = [ - filteredRoads[0], - filteredRoads[Math.floor(filteredRoads.length / 2)], - filteredRoads[filteredRoads.length - 1], - ]; - for (const probe of probes) { - const wp = probe?.waypoints?.[0]; - if (wp) areaDist = Math.max(areaDist, Cesium.Cartesian3.distance(_viewer.camera.positionWC, wp)); - } - } - _fadeScaleFar = Math.max(8000, areaDist * 1.5); - _fadeTransFar = Math.max(10000, areaDist * 1.8); - - const dotStart = state ? trafficTimingMark(state, 'dot-construction-start', { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - }) : null; - const roadBudgets = allocateRoadDotBudgets(filteredRoads, altitude, MAX_DOTS); - for (let i = 0; i < filteredRoads.length; i++) { - const road = filteredRoads[i]; - const budget = roadBudgets[i] || 0; - if (budget <= 0) continue; - spawnDotsForRoad(road, altitude, budget); - if (_dots.length >= MAX_DOTS) break; - } - - const renderMetrics = state ? { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - dotCount: _dots.length, - } : null; - if (state) { - const dotEnd = trafficTimingMark(state, 'dot-construction-end', renderMetrics); - trafficTimingMeasure('dot-construction', state, dotStart, dotEnd, renderMetrics); - } - - const heatStart = state ? trafficTimingMark(state, 'rebuild-heat-lines-start', renderMetrics) : null; - rebuildHeatLines(filteredRoads); - if (state) { - const heatEnd = trafficTimingMark(state, 'rebuild-heat-lines-end', { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - trafficTimingMeasure('rebuild-heat-lines', state, heatStart, heatEnd, { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - } - - _count = _dots.length; - _lastUpdate = Date.now(); - console.log(`[Data:Traffic] ${label}: ${_count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`); - if (state) { - const renderEnd = trafficTimingMark(state, 'render-return', renderMetrics); - scheduleTrafficTimingPostRender(state, renderEnd, renderId, renderMetrics); - } -} - -// ─── Development-only causal timing ─────────────────────── - -/** - * Return (and optionally update) the current trace's state for a render pass. - * This function is only reachable when `TRAFFIC_TIMING_ENABLED` is true. - * - * @param {Object|null} trace - Correlated load trace. - * @param {'major'|'full'} pass - Road-fetch/render pass. - * @param {string} [source] - Client cache or proxy/network source. - * @returns {Object|null} Mutable pass timing state. - */ -function trafficTimingPass(trace, pass, source) { - if (!trace) return null; - let state = trace.passes.get(pass); - if (!state) { - state = { - trace, - pass, - source: source || 'unknown', - proxyCache: null, - proxyUpstream: null, - }; - trace.passes.set(pass, state); - } else if (source) { - state.source = source; - } - return state; -} - -/** Build a structured-clone-safe detail object for User Timing entries. */ -function trafficTimingDetail(state, segment, extra = {}) { - return { - trafficTiming: true, - segment, - traceId: state?.trace?.id ?? null, - interactionId: state?.trace?.interactionId ?? null, - cameraChangeTimestamp: state?.trace?.cameraChangeTimestamp ?? null, - generation: state?.trace?.generation ?? null, - cacheKey: state?.trace?.cacheKey ?? null, - pass: state?.pass || 'load', - source: state?.source || 'unknown', - proxyCache: state?.proxyCache || null, - proxyUpstream: state?.proxyUpstream || null, - ...extra, - }; -} - -/** Add a uniquely named User Timing mark and return its name. */ -function trafficTimingMark(state, phase, extra = {}, startTime) { - const traceId = state?.trace?.id ?? 'interaction'; - const pass = state?.pass || 'load'; - const name = `traffic:${traceId}:${pass}:${phase}:${++_trafficTimingSequence}`; - const options = { detail: trafficTimingDetail(state, phase, extra) }; - if (Number.isFinite(startTime)) options.startTime = startTime; - performance.mark(name, options); - return name; -} - -/** Emit a named User Timing measure between two marks. */ -function trafficTimingMeasure(segment, state, start, end, extra = {}) { - performance.measure(`traffic:${segment}:${state?.pass || 'load'}`, { - start, - end, - detail: trafficTimingDetail(state, segment, extra), - }); -} - -/** Emit an aggregate-duration measure without pretending its work was contiguous. */ -function trafficTimingAggregate(segment, state, anchorTime, duration, extra = {}) { - const start = trafficTimingMark(state, `${segment}-aggregate-start`, extra, anchorTime); - const end = trafficTimingMark(state, `${segment}-aggregate-end`, extra, anchorTime + duration); - trafficTimingMeasure(segment, state, start, end, { aggregate: true, ...extra }); -} - -/** Clear only this module's stale User Timing entries before a new debug run. */ -function clearTrafficTimingEntries() { - const markNames = new Set( - performance.getEntriesByType('mark') - .filter((entry) => entry.name.startsWith('traffic:')) - .map((entry) => entry.name), - ); - const measureNames = new Set( - performance.getEntriesByType('measure') - .filter((entry) => entry.name.startsWith('traffic:')) - .map((entry) => entry.name), - ); - for (const name of markNames) performance.clearMarks(name); - for (const name of measureNames) performance.clearMeasures(name); -} - -/** Return the stable User Timing mark name for a scheduling interaction. */ -function trafficTimingCameraChangeMarkName(interactionId) { - return `traffic:interaction:${interactionId}:last-camera-change`; -} - -/** Mint and mark the exact camera-change interaction that armed a debounced load. */ -function markTrafficTimingCameraChange() { - const interactionId = ++_trafficTimingSequence; - const timestamp = performance.now(); - const anchor = { interactionId, timestamp }; - _trafficTimingCurrentAnchor = anchor; - performance.mark(trafficTimingCameraChangeMarkName(interactionId), { - startTime: timestamp, - detail: { - trafficTiming: true, - segment: 'last-camera-change', - interactionId, - timestamp, - }, - }); - return anchor; -} - -/** - * Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this - * about 500 ms after stillness, so fetch has typically already started and - * never waits for this boundary. - */ -function markTrafficTimingMoveEnd() { - const diagnosticId = ++_trafficTimingSequence; - const timestamp = performance.now(); - const name = `traffic:diagnostic:${diagnosticId}:camera-move-end`; - performance.mark(name, { - startTime: timestamp, - detail: { - trafficTiming: true, - segment: 'camera-move-end', - diagnosticOnly: true, - cameraEventWaitTimeMs: 500, - fetchWaitsForMoveEnd: false, - timestamp, - }, - }); -} - -/** - * Instrumented twin of `parseRoads`. Operation ordering and road output match - * the normal function; debug-only clocks accumulate synchronous height and - * waypoint-materialization time independently. - */ -function parseRoadsTimed(overpassData, trace) { - /* TRACE_ONLY_BEGIN */ - const _trafficTimingState = trafficTimingPass(trace, trace?.currentPass || 'full'); - const _trafficTimingParseStartTime = performance.now(); - const _trafficTimingParseStart = trafficTimingMark( - _trafficTimingState, 'road-parse-start', {}, _trafficTimingParseStartTime - ); - /* TRACE_ONLY_END */ - if (!overpassData || !overpassData.elements) { - /* TRACE_ONLY_BEGIN */ - const _trafficTimingParseEnd = trafficTimingMark( - _trafficTimingState, 'road-parse-end', { roadCount: 0 } - ); - trafficTimingMeasure( - 'road-parse-total', _trafficTimingState, - _trafficTimingParseStart, _trafficTimingParseEnd, { roadCount: 0 } - ); - trafficTimingAggregate('sample-height-total', _trafficTimingState, _trafficTimingParseStartTime, 0, { - sampleHeightCalls: 0, - sampleHeightMeanMs: 0, - distinctCells: 0, - roadCount: 0, - }); - trafficTimingAggregate( - 'waypoint-materialization', _trafficTimingState, _trafficTimingParseStartTime, 0, - { roadCount: 0 } - ); - /* TRACE_ONLY_END */ - return []; - } - - const roads = []; - /* TRACE_ONLY_BEGIN */ - const _trafficTimingSampledCells = new Set(); - let _trafficTimingSampleHeightCalls = 0; - let _trafficTimingSampleHeightMs = 0; - let _trafficTimingWaypointMaterializationMs = 0; - /* TRACE_ONLY_END */ - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; - - const rawCoords = el.geometry.map(g => [g.lon, g.lat]); - const simplifyStep = rawCoords.length > MAX_WAYPOINTS_PER_ROAD - ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) - : 1; - const coords = []; - for (let i = 0; i < rawCoords.length; i += simplifyStep) { - coords.push(rawCoords[i]); - } - - const last = rawCoords[rawCoords.length - 1]; - const tail = coords[coords.length - 1]; - if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { - coords.push(last); - } - if (coords.length < 2) continue; - - const type = el.tags?.highway || 'unclassified'; - const onewayTag = el.tags?.oneway; - const oneway = (onewayTag === 'yes' || onewayTag === '1' || onewayTag === 'true' || el.tags?.junction === 'roundabout') - ? 1 - : (onewayTag === '-1' ? -1 : 0); - - let baseHeight = 0; - const firstCoord = coords[0]; - if (_viewer?.scene?.sampleHeightSupported && firstCoord) { - /* TRACE_ONLY_BEGIN */ - _trafficTimingSampleHeightCalls += 1; - _trafficTimingSampledCells.add(`${firstCoord[1].toFixed(3)},${firstCoord[0].toFixed(3)}`); - /* TRACE_ONLY_END */ - const carto = Cesium.Cartographic.fromDegrees(firstCoord[0], firstCoord[1]); - /* TRACE_ONLY_BEGIN */ - const _trafficTimingSampleStart = performance.now(); - /* TRACE_ONLY_END */ - const sampled = _viewer.scene.sampleHeight(carto); - /* TRACE_ONLY_BEGIN */ - _trafficTimingSampleHeightMs += performance.now() - _trafficTimingSampleStart; - /* TRACE_ONLY_END */ - if (Number.isFinite(sampled)) baseHeight = sampled; - } - - /* TRACE_ONLY_BEGIN */ - const _trafficTimingMaterializeStart = performance.now(); - /* TRACE_ONLY_END */ - const waypoints = coords.map(([lng, lat]) => { - const h = baseHeight + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); - }); - const segmentDist = []; - for (let i = 0; i < waypoints.length - 1; i++) { - segmentDist.push(Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1])); - } - /* TRACE_ONLY_BEGIN */ - _trafficTimingWaypointMaterializationMs += performance.now() - _trafficTimingMaterializeStart; - /* TRACE_ONLY_END */ - - roads.push({ coords, type, oneway, waypoints, segmentDist }); - } - - /* TRACE_ONLY_BEGIN */ - const _trafficTimingMetrics = { - roadCount: roads.length, - sampleHeightCalls: _trafficTimingSampleHeightCalls, - sampleHeightMeanMs: _trafficTimingSampleHeightCalls - ? _trafficTimingSampleHeightMs / _trafficTimingSampleHeightCalls - : 0, - distinctCells: _trafficTimingSampledCells.size, - }; - trafficTimingAggregate( - 'sample-height-total', _trafficTimingState, _trafficTimingParseStartTime, - _trafficTimingSampleHeightMs, _trafficTimingMetrics - ); - trafficTimingAggregate( - 'waypoint-materialization', _trafficTimingState, _trafficTimingParseStartTime, - _trafficTimingWaypointMaterializationMs, _trafficTimingMetrics - ); - const _trafficTimingParseEnd = trafficTimingMark( - _trafficTimingState, 'road-parse-end', _trafficTimingMetrics - ); - trafficTimingMeasure( - 'road-parse-total', _trafficTimingState, - _trafficTimingParseStart, _trafficTimingParseEnd, _trafficTimingMetrics - ); - /* TRACE_ONLY_END */ - return roads; -} - -/** Resolve a render label into its correlated pass and data source. */ -function trafficTimingRenderState(trace, label) { - const pass = label.toLowerCase().includes('major') ? 'major' : 'full'; - const source = label.startsWith('Cache') ? 'client-cache' : 'proxy'; - return trafficTimingPass(trace, pass, source); -} - -/** Record the first Cesium postRender following a completed dot render. */ -function scheduleTrafficTimingPostRender(state, renderEnd, renderId, renderMetrics) { - if (!_viewer?.scene) return; - let remove = null; - remove = _viewer.scene.postRender.addEventListener(() => { - remove?.(); - _trafficTimingPostRenderRemovers?.delete(remove); - const visibleTime = performance.now(); - const postRender = trafficTimingMark(state, 'next-post-render', { - renderId, - ...renderMetrics, - }, visibleTime); - const firstVisible = trafficTimingMark(state, 'first-visible-pixel', { - renderId, - visibleBoundary: 'next-postRender', - ...renderMetrics, - }, visibleTime); - trafficTimingMeasure('render-to-post-render', state, renderEnd, postRender, { - renderId, - visibleBoundary: 'next-postRender', - ...renderMetrics, - }); - trafficTimingMeasure( - 'last-camera-change-to-first-visible', state, state.trace.cameraChangeMark, firstVisible, - { renderId, visibleBoundary: 'next-postRender', ...renderMetrics } - ); - }); - _trafficTimingPostRenderRemovers?.add(remove); -} - -/** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */ -async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) { - const generation = _loadGeneration + 1; - if (!expectedAnchor || expectedAnchor !== _trafficTimingCurrentAnchor) { - _trafficTimingDroppedTraces += 1; - await loadRoadsForBounds(bounds, altitude); - return; - } - _trafficTimingCurrentAnchor = null; - const clamped = clampBounds(bounds); - const trace = { - id: ++_trafficTimingSequence, - interactionId: expectedAnchor.interactionId, - cameraChangeTimestamp: expectedAnchor.timestamp, - cameraChangeMark: trafficTimingCameraChangeMarkName(expectedAnchor.interactionId), - generation, - cacheKey: `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`, - currentPass: null, - renderSequence: 0, - passes: new Map(), - }; - _trafficTimingTracesCreated += 1; - await loadRoadsForBounds(bounds, altitude, trace); -} - -// Disabled-path contract: these references resolve directly to the original -// functions. Instrumentation adds no load-path callbacks or per-item checks. -const _parseRoads = TRAFFIC_TIMING_ENABLED - ? (data, trace) => (trace ? parseRoadsTimed(data, trace) : parseRoads(data)) - : parseRoads; -const _loadRoadsForBounds = TRAFFIC_TIMING_ENABLED - ? loadRoadsForBoundsTimed - : loadRoadsForBounds; - -/** - * Load road data for the given viewport bounds and render traffic dots. - * - * Implements a two-pass fetch strategy with tile caching: - * - * 1. Check the tile cache (keyed by clamped bounding-box coordinates). - * - If a full road set is cached, render immediately and return. - * - If only major roads are cached, render those first. - * 2. Fetch major roads from Overpass (fast, small payload). Render. - * 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full - * road graph (includes tertiary/residential). Render again to upgrade. - * - * Each fetch is guarded by a monotonic `_loadGeneration` counter so that - * stale responses from superseded requests are silently discarded. - * - * @param {{south:number, west:number, north:number, east:number}} bounds - * Viewport bounds (will be clamped internally). - * @param {number} altitude - Camera altitude in meters. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} - */ -async function loadRoadsForBounds(bounds, altitude, trace = null) { - // Increment generation to invalidate any in-flight responses from prior calls - const generation = ++_loadGeneration; - cancelActiveFetch(); - const clamped = clampBounds(bounds); - - // Cache key: fixed-precision bounding-box string for deterministic lookups - const cacheKey = `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`; - - // Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road - // fetch — sequential fetches doubled first-paint latency (field-test - // round 1). Failures are irrelevant; applyFlowToRoads settles the truth. - ensureFlowStatus().then(() => { - if (_liveMode && _enabled && generation === _loadGeneration) { - fetchFlowForBounds(clamped, {}).catch(() => { /* warm-up only */ }); - } - }); - - _fetching = true; - // Only COMMIT these on success. Committing up-front means a failed Overpass - // fetch (rate-limited / feed down) still trips the overlap gate in - // onCameraChanged, so a stationary user never retries (H3/H5). Stage the - // prospective values and roll back if nothing rendered. - const prevBounds = _lastBounds; - const prevViewCenter = _lastViewCenter; - _lastBounds = clamped; - _lastViewCenter = getBoundsCenter(clamped); - let renderedSomething = false; - - try { - let cache = _tileCache.get(cacheKey); - if (!cache) { - // LRU eviction: drop the oldest entry when cache exceeds the cap - if (_tileCache.size >= TILE_CACHE_MAX_ENTRIES) { - const oldest = _tileCache.keys().next().value; - _tileCache.delete(oldest); - } - cache = { major: null, full: null }; - _tileCache.set(cacheKey, cache); - } - - // Fast path: full road set already cached — render and return. - // Flow is (re)applied even on cache hits: roads cache for the session, - // but congestion data has a 120s shelf life. The race renders within - // FLOW_RENDER_RACE_MS either way; late flow recolors in place. - if (cache.full) { - renderedSomething = await applyFlowThenRender( - cache.full, clamped, generation, altitude, 'Cache full', trace - ); - return; - } - - // Intermediate path: render cached major roads while fetching the rest - if (cache.major) { - if (!await applyFlowThenRender( - cache.major, clamped, generation, altitude, 'Cache major', trace - )) return; - renderedSomething = true; - } else { - // Fetch major roads first (smaller payload, faster response) - _activeFetchAbort = new AbortController(); - console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`); - const majorData = await fetchRoads( - clamped.south, clamped.west, clamped.north, clamped.east, - { majorOnly: true, timeoutSec: 12, signal: _activeFetchAbort.signal }, - trace, - ); - // Discard stale response if a newer load was triggered while waiting - if (generation !== _loadGeneration) return; - cache.major = _parseRoads(majorData, trace); - if (!await applyFlowThenRender( - cache.major, clamped, generation, altitude, 'Loaded major', trace - )) return; - renderedSomething = true; - } - - // At higher altitude, major roads provide sufficient motion density - if (altitude > FAST_FETCH_ALTITUDE) return; - - // Detailed pass: fetch the full road graph (tertiary, residential, etc.) - _activeFetchAbort = new AbortController(); - console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`); - const fullData = await fetchRoads( - clamped.south, clamped.west, clamped.north, clamped.east, - { majorOnly: false, timeoutSec: 20, signal: _activeFetchAbort.signal }, - trace, - ); - if (generation !== _loadGeneration) return; - - cache.full = _parseRoads(fullData, trace); - if (!await applyFlowThenRender( - cache.full, clamped, generation, altitude, 'Loaded full', trace - )) return; - renderedSomething = true; - - } catch (e) { - if (e?.name === 'AbortError') return; - console.warn('[Data:Traffic] Fetch error:', e); - } finally { - if (generation === _loadGeneration) { - _fetching = false; - // Roll back the bounds commit if this load rendered nothing (e.g. the - // Overpass fetch failed). Leaving them committed would make the overlap - // gate skip the retry while the user sits still. Guarded on generation so - // a superseding load's commit is not clobbered. - if (!renderedSomething) { - _lastBounds = prevBounds; - _lastViewCenter = prevViewCenter; - } - } - _activeFetchAbort = null; - } -} - -// ─── Cleanup ─────────────────────────────────────────────── - -/** Remove all point primitives and reset dot/road arrays and counters. */ -function clearDots() { - if (_pointCollection) _pointCollection.removeAll(); - removeHeatLines(); - _dots = []; - _roads = []; - _count = 0; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - _closedRoads = 0; -} - -// ─── Data Layer Interface ────────────────────────────────── - -/** - * Traffic data layer — conforms to the God's Eye View data-layer interface. - * - * Lifecycle: init -> enable -> (animate loop + camera-driven loads) -> disable -> destroy. - * The layer is self-updating: no external tick is needed (`updateInterval: 0`). - * - * @type {Object} - */ -const trafficLayer = { - id: 'traffic', - name: 'Street Traffic', - icon: '🚗', - source: 'OpenStreetMap', - /** @type {number} Zero — layer is self-managed via camera listener + preRender */ - updateInterval: 0, - - /** - * One-time initialisation. Creates the PointPrimitiveCollection and adds it - * to the scene (hidden). The collection is never removed and re-added — only - * toggled via `.show` to avoid destroy-on-remove errors. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - init(viewer) { - _viewer = viewer; - _pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - // Add permanently — toggle with .show to avoid destroy-on-remove errors - viewer.scene.primitives.add(_pointCollection); - _pointCollection.show = false; - _dots = []; - _roads = []; - _count = 0; - _lastUpdate = null; - _lastBounds = null; - _fetching = false; - _loadGeneration = 0; - _densityScale = 1.0; - _speedScale = 1.0; - _lastViewCenter = null; - _flowCoveragePct = 0; - _flowError = null; - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingCurrentAnchor = null; - _trafficTimingSequence = 0; - _trafficTimingTracesCreated = 0; - _trafficTimingDroppedTraces = 0; - } - - // Preset-aware dot styling: adopt the active post-FX style (persisted - // style restore may run before layer registration, so read the dataset) - // and follow StyleManager's gev:style-change event thereafter. Guarded - // for non-browser contexts; bound once per page (init survives layer - // destroy/re-register). - if (typeof window !== 'undefined') { - _stylePreset = document?.documentElement?.dataset?.gevStyle || 'normal'; - if (!_styleListenerBound) { - window.addEventListener('gev:style-change', (e) => setStylePreset(e?.detail?.style)); - _styleListenerBound = true; - } - } - refreshBucketColors(); - console.log('[Data:Traffic] Initialized'); - }, - - /** - * Enable the traffic layer. Shows the point collection, subscribes to the - * preRender animation loop and camera-change events, and kicks off an - * initial viewport check. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - enable(viewer) { - _enabled = true; - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) - _lastAnimTime = 0; - _pointCollection.show = true; - - // One status check per session decides sim vs live-TomTom mode. - ensureFlowStatus(); - - _preRenderRemover = viewer.scene.preRender.addEventListener(animate); - if (TRAFFIC_TIMING_ENABLED) { - clearTrafficTimingEntries(); - _trafficTimingCurrentAnchor = null; - _trafficTimingPostRenderRemovers = new Set(); - _trafficTimingMoveEndRemover = viewer.camera.moveEnd.addEventListener(markTrafficTimingMoveEnd); - } - - // Subscribe to camera changes with a 5% movement threshold. Save the prior - // value so disable() can restore it — percentageChanged is a shared global - // on the camera and leaving it mutated affects every other camera.changed - // listener in the app. - viewer.camera.changed.addEventListener(onCameraChanged); - _prevPercentageChanged = viewer.camera.percentageChanged; - viewer.camera.percentageChanged = 0.05; - - // Kick off initial viewport check - onCameraChanged(); - - // Boot-order guard (field-test round 1: layer sat empty until the user - // moved): when the persisted layer state re-enables traffic during the - // intro flyTo, the initial check bails at high altitude — and a camera - // that then parks never re-fires camera.changed. Retry cheaply until the - // first load commits, then self-clear. Also acts as a safety kick if a - // failed first fetch left the viewport unloaded while parked. - clearInterval(_enableKickTimer); - _enableKickTimer = setInterval(() => { - if (!_enabled || _lastUpdate) { - clearInterval(_enableKickTimer); - _enableKickTimer = null; - return; - } - if (!_fetching) onCameraChanged(); - }, 1500); - }, - - /** - * Disable the traffic layer. Cancels pending fetches, clears all dots, - * unsubscribes from events, and hides the point collection. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - disable(viewer) { - _enabled = false; - releaseContinuousRender('traffic'); - clearTimeout(_fetchTimeout); - clearInterval(_enableKickTimer); - _enableKickTimer = null; - cancelActiveFetch(); - _loadGeneration++; - clearDots(); - _lastViewCenter = null; - // A stale outage from the last session would misreport a fresh enable — - // the next load re-derives feed health from real evidence. - _flowError = null; - - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingMoveEndRemover?.(); - _trafficTimingMoveEndRemover = null; - for (const remove of _trafficTimingPostRenderRemovers || []) remove(); - _trafficTimingPostRenderRemovers = null; - _trafficTimingCurrentAnchor = null; - } - - viewer.camera.changed.removeEventListener(onCameraChanged); - // Restore the camera's global percentageChanged so other layers/listeners - // keep their expected sensitivity. - if (_prevPercentageChanged != null) { - viewer.camera.percentageChanged = _prevPercentageChanged; - _prevPercentageChanged = null; - } - if (_pointCollection) _pointCollection.show = false; - }, - - /** - * No-op — traffic updates are entirely camera-driven, not timer-driven. - * @returns {Promise} - */ - async update() { - // No-op — updates are camera-driven - }, - - /** - * Update user-adjustable parameters (density and speed scaling). - * - * @param {Object} [params] - * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). - * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). - */ - setParams(params = {}) { - if (typeof params.densityScale === 'number') { - _densityScale = Math.max(0.2, Math.min(2.5, params.densityScale)); - } - if (typeof params.speedScale === 'number') { - _speedScale = Math.max(0.3, Math.min(3.0, params.speedScale)); - } - // Live-mode treatment of roads TomTom has no flow data for: - // 'sim' (default) keeps them as today's white ambient dots — colored = - // real data, white = simulation; 'hide' spawns nothing on them (strict - // data-integrity view). Owner-explorable; re-render applies on the next - // camera-driven load. - if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') { - _uncoveredMode = params.uncoveredRoads; - } - // Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior; - // live mode only, applies on the next camera-driven load like the - // uncoveredRoads param above. - if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) { - _jamViz = params.jamViz; - } - // Preset-aware dot styling kill switch (owner A/B): 'off' forces the - // shipped palette under every post-FX preset. Applies immediately via - // in-place restyle — no refetch — so A/B legs share identical dots. - if (params.presetDots === 'on' || params.presetDots === 'off') { - if (params.presetDots !== _presetDots) { - _presetDots = params.presetDots; - restyleDotsInPlace(); - } - } - }, - - /** - * Return the current user-adjustable parameters. - * @returns {{densityScale:number, speedScale:number}} - */ - getParams() { - return { - densityScale: _densityScale, - speedScale: _speedScale, - uncoveredRoads: _uncoveredMode, - jamViz: _jamViz, - presetDots: _presetDots, - }; - }, - - /** - * Return a sub-sampled list of active dot positions for detection overlays - * (e.g. CCTV bounding-box rendering). - * - * Uses a deterministic stride-based sampling so different seeds yield - * non-overlapping subsets without sorting or shuffling. - * - * @param {Object} [options] - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). - * @param {number} [options.seed] - Integer seed to offset the sampling start. - * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} - */ - getDetectableObjects(options = {}) { - if (!_enabled || _dots.length === 0) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _dots.length; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Stride-based sampling: step through dots evenly to get ~maxCount samples - const stride = Math.max(1, Math.ceil(_dots.length / maxCount)); - const start = seed % stride; - - const result = []; - for (let i = start; i < _dots.length; i += stride) { - const pos = _dots[i].point.position; - if (!pos) continue; - const entry = { - position: pos, - id: `VEH-${String(i).padStart(4, '0')}`, - type: 'VEH', - }; - // Live mode: the detection bracket carries the congestion signal — - // its canvas sits ABOVE the post-FX chain, so tier colors survive - // every preset (owner round 2: "bounding boxes do the heavy - // lifting"). Keyless mode sets no tier: contacts keep the stock - // 'vehicle' bracket and the keyless experience stays untouched. - if (_liveMode) { - const tier = trafficBucketTier(_dots[i].bucket || 'sim'); - if (tier) entry.tier = tier; - } - result.push(entry); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * Permanently tear down the layer. Disables it, removes the point collection - * from the scene, and clears the tile cache. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - destroy(viewer) { - this.disable(viewer); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - removeHeatLines(); - _tileCache.clear(); - resetFlowTileCache(); - _count = 0; - _lastUpdate = null; - }, - - /** - * Return current layer statistics for UI status chips. - * `mode` is the CONFIGURED source — 'live' (a TomTom key is present) or - * 'sim' (keyless simulation, which the manager renders as a FALLBACK chip); - * `error` carries this instant's health, so a live-configured layer whose - * flow feed went down reads DEGRADED with the reason instead of a stale - * LIVE coverage number. `flowCoveragePct` is matched roads / roads with any - * flow candidates (0–100 int); `tilesFetched` counts flow-tile requests - * issued to the proxy this session (decode-cache hits excluded). - * @returns {{count:number, lastUpdate:number|null, loading:boolean, - * mode:'live'|'sim', error:string|null, flowCoveragePct:number, - * tilesFetched:number}} - */ - getStats() { - // Outstanding flow work counts as loading: the paint race can leave a - // TomTom request in flight after the roads have settled, and the shared - // loading batch has to stay open long enough to announce its failure. - const loading = _fetching || _flowPending > 0; - const feed = trafficFeedPresentation({ - liveMode: _liveMode, - fetching: loading, - flowError: _flowError, - coveragePct: _flowCoveragePct, - statusUnavailable: _flowStatusUnavailable, - }); - return { - count: _count, - lastUpdate: _lastUpdate, - loading, - mode: feed.mode, - error: feed.error, - flowCoveragePct: _flowCoveragePct, - tilesFetched: getFlowSessionStats().tilesFetched, - ...(TRAFFIC_TIMING_ENABLED ? { trafficTiming: getTrafficTimingDiagnostics() } : {}), - // Per-bucket rendered-dot counts (sim = white ambient). Drives the - // qa-traffic color assertions and the sync-chip mode label below. - flowBuckets: { ..._bucketCounts }, - closedRoads: _closedRoads, - // Jam-viz prototype diagnostics (additive — harness contract untouched). - heatLines: _heatLineCount, - jamViz: _jamViz, - // Preset-styling diagnostics (additive): active style + profile. - stylePreset: _stylePreset, - styleProfile: _presetDots === 'on' ? trafficStyleProfile(_stylePreset) : 'normal', - // Sync-chip text: shown while busy, and flashed on its own for 1.5 s - // after each completed load (ui.js _updateTrafficSyncChip semantics). - // The settled flash carries NO progress number beside it — this label's - // coverage figure is the chip's only percentage — so a label that ends - // in one had better be the honest one. This is also where LIVE vs - // SIMULATED mode is surfaced, and it must never imply a live feed the - // layer does not have. - loadingLabel: feed.loadingLabel, - }; - }, -}; - -export default trafficLayer; +import { createTrafficSource } from '../layers/traffic/source.js'; +import { createTrafficLayer } from '../layers/traffic/index.js'; +import * as credits from './dataCredits.js'; +import * as render from '../renderGovernor.js'; + +const layer = createTrafficLayer({ + source: createTrafficSource(), + services: { credits, render }, +}); +export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; +export const deriveTrafficFlowError = layer.deriveTrafficFlowError; +export const trafficFeedPresentation = layer.trafficFeedPresentation; +export default layer; diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index eb354cda4..1b87deb5b 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { getTrafficTimingDiagnostics } from './traffic.js'; -const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8'); +const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, ''); function functionBody(name) { const declaration = `function ${name}(`; @@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- plugins: [{ name: 'traffic-timing-test-hooks', transform(code, id) { - if (!id.endsWith('/src/data/traffic.js')) return null; - return `${code}\nexport const __trafficTimingTestHooks = {\n` + - ` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`; + if (!id.endsWith('/src/layers/traffic/index.js')) return null; + return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },'); }, }], }); @@ -240,7 +239,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- }); trafficLayer.enable(viewer); assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1); - assert.equal(moveEnd.listenerCount(), 1, 'marksInstalled must represent a live listener'); + assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check'); assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0); assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0); @@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- // and moveEnd arrives only after cameraEventWaitTime (~500 ms). setLongitude(-97.72); changed.raise(); - const anchorA = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor(); const anchorMarkA = performance.getEntriesByType('mark').find((entry) => ( entry.detail?.segment === 'last-camera-change' && entry.detail?.interactionId === anchorA.interactionId @@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- traceObjectsCreated: 1, uncorrelatedTracesDropped: 0, }); - assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null); + assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null); const fetchFromA = performance.getEntriesByType('measure').find((entry) => ( entry.detail?.segment === 'last-camera-change-to-fetch-start' && entry.detail?.interactionId === anchorA.interactionId @@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- // and must count one drop without consuming C's current anchor. setLongitude(-97.70); changed.raise(); - const anchorB = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor(); const [timerBId, timerB] = pendingDebounce(); setLongitude(-97.68); changed.raise(); - const anchorC = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor(); assert.notEqual(anchorC.interactionId, anchorB.interactionId); assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B'); await timerB.callback(); assert.equal( - traffic.__trafficTimingTestHooks.currentAnchor().interactionId, + traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId, anchorC.interactionId, 'a stale callback must not consume the newer C anchor', ); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index c45446183..002959ba9 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.deepEqual( [...EXCLUSIVE_SURFACE_CLASSES].sort(), @@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', ( test('an overlay with NO class to watch still disarms the launcher', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // The repro, pinned as the stacking it actually is: the attribution lightbox // is full-screen ABOVE the card and announces itself with nothing. The card @@ -263,14 +264,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => { }); test('one ESC does one thing — the radio disclosure stops the launcher outright', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher // dismissed itself off the same key. The earlier listener is the only one that // can stop the later one — and only the immediate form does it. - const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock")); + const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||")); const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); @@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', () test('the scroll fade only appears when the list really overflows', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // A fade on a card where all five tiles fit promises a sixth mission that does // not exist, which is worse than no affordance at all. assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/); @@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', () test('markup, startup ordering and accessibility remain pinned', () => { const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4); @@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt }); test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8'); // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON // now, so it was opening to offer controls for things already happening — // while competing with the mission card for the one first impression there is. diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index 3e6cfa541..5244cffdc 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,8 +1,9 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); function ruleBody(selector) { const start = css.indexOf(selector); diff --git a/src/layers/aircraft/classification.js b/src/layers/aircraft/classification.js new file mode 100644 index 000000000..09f136912 --- /dev/null +++ b/src/layers/aircraft/classification.js @@ -0,0 +1,167 @@ +/** Instance-owned military identity and active-layer classification. + * Both aircraft layers share the same application-supplied registry. */ +export function createMilitaryRegistry({ source, now = Date.now } = {}) { + const MIL_POLL_INTERVAL_MS = 60000; + + /** @type {Set} Lowercase ICAO24 hexes known to be military. */ + const _milIcaos = new Set(); + /** @type {boolean} True while the dedicated military layer is enabled. */ + let _militaryLayerActive = false; + /** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ + const _activeChangeListeners = new Set(); + /** @type {number} Epoch ms of the last registry refresh (any source). */ + let _lastRefreshMs = 0; + /** @type {boolean} A self-poll fetch is in flight. */ + let _polling = false; + let currentSource = null; + let lifetime = null; + let releaseSource = null; + + /** Replace the classification source and cancel its outstanding work. */ + function configureSource(nextSource, { signal } = {}) { + if (typeof nextSource?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + releaseSource?.(); + const controller = new AbortController(); + lifetime = controller; + currentSource = nextSource; + _milIcaos.clear(); + _lastRefreshMs = 0; + _polling = false; + const release = () => { + controller.abort(); + signal?.removeEventListener('abort', release); + if (lifetime !== controller) return; + currentSource = null; + lifetime = null; + releaseSource = null; + _milIcaos.clear(); + _lastRefreshMs = 0; + _polling = false; + }; + releaseSource = release; + if (signal?.aborted) release(); + else signal?.addEventListener('abort', release, { once: true }); + return release; + } + + /** + * True when the dedicated military layer currently renders these aircraft + * (the flights layer should suppress duplicates rather than restyle them). + * @returns {boolean} + */ + function isMilitaryLayerActive() { + return _militaryLayerActive; + } + + /** + * Marks the dedicated military layer enabled/disabled. On a TRANSITION + * (value actually changed) the registered change listeners fire so the + * flights layer can reconcile its duplicates immediately instead of waiting + * out its 30 s poll (pre-ship audit M2). + * @param {boolean} active - Whether the military layer renders. + * @returns {void} + */ + function setMilitaryLayerActive(active) { + const next = !!active; + if (next === _militaryLayerActive) return; + _militaryLayerActive = next; + for (const listener of _activeChangeListeners) { + try { + listener(next); + } catch { + // a broken listener must never break the layer toggle + } + } + } + + /** + * Subscribes to military-layer active-state transitions (fired only when the + * value changes, AFTER the new state is committed). + * @param {(active: boolean) => void} listener - Change callback. + * @returns {() => void} Unsubscribe function. + */ + function onMilitaryLayerActiveChange(listener) { + if (typeof listener !== 'function') return () => {}; + _activeChangeListeners.add(listener); + return () => _activeChangeListeners.delete(listener); + } + + /** + * Replaces/extends the known-military set from a fresh poll. + * Adds only — transient dropouts from one poll must not declassify an + * aircraft mid-session (the set stays small: a few hundred hexes). + * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. + * @returns {void} + */ + function registerMilitaryIcaos(icaos) { + for (const icao of icaos || []) { + const hex = String(icao || '') + .trim() + .toLowerCase(); + if (hex) _milIcaos.add(hex); + } + _lastRefreshMs = now(); + } + + /** + * Whether an aircraft is known military. + * @param {string} icao24 - ICAO24 hex (any case). + * @returns {boolean} + */ + function isMilitaryIcao(icao24) { + return _milIcaos.has(String(icao24 || '').toLowerCase()); + } + + /** Refresh known identities while the dedicated layer is disabled. + * A failed or cancelled request retains the current set and never swaps sources. */ + async function refreshMilitaryRegistryIfStale() { + if ( + !currentSource || + !lifetime || + lifetime.signal.aborted || + _militaryLayerActive + ) + return; + if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return; + const owner = lifetime; + const requestSource = currentSource; + const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]); + _polling = true; + try { + const ids = + typeof requestSource.getIdentities === 'function' + ? await requestSource.getIdentities({}, { signal }) + : (await requestSource.getSnapshot({}, { signal })).records.map( + (record) => record.id, + ); + signal.throwIfAborted(); + if (lifetime !== owner) return; + if (!Array.isArray(ids)) + throw new TypeError('Expected aircraft identities'); + registerMilitaryIcaos(ids); + } catch { + // Keep the existing classification through source outages. + } finally { + if (lifetime === owner) _polling = false; + } + } + function dispose() { + releaseSource?.(); + _milIcaos.clear(); + _lastRefreshMs = 0; + _activeChangeListeners.clear(); + _militaryLayerActive = false; + } + if (source) configureSource(source); + return { + configureSource, + dispose, + isMilitaryLayerActive, + setMilitaryLayerActive, + onMilitaryLayerActiveChange, + registerMilitaryIcaos, + isMilitaryIcao, + refreshMilitaryRegistryIfStale, + }; +} diff --git a/src/layers/aircraft/classification.test.mjs b/src/layers/aircraft/classification.test.mjs new file mode 100644 index 000000000..c0416b466 --- /dev/null +++ b/src/layers/aircraft/classification.test.mjs @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMilitaryRegistry } from './classification.js'; + +test('classification instances have independent identities, listeners and active states', () => { + let requests = 0; + const source = { + getSnapshot() { + requests++; + }, + }; + const first = createMilitaryRegistry({ source }); + const second = createMilitaryRegistry({ source }); + let firstEvents = 0; + let secondEvents = 0; + first.onMilitaryLayerActiveChange(() => firstEvents++); + second.onMilitaryLayerActiveChange(() => secondEvents++); + first.registerMilitaryIcaos([' ABC123 ']); + first.setMilitaryLayerActive(true); + assert.equal(first.isMilitaryIcao('abc123'), true); + assert.equal(second.isMilitaryIcao('abc123'), false); + assert.equal(firstEvents, 1); + assert.equal(secondEvents, 0); + assert.equal(second.isMilitaryLayerActive(), false); + assert.equal(requests, 0); + first.dispose(); + second.dispose(); +}); + +test('classification uses normalized identities and respects its polling interval and active layer', async () => { + let time = 120000; + let calls = 0; + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + return { records: [{ id: 'abc123' }] }; + }, + }, + }); + await registry.refreshMilitaryRegistryIfStale(); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 1); + assert.equal(registry.isMilitaryIcao('abc123'), true); + time += 60000; + registry.setMilitaryLayerActive(true); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 1); + registry.setMilitaryLayerActive(false); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 2); + registry.dispose(); +}); + +test('source replacement rejects an old response and disposal cancels the current request', async () => { + let resolveOld; + let resolveNew; + let oldSignal; + let newSignal; + const registry = createMilitaryRegistry({ + source: { + getSnapshot(query, { signal }) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + }, + }, + }); + const old = registry.refreshMilitaryRegistryIfStale(); + const application = new AbortController(); + registry.configureSource( + { + getSnapshot(query, { signal }) { + newSignal = signal; + return new Promise((resolve) => { + resolveNew = resolve; + }); + }, + }, + { signal: application.signal }, + ); + const current = registry.refreshMilitaryRegistryIfStale(); + resolveOld({ records: [{ id: 'abc123' }] }); + await old; + application.abort(); + resolveNew({ records: [{ id: 'def456' }] }); + await current; + assert.equal(oldSignal.aborted, true); + assert.equal(newSignal.aborted, true); + assert.equal(registry.isMilitaryIcao('abc123'), false); + assert.equal(registry.isMilitaryIcao('def456'), false); + registry.dispose(); +}); + +test('a failed classification source preserves known identities and makes no fallback request', async () => { + let time = 120000; + let calls = 0; + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + throw new Error('fixture source unavailable'); + }, + }, + }); + registry.registerMilitaryIcaos(['abc123']); + time += 60000; + await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale()); + assert.equal(calls, 1); + assert.equal(registry.isMilitaryIcao('abc123'), true); + registry.dispose(); + assert.equal(registry.isMilitaryIcao('abc123'), false); +}); + +test('disposing a registry fed directly by its layer also clears classification', () => { + const registry = createMilitaryRegistry(); + registry.registerMilitaryIcaos(['abc123']); + registry.dispose(); + assert.equal(registry.isMilitaryIcao('abc123'), false); +}); + +test('classification prefers an explicit identity capability over positioned observations', async () => { + const registry = createMilitaryRegistry({ + source: { + getSnapshot() { + assert.fail( + 'position snapshots must not replace an available identity capability', + ); + }, + async getIdentities() { + return ['abc123']; + }, + }, + }); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + registry.dispose(); +}); diff --git a/src/layers/aircraft/index.js b/src/layers/aircraft/index.js new file mode 100644 index 000000000..b9b7315f7 --- /dev/null +++ b/src/layers/aircraft/index.js @@ -0,0 +1,17 @@ +/** Common aircraft calculations and explicitly constructed classification. */ +export { createMilitaryRegistry } from './classification.js'; +export * from '../../data/aircraftClass.js'; +export * from '../../data/aircraftIcons.js'; +export * from '../../data/aircraftMeta.js'; +export * from '../../data/aircraftNearbyPolicy.js'; +export * from '../../data/cockpitAirLod.js'; +export * from '../../data/cockpitContactDot.js'; +export * from '../../data/contactMatch.js'; +export * from '../../data/iconOrientation.js'; +export * from '../../data/layerState.js'; +export * from '../../data/modelVisualAnchor.js'; +export * from '../../data/motionModel.js'; +export * from '../../data/renderAltitude.js'; +export * from '../../data/routePlausible.js'; +export * from '../../data/trackedModelRegime.js'; +export * from '../../data/trackingClickGesture.js'; diff --git a/src/layers/awareness/controls.js b/src/layers/awareness/controls.js new file mode 100644 index 000000000..ca8fd7781 --- /dev/null +++ b/src/layers/awareness/controls.js @@ -0,0 +1,126 @@ +import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + const aisLiveVesselsLayer = services.vessels; + + const methods = { + id: 'military-awareness', + + name: 'Global Context', + + icon: '◎', + + source: 'Open-source proximity context', + + // Context is entered from its dedicated right rail, not as a raw layer. + showInTogglePanel: false, + + updateInterval: 0, + + statsRefreshInterval: 1000, + + attachDataManager(dataManager) { + layerState.dataManager = dataManager; + }, + + setParams(params = {}) { + if (typeof params.passive !== 'boolean') return; + const wasPassive = layerState.passive; + layerState.passive = params.passive; + if (layerState.enabled && wasPassive && !layerState.passive) + parts.dependencies.activateOperationalContext(); + }, + + /** @returns {{ passive: boolean }} Current runtime parameters. */ + getParams() { + return { passive: layerState.passive }; + }, + + getStats() { + return { + count: layerState.results ? 1 : 0, + lastUpdate: layerState.results?.evaluatedAt || null, + stale: false, + error: null, + status: layerState.enabled ? 'ready' : 'idle', + }; + }, + + /** Return the latest read-only context result for compact HUD consumers. */ + getContextSnapshot() { + if (!layerState.enabled || !layerState.subject) return null; + if (!layerState.results) { + return parts.model.buildAwarenessContextSnapshot( + { + subject: { ...layerState.subject }, + evaluatedAt: null, + radiusM: AWARENESS_RADIUS_M, + cohorts: [], + }, + parts.model.navigationState(), + { + subjectPresent: !layerState.subjectMissing, + }, + ); + } + return parts.model.buildAwarenessContextSnapshot( + layerState.results, + parts.model.navigationState(), + { + subjectPresent: !layerState.subjectMissing, + }, + ); + }, + + /** + * Release Contact-owned camera tracking without discarding the selected + * subject. Reset-to-globe uses this route so the normal Context FOCUS action + * can explicitly return to the same contact, while delayed activation work + * cannot silently reclaim the camera after the reset. + * @returns {boolean} Whether a Contact subject remains selected. + */ + releaseCameraOwnership({ + preserveVesselSelection = false, + origin = 'programmatic', + } = {}) { + ++layerState.activationId; + layerState.autoFocusAttempted = true; + layerState.autoFocusRetryPending = false; + + const preservedSelectionKey = + parts.subject.subjectKey(layerState.subject) || 'camera-release'; + layerState.pendingSelectionKey = preservedSelectionKey; + try { + flightsLayer.stopTracking?.({ origin }); + militaryFlightsLayer.stopTracking?.({ origin }); + if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.(); + } finally { + if (layerState.pendingSelectionKey === preservedSelectionKey) { + layerState.pendingSelectionKey = null; + } + } + return Boolean(layerState.subject); + }, + + navigatePrevious(options = {}) { + return parts.history.navigateHistory(-1, options); + }, + + focusCurrent(options = {}) { + return parts.focus.focusCurrentSubject(options); + }, + + navigateNext(options = {}) { + return parts.history.navigateHistory(1, options); + }, + + /** Select a context target through its owning layer's established tracker. */ + focusTarget(layerId, id, options = {}) { + return parts.focus.requestFocus(layerId, id, false, options); + }, + }; + + return { methods }; +} diff --git a/src/layers/awareness/dependencies.js b/src/layers/awareness/dependencies.js new file mode 100644 index 000000000..15a23e455 --- /dev/null +++ b/src/layers/awareness/dependencies.js @@ -0,0 +1,118 @@ +import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js'; + +export function createDependencies({ + state: layerState, + services, + parts, + source, +}) { + function refreshAfterDeferredDependency(activationId) { + if ( + !layerState.enabled || + layerState.passive || + activationId !== layerState.activationId + ) + return; + if (layerState.subject) { + parts.subject.refreshSelectedSubject(true); + return; + } + if (layerState.autoFocusRetryPending) { + layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget(); + } + } + + async function enableDependencies(activationId = layerState.activationId) { + const aircraftPending = []; + for (const layerId of DEPENDENCIES) { + if (!layerState.enabled) return; + if (!layerState.dataManager) continue; + const alreadyEnabled = + layerState.dataManager.isEffectivelyEnabled?.(layerId) ?? + layerState.dataManager.isEnabled(layerId); + if (alreadyEnabled) continue; + layerState.ownedDependencies.add(layerId); + // Each layer serializes its own lifecycle internally. Start them together + // so a slow or unavailable live feed cannot prevent the remaining context + // sources from turning on. + let pending; + try { + // Register the ON intent synchronously. If Contacts is turned OFF in the + // same turn, DataLayerManager then sees OFF as the newest intent and a + // stale deferred ON cannot resurrect a released dependency. + pending = Promise.resolve( + layerState.dataManager.setEnabled(layerId, true), + ); + } catch (error) { + pending = Promise.reject(error); + } + if (AIRCRAFT_DEPENDENCIES.includes(layerId)) { + aircraftPending.push(pending); + } else { + // Vessels and mapped installations enrich an already usable aircraft + // context. Their settlement must never hold the Contacts transaction or + // Cockpit gate open, and the activation token prevents a late result + // from repainting or refocusing a newer/closed session. + pending.then( + () => refreshAfterDeferredDependency(activationId), + () => refreshAfterDeferredDependency(activationId), + ); + } + } + await Promise.allSettled(aircraftPending); + } + + function releaseOwnedDependencies(releaseActivationId) { + const owned = [...layerState.ownedDependencies]; + layerState.ownedDependencies.clear(); + if (!layerState.dataManager || !owned.length) return Promise.resolve(); + return Promise.allSettled( + owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)), + ).then(() => { + if ( + layerState.enabled && + layerState.activationId !== releaseActivationId + ) { + return enableDependencies(layerState.activationId); + } + return null; + }); + } + + function activateOperationalContext() { + const activationId = ++layerState.activationId; + layerState.autoFocusRetryPending = false; + const initialTrackedSubject = parts.subject.currentTrackedFlightSubject(); + if (initialTrackedSubject) + parts.subject.selectSubject(initialTrackedSubject); + return enableDependencies(activationId) + .then(() => { + if ( + !layerState.enabled || + layerState.passive || + activationId !== layerState.activationId + ) + return; + const trackedSubject = parts.subject.currentTrackedFlightSubject(); + if (trackedSubject) { + parts.subject.selectSubject(trackedSubject); + return; + } + if (layerState.subject) { + parts.subject.selectSubject(layerState.subject); + return; + } + if (layerState.autoFocusAttempted) return; + layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget(); + }) + .catch((error) => + console.warn('[Global Context] dependency enable failed', error), + ); + } + return { + refreshAfterDeferredDependency, + enableDependencies, + releaseOwnedDependencies, + activateOperationalContext, + }; +} diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js new file mode 100644 index 000000000..e7681b14b --- /dev/null +++ b/src/layers/awareness/focus.js @@ -0,0 +1,198 @@ +import * as Cesium from 'cesium'; +import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; + +export function createFocus({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + const { announceNavigationAuthority } = services.navigation; + const militaryInstallationsLayer = services.installations; + const aisLiveVesselsLayer = services.vessels; + + /** Focus a nearby example through the source layer that owns its selection. */ + + function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) { + if (!layerId || !id) return false; + if (layerId === 'flights') { + return ( + flightsLayer.refocusTrackedById?.(id, { origin }) || + flightsLayer.trackById(id, { origin }) + ); + } + if (layerId === 'military') { + return ( + militaryFlightsLayer.refocusTrackedById?.(id, { origin }) || + militaryFlightsLayer.trackById(id, { origin }) + ); + } + if (layerId === 'military-installations') { + if (!parts.model.contextTargetFlyToAllowed(layerId)) + return selectKnownContextTarget(layerId, id); + // focusById flies the camera; it never assigns a tracked entity, so the + // stamp has to be announced here. + announceNavigationAuthority('context-installation-focus'); + return militaryInstallationsLayer.focusById(id); + } + if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id)) + return false; + + const vessel = aisLiveVesselsLayer + .getAllPositions(12000) + .find((item) => String(item.id) === String(id)); + if ( + !parts.model.contextTargetFlyToAllowed(layerId) || + !vessel?.position || + !layerState.viewer + ) + return true; + announceNavigationAuthority('context-vessel-focus'); + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), + { duration: 1.4 }, + ); + return true; + } + + function selectKnownContextTarget(layerId, id) { + const key = `${layerId}:${id}`; + const known = [ + layerState.subject, + ...layerState.navigationHistory, + ...(layerState.results?.cohorts || []).flatMap((cohort) => + cohort.id === layerId + ? cohort.summary.navigationNearest || cohort.summary.nearest || [] + : [], + ), + ].find( + (item) => + item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key, + ); + if (!known?.position) return false; + if (parts.model.contextTargetFlyToAllowed(layerId)) + parts.model.releaseAircraftTracking(); + parts.subject.selectSubject({ + ...known, + layerId, + id: String(known.id || known.mmsi), + label: known.label || known.name || known.callsign || String(id), + position: Cesium.Cartesian3.clone(known.position), + }); + return true; + } + + function requestFocus( + layerId, + id, + preserveHistory = false, + { origin = 'programmatic' } = {}, + ) { + const key = `${layerId}:${id}`; + layerState.pendingSelectionKey = key; + if (preserveHistory) layerState.suppressedHistoryKey = key; + const focused = focusNearbyTarget(layerId, id, { origin }); + layerState.pendingSelectionKey = null; + layerState.suppressedHistoryKey = null; + return focused; + } + + function focusSubject(subject, preserveHistory = false, options = {}) { + if (!subject) return false; + return requestFocus(subject.layerId, subject.id, preserveHistory, options); + } + + function focusCurrentSubject(options = {}) { + return focusSubject(layerState.subject, true, options); + } + + /** + * Pick the observed candidate closest to the current view. This is intentionally + * a navigation preference, not a risk, capability, or affiliation calculation. + * @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates. + * @returns {Object|null} The best currently observable candidate. + */ + + function closestToCurrentView(candidates) { + if (!Array.isArray(candidates) || !candidates.length) return null; + const cameraPosition = layerState.viewer?.camera?.positionWC; + if (!cameraPosition) + return candidates.find((candidate) => candidate?.position) || null; + + let closest = null; + let closestDistance = Infinity; + for (const candidate of candidates) { + if (!candidate?.position) continue; + const distance = Cesium.Cartesian3.distance( + cameraPosition, + candidate.position, + ); + if (Number.isFinite(distance) && distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + return closest; + } + + /** + * Focus a single observed, context-priority target after activation. Civilian + * and military aircraft compete in one nearest-to-view pool; military is + * concatenated first so exact distance ties resolve to military under the + * strict comparison. An AIS vessel is only a fallback and is never inferred + * to be military. There is deliberately no distance cap. + * @returns {boolean} Whether a target was selected and framed. + */ + + function focusAttentionTarget() { + if ( + !layerState.enabled || + layerState.subject || + !layerState.viewer || + layerState.autoFocusAttempted + ) + return false; + + const nearestFlight = closestToCurrentView([ + ...militaryFlightsLayer + .getAllPositions(800) + .map((item) => ({ ...item, layerId: 'military' })), + ...flightsLayer + .getAllPositions(1000) + .map((item) => ({ ...item, layerId: 'flights' })), + ]); + if (nearestFlight) { + const layer = + nearestFlight.layerId === 'military' + ? militaryFlightsLayer + : flightsLayer; + if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) { + layerState.autoFocusAttempted = true; + return true; + } + } + + const vessel = closestToCurrentView( + aisLiveVesselsLayer.getAllPositions(12000), + ); + if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false; + + layerState.autoFocusAttempted = true; + if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true; + + announceNavigationAuthority('context-vessel-autofocus', { + cancelPendingSelection: false, + }); + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), + { duration: 1.6 }, + ); + return true; + } + return { + focusNearbyTarget, + selectKnownContextTarget, + requestFocus, + focusSubject, + focusCurrentSubject, + closestToCurrentView, + focusAttentionTarget, + }; +} diff --git a/src/layers/awareness/history.js b/src/layers/awareness/history.js new file mode 100644 index 000000000..e8e05505a --- /dev/null +++ b/src/layers/awareness/history.js @@ -0,0 +1,239 @@ +export function createHistory({ state: layerState, services, parts, source }) { + const militaryFlightsLayer = services.military; + const flightsLayer = services.flights; + + /** + * Walk navigation history for the next entry that can still be focused. + * + * A history entry only holds a SNAPSHOT of a contact; the live contact behind + * it can be evicted from its layer at any time (the cull that drives + * CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the + * only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead + * entry strands the operator there for the rest of the session, because the + * index never advances past it. + * + * `state.navigationIndex` moves only on a successful focus: a caller that + * falls through to the live cohort must still splice forward history from the + * position the operator actually occupies. + * @param {number} direction Walk direction (negative = previous). + * @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters. + * @returns {boolean} Whether a history entry was focused. + */ + + function focusCompatibleHistory( + direction, + { targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' }, + ) { + let cursor = layerState.navigationIndex; + for (;;) { + const historyIndex = findCompatibleHistoryIndex( + layerState.navigationHistory, + cursor, + direction, + { + targetLayer, + aircraftClass, + aircraftOnly, + resolveItem: historySubjectItem, + }, + ); + if (historyIndex === -1) return false; + if ( + parts.focus.focusSubject( + layerState.navigationHistory[historyIndex], + true, + { origin }, + ) + ) { + layerState.navigationIndex = historyIndex; + return true; + } + // Contact evicted since it entered history — step over it. The scan is + // strictly monotonic, so this terminates at the end of history. + cursor = historyIndex; + } + } + + function navigateHistory( + direction, + { + targetLayer = null, + aircraftClass = null, + aircraftOnly = false, + origin = 'programmatic', + } = {}, + ) { + if ( + focusCompatibleHistory(direction, { + targetLayer, + aircraftClass, + aircraftOnly, + origin, + }) + ) + return true; + + if (direction < 0) return false; + + let targets = parts.navigation.selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass, aircraftOnly }, + ); + const targetHistoryFullyVisited = + targets.length > 0 && targets.every((target) => target.visited); + const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown(); + if ( + targetHistoryFullyVisited && + parts.navigation.isFlightLayer(layerState.subject?.layerId) && + !subjectCohortUnknown + ) { + const expanded = parts.navigation.findExpandedFlightTarget({ + targetLayer, + aircraftClass, + }); + if (expanded) { + return parts.focus.requestFocus( + expanded.candidate.layerId, + expanded.candidate.id, + false, + { origin }, + ); + } + } + if (targetHistoryFullyVisited) { + parts.navigation.resetNavigationVisitedCycle(); + targets = parts.navigation.selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass, aircraftOnly }, + ); + } + const hasNearbyFlight = targets.some((target) => + parts.navigation.isFlightLayer(target.layerId), + ); + if ( + parts.navigation.isFlightLayer(layerState.subject?.layerId) && + !hasNearbyFlight && + !subjectCohortUnknown + ) { + const expanded = parts.navigation.findExpandedFlightTarget({ + targetLayer, + aircraftClass, + }); + if (expanded) { + return parts.focus.requestFocus( + expanded.candidate.layerId, + expanded.candidate.id, + false, + { origin }, + ); + } + } + if (parts.subject.subjectCohortFeedUnknown()) return false; + const candidate = targets[0]; + if (!candidate) return false; + return parts.focus.requestFocus(candidate.layerId, candidate.id, false, { + origin, + }); + } + + function historySubjectItem(subject) { + if (!subject) return null; + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === subject.layerId, + ); + const items = + cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; + return ( + items.find( + (item) => + String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id), + ) || subject + ); + } + + /** Retain filter metadata when a production subject enters navigation history. */ + + function historySubjectSnapshot(subject, sourceItem = null) { + const aircraftClass = + sourceItem?.aircraftClass || + sourceItem?.klass || + sourceItem?.type || + subject?.aircraftClass || + null; + return aircraftClass ? { ...subject, aircraftClass } : { ...subject }; + } + + function historySourceItem(subject) { + if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position) + return null; + // The current sweep already holds this contact's record. Reusing it keeps + // selection O(1) against the cohort instead of paying a fresh full-layer + // proximity scan per selection — the burst cost when NEXT walks a cohort. + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === subject.layerId, + ); + const items = + cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; + const fromSweep = items.find( + (item) => String(item?.icao24 || item?.id) === String(subject.id), + ); + if (fromSweep) return fromSweep; + const layer = + subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer; + return ( + layer + .getNearby(subject.position, 1000, 25, { includeHidden: true }) + .find( + (item) => String(item?.icao24 || item?.id) === String(subject.id), + ) || null + ); + } + + /** Find the next history entry compatible with requested navigation filters. */ + + function findCompatibleHistoryIndex( + history, + startIndex, + direction, + { + targetLayer = null, + aircraftClass = null, + aircraftOnly = false, + resolveItem = (subject) => subject, + } = {}, + ) { + const step = direction < 0 ? -1 : 1; + for ( + let index = startIndex + step; + index >= 0 && index < history.length; + index += step + ) { + const subject = history[index]; + if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId)) + continue; + if (targetLayer && subject?.layerId !== targetLayer) continue; + if ( + aircraftClass && + !parts.navigation.aircraftClassMatchesFilter( + resolveItem(subject), + aircraftClass, + ) + ) + continue; + return index; + } + return -1; + } + return { + focusCompatibleHistory, + navigateHistory, + historySubjectItem, + historySubjectSnapshot, + historySourceItem, + findCompatibleHistoryIndex, + }; +} diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js new file mode 100644 index 000000000..cd01e0cfc --- /dev/null +++ b/src/layers/awareness/index.js @@ -0,0 +1,66 @@ +import { createModel } from './model.js'; +import { createSubject } from './subject.js'; +import { createPanel } from './panel.js'; +import { createQueries } from './queries.js'; +import { createNavigation } from './navigation.js'; +import { createFocus } from './focus.js'; +import { createTesting } from './testing.js'; +import { createHistory } from './history.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; +import { createDependencies } from './dependencies.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createAwarenessLayer({ services, source }) { + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.subject = createSubject(context); + parts.panel = createPanel(context); + parts.queries = createQueries(context); + parts.navigation = createNavigation(context); + parts.focus = createFocus(context); + parts.testing = createTesting(context); + parts.history = createHistory(context); + parts.rendering = createRendering(context); + parts.lifecycle = createLifecycle(context); + parts.dependencies = createDependencies(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed, + awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject, + awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs, + awarenessRefreshDecision: parts.model.awarenessRefreshDecision, + awarenessClearIsEviction: parts.model.awarenessClearIsEviction, + awarenessRefreshRequired: parts.model.awarenessRefreshRequired, + summarizeInstallationViewport: + parts.queries.summarizeInstallationViewport, + contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot, + buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot, + collectAircraftProximityWindow: + parts.queries.collectAircraftProximityWindow, + _getAwarenessNavigationStateForTest: + parts.testing._getAwarenessNavigationStateForTest, + canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext, + historySubjectSnapshot: parts.history.historySubjectSnapshot, + findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex, + awarenessPanelControlKey: parts.panel.awarenessPanelControlKey, + captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus, + restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus, + awarenessResultsAreLive: parts.panel.awarenessResultsAreLive, + awarenessNeedsContinuousRender: + parts.model.awarenessNeedsContinuousRender, + }, + ); +} +export { AWARENESS_QUERY_LIMIT } from './policy.js'; diff --git a/src/layers/awareness/ingestion.js b/src/layers/awareness/ingestion.js new file mode 100644 index 000000000..68d0db43f --- /dev/null +++ b/src/layers/awareness/ingestion.js @@ -0,0 +1,15 @@ +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + update() { + parts.subject.refreshSelectedSubject(); + return Promise.resolve(); + }, + }; + + return { methods }; +} diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js new file mode 100644 index 000000000..ff8e30ec0 --- /dev/null +++ b/src/layers/awareness/lifecycle.js @@ -0,0 +1,263 @@ +import { cameraPoseSignature } from '../../data/iconOrientation.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + /** + * Quantized camera-pose signature, or '' when the camera cannot report a full + * pose yet. An unknown pose reads as PARKED, so a camera that is still coming + * up can never be mistaken for continuous movement. + * @param {Cesium.Camera|null|undefined} camera + * @returns {string} + */ + + function cameraMotionSignature(camera) { + if (!camera?.positionWC || !Number.isFinite(camera.heading)) return ''; + return cameraPoseSignature(camera); + } + + /** + * Take or drop the continuous-render hold to match the current need. Idempotent + * (the governor is identity-keyed), and self-healing: the release decision is + * made on a frame the hold itself guaranteed, so a hold can never strand the + * scene in continuous mode. + * @returns {void} + */ + + function syncAwarenessRenderHold() { + if (layerState.enabled && parts.model.awarenessNeedsContinuousRender()) + holdContinuousRender('military-awareness'); + else releaseContinuousRender('military-awareness'); + } + + function attachRuntimeListeners() { + if (layerState.runtimeListenersAttached || !layerState.viewer) return; + window.addEventListener( + 'gev:awareness-subject-selected', + layerState.subjectListener, + ); + window.addEventListener('gev:entity-selected', layerState.contextListener); + window.addEventListener( + 'gev:entity-selection-cleared', + layerState.clearListener, + ); + window.addEventListener( + 'gev:awareness-subject-cleared', + layerState.subjectClearListener, + ); + layerState.preRenderRemover = + layerState.viewer.scene.preRender.addEventListener(() => { + if (!layerState.enabled) return; + const now = Date.now(); + // "Did the camera move" uses the SAME quantized pose signature the fleet + // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than + // camera.changed, whose granularity is globally degraded by other layers + // mutating camera.percentageChanged. + const poseSig = cameraMotionSignature(layerState.viewer.camera); + if (poseSig !== layerState.lastCameraPoseSig) { + layerState.lastCameraPoseSig = poseSig; + layerState.lastCameraPoseChangeMs = now; + } + const decision = parts.model.awarenessRefreshDecision({ + nowMs: now, + lastRefreshMs: layerState.lastSubjectRefreshMs, + lastPoseChangeMs: layerState.lastCameraPoseChangeMs, + wasMoving: layerState.cameraMoving, + }); + layerState.cameraMoving = decision.moving; + syncAwarenessRenderHold(); + if (!decision.refresh) return; + layerState.lastSubjectRefreshMs = now; + if ( + layerState.subject && + ['flights', 'military', 'ais-live-vessels'].includes( + layerState.subject.layerId, + ) + ) { + parts.subject.refreshSelectedSubject(); + } else if (!layerState.passive && layerState.autoFocusRetryPending) { + layerState.autoFocusRetryPending = false; + parts.focus.focusAttentionTarget(); + } else { + parts.rendering.scheduleDirectionOverlayUpdate(); + } + }); + layerState.runtimeListenersAttached = true; + } + + function detachRuntimeListeners() { + if (layerState.runtimeListenersAttached) { + window.removeEventListener( + 'gev:awareness-subject-selected', + layerState.subjectListener, + ); + window.removeEventListener( + 'gev:entity-selected', + layerState.contextListener, + ); + window.removeEventListener( + 'gev:entity-selection-cleared', + layerState.clearListener, + ); + window.removeEventListener( + 'gev:awareness-subject-cleared', + layerState.subjectClearListener, + ); + } + layerState.preRenderRemover?.(); + layerState.preRenderRemover = null; + layerState.lastCameraPoseSig = ''; + layerState.lastCameraPoseChangeMs = 0; + layerState.cameraMoving = false; + // No frames will arrive to run the per-frame release once the listener is + // gone, so drop the hold here. + releaseContinuousRender('military-awareness'); + parts.rendering.cancelDirectionOverlayUpdate(); + layerState.runtimeListenersAttached = false; + } + const methods = { + init(viewer) { + detachRuntimeListeners(); + layerState.viewer = viewer; + layerState.subjectListener = (event) => { + if (!layerState.enabled) return; + parts.subject.selectSubject(event.detail); + }; + layerState.contextListener = (event) => { + if (!layerState.enabled) return; + const subject = parts.subject.subjectFromContext(event.detail); + if (!subject) return; + if (parts.model.contextTargetFlyToAllowed(subject.layerId)) + parts.model.releaseAircraftTracking(); + parts.subject.selectSubject(subject); + }; + layerState.clearListener = (event) => { + if (!layerState.enabled || layerState.pendingSelectionKey) return; + if ( + !parts.subject.awarenessClearMatchesSubject( + layerState.subject, + event.detail, + ) + ) + return; + // An eviction keeps the subject so the readout can hold last-known + // values; only a deliberate clear tears the selection down. + if (parts.model.awarenessClearIsEviction(event.detail)) { + parts.subject.markSubjectEvicted(); + return; + } + parts.subject.clearAwarenessSubject(); + }; + layerState.subjectClearListener = (event) => { + if (!layerState.enabled) return; + const cleared = event.detail; + if (layerState.pendingSelectionKey) return; + if ( + parts.subject.awarenessClearMatchesSubject( + layerState.subject, + cleared, + ) && + String(layerState.subject?.id) === String(cleared?.id) + ) { + if (parts.model.awarenessClearIsEviction(cleared)) { + // Deliberately does NOT set autoFocusAttempted: the subject survives, + // so the entry fallback has nothing to replace and the settlement + // guard below stays scoped to real deselects. + parts.subject.markSubjectEvicted(); + return; + } + // A deliberate clear during dependency settlement must win over the + // entry fallback; otherwise Contacts can silently select a replacement. + layerState.autoFocusAttempted = true; + parts.subject.clearAwarenessSubject(); + } + }; + }, + + enable() { + layerState.enabled = true; + // NO unconditional continuous-render hold here. Contacts animates per frame + // only while the VIEW is moving (the direction arrows are screen-projected); + // parked, it is a throttled readout with nothing to animate. The hold is + // taken and released per frame by syncAwarenessRenderHold(). + attachRuntimeListeners(); + parts.panel.startAwarenessPageRotation(); + layerState.autoFocusAttempted = false; + layerState.autoFocusRetryPending = false; + parts.panel.ensurePanel(); + parts.panel.hidePanel(); + if (!layerState.passive) + return parts.dependencies.activateOperationalContext(); + return true; + }, + + disable() { + layerState.enabled = false; + releaseContinuousRender('military-awareness'); + detachRuntimeListeners(); + parts.panel.stopAwarenessPageRotation(); + layerState.cohortPages.clear(); + const releaseActivationId = ++layerState.activationId; + layerState.autoFocusAttempted = false; + layerState.autoFocusRetryPending = false; + layerState.subject = null; + layerState.results = null; + layerState.lastSubjectRefreshMs = 0; + layerState.lastEvaluatedPosition = null; + layerState.sourceRevision = ''; + layerState.navigationHistory = []; + layerState.navigationVisited.clear(); + layerState.navigationIndex = -1; + layerState.suppressedHistoryKey = null; + layerState.pendingSelectionKey = null; + parts.rendering.clearVisual(); + parts.panel.hidePanel(); + // The manager must not publish this coordinator as settled OFF while its + // owned dependency releases are still issuing newer absolute intents. + // Context mode handoffs restore the pre-entry snapshot only after this + // promise resolves, preventing those releases from superseding restore. + layerState.passive = true; + return parts.dependencies.releaseOwnedDependencies(releaseActivationId); + }, + + destroy() { + this.disable(); + detachRuntimeListeners(); + layerState.panel?.removeEventListener( + 'click', + layerState.panelClickListener, + ); + layerState.panelClickListener = null; + if (layerState.panelOwned) layerState.panel?.remove(); + else if (layerState.panel) layerState.panel.replaceChildren(); + layerState.panel = null; + layerState.panelOwned = false; + layerState.directionRoot?.remove(); + layerState.directionRoot = null; + layerState.compassRing = null; + layerState.compassLabels = []; + layerState.compassHeading = null; + layerState.directionMarkers = []; + layerState.panelMarkup = ''; + layerState.subjectListener = null; + layerState.contextListener = null; + layerState.clearListener = null; + layerState.subjectClearListener = null; + layerState.viewer = null; + layerState.dataManager = null; + }, + }; + + return { + cameraMotionSignature, + syncAwarenessRenderHold, + attachRuntimeListeners, + detachRuntimeListeners, + methods, + }; +} diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js new file mode 100644 index 000000000..c99f35fee --- /dev/null +++ b/src/layers/awareness/model.js @@ -0,0 +1,251 @@ +import { + AWARENESS_MOTION_REFRESH_MS, + AWARENESS_REFRESH_MS, + AWARENESS_MOTION_SETTLE_MS, + AWARENESS_REEVALUATE_DISTANCE_M, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + /** + * Whether focusing a Context target may take ownership of the camera. + * Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable + * there but must not start a competing flyTo animation. + * + * @param {string} layerId Context target layer id. + * @param {HTMLElement|null} [body=document.body] Document body to inspect. + * @returns {boolean} Whether the target may start a camera flight. + */ + + function contextTargetFlyToAllowed( + layerId, + body = globalThis.document?.body, + ) { + const nonAircraft = + layerId === 'ais-live-vessels' || layerId === 'military-installations'; + return !nonAircraft || !body?.classList?.contains('cockpit-mode'); + } + + /** + * Refresh cadence for the Contacts direction arrows and card readouts. + * + * A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms + * one. This is safe for requestRenderMode because the cadence is applied inside + * `scene.preRender`: a faster interval can only ever consume frames the camera + * movement is ALREADY forcing. A parked scene renders no frames at all, so no + * extra refresh happens and no timer is introduced — the render governor and + * the idle-leak fix both depend on parked staying quiet. + * @param {boolean} cameraMoving Whether the camera pose changed since the last frame. + * @returns {number} Minimum ms between refreshes. + */ + + function awarenessRefreshIntervalMs(cameraMoving) { + return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS; + } + + /** + * Decide, for one rendered frame, whether the view counts as MOVING and whether + * the Contacts readout may refresh. + * + * Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a + * slow drag crosses a bin only every few frames: treating a single unchanged + * frame as "parked" made every crossing look like a motion-end and refreshed at + * nearly display rate. Motion therefore ends only after the pose has been + * unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD + * floor in every state — no bin-crossing pattern, and no motion-end settle, can + * refresh faster than AWARENESS_MOTION_REFRESH_MS. + * + * @param {object} input + * @param {number} input.nowMs This frame's clock. + * @param {number} input.lastRefreshMs When the readout last refreshed. + * @param {number} input.lastPoseChangeMs When the camera pose last changed bins. + * @param {boolean} input.wasMoving Whether the previous frame counted as moving. + * @returns {{moving: boolean, refresh: boolean}} + */ + + function awarenessRefreshDecision({ + nowMs, + lastRefreshMs, + lastPoseChangeMs, + wasMoving, + }) { + const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS; + // The single frame where hysteresis expires: settle the readout at rest + // instead of leaving it up to a parked interval stale. + const settling = Boolean(wasMoving) && !moving; + const sinceRefresh = nowMs - lastRefreshMs; + const refresh = + sinceRefresh >= awarenessRefreshIntervalMs(true) && + (moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false)); + return { moving, refresh }; + } + + /** + * Decide whether a source-scoped clear was an eviction rather than a deselect. + * + * Both arrive on the same events: the owning layer calls the same teardown for + * "the user clicked away" and "this contact aged out of the feed". Only the + * origin separates them, and it matters — a deliberate clear takes the Contact + * panel down, while an eviction must keep it up in its CONTACT LOST state, or + * the panel disappears out from under its own PREVIOUS/NEXT controls. + * @param {{reason?: string}|null} cleared Cleared source detail. + * @returns {boolean} Whether the subject left the feed on its own. + */ + + function awarenessClearIsEviction(cleared) { + return cleared?.reason === 'evicted'; + } + + /** + * Decide whether proximity cohorts need an expensive rescan. + * @param {object} input Refresh evidence. + * @param {boolean} input.force Explicit invalidation. + * @param {boolean} input.hasResults Whether a prior evaluation exists. + * @param {number} input.movementM Subject displacement since evaluation. + * @param {boolean} input.sourceRevisionChanged Whether any source state changed. + * @returns {boolean} Whether cohorts should be evaluated again. + */ + + function awarenessRefreshRequired({ + force, + hasResults, + movementM, + sourceRevisionChanged, + }) { + return Boolean( + force || + !hasResults || + sourceRevisionChanged || + !Number.isFinite(movementM) || + movementM >= AWARENESS_REEVALUATE_DISTANCE_M, + ); + } + + /** + * The per-layer counts the Contacts panel is showing, as one flat block. + * + * Three honest numbers were reaching the operator at once: this cohort count + * (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the + * layer-wide loaded total in the coverage note. After the camera dives to a + * tracked contact the flights layer reloads by viewport, so the loaded set can + * hold a fraction of the cohort — 8 against the panel's 42 in the field. The + * numbers are all correct and the disagreement still reads as chaos. + * + * Derived from the same snapshot the panel renders (`cohort.summary.count` via + * `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort + * whose feed cannot answer reports 'unknown' rather than a misleading zero. + * @param {object|null} snapshot `getContextSnapshot()` result. + * @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string, + * flights: number|string, military: number|string, vessels: number|string}|null} + * Panel-equivalent counts. + */ + + function contactsWindowFromSnapshot(snapshot) { + if (!snapshot?.subject) return null; + const countFor = (cohortId) => { + const cohort = Array.isArray(snapshot.cohorts) + ? snapshot.cohorts.find((item) => item?.id === cohortId) + : null; + return Number.isFinite(cohort?.count) ? cohort.count : 'unknown'; + }; + const flights = countFor('flights'); + const military = countFor('military'); + return { + centeredOn: snapshot.subject.label || snapshot.subject.id || null, + radiusKm: Number.isFinite(snapshot.radiusM) + ? Math.round(snapshot.radiusM / 1000) + : null, + aircraft: + Number.isFinite(flights) && Number.isFinite(military) + ? flights + military + : 'unknown', + flights, + military, + vessels: countFor('ais-live-vessels'), + }; + } + + /** Build the read-only Awareness snapshot shared with compact HUD consumers. */ + + function buildAwarenessContextSnapshot( + results, + navigation = {}, + { subjectPresent = true } = {}, + ) { + if (!results) return null; + return { + subject: { ...results.subject }, + // Whether the subject is still reported by its source. The cockpit Contact + // readout holds its last-known values behind a CONTACT LOST cue when this + // is false, rather than presenting frozen geometry as a live reading. + subjectPresent: subjectPresent !== false, + evaluatedAt: results.evaluatedAt, + radiusM: results.radiusM, + cohorts: results.cohorts.map((cohort) => ({ + id: cohort.id, + label: cohort.label, + source: cohort.source, + coverage: cohort.coverage || null, + relationship: cohort.summary.relationship, + count: cohort.summary.count, + reason: cohort.summary.reason, + nearest: cohort.summary.nearest.slice(), + })), + navigation: { ...navigation }, + }; + } + + function navigationState() { + return { + canPrevious: layerState.navigationIndex > 0, + canFocus: Boolean(layerState.subject), + canNext: parts.navigation.canNavigateNext(), + }; + } + + /** + * Whether Contacts genuinely has per-frame work right now. + * + * The direction arrows are screen-projected against the camera basis, so they + * must be redrawn every frame while the view MOVES. Parked, or with no live + * cohort to point at — nothing selected, every feed empty, every feed failed — + * there is nothing to animate and Contacts must not be the reason the whole + * scene keeps repainting. + * @param {object} [snapshot] Explicit state, for tests. + * @returns {boolean} + */ + + function awarenessNeedsContinuousRender({ + cameraMoving = layerState.cameraMoving, + hasSubject = Boolean(layerState.subject), + hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results), + } = {}) { + return Boolean(cameraMoving && hasSubject && hasLiveResults); + } + + /** + * Release any aircraft-owned follow camera before a non-aircraft context + * selection takes ownership. Both layers are safe no-ops when idle and release + * Cesium tracking in place, so the subsequent vessel/site framing starts from + * the current view without the previous aircraft continuing to drag it. + */ + + function releaseAircraftTracking() { + flightsLayer.stopTracking?.(); + militaryFlightsLayer.stopTracking?.(); + } + return { + contextTargetFlyToAllowed, + awarenessRefreshIntervalMs, + awarenessRefreshDecision, + awarenessClearIsEviction, + awarenessRefreshRequired, + contactsWindowFromSnapshot, + buildAwarenessContextSnapshot, + navigationState, + awarenessNeedsContinuousRender, + releaseAircraftTracking, + }; +} diff --git a/src/layers/awareness/navigation.js b/src/layers/awareness/navigation.js new file mode 100644 index 000000000..23505e944 --- /dev/null +++ b/src/layers/awareness/navigation.js @@ -0,0 +1,274 @@ +import { + summarizeAwarenessCohort, + getAwarenessNavigationTargets, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + findByDoublingRadius, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { + AWARENESS_MAX_EXAMPLES, + AWARENESS_MAX_NAVIGATION_EXAMPLES, +} from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + function summarizeAwarenessCohortForNavigation( + items, + source, + { + displayLimit = AWARENESS_MAX_EXAMPLES, + navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES, + } = {}, + ) { + const summary = summarizeAwarenessCohort(items, { + ...source, + limit: navigationLimit, + }); + if (summary.count === null) return summary; + return { + ...summary, + nearest: summary.nearest.slice(0, displayLimit), + navigationNearest: summary.nearest, + }; + } + + function resetNavigationVisitedCycle() { + layerState.navigationVisited.clear(); + const currentKey = parts.subject.subjectKey(layerState.subject); + if (currentKey) layerState.navigationVisited.add(currentKey); + } + + function isFlightLayer(layerId) { + return layerId === 'flights' || layerId === 'military'; + } + + function normalizeAircraftClass(value) { + return String(value || '') + .trim() + .toLowerCase(); + } + + function aircraftClassMatchesFilter(item, aircraftClass) { + const requested = normalizeAircraftClass(aircraftClass); + if (!requested) return true; + const candidates = [ + item?.aircraftClass, + item?.klass, + item?.type, + item?.typeCode, + item?.typeName, + ] + .map((value) => normalizeAircraftClass(value)) + .filter(Boolean); + if (!candidates.length) return false; + return candidates.some( + (candidate) => candidate === requested || candidate.includes(requested), + ); + } + + function selectNavigationTargets( + sourceCohorts, + subject, + visitedKeys, + { targetLayer = null, aircraftClass = null, aircraftOnly = false } = {}, + ) { + let targets = getAwarenessNavigationTargets( + sourceCohorts, + subject, + visitedKeys, + ); + if (aircraftOnly) { + targets = targets.filter((target) => isFlightLayer(target.layerId)); + } + if (targetLayer) { + targets = targets.filter((target) => target.layerId === targetLayer); + } + if (aircraftClass) { + targets = targets.filter((target) => + aircraftClassMatchesFilter(target.item, aircraftClass), + ); + } + return targets; + } + + function alternativeFlightAvailable({ + targetLayer = null, + aircraftClass = null, + } = {}) { + if ( + !isFlightLayer(layerState.subject?.layerId) || + !layerState.subject?.position + ) + return false; + if (targetLayer && !isFlightLayer(targetLayer)) return false; + + const flights = + targetLayer === 'military' + ? [] + : flightsLayer + .getNearby( + layerState.subject.position, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + 2, + { includeHidden: true }, + ) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); + + const military = + targetLayer === 'flights' + ? [] + : militaryFlightsLayer + .getNearby( + layerState.subject.position, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + 2, + { includeHidden: true }, + ) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); + + return [ + ['flights', flights], + ['military', military], + ].some(([layerId, items]) => + items.some( + (item) => + `${layerId}:${item.icao24 || item.id}` !== + parts.subject.subjectKey(layerState.subject), + ), + ); + } + + function closestFlightWithinRadius( + radiusM, + visitedKeys, + excludeVisited, + { targetLayer = null, aircraftClass = null } = {}, + ) { + if (!layerState.subject?.position) return null; + const visited = new Set(visitedKeys); + if (targetLayer && !isFlightLayer(targetLayer)) return null; + const candidates = [ + ...(targetLayer === 'military' + ? [] + : flightsLayer + .getNearby(layerState.subject.position, radiusM, 25000, { + includeHidden: true, + }) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) + .map((item) => ({ + layerId: 'flights', + id: String(item.icao24), + item, + }))), + ...(targetLayer === 'flights' + ? [] + : militaryFlightsLayer + .getNearby(layerState.subject.position, radiusM, 5000, { + includeHidden: true, + }) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) + .map((item) => ({ + layerId: 'military', + id: String(item.icao24), + item, + }))), + ].filter((target) => { + const key = `${target.layerId}:${target.id}`; + if (key === parts.subject.subjectKey(layerState.subject)) return false; + return !excludeVisited || !visited.has(key); + }); + candidates.sort( + (a, b) => + (a.item.distanceM ?? a.item.distance ?? Infinity) - + (b.item.distanceM ?? b.item.distance ?? Infinity), + ); + return candidates[0] || null; + } + + function findExpandedFlightTarget(options = {}) { + if (!isFlightLayer(layerState.subject?.layerId)) return null; + let visitedKeys = [...layerState.navigationVisited]; + const search = () => + findByDoublingRadius( + (radiusM) => + closestFlightWithinRadius(radiusM, visitedKeys, true, options), + { + initialRadiusM: AWARENESS_RADIUS_M, + maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + }, + ); + const target = search(); + if (target) return target; + resetNavigationVisitedCycle(); + visitedKeys = [...layerState.navigationVisited]; + return search(); + } + + /** + * Resolves NEXT availability from the same three branches used by navigation. + * Forward history remains usable while a feed is unavailable; discovering a + * new nearby or expanded target requires a known subject cohort. + * @param {Object} availability Navigation branch availability. + * @returns {boolean} Whether NEXT has a path that can run. + */ + + function canNavigateAwarenessNext({ + hasForwardHistory = false, + hasExpandedFlightTarget = false, + hasNearbyTarget = false, + subjectCohortUnknown = false, + } = {}) { + return Boolean( + hasForwardHistory || + (!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)), + ); + } + + function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) { + return canNavigateAwarenessNext({ + hasForwardHistory: + parts.history.findCompatibleHistoryIndex( + layerState.navigationHistory, + layerState.navigationIndex, + 1, + { + targetLayer, + aircraftClass, + resolveItem: parts.history.historySubjectItem, + }, + ) !== -1, + hasNearbyTarget: + selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass }, + ).length > 0, + hasExpandedFlightTarget: alternativeFlightAvailable({ + targetLayer, + aircraftClass, + }), + subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(), + }); + } + return { + summarizeAwarenessCohortForNavigation, + resetNavigationVisitedCycle, + isFlightLayer, + normalizeAircraftClass, + aircraftClassMatchesFilter, + selectNavigationTargets, + alternativeFlightAvailable, + closestFlightWithinRadius, + findExpandedFlightTarget, + canNavigateAwarenessNext, + canNavigateNext, + }; +} diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js new file mode 100644 index 000000000..8a831907a --- /dev/null +++ b/src/layers/awareness/panel.js @@ -0,0 +1,249 @@ +import { + formatAwarenessLabel, + formatAwarenessDistance, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; + +export function createPanel({ state: layerState, services, parts, source }) { + function ensurePanel() { + if (layerState.panel) return layerState.panel; + const existing = document.getElementById('military-awareness-panel'); + const panel = existing || document.createElement('aside'); + layerState.panelOwned = !existing; + if (!existing) { + panel.id = 'military-awareness-panel'; + } + layerState.panelClickListener = (event) => { + const action = event.target.closest('button[data-awareness-action]'); + if (action) { + event.preventDefault(); + if (action.dataset.awarenessAction === 'previous') + parts.history.navigateHistory(-1, { origin: 'user' }); + if (action.dataset.awarenessAction === 'next') + parts.history.navigateHistory(1, { origin: 'user' }); + if (action.dataset.awarenessAction === 'focus') + parts.focus.focusCurrentSubject({ origin: 'user' }); + return; + } + const target = event.target.closest( + 'button[data-awareness-layer][data-awareness-id]', + ); + if (!target) return; + event.preventDefault(); + parts.focus.requestFocus( + target.dataset.awarenessLayer, + target.dataset.awarenessId, + false, + { origin: 'user' }, + ); + }; + panel.addEventListener('click', layerState.panelClickListener); + if (!existing) document.body.appendChild(panel); + layerState.panel = panel; + return panel; + } + + function hidePanel() { + if (layerState.panel) { + const markup = ` + ${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'} + ${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'} + `; + layerState.panel.hidden = false; + if (layerState.panelMarkup !== markup) { + layerState.panel.innerHTML = markup; + layerState.panelMarkup = markup; + } + } + if (layerState.directionRoot) layerState.directionRoot.hidden = true; + } + + function rowHtml(cohort) { + const summary = cohort.summary; + const count = summary.count === null ? '?' : String(summary.count); + const page = layerState.cohortPages.get(cohort.id) || 0; + const nearest = summary.nearest + .slice(page, page + AWARENESS_PAGE_SIZE) + .map((item) => { + const label = formatAwarenessLabel(item); + const targetId = item.icao24 || item.mmsi || item.id; + if (!targetId) { + return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; + } + const accessibleLabel = label === '—' ? 'Unavailable' : label; + return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; + }) + .join(''); + const pageCount = Math.max( + 1, + Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE), + ); + const pageLabel = + pageCount > 1 + ? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}` + : ''; + const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : ''; + return ` + ${escapeHtml(cohort.label)}${count}${pageLabel} + ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)} + ${nearest ? `${nearest}` : ''} + `; + } + + function navigationControlsHtml() { + const canPrevious = layerState.navigationIndex > 0; + return ` + PREVIOUS + FOCUS + NEXT + `; + } + + /** Stable identity for a delegated Contacts-panel button across live repaints. */ + + function awarenessPanelControlKey(element) { + const control = element?.closest?.( + 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', + ); + if (!control) return null; + if (control.dataset.awarenessAction) + return `action:${control.dataset.awarenessAction}`; + return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`; + } + + /** Capture stable identity so a live repaint can restore only the same control. */ + + function captureAwarenessPanelFocus( + panel, + activeElement = document.activeElement, + ) { + if (!panel?.contains?.(activeElement)) return null; + if (activeElement?.matches?.('[data-awareness-focus-continuation]')) { + return { key: 'continuation' }; + } + const key = awarenessPanelControlKey(activeElement); + if (!key) return null; + return { key }; + } + + /** Restore the same control, or continue beyond the list when it is no longer rendered. */ + + function restoreAwarenessPanelFocus(panel, snapshot) { + if (!panel || !snapshot) return null; + const continuation = panel.querySelector( + '[data-awareness-focus-continuation]', + ); + const controls = [ + ...panel.querySelectorAll( + 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', + ), + ].filter((control) => !control.disabled); + const retained = + snapshot.key === 'continuation' + ? continuation + : controls.find( + (control) => awarenessPanelControlKey(control) === snapshot.key, + ); + const target = retained || continuation; + target?.focus?.({ preventScroll: true }); + return target || null; + } + + function escapeHtml(value) { + return String(value ?? '').replace( + /[&<>'"]/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"', + })[char], + ); + } + + function renderResults() { + if (!layerState.enabled || !layerState.results) return hidePanel(); + const panel = ensurePanel(); + const { subject, cohorts } = layerState.results; + const markup = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW + ${navigationControlsHtml()} + ${cohorts.map(rowHtml).join('')} + Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.`; + panel.hidden = false; + if (layerState.panelMarkup !== markup) { + const focusSnapshot = captureAwarenessPanelFocus(panel); + panel.innerHTML = markup; + layerState.panelMarkup = markup; + restoreAwarenessPanelFocus(panel, focusSnapshot); + } + } + + function rotateAwarenessPages() { + if (!layerState.enabled || !layerState.results) return; + let changed = false; + for (const cohort of layerState.results.cohorts) { + const pageCount = Math.max( + 1, + Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE), + ); + if (pageCount <= 1) continue; + const current = layerState.cohortPages.get(cohort.id) || 0; + layerState.cohortPages.set( + cohort.id, + ((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) * + AWARENESS_PAGE_SIZE, + ); + changed = true; + } + if (changed) { + renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(true); + } + } + + function startAwarenessPageRotation() { + if (!layerState.pageTimer) + layerState.pageTimer = window.setInterval( + rotateAwarenessPages, + AWARENESS_PAGE_ROTATE_MS, + ); + } + + function stopAwarenessPageRotation() { + if (layerState.pageTimer) window.clearInterval(layerState.pageTimer); + layerState.pageTimer = null; + } + + /** + * Whether an evaluation actually has a contact to point an arrow at. + * + * A cohort reports `count: null` when its feed is unavailable or stale, and `0` + * when the feed is healthy but empty. Only a positive count puts a marker on + * the compass rim, so anything else means there is nothing to animate. + * @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results + * @returns {boolean} + */ + + function awarenessResultsAreLive(results) { + const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : []; + return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0); + } + return { + ensurePanel, + hidePanel, + rowHtml, + navigationControlsHtml, + awarenessPanelControlKey, + captureAwarenessPanelFocus, + restoreAwarenessPanelFocus, + escapeHtml, + renderResults, + rotateAwarenessPages, + startAwarenessPageRotation, + stopAwarenessPageRotation, + awarenessResultsAreLive, + }; +} diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js new file mode 100644 index 000000000..621c3983a --- /dev/null +++ b/src/layers/awareness/policy.js @@ -0,0 +1,68 @@ +export const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; + +export const DEFERRED_DEPENDENCIES = [ + 'ais-live-vessels', + 'military-installations', +]; + +export const DEPENDENCIES = [ + ...AIRCRAFT_DEPENDENCIES, + ...DEFERRED_DEPENDENCIES, +]; + +export const AWARENESS_REFRESH_MS = 750; + +/** @constant {number} Refresh cadence while the camera pose is CHANGING. + * Owner playtest 2026-08-18: the Contacts direction arrows and card readouts + * "feel sluggish when you look around" — at the parked 750 ms cadence the + * arrows lag the view by up to three quarters of a second. */ + +export const AWARENESS_MOTION_REFRESH_MS = 175; + +/** @constant {number} How long the pose signature must stay UNCHANGED before + * the view counts as parked again. The signature is quantized, so a slow drag + * crosses a bin only every few frames; without this hysteresis every crossing + * read as a motion-end and refreshed at nearly display rate. */ + +export const AWARENESS_MOTION_SETTLE_MS = 250; + +export const AWARENESS_REEVALUATE_DISTANCE_M = 250; + +export const AWARENESS_PAGE_ROTATE_MS = 10000; + +export const AWARENESS_PAGE_SIZE = 3; + +export const AWARENESS_MAX_EXAMPLES = 10; + +// Navigation walks the whole 250 km cohort, not the ten rows the panel shows, +// so these caps sit far above any realistic in-range population rather than at +// the display limit. They are still FINITE: an unbounded materialization would +// let a pathological feed sort an eleven-thousand-contact array on every +// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort` +// derives `count` from the full in-range set before any slice. + +export const AWARENESS_QUERY_LIMIT = 20000; + +export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000; + +export const CONTEXT_RIM_HEIGHT_M = 2500; + +export const VESSEL_FOCUS_RADIUS_M = 3000; + +export const SOURCE_LABEL = { + flights: 'OpenSky', + military: 'adsb.lol', + 'ais-live-vessels': 'AISStream', + 'military-installations': 'OpenStreetMap', +}; + +/** + * Whether a refresh tick could confirm the subject in its source collection. + * UNCHECKED means the tick did not look — it must never change the verdict. + */ + +export const SUBJECT_PRESENCE = Object.freeze({ + LIVE: 'live', + MISSING: 'missing', + UNCHECKED: 'unchecked', +}); diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js new file mode 100644 index 000000000..d5a2c9e5c --- /dev/null +++ b/src/layers/awareness/queries.js @@ -0,0 +1,164 @@ +import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; +import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js'; + +export function createQueries({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + function sourceState(layerId) { + const lifecycle = + layerState.dataManager?.getLayerLifecycleState?.(layerId) || null; + const enabled = + lifecycle?.enabled === true || + layerState.dataManager?.isEnabled(layerId) === true; + const enabling = lifecycle?.lifecycleState === 'enabling'; + const moduleStats = + layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {}; + const stats = enabling + ? { ...moduleStats, loading: true, status: 'loading' } + : moduleStats; + // A deferred source that has started but not settled is not evidence of an + // empty cohort. Keep it explicitly unavailable so the panel cannot flash a + // false all-clear, even if a lifecycle adapter briefly reports the requested + // visibility intent as enabled. + // + // `enabling` and the never-answered predicate cover two DIFFERENT windows, and + // both are needed because these dependencies do not settle the same way. + // + // - military-installations is covered by `enabling` alone. Its enable() is + // SYNCHRONOUS and the manager awaits update(), which owns the first + // Overpass fetch, so the lifecycle stays `enabling` for that whole window + // however long the fetch takes. Confirmed live: a held 17 s first fetch + // read `enabling` across 34 samples with the panel non-numeric throughout, + // and a failing one settled to `enabled` with status 'unavailable'. Its + // getStats() also reports loading while a request is in progress — + // setInstallationStatus is only ever called with + // loading/zoom-in/ready/stale/empty/unavailable. + // - ais-live-vessels is what the predicate below is FOR. Its enable() and + // update() both resolve as soon as the first /api/ais-live poll answers, + // so the lifecycle settles to `enabled` — but until the server-side socket + // delivers a position, firstConnectPhase is 'loading' and getStats() + // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED + // status. That zero is an absence of evidence, not evidence of absence, + // and neither the lifecycle nor the status list can tell. + // + // Deliberately `loading === true` and not truthiness: layers that never report + // a busy flag (flights, military) must keep answering for themselves, and a + // source that HAS answered once (lastUpdate set) keeps its last real count + // through every later poll rather than blanking to `?` on each refresh. + const neverAnswered = stats.loading === true && !stats.lastUpdate; + const unavailable = + enabling || + !enabled || + neverAnswered || + ['unavailable', 'zoom-in'].includes(stats.status) || + Boolean(stats.error && stats.count === 0); + return { available: !unavailable, stale: Boolean(stats.stale), stats }; + } + + function collectSourceStates() { + return Object.fromEntries( + DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]), + ); + } + + function sourceRevision(sourceStates) { + return DEPENDENCIES.map((layerId) => { + const source = sourceStates[layerId]; + const stats = source?.stats || {}; + return [ + layerId, + source?.available, + source?.stale, + stats.lastUpdate || null, + stats.count ?? null, + stats.status || null, + stats.error || null, + ]; + }) + .map((parts) => parts.join(':')) + .join('|'); + } + + function isSame(subject, item, prefix, key) { + return ( + subject?.layerId === prefix && String(subject.id) === String(item?.[key]) + ); + } + + /** + * Summarize viewport-backed installations without implying radius-complete coverage. + * @param {Array} items Loaded installation matches. + * @param {object} source Current source availability and staleness. + * @returns {object} Awareness summary with viewport-honest reasoning. + */ + + function summarizeInstallationViewport(items, source) { + const summary = parts.navigation.summarizeAwarenessCohortForNavigation( + items, + source, + ); + if (summary.count === null) + return source.stats?.statusMessage + ? { ...summary, reason: source.stats.statusMessage } + : summary; + return { + ...summary, + reason: summary.count + ? 'mapped matches from the loaded viewport' + : 'viewport feed is not a complete 250 km survey', + }; + } + + /** + * THE aircraft-proximity engine. One computation, two consumers: the Contacts + * panel window and the voice analyst's entity-centred "how many nearby". + * + * They used to be separate. The panel read live billboard positions through + * `getNearby` with a 20 000 cap; the analyst re-derived its own answer from + * last-fix coordinates over a 2 000-record slice. Same question, same centre, + * two numbers — and in the owner's trial the spoken answer (15) and the panel + * (111) disagreed badly enough that the model narrated the difference away. + * Routing both through here makes them the same number BY CONSTRUCTION, so + * they cannot drift again. + * + * The subject is excluded from its own window, which is why the panel reads + * "contacts around X" rather than "including X". + * @param {Cesium.Cartesian3} position Window centre. + * @param {object} [options] + * @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius. + * @param {object|null} [options.subject=null] Contact at the centre, excluded. + * @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts + * plus the combined aircraft count, or null without a position. + */ + + function collectAircraftProximityWindow( + position, + { radiusM = AWARENESS_RADIUS_M, subject = null } = {}, + ) { + if (!position) return null; + const flights = flightsLayer + .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { + includeHidden: true, + }) + .filter( + (item) => !subject || !isSame(subject, item, 'flights', 'icao24'), + ); + const military = militaryFlightsLayer + .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { + includeHidden: true, + }) + .filter( + (item) => !subject || !isSame(subject, item, 'military', 'icao24'), + ); + return { flights, military, aircraft: flights.length + military.length }; + } + return { + sourceState, + collectSourceStates, + sourceRevision, + isSame, + summarizeInstallationViewport, + collectAircraftProximityWindow, + }; +} diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js new file mode 100644 index 000000000..8ef9b2afe --- /dev/null +++ b/src/layers/awareness/rendering.js @@ -0,0 +1,288 @@ +import * as Cesium from 'cesium'; +import { + formatAwarenessLabel, + formatAwarenessDistance, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { bearingBetweenCoordinates } from '../../cockpitMath.js'; +import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { getKeyholeGeometry, celestialScreenAngle } = services.geometry; + const { governorRequestRender } = services.render; + + function ensureDirectionOverlay() { + if (layerState.directionRoot || !layerState.viewer) + return layerState.directionRoot; + const root = document.createElement('div'); + root.id = 'military-awareness-direction-overlay'; + root.hidden = true; + root.setAttribute('aria-hidden', 'true'); + + const compass = document.createElement('div'); + compass.className = 'military-awareness-compass-ring'; + const cardinals = [ + ['N', 0], + ['E', 90], + ['S', 180], + ['W', 270], + ]; + layerState.compassLabels = cardinals.map(([label, bearing]) => { + const element = document.createElement('span'); + element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`; + element.textContent = label; + element.dataset.bearing = String(bearing); + root.appendChild(element); + return element; + }); + const heading = document.createElement('span'); + heading.className = 'military-awareness-compass-heading'; + root.append(compass, heading); + + layerState.directionMarkers = Array.from({ length: 3 }, () => { + const marker = document.createElement('div'); + marker.className = 'military-awareness-direction-marker'; + const arrow = document.createElement('span'); + arrow.className = 'military-awareness-direction-arrow'; + arrow.textContent = '➜'; + const label = document.createElement('span'); + label.className = 'military-awareness-direction-label'; + marker.append(arrow, label); + marker._arrow = arrow; + marker._label = label; + marker._lastAngle = 0; + root.appendChild(marker); + return marker; + }); + + layerState.compassRing = compass; + layerState.compassHeading = heading; + layerState.viewer.container.appendChild(root); + layerState.directionRoot = root; + return root; + } + + function scheduleDirectionOverlayUpdate(force = false) { + if (layerState.directionFrame !== null || !layerState.enabled) return; + const now = Date.now(); + if ( + !force && + now - layerState.lastDirectionUpdateMs < + parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving) + ) + return; + layerState.directionFrame = window.requestAnimationFrame(() => { + layerState.directionFrame = null; + layerState.lastDirectionUpdateMs = Date.now(); + updateDirectionOverlay(); + }); + } + + function cancelDirectionOverlayUpdate() { + if (layerState.directionFrame !== null) + window.cancelAnimationFrame(layerState.directionFrame); + layerState.directionFrame = null; + layerState.lastDirectionUpdateMs = 0; + } + + function updateDirectionOverlay() { + const root = ensureDirectionOverlay(); + if ( + !root || + !layerState.enabled || + !layerState.subject?.position || + !layerState.results + ) { + if (root) root.hidden = true; + return; + } + + const canvas = layerState.viewer.scene.canvas; + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + const geometry = getKeyholeGeometry(width, height); + if (!(geometry.radius > 0)) { + root.hidden = true; + return; + } + + root.hidden = false; + // Keep the compass comfortably inside the shader keyhole so its cardinals + // remain visible around the subject instead of hiding under edge panels. + const compassRadius = Math.max( + 120, + Math.min(geometry.radius - 48, geometry.radius * 0.66), + ); + layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`; + layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`; + layerState.compassRing.style.width = `${compassRadius * 2}px`; + layerState.compassRing.style.height = `${compassRadius * 2}px`; + + const cameraHeading = layerState.viewer.camera.heading || 0; + const headingDeg = + (Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360; + layerState.compassRing.style.setProperty( + '--compass-rotation', + `${-headingDeg}deg`, + ); + layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`; + layerState.compassHeading.style.left = `${geometry.centerX}px`; + layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`; + for (const label of layerState.compassLabels) { + const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing)); + const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO; + const labelRadius = compassRadius - 17; + label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`; + label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`; + } + + const directionalCohort = + layerState.results.cohorts.find( + (cohort) => cohort.id === layerState.subject.layerId, + ) || + layerState.results.cohorts.find((cohort) => cohort.id === 'military'); + const page = layerState.cohortPages.get(directionalCohort?.id) || 0; + const military = + directionalCohort?.summary.nearest.slice( + page, + page + AWARENESS_PAGE_SIZE, + ) || []; + for (let index = 0; index < layerState.directionMarkers.length; index++) { + const marker = layerState.directionMarkers[index]; + const item = military[index]; + if (!item?.position) { + marker.hidden = true; + continue; + } + const direction = Cesium.Cartesian3.subtract( + item.position, + layerState.subject.position, + layerState.DIRECTION_SCRATCH[index], + ); + if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) { + marker.hidden = true; + continue; + } + Cesium.Cartesian3.normalize(direction, direction); + const projection = celestialScreenAngle( + Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC), + Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC), + marker._lastAngle, + ); + if (projection.stable) marker._lastAngle = projection.angle; + // Every detected contact shares the compass rim. Keeping the bearing + // markers on one radius makes them read as compass contacts rather than + // unconstrained labels floating over the map. + const markerRadius = compassRadius; + marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`; + marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`; + marker.style.opacity = String(projection.opacity); + marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`; + marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`; + const label = formatAwarenessLabel(item); + const subjectCartographic = Cesium.Cartographic.fromCartesian( + layerState.subject.position, + undefined, + layerState.SUBJECT_CARTOGRAPHIC_SCRATCH, + ); + const targetCartographic = Cesium.Cartographic.fromCartesian( + item.position, + undefined, + layerState.TARGET_CARTOGRAPHIC_SCRATCH[index], + ); + const bearing = + subjectCartographic && targetCartographic + ? bearingBetweenCoordinates( + Cesium.Math.toDegrees(subjectCartographic.latitude), + Cesium.Math.toDegrees(subjectCartographic.longitude), + Cesium.Math.toDegrees(targetCartographic.latitude), + Cesium.Math.toDegrees(targetCartographic.longitude), + ) + : null; + const bearingText = Number.isFinite(bearing) + ? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°` + : 'BRG —'; + const courseText = Number.isFinite(item.track) + ? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°` + : ''; + marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; + marker.hidden = false; + } + } + + function clearVisual() { + if (layerState.visual?.entities && layerState.viewer) { + for (const entity of layerState.visual.entities) + layerState.viewer.entities.remove(entity); + // Idle mode renders only on request; a removed ring must not linger. + governorRequestRender('awareness-visual'); + } + layerState.visual = null; + } + + function renderVisual(subject) { + if (!layerState.viewer || !subject?.position) return; + const cartographic = Cesium.Cartographic.fromCartesian(subject.position); + if (!cartographic) return; + const groundCenter = Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + 0, + ); + const key = `${subject.layerId}:${subject.id}`; + if (layerState.visual?.key === key) { + // Large ellipse geometry is more reliable as a ConstantPositionProperty. + // Move it only after a meaningful displacement so live tracking does not + // churn ten large geometries for sub-pixel motion every 750 ms. + if ( + Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >= + 250 + ) { + for (const entity of layerState.visual.entities) + entity.position.setValue(groundCenter); + Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); + // Content actually changed — buy exactly one frame instead of holding. + governorRequestRender('awareness-visual'); + } + return; + } + clearVisual(); + + const entities = []; + + entities.push( + layerState.viewer.entities.add({ + position: groundCenter, + ellipse: { + semiMajorAxis: AWARENESS_RADIUS_M, + semiMinorAxis: AWARENESS_RADIUS_M, + fill: false, + outline: true, + outlineColor: + Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72), + height: CONTEXT_RIM_HEIGHT_M, + }, + }), + ); + + layerState.visual = { + key, + entities, + center: Cesium.Cartesian3.clone(groundCenter), + }; + governorRequestRender('awareness-visual'); + } + return { + ensureDirectionOverlay, + scheduleDirectionOverlayUpdate, + cancelDirectionOverlayUpdate, + updateDirectionOverlay, + clearVisual, + renderVisual, + }; +} diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js new file mode 100644 index 000000000..17ffd0e28 --- /dev/null +++ b/src/layers/awareness/state.js @@ -0,0 +1,70 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const state = {}; + + state.DIRECTION_SCRATCH = Array.from( + { length: 3 }, + () => new Cesium.Cartesian3(), + ); + + state.SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic(); + + state.TARGET_CARTOGRAPHIC_SCRATCH = Array.from( + { length: 3 }, + () => new Cesium.Cartographic(), + ); + + Object.assign(state, { + viewer: null, + dataManager: null, + enabled: false, + // Set once a refresh observes the subject gone from a still-reporting source. + // The Contact readout turns this into its CONTACT LOST hold state. + subjectMissing: false, + // The dedicated Context chooser exposes a passive shell. Operational source + // layers activate only after Contacts is explicitly selected. + passive: true, + ownedDependencies: new Set(), + subject: null, + results: null, + visual: null, + panel: null, + panelOwned: false, + subjectListener: null, + contextListener: null, + clearListener: null, + subjectClearListener: null, + runtimeListenersAttached: false, + preRenderRemover: null, + panelClickListener: null, + directionRoot: null, + compassRing: null, + compassLabels: [], + compassHeading: null, + directionMarkers: [], + navigationHistory: [], + navigationVisited: new Set(), + navigationIndex: -1, + suppressedHistoryKey: null, + pendingSelectionKey: null, + lastSubjectRefreshMs: 0, + /** Camera-pose signature observed on the previous rendered frame. */ + lastCameraPoseSig: '', + /** When the pose signature last changed bins (hysteresis anchor). */ + lastCameraPoseChangeMs: 0, + /** Whether the view currently counts as moving (hysteretic, not per-frame). */ + cameraMoving: false, + lastEvaluatedPosition: null, + sourceRevision: '', + panelMarkup: '', + directionFrame: null, + lastDirectionUpdateMs: 0, + activationId: 0, + autoFocusAttempted: false, + autoFocusRetryPending: false, + pageTimer: null, + cohortPages: new Map(), + }); + return state; +} diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js new file mode 100644 index 000000000..742d96f20 --- /dev/null +++ b/src/layers/awareness/subject.js @@ -0,0 +1,447 @@ +import { + AWARENESS_RADIUS_M, + AWARENESS_RELATIONSHIP, +} from '../../data/militaryAwarenessEngine.js'; +import * as Cesium from 'cesium'; +import { + AWARENESS_QUERY_LIMIT, + SOURCE_LABEL, + SUBJECT_PRESENCE, + AWARENESS_PAGE_SIZE, +} from './policy.js'; + +export function createSubject({ state: layerState, services, parts, source }) { + const aisLiveVesselsLayer = services.vessels; + const militaryInstallationsLayer = services.installations; + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + /** + * Decide whether a source-scoped context clear belongs to the current subject. + * @param {{layerId?: string}|null} subject Current awareness subject. + * @param {{layerId?: string}|null} cleared Cleared source detail. + * @returns {boolean} Whether the current subject should be cleared. + */ + + function awarenessClearMatchesSubject(subject, cleared) { + return Boolean(subject?.layerId && cleared?.layerId === subject.layerId); + } + + function evaluateSubject( + subject, + sourceStates = parts.queries.collectSourceStates(), + ) { + const position = subject?.position; + if (!position) return null; + const flightsState = sourceStates.flights; + const militaryState = sourceStates.military; + const vesselsState = sourceStates['ais-live-vessels']; + const installationsState = sourceStates['military-installations']; + // Same engine the voice analyst calls — see collectAircraftProximityWindow. + const { flights, military } = parts.queries.collectAircraftProximityWindow( + position, + { subject }, + ); + const vessels = aisLiveVesselsLayer + .getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) + .filter( + (item) => + !parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'), + ); + const installations = militaryInstallationsLayer + .getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) + .filter( + (item) => + !parts.queries.isSame(subject, item, 'military-installations', 'id'), + ); + return { + subject, + evaluatedAt: Date.now(), + radiusM: AWARENESS_RADIUS_M, + cohorts: [ + { + id: 'flights', + label: 'Flights', + source: flightsState.stats.source || SOURCE_LABEL.flights, + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + flights, + flightsState, + ), + }, + { + id: 'military', + label: 'Military flights', + source: militaryState.stats.source || SOURCE_LABEL.military, + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + military, + militaryState, + ), + }, + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + vessels, + vesselsState, + ), + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: + installationsState.stats.source || + SOURCE_LABEL['military-installations'], + coverage: 'CURRENT VIEWPORT ONLY', + summary: parts.queries.summarizeInstallationViewport( + installations, + installationsState, + ), + }, + ], + }; + } + + function subjectKey(subject) { + return subject ? `${subject.layerId}:${subject.id}` : ''; + } + + function normalizeContextId(value) { + return String(value || '') + .trim() + .toLowerCase(); + } + + function normalizedSubjectKey(subject) { + if (!subject) return ''; + return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`; + } + + /** + * Resolve the source-owned aircraft that currently owns Cesium tracking. + * Both flight layers may briefly retain local state during a cross-layer + * handoff, so the viewer's normalized tracked identity is the tie-breaker. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached awareness subject, or null when no flight owns the follow camera. + */ + + function currentTrackedFlightSubject() { + const trackedKey = normalizeContextId( + layerState.viewer?.trackedEntity?.gevTrackedId, + ); + if (!trackedKey) return null; + const subjects = [ + flightsLayer.getTrackedSubject?.(), + militaryFlightsLayer.getTrackedSubject?.(), + ]; + return ( + subjects.find( + (subject) => normalizedSubjectKey(subject) === trackedKey, + ) || null + ); + } + + function subjectCohortFeedUnknown() { + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === layerState.subject?.layerId, + ); + return ( + cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN && + cohort.summary.count === null + ); + } + + function selectSubject(subject) { + if (!layerState.enabled || !subject?.position) return; + parts.panel.startAwarenessPageRotation(); + const key = subjectKey(subject); + layerState.navigationVisited.add(key); + const suppressHistory = layerState.suppressedHistoryKey === key; + layerState.pendingSelectionKey = null; + layerState.suppressedHistoryKey = null; + if (!suppressHistory) { + const current = layerState.navigationHistory[layerState.navigationIndex]; + if (subjectKey(current) !== key) { + layerState.navigationHistory.splice(layerState.navigationIndex + 1); + layerState.navigationHistory.push({ + ...parts.history.historySubjectSnapshot( + subject, + parts.history.historySourceItem(subject), + ), + position: Cesium.Cartesian3.clone(subject.position), + }); + layerState.navigationIndex = layerState.navigationHistory.length - 1; + } + } + layerState.subject = subject; + // A newly chosen contact starts present; the next refresh re-decides. + layerState.subjectMissing = false; + refreshSelectedSubject(true); + } + + /** + * Resolve the currently displayed position for a selected live subject. Flight + * tracking owns the motion callback, so awareness consumes its cached display + * position instead of creating a second tracker or extrapolation path. + * @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject. + * @param {object} [options] Position materialization controls. + * @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run. + * @returns {Cesium.Cartesian3|null} A cloned live position when one is available. + */ + + function resolveSubjectPosition( + subject, + { allowCollectionMaterialization = true } = {}, + ) { + if (!subject?.position) return null; + if (subject.layerId === 'flights' || subject.layerId === 'military') { + const trackedPosition = + layerState.viewer?.trackedEntity?.gevDisplayPosition?.(); + if (trackedPosition) { + return { + position: Cesium.Cartesian3.clone(trackedPosition), + // Only the follow camera's own contact proves presence this way; a + // different tracked entity says nothing about this subject. + presence: + String(layerState.viewer?.trackedEntity?.gevTrackedId || '') === + subjectKey(subject) + ? SUBJECT_PRESENCE.LIVE + : SUBJECT_PRESENCE.UNCHECKED, + }; + } + // Cockpit already materializes its tracked position on a fixed 20 Hz loop. + // If that frame-owned cache is temporarily unavailable, keep the last + // awareness position instead of allocating/scanning up to 1,000 contacts. + if (!allowCollectionMaterialization) { + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.UNCHECKED, + }; + } + const layer = + subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; + return collectionSubjectPosition( + subject, + layer.getAllPositions(1000), + layer, + ); + } + if (subject.layerId === 'ais-live-vessels') { + if (!allowCollectionMaterialization) { + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.UNCHECKED, + }; + } + return collectionSubjectPosition( + subject, + aisLiveVesselsLayer.getAllPositions(12000), + aisLiveVesselsLayer, + ); + } + // Mapped installations come from static geometry, not a live feed: there is + // nothing to be culled from. + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.LIVE, + }; + } + + /** + * Locate a subject inside its layer's live collection, keeping the last known + * position when it is gone. + * + * Presence comes from the layer's O(1) `hasContact`, never from the rows: + * `getAllPositions` stops at its cap and the flights layer routinely carries + * ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone". + * The rows are only consulted for a fresher position — a contact past the cap + * keeps its last known position while still reading as present. + * @param {{id: string, position: Cesium.Cartesian3}} subject Current subject. + * @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows. + * @param {{hasContact?: function}} layer The owning source layer. + * @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict. + */ + + function collectionSubjectPosition(subject, rows, layer) { + const collection = Array.isArray(rows) ? rows : []; + const current = collection.find( + (item) => String(item.id) === String(subject.id), + ); + // null/undefined means the layer cannot answer (disabled or not yet loaded). + const known = layer?.hasContact?.(subject.id); + const presence = + known === true + ? SUBJECT_PRESENCE.LIVE + : known === false + ? SUBJECT_PRESENCE.MISSING + : SUBJECT_PRESENCE.UNCHECKED; + return { + position: current?.position + ? Cesium.Cartesian3.clone(current.position) + : Cesium.Cartesian3.clone(subject.position), + presence, + }; + } + + /** + * Re-resolve a live subject's DISPLAY label from the layer that owns it. + * + * The subject snapshot is captured once at selection time, but a contact's + * label INPUTS can arrive later: adsbdb enrichment supplies a registration + * seconds after a callsign-less aircraft is selected, so every other surface + * swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex. + * The owning layer's tracked-subject accessor already applies the layer's + * label convention (callsign → registration → icao24), so ask it rather than + * re-deriving the chain here. + * + * Identity (`subject.id`) is NEVER re-derived — only the rendered string. + * @param {{layerId: string, id: string, label: string}} subject Current subject. + * @returns {string} The current label, or the cached one when the owning layer + * has no tracked subject (e.g. a cross-layer handoff is in flight). + */ + + function resolveSubjectLabel(subject) { + if (!parts.navigation.isFlightLayer(subject?.layerId)) + return subject?.label; + const layer = + subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; + const tracked = layer.getTrackedSubject?.(); + if (tracked?.label && String(tracked.id) === String(subject.id)) + return tracked.label; + return subject.label; + } + + /** Refresh proximity counts/distances against the subject's current live position. */ + + function refreshSelectedSubject(force = false) { + if (!layerState.enabled || !layerState.subject) return; + const sources = parts.queries.collectSourceStates(); + const nextSourceRevision = parts.queries.sourceRevision(sources); + const sourceRevisionChanged = + nextSourceRevision !== layerState.sourceRevision; + const resolved = resolveSubjectPosition(layerState.subject, { + allowCollectionMaterialization: + !document.body?.classList?.contains('cockpit-mode') || + sourceRevisionChanged, + }); + if (!resolved) return; + const { position, presence } = resolved; + // UNCHECKED leaves the verdict alone: this tick simply did not look. + if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false; + else if (presence === SUBJECT_PRESENCE.MISSING) + layerState.subjectMissing = true; + const nextLabel = resolveSubjectLabel(layerState.subject); + const labelChanged = nextLabel !== layerState.subject.label; + layerState.subject = { ...layerState.subject, position, label: nextLabel }; + const movementM = layerState.lastEvaluatedPosition + ? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position) + : Infinity; + if ( + !parts.model.awarenessRefreshRequired({ + force, + hasResults: Boolean(layerState.results), + movementM, + sourceRevisionChanged, + }) + ) { + layerState.results.subject = layerState.subject; + parts.rendering.renderVisual(layerState.subject); + // The panel markup embeds `subject.label`, so a label that changed while + // the contact was stationary (async enrichment answering after selection) + // is render-worthy ON ITS OWN. Without this the standalone Context panel + // kept the ICAO hex until unrelated movement, a source-revision bump, or + // page rotation happened to repaint it. Gated on an ACTUAL change so the + // common no-op refresh (every 750 ms) still costs no repaint. + if (labelChanged) parts.panel.renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(force); + return; + } + layerState.results = evaluateSubject(layerState.subject, sources); + layerState.sourceRevision = nextSourceRevision; + layerState.lastEvaluatedPosition = Cesium.Cartesian3.clone( + position, + layerState.lastEvaluatedPosition, + ); + for (const cohort of layerState.results.cohorts) { + const maxPage = Math.max( + 0, + Math.floor( + Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE, + ) * AWARENESS_PAGE_SIZE, + ); + layerState.cohortPages.set( + cohort.id, + Math.min(layerState.cohortPages.get(cohort.id) || 0, maxPage), + ); + } + parts.rendering.renderVisual(layerState.subject); + parts.panel.renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(force); + } + + function subjectFromContext(record) { + if ( + !record || + !['ais-live-vessels', 'military-installations'].includes(record.layerId) + ) + return null; + const latitude = Number(record.latitude); + const longitude = Number(record.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { + layerId: record.layerId, + id: record.properties?.mmsi || record.id, + label: record.label || record.id, + position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), + }; + } + + /** + * Record that the current subject left its feed on its own. + * + * The subject, its cohort results and its navigation history all survive: the + * Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS / + * NEXT stay operable so the operator can step off. Nulling the subject here + * instead would take the panel — and those controls — off screen. + */ + + function markSubjectEvicted() { + if (!layerState.subject) return; + layerState.subjectMissing = true; + } + + function clearAwarenessSubject() { + layerState.autoFocusRetryPending = false; + layerState.subject = null; + layerState.subjectMissing = false; + layerState.results = null; + layerState.navigationHistory = []; + layerState.navigationVisited.clear(); + layerState.navigationIndex = -1; + layerState.suppressedHistoryKey = null; + layerState.pendingSelectionKey = null; + layerState.lastEvaluatedPosition = null; + layerState.sourceRevision = ''; + layerState.cohortPages.clear(); + parts.panel.stopAwarenessPageRotation(); + parts.rendering.clearVisual(); + parts.panel.hidePanel(); + } + return { + awarenessClearMatchesSubject, + evaluateSubject, + subjectKey, + normalizeContextId, + normalizedSubjectKey, + currentTrackedFlightSubject, + subjectCohortFeedUnknown, + selectSubject, + resolveSubjectPosition, + collectionSubjectPosition, + resolveSubjectLabel, + refreshSelectedSubject, + subjectFromContext, + markSubjectEvicted, + clearAwarenessSubject, + }; +} diff --git a/src/layers/awareness/testing.js b/src/layers/awareness/testing.js new file mode 100644 index 000000000..d7bee51cd --- /dev/null +++ b/src/layers/awareness/testing.js @@ -0,0 +1,15 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */ + + function _getAwarenessNavigationStateForTest() { + return { + historyKeys: layerState.navigationHistory.map(parts.subject.subjectKey), + navigationVisitedKeys: [...layerState.navigationVisited], + historyLength: layerState.navigationHistory.length, + navigationIndex: layerState.navigationIndex, + suppressedHistoryKey: layerState.suppressedHistoryKey, + pendingSelectionKey: layerState.pendingSelectionKey, + }; + } + return { _getAwarenessNavigationStateForTest }; +} diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js new file mode 100644 index 000000000..bfeccf924 --- /dev/null +++ b/src/layers/bikeshare/controls.js @@ -0,0 +1,46 @@ +import { STATUS_POLL_MS } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'bikeshare', + + name: 'Bikeshare', + + icon: '🚲', + + source: 'GBFS', + + updateInterval: STATUS_POLL_MS, + + /** + * 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 parts.queries.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: layerState._count, + lastUpdate: layerState._lastUpdate, + loading: layerState._loading, + }; + if (layerState._loading) { + stats.loadingLabel = + layerState._activeCityIds.size > 0 + ? `syncing ${layerState._activeCityIds.size} city feeds...` + : 'scanning nearby systems...'; + } + if (layerState._error) stats.error = layerState._error; + return stats; + }, + }; + + return { methods }; +} diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js new file mode 100644 index 000000000..b66bbae35 --- /dev/null +++ b/src/layers/bikeshare/index.js @@ -0,0 +1,50 @@ +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createQueries } from './queries.js'; +import { createTesting } from './testing.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createBikeshareLayer({ services, source }) { + if (typeof source?.getStations !== 'function') + throw new TypeError('A bikeshare source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.ingestion = createIngestion(context); + parts.model = createModel(context); + parts.viewport = createViewport(context); + parts.rendering = createRendering(context); + parts.selection = createSelection(context); + parts.queries = createQueries(context); + parts.testing = createTesting(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + createBikeshareSelectedOverlayEntry: + parts.selection.createBikeshareSelectedOverlayEntry, + _setBikeshareSelectionStateForTest: + parts.testing._setBikeshareSelectionStateForTest, + _selectBikeshareStationForTest: + parts.testing._selectBikeshareStationForTest, + _clearBikeshareSelectionForTest: + parts.testing._clearBikeshareSelectionForTest, + }, + ); +} +export { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export { createBikeshareSource } from './source.js'; diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js new file mode 100644 index 000000000..732ec9c90 --- /dev/null +++ b/src/layers/bikeshare/ingestion.js @@ -0,0 +1,212 @@ +import { CITY_BY_ID } from './registry.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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() { + layerState._loadingOps++; + layerState._loading = true; + } + + /** Decrement the loading reference count; clear loading flag when zero. */ + + function endLoading() { + layerState._loadingOps = Math.max(0, layerState._loadingOps - 1); + layerState._loading = layerState._loadingOps > 0; + } + + /** + * 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, options) { + return source.getStations(upstreamUrl, options); + } + + /** + * 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 layerState._inFlightInfo.keys()) + abortInFlight(layerState._inFlightInfo, cityId); + for (const cityId of layerState._inFlightStatus.keys()) + abortInFlight(layerState._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 (layerState._stationInfoCache.has(cityId)) + return layerState._stationInfoCache.get(cityId); + const inFlight = layerState._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 = parts.model.parseStationInformation(payload); + if (stationMap.size === 0) { + throw new Error(`No station information for ${city.city}`); + } + layerState._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 = layerState._inFlightInfo.get(cityId); + if (current && current.controller === controller) + layerState._inFlightInfo.delete(cityId); + }); + + layerState._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 = layerState._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 = parts.model.parseStationStatus(payload); + layerState._statusCache.set(cityId, { + statusMap, + timestamp: Date.now(), + }); + return statusMap; + } finally { + endLoading(); + } + })().finally(() => { + const current = layerState._inFlightStatus.get(cityId); + if (current && current.controller === controller) + layerState._inFlightStatus.delete(cityId); + }); + + layerState._inFlightStatus.set(cityId, { promise, controller, generation }); + return promise; + } + const methods = { + /** + * 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 (!layerState._enabled || layerState._activeCityIds.size === 0) return; + + const generation = layerState._proximityGeneration; + const cityIds = Array.from(layerState._activeCityIds); + await Promise.all( + cityIds.map(async (cityId) => { + try { + const statusMap = await loadCityStationStatus(cityId, generation); + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + parts.rendering.applyStatusToPoints(cityId, statusMap); + } catch (error) { + if (error?.name === 'AbortError') return; + console.warn( + `[Data:Bikeshare] ${cityId} status update error:`, + error, + ); + layerState._error = 'GBFS status update failed'; + } + }), + ); + + layerState._count = layerState._stationRenderMap.size; + if (layerState._count > 0) layerState._lastUpdate = Date.now(); + }, + }; + + return { + toProxyUrl, + beginLoading, + endLoading, + fetchGbfsJson, + abortInFlight, + abortAllInFlight, + loadCityStationInfo, + loadCityStationStatus, + methods, + }; +} diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js new file mode 100644 index 000000000..aeabac64b --- /dev/null +++ b/src/layers/bikeshare/lifecycle.js @@ -0,0 +1,171 @@ +import * as Cesium from 'cesium'; +import { GBFS_CITY_REGISTRY } from './registry.js'; +import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + /** + * 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) { + layerState._viewer = viewer; + layerState._pointCollection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + viewer.scene.primitives.add(layerState._pointCollection); + registerSpriteCollection('bikeshare', layerState._pointCollection); + layerState._pointCollection.show = false; + + layerState._enabled = false; + layerState._cameraDebounceTimer = null; + layerState._cameraChangedAttached = false; + layerState._altitudeGateEnabled = false; + layerState._proximityGeneration = 0; + + layerState._activeCityIds = new Set(); + layerState._cityRuntime = new Map(); + layerState._stationInfoCache = new Map(); + layerState._statusCache = new Map(); + layerState._inFlightInfo = new Map(); + layerState._inFlightStatus = new Map(); + layerState._stationRenderMap = new Map(); + layerState._clickHandler = null; + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._loading = false; + layerState._loadingOps = 0; + layerState._error = null; + layerState._limitWarned = false; + + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + + parts.selection._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) { + layerState._enabled = true; + layerState._error = null; + layerState._pointCollection.show = true; + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + true, + ); + parts.selection._installClickHandler(viewer); + // Pick-ownership (H2): station point ids are string render-map keys. + registerPickOwner('bikeshare', (pickedId) => + layerState._stationRenderMap.has(pickedId), + ); + + if (!layerState._cameraChangedAttached) { + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + viewer.camera.percentageChanged = Math.min( + viewer.camera.percentageChanged || 1, + 0.05, + ); + layerState._cameraChangedAttached = true; + } + + void parts.viewport.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) { + layerState._enabled = false; + layerState._proximityGeneration++; + layerState._altitudeGateEnabled = false; + clearTimeout(layerState._cameraDebounceTimer); + layerState._cameraDebounceTimer = null; + parts.selection._clearSelection(); + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + document.removeEventListener('keydown', parts.selection._onKeyDown); + unregisterPickOwner('bikeshare'); + + if (layerState._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + layerState._cameraChangedAttached = false; + } + + parts.ingestion.abortAllInFlight(); + parts.viewport.deactivateAllCities(); + layerState._cityRuntime.clear(); + layerState._pointCollection.show = false; + layerState._count = 0; + layerState._loading = false; + layerState._loadingOps = 0; + }, + + /** Permanently release primitives, handlers, and the selected host source. */ + destroy(viewer) { + if (layerState._enabled) this.disable(viewer); + else { + parts.selection._clearSelection(); + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + document.removeEventListener('keydown', parts.selection._onKeyDown); + unregisterPickOwner('bikeshare'); + } + if (layerState._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + layerState._cameraChangedAttached = false; + } + parts.ingestion.abortAllInFlight(); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + layerState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js new file mode 100644 index 000000000..89628f613 --- /dev/null +++ b/src/layers/bikeshare/model.js @@ -0,0 +1,162 @@ +export function createModel({ state: layerState, services, parts, source }) { + /** + * 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}`; + } + + /** + * 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)); + } + + /** + * 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; + } + return { + normalizeGbfsBool, + toNonNegativeInteger, + stationKey, + haversineKm, + extractStationsArray, + parseStationInformation, + parseStationStatus, + }; +} diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js new file mode 100644 index 000000000..14f1edf6e --- /dev/null +++ b/src/layers/bikeshare/policy.js @@ -0,0 +1,85 @@ +import * as Cesium from 'cesium'; + +export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected'; + +export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 0, + moving: false, +}); + +// --- Activation / display thresholds --- +/** Altitude (m) at which bikeshare layer becomes eligible for display. */ + +export const ACTIVATION_ALTITUDE_M = 50000; + +/** Hysteresis enter threshold — layer activates when camera drops below this. */ + +export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000; + +/** Hysteresis exit threshold — layer deactivates when camera rises above this. */ + +export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000; + +/** Debounce interval (ms) for camera-change proximity checks. */ + +export const CAMERA_DEBOUNCE_MS = 340; + +/** Default radius (km) for determining whether a city is in camera range. */ + +export const CITY_RANGE_BASE_KM = 100; + +/** Polling interval (ms) for refreshing station status data. */ + +export const STATUS_POLL_MS = 60000; + +// --- Point rendering constants --- +/** Minimum rendered point size in pixels. */ + +export const POINT_SIZE_MIN = 4; + +/** Maximum rendered point size in pixels. */ + +export const POINT_SIZE_MAX = 12; + +/** Fallback station capacity when real data is unavailable. */ + +export const DEFAULT_CAPACITY = 15; + +/** Vertical offset (m) above terrain for station points. */ + +export const POINT_HEIGHT_OFFSET_M = 2.0; + +/** Hard cap on total rendered station points across all cities. */ + +export const MAX_TOTAL_POINTS = 8000; + +// --- Availability color palette --- +/** Station has >60% bikes available. */ + +export const COLOR_GREEN = + Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95); + +/** Station has 30-60% bikes available. */ + +export const COLOR_YELLOW = + Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94); + +/** Station has <30% bikes available. */ + +export const COLOR_RED = + Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94); + +/** No status data available for station. */ + +export const COLOR_NEUTRAL = + Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62); + +/** Station is offline (not installed, not renting, or not returning). */ + +export const COLOR_MUTED = + Cesium.Color.fromCssColorString('#687581').withAlpha(0.48); + +/** Outline color for all station points. */ + +export const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25); diff --git a/src/layers/bikeshare/queries.js b/src/layers/bikeshare/queries.js new file mode 100644 index 000000000..550dbf030 --- /dev/null +++ b/src/layers/bikeshare/queries.js @@ -0,0 +1,75 @@ +export function createQueries({ state: layerState, services, parts, source }) { + /** + * 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 ( + !layerState._enabled || + !layerState._pointCollection || + !layerState._pointCollection.show || + layerState._stationRenderMap.size === 0 + ) + return []; + + // Gather all visible station records (include selected even though its point is hidden) + const records = []; + for (const record of layerState._stationRenderMap.values()) { + const isSelected = record.key === layerState._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 === layerState._selectedKey, + }); + if (result.length >= maxCount) break; + } + + return result; + } + return { buildDetectionId, collectDetectableStations }; +} diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js new file mode 100644 index 000000000..ff752f664 --- /dev/null +++ b/src/layers/bikeshare/registry.js @@ -0,0 +1,394 @@ +function buildBcycleUrls(systemId) { + return { + stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`, + stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`, + }; +} +function bcycleEntry({ + id, + city, + centerLat, + centerLon, + systemId, + loadRadiusKm = 100, + provider = 'BCycle', +}) { + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + ...buildBcycleUrls(systemId), + }; +} +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.015, + 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.891, + 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.512, + systemId: 'bcycle_cincyredbike', + provider: 'Red Bike', + }), + bcycleEntry({ + id: 'el-paso-bcycle', + city: 'El Paso, TX', + centerLat: 31.7619, + centerLon: -106.485, + 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.049, + systemId: 'bcycle_memphis', + }), + bcycleEntry({ + id: 'des-moines-bcycle', + city: 'Des Moines, IA', + centerLat: 41.5868, + centerLon: -93.625, + 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.394, + 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', + }), +]; +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, + }; +} +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; + }); +})(); +const CITY_BY_ID = new Map( + GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry]), +); +export { GBFS_CITY_REGISTRY, CITY_BY_ID }; diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js new file mode 100644 index 000000000..f0c737d1d --- /dev/null +++ b/src/layers/bikeshare/rendering.js @@ -0,0 +1,282 @@ +import * as Cesium from 'cesium'; +import { + DEFAULT_CAPACITY, + POINT_SIZE_MIN, + POINT_SIZE_MAX, + COLOR_NEUTRAL, + COLOR_MUTED, + COLOR_GREEN, + COLOR_YELLOW, + COLOR_RED, + POINT_HEIGHT_OFFSET_M, + MAX_TOTAL_POINTS, + COLOR_OUTLINE, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { governorRequestRender } = services.render; + + /** + * 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 = parts.model.toNonNegativeInteger(capacityFromInfo); + if (fromInfo && fromInfo > 0) return fromInfo; + + // Derive capacity from current bikes + available docks + const bikes = parts.model.toNonNegativeInteger(status?.bikesAvailable, 0); + const docks = parts.model.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 = parts.model.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; + } + + /** + * 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 (layerState._viewer?.scene?.sampleHeightSupported) { + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + const sampled = layerState._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 (layerState._cityRuntime.has(cityId)) + return layerState._cityRuntime.get(cityId); + const runtime = { stationKeys: new Set() }; + layerState._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 = parts.model.stationKey(cityId, station.stationId); + if (layerState._stationRenderMap.has(key)) { + runtime.stationKeys.add(key); + continue; + } + + // Enforce global point cap + if (layerState._stationRenderMap.size >= MAX_TOTAL_POINTS) { + if (!layerState._limitWarned) { + layerState._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 = layerState._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, + }); + + layerState._stationRenderMap.set(key, { + key, + cityId, + stationId: station.stationId, + stationName: station.name, + point, + capacity: parts.model.toNonNegativeInteger(station.capacity), + bikesAvailable: null, + docksAvailable: null, + isInstalled: station.isInstalled, + isRenting: station.isRenting, + isReturning: station.isReturning, + }); + + runtime.stationKeys.add(key); + } + + layerState._count = layerState._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 = layerState._cityRuntime.get(cityId); + if (!runtime) return; + + // Clear selection if it belongs to the city being removed + if ( + layerState._selectedKey && + runtime.stationKeys.has(layerState._selectedKey) + ) { + parts.selection._clearSelection(); + } + + for (const key of runtime.stationKeys) { + const record = layerState._stationRenderMap.get(key); + if (!record) continue; + layerState._pointCollection.remove(record.point); + layerState._stationRenderMap.delete(key); + } + + layerState._cityRuntime.delete(cityId); + layerState._count = layerState._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 = layerState._cityRuntime.get(cityId); + if (!runtime) return; + + for (const key of runtime.stationKeys) { + const record = layerState._stationRenderMap.get(key); + if (!record) continue; + + const status = statusMap.get(record.stationId) || null; + const capacity = resolveCapacity(record.capacity, status); + record.capacity = capacity; + + record.bikesAvailable = parts.model.toNonNegativeInteger( + status?.bikesAvailable, + ); + record.docksAvailable = parts.model.toNonNegativeInteger( + status?.docksAvailable, + ); + record.isInstalled = parts.model.normalizeGbfsBool( + status?.isInstalled, + true, + ); + record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true); + record.isReturning = parts.model.normalizeGbfsBool( + status?.isReturning, + true, + ); + + // Update visual properties based on current status + record.point.pixelSize = capacityToPixelSize(capacity); + record.point.color = statusToColor(status, capacity); + } + } + return { + resolveCapacity, + capacityToPixelSize, + statusToColor, + createStationPosition, + ensureCityRuntime, + ensureCityPoints, + removeCityPoints, + applyStatusToPoints, + }; +} diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/selection.js new file mode 100644 index 000000000..74e540230 --- /dev/null +++ b/src/layers/bikeshare/selection.js @@ -0,0 +1,206 @@ +import * as Cesium from 'cesium'; +import { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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} + */ + + 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 (layerState._selectedKey) { + const record = layerState._stationRenderMap.get(layerState._selectedKey); + if (record?.point) { + record.point.show = true; + } + } + + if (layerState._selectedEntity && layerState._viewer) { + layerState._viewer.entities.remove(layerState._selectedEntity); + } + + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._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 = layerState._stationRenderMap.get(key); + if (!record || !record.point?.position || !layerState._viewer) return; + + layerState._selectedKey = key; + // Hide the base point so the highlight entity replaces it visually + record.point.show = false; + + layerState._selectedEntity = layerState._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) { + layerState._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 (layerState._clickHandler) return; + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking selected entity itself — ignore (don't deselect). + if (picked.id === layerState._selectedEntity) return; + + // Check if the picked primitive or entity id matches a station key + const primitive = picked.primitive; + if ( + primitive && + typeof primitive.id === 'string' && + layerState._stationRenderMap.has(primitive.id) + ) { + _selectStation(primitive.id); + return; + } + if ( + typeof picked.id === 'string' && + layerState._stationRenderMap.has(picked.id) + ) { + _selectStation(picked.id); + return; + } + } + + // Clicked empty space — deselect. + if (layerState._selectedKey) _clearSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + document.addEventListener('keydown', _onKeyDown); + } + + /** + * Global keydown handler — deselects the current station on Escape. + * @param {KeyboardEvent} e - Keyboard event. + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && layerState._selectedKey) { + _clearSelection(); + } + } + return { + buildSelectionLabel, + createBikeshareSelectedOverlayEntry, + _clearSelection, + _selectStation, + _installClickHandler, + _onKeyDown, + }; +} diff --git a/src/layers/bikeshare/source.js b/src/layers/bikeshare/source.js new file mode 100644 index 000000000..effcb9a57 --- /dev/null +++ b/src/layers/bikeshare/source.js @@ -0,0 +1,23 @@ +/** Read a GBFS station document through the existing bounded server proxy. */ +export function createBikeshareSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getStations(upstreamUrl, { signal } = {}) { + const url = new URL(upstreamUrl); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) + throw new TypeError('A public HTTPS GBFS URL is required'); + signal?.throwIfAborted(); + const response = await fetchImpl( + '/api/gbfs?url=' + encodeURIComponent(url.href), + { method: 'GET', headers: { Accept: 'application/json' }, signal }, + ); + if (!response.ok) throw new Error('GBFS HTTP ' + response.status); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!payload || typeof payload !== 'object') + throw new Error('Malformed GBFS payload'); + return payload; + }, + }; +} diff --git a/src/layers/bikeshare/source.test.mjs b/src/layers/bikeshare/source.test.mjs new file mode 100644 index 000000000..cee7e2ab1 --- /dev/null +++ b/src/layers/bikeshare/source.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBikeshareSource } from './source.js'; +test('station source keeps upstream URLs behind the fixed GBFS endpoint', async () => { + const calls = []; + const source = createBikeshareSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"data":{"stations":[]}}'); + }, + }); + for (const url of [ + 'file:///etc/passwd', + 'http://example.test/stations', + 'https://user:pass@example.test/stations', + ]) + await assert.rejects(source.getStations(url), /HTTPS GBFS/); + assert.equal(calls.length, 0); + await source.getStations('https://example.test/stations.json'); + const url = new URL(calls[0][0], 'https://app.example'); + assert.equal(url.pathname, '/api/gbfs'); + assert.equal( + url.searchParams.get('url'), + 'https://example.test/stations.json', + ); +}); +test('cancelled station parsing never publishes the response', async () => { + const controller = new AbortController(); + const source = createBikeshareSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { data: { stations: [] } }; + }, + }), + }); + await assert.rejects( + source.getStations('https://example.test/stations', { + signal: controller.signal, + }), + { name: 'AbortError' }, + ); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js new file mode 100644 index 000000000..381384147 --- /dev/null +++ b/src/layers/bikeshare/state.js @@ -0,0 +1,110 @@ +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const layerState = {}; + + layerState.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + + // --------------------------------------------------------------------------- + // Module-level mutable state + // --------------------------------------------------------------------------- + + /** @type {Cesium.Viewer|null} Active Cesium viewer instance. */ + + layerState._viewer = null; + + /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ + + layerState._pointCollection = null; + + /** Whether the bikeshare layer is currently enabled. */ + + layerState._enabled = false; + + /** Timer handle for camera-move debounce. */ + + layerState._cameraDebounceTimer = null; + + /** Whether the camera.changed listener is currently attached. */ + + layerState._cameraChangedAttached = false; + + /** Hysteresis flag for altitude-based activation. */ + + layerState._altitudeGateEnabled = false; + + /** Monotonic generation counter; incremented on each proximity check to cancel stale work. */ + + layerState._proximityGeneration = 0; + + /** @type {Set} City ids currently considered in-range. */ + + layerState._activeCityIds = new Set(); + + /** @type {Map }>} Per-city runtime tracking of rendered station keys. */ + + layerState._cityRuntime = new Map(); + + /** @type {Map>} Cached station information per city (cityId -> stationId -> StationInfo). */ + + layerState._stationInfoCache = new Map(); + + /** @type {Map }>} Cached station status per city. */ + + layerState._statusCache = new Map(); + + /** @type {Map} In-flight station info requests. */ + + layerState._inFlightInfo = new Map(); + + /** @type {Map} In-flight station status requests. */ + + layerState._inFlightStatus = new Map(); + + /** @type {Map} Render records keyed by "cityId:stationId". */ + + layerState._stationRenderMap = new Map(); + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */ + + layerState._clickHandler = null; + + /** @type {string|null} Key of the currently selected station, or null. */ + + layerState._selectedKey = null; + + /** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */ + + layerState._selectedEntity = null; + + /** Total number of currently rendered station points. */ + + layerState._count = 0; + + /** Timestamp (ms) of the last successful status update. */ + + layerState._lastUpdate = null; + + /** Whether any GBFS fetch is currently in progress. */ + + layerState._loading = false; + + /** Reference count of concurrent loading operations. */ + + layerState._loadingOps = 0; + + /** Most recent error message string, or null. */ + + layerState._error = null; + + /** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */ + + layerState._limitWarned = false; + return layerState; +} diff --git a/src/layers/bikeshare/testing.js b/src/layers/bikeshare/testing.js new file mode 100644 index 000000000..6dca0e658 --- /dev/null +++ b/src/layers/bikeshare/testing.js @@ -0,0 +1,34 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** Seed a selected-station runtime record while still exercising real select/clear paths. */ + + function _setBikeshareSelectionStateForTest({ + viewer, + key, + record, + overlayHost, + }) { + layerState._viewer = viewer; + layerState._stationRenderMap = new Map([[key, record]]); + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST; + } + + /** Exercise the production selection path in focused runtime tests. */ + + function _selectBikeshareStationForTest(key) { + parts.selection._selectStation(key); + } + + /** Exercise the production clear path and restore the production host seam. */ + + function _clearBikeshareSelectionForTest() { + parts.selection._clearSelection(); + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + } + return { + _setBikeshareSelectionStateForTest, + _selectBikeshareStationForTest, + _clearBikeshareSelectionForTest, + }; +} diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js new file mode 100644 index 000000000..e4c03a690 --- /dev/null +++ b/src/layers/bikeshare/viewport.js @@ -0,0 +1,257 @@ +import * as Cesium from 'cesium'; +import { GBFS_CITY_REGISTRY } from './registry.js'; +import { + ACTIVATION_EXIT_ALTITUDE_M, + ACTIVATION_ENTER_ALTITUDE_M, + CITY_RANGE_BASE_KM, + CAMERA_DEBOUNCE_MS, +} from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + const { governorRequestRender } = services.render; + + /** + * 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; + } + + /** + * 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)) { + layerState._altitudeGateEnabled = false; + return false; + } + if (layerState._altitudeGateEnabled) { + // Deactivate only after crossing the higher exit threshold + if (altitude >= ACTIVATION_EXIT_ALTITUDE_M) + layerState._altitudeGateEnabled = false; + } else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) { + // Activate when dropping below the lower enter threshold + layerState._altitudeGateEnabled = true; + } + return layerState._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 = parts.model.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; + } + + /** + * 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'); + parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId); + parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId); + parts.rendering.removeCityPoints(cityId); + } + + /** Deactivate all currently active cities and clear the active set. */ + + function deactivateAllCities() { + const cityIds = Array.from(layerState._activeCityIds); + for (const cityId of cityIds) deactivateCity(cityId); + layerState._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 (!layerState._enabled || !layerState._activeCityIds.has(cityId)) return; + + try { + const stationMap = await parts.ingestion.loadCityStationInfo( + cityId, + generation, + ); + // Bail if context changed during fetch + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + + parts.rendering.ensureCityPoints(cityId, stationMap); + + // Apply any cached status immediately for snappier initial rendering + const cachedStatus = layerState._statusCache.get(cityId)?.statusMap; + if (cachedStatus) { + parts.rendering.applyStatusToPoints(cityId, cachedStatus); + } + + const statusMap = await parts.ingestion.loadCityStationStatus( + cityId, + generation, + ); + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + parts.rendering.applyStatusToPoints(cityId, statusMap); + layerState._lastUpdate = Date.now(); + layerState._error = null; + } catch (error) { + if (error?.name === 'AbortError') return; + console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); + layerState._error = 'GBFS fetch error'; + deactivateCity(cityId); + layerState._activeCityIds.delete(cityId); + } finally { + layerState._count = layerState._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 (!layerState._enabled || !layerState._viewer) return; + + const generation = ++layerState._proximityGeneration; + const altitude = getCameraAltitude(layerState._viewer); + // Altitude gate: disable all cities when camera is too high + if (!shouldActivateAtAltitude(altitude)) { + deactivateAllCities(); + return; + } + + const center = getCameraCenterLatLon(layerState._viewer); + if (!center) return; + + // Diff active set against newly computed in-range set + const nextActive = computeInRangeCities(center); + for (const cityId of layerState._activeCityIds) { + if (!nextActive.has(cityId)) { + deactivateCity(cityId); + } + } + + layerState._activeCityIds = nextActive; + if (layerState._activeCityIds.size === 0) { + layerState._count = 0; + return; + } + + // Only activate cities that don't already have rendered points + const toActivate = []; + for (const cityId of layerState._activeCityIds) { + if (!layerState._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(layerState._cameraDebounceTimer); + layerState._cameraDebounceTimer = setTimeout(() => { + void runProximityCheck(); + }, CAMERA_DEBOUNCE_MS); + } + + /** Camera change event handler — triggers a debounced proximity check. */ + + function onCameraChanged() { + if (!layerState._enabled) return; + scheduleProximityCheck(); + } + return { + getCameraAltitude, + getCameraCenterLatLon, + shouldActivateAtAltitude, + computeInRangeCities, + deactivateCity, + deactivateAllCities, + activateCity, + runProximityCheck, + scheduleProximityCheck, + onCameraChanged, + }; +} diff --git a/src/layers/cctv/calibration.js b/src/layers/cctv/calibration.js new file mode 100644 index 000000000..35402d530 --- /dev/null +++ b/src/layers/cctv/calibration.js @@ -0,0 +1,309 @@ +import { createCalibrationGizmo } from '../../data/cctvGizmo.js'; +import { + DEFAULT_CAMERA_CALIBRATION, + CCTV_CALIBRATION_STORAGE_KEY_V2, +} from './policy.js'; + +export function createCalibration({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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} + */ + + function calibrationPatchMovesAnchor(patch) { + if (!patch || typeof patch !== 'object') return false; + return ( + Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') || + Object.prototype.hasOwnProperty.call(patch, 'offsetEastM') + ); + } + + /** + * Sanitizes and clamps a calibration object to valid ranges. + * Missing or non-finite fields fall back to defaults. + * @param {Object} [value={}] - Raw calibration values. + * @returns {{ offsetNorthM: number, offsetEastM: number, headingDeg: number, pitchDeg: number, fovDeg: number, rangeScale: number, heightM: number }} + */ + + function normalizeCalibration(value = {}) { + const raw = value && typeof value === 'object' ? value : {}; + return { + offsetNorthM: parts.model.quantize( + parts.model.clamp( + parts.model.safeNumber(raw.offsetNorthM, 0), + -900, + 900, + ), + 0.1, + ), + offsetEastM: parts.model.quantize( + parts.model.clamp( + parts.model.safeNumber(raw.offsetEastM, 0), + -900, + 900, + ), + 0.1, + ), + headingDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.headingDeg, 0), -180, 180), + 0.1, + ), + pitchDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.pitchDeg, 0), -45, 45), + 0.1, + ), + fovDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.fovDeg, 0), -50, 50), + 0.1, + ), + rangeScale: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.rangeScale, 1), 0.35, 3.0), + 0.01, + ), + heightM: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.heightM, 0), -120, 240), + 0.1, + ), + }; + } + + /** + * Returns true if the given calibration is effectively the default (all offsets near zero). + * @param {Object} calibration + * @returns {boolean} + */ + + function isDefaultCalibration(calibration) { + const probe = normalizeCalibration(calibration); + return Object.keys(DEFAULT_CAMERA_CALIBRATION).every( + (key) => Math.abs(probe[key] - DEFAULT_CAMERA_CALIBRATION[key]) < 0.0001, + ); + } + + /** + * Loads all persisted per-camera calibration overrides from the v2 store. + * + * v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual', + * savedAt: }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is + * NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import. + * + * @param {{getItem:function}|null} [storage] - Injectable storage (defaults + * to `window.localStorage`); lets the unit suite test this pure of a DOM. + * @returns {Map} + */ + + function readCalibrationStoreV2( + storage = parts.model.safeWindowLocalStorage(), + ) { + const map = new Map(); + if (!storage) return map; + try { + const raw = storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2); + if (!raw) return map; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return map; + for (const [cameraId, entry] of Object.entries(parsed)) { + if (!cameraId || !entry || typeof entry !== 'object') continue; + if (!entry.values || typeof entry.values !== 'object') continue; + // 'manual' is the only provenance v2 knows (§9.3 killed 'legacy'); a + // malformed/foreign source string still normalizes to 'manual' rather + // than surfacing an unrecognized value into the badge logic. + map.set(cameraId, { + values: normalizeCalibration(entry.values), + source: 'manual', + savedAt: parts.model.safeNumber(entry.savedAt, 0), + }); + } + return map; + } catch { + return map; + } + } + + /** + * Persists a calibration map to the v2 store. + * @param {Map} map + * @param {{setItem:function}|null} [storage] - Injectable storage (defaults + * to `window.localStorage`). + */ + + function writeCalibrationStoreV2( + map, + storage = parts.model.safeWindowLocalStorage(), + ) { + if (!storage) return; + try { + const payload = {}; + for (const [cameraId, entry] of map.entries()) { + payload[cameraId] = { + values: normalizeCalibration(entry.values), + source: 'manual', + savedAt: parts.model.safeNumber(entry.savedAt, Date.now()), + }; + } + storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload)); + } catch { + // storage unavailable + } + } + + /** + * Loads the v2 calibration store. `_calibrationById` holds these entries + * directly (`{values, source:'manual', savedAt}`) — never bare offset values + * — so it round-trips straight back through `writeCalibrationStoreV2`. + * @returns {Map} + */ + + function loadCalibrationStore() { + return readCalibrationStoreV2(); + } + + /** Persists the in-memory calibration entries (values + provenance) to the v2 store. */ + + function saveCalibrationStore() { + writeCalibrationStoreV2(layerState._calibrationById); + } + + /** + * Derives the panel CAL badge state for a camera (design §3b, as amended by + * the LOCKED §9.2 — panel-only, no in-world tint). + * + * Three states: + * - 'calibrated' — a human explicitly saved a v2 calibration (`source:'manual'`). + * - 'curated' — no manual save, but the catalog entry was hand-authored + * (`poseSource:'curated'`, file/env sources only). + * - 'raw-prior' — everything else (all Austin Open Data today). + * + * Pure — no scoring math, no raycasts. `confidenceFromScore` and score-based + * quality seeding are retired; this replaces them. + * @param {{calSource?: string|null, poseSource?: string|null}} camera + * @returns {'calibrated'|'curated'|'raw-prior'} + */ + + function deriveCalBadge(camera) { + if (camera?.calSource === 'manual') return 'calibrated'; + if (camera?.poseSource === 'curated') return 'curated'; + return 'raw-prior'; + } + + /** + * Applies a calibration patch to a record's IN-MEMORY pose (save-gated + * persistence, design §3e: no localStorage write here — only the explicit + * `calibration.save` action persists). + * + * Transient grade (gizmo mid-drag): recompute pose + frustum geometry only + * (the cheap v2 path) with throttled notify — no ground re-arm, no frame + * re-fetch, no store touch. + * Commit grade (drag end, numeric entry, voice): only an E/N anchor move + * resolves a new shared floor; all other edits keep the frozen reference. + * + * @param {Object} record - Camera record. + * @param {Object} patch - Partial 7-field calibration (absolute offset values). + * @param {{transient?: boolean}} [options] + * @returns {boolean} True when the patch applied. + */ + + function applyCalibrationPatch(record, patch, options = {}) { + if (!record || !patch || typeof patch !== 'object') return false; + record.camera.calibration = normalizeCalibration({ + ...record.camera.calibration, + ...patch, + }); + parts.model.ensureCameraPose(record.camera); + // §9.1: touching range takes manual control — clear the activation clamp. + if ('rangeScale' in patch) { + record.probeClampRangeM = null; + } + const anchorMoved = calibrationPatchMovesAnchor(patch); + if (options.transient === true && anchorMoved) { + record.calibrationAnchorDirty = true; + } + record.calDirty = true; + if (options.transient === true) { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + parts.presentation.notifyListenersThrottled(); + return true; + } + if (anchorMoved) { + parts.ground.resolveCommittedGroundAnchor(record); + } else { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + } + parts.frames.refreshProjectionImage(record, true); + return true; + } + + /** + * Lazily creates the calibration gizmo controller. The gizmo sees the layer + * only through these callbacks: it attaches to the active record while the + * layer is enabled AND ADJUST mode is on, funnels drags through + * applyCalibrationPatch (transient), and runs the commit tail on release. + */ + + function ensureGizmo() { + if (layerState._gizmo || !layerState._viewer) return; + // Both patch callbacks receive the gizmo's PINNED drag record — never + // re-resolve the active camera here: a mid-drag voice select or auto-hop + // would route the captured offsets onto a camera with a different basePose. + const liveRecord = (record) => + record && layerState._recordById.get(record.camera?.id) === record + ? record + : null; + layerState._gizmo = createCalibrationGizmo({ + viewer: layerState._viewer, + getActiveRecord: () => + layerState._enabled && layerState._calibrationMode + ? parts.selection.getActiveRecord() + : null, + applyPatch: (patch, draggedRecord) => { + const record = + layerState._enabled && layerState._calibrationMode + ? liveRecord(draggedRecord) + : null; + if (record) applyCalibrationPatch(record, patch, { transient: true }); + }, + endPatch: (draggedRecord) => { + const record = liveRecord(draggedRecord); + if (!record) return; + if (record.calibrationAnchorDirty) { + record.calibrationAnchorDirty = false; + parts.ground.resolveCommittedGroundAnchor(record); + } else { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + } + parts.frames.refreshProjectionImage(record, true); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }, + }); + } + return { + calibrationPatchMovesAnchor, + normalizeCalibration, + isDefaultCalibration, + readCalibrationStoreV2, + writeCalibrationStoreV2, + loadCalibrationStore, + saveCalibrationStore, + deriveCalBadge, + applyCalibrationPatch, + ensureGizmo, + }; +} diff --git a/src/layers/cctv/cards.js b/src/layers/cctv/cards.js new file mode 100644 index 000000000..72f0b88a4 --- /dev/null +++ b/src/layers/cctv/cards.js @@ -0,0 +1,404 @@ +import { + createFrameSlot, + CCTV_OVERLAY_SOURCE_ID, + declutterCctvCards, + planFrameCachePrune, + createCctvThumbnailOverlayEntry, + CCTV_FRAME_CANVAS_W, + CCTV_FRAME_CANVAS_H, + applyFrameResult, +} from '../../data/cctvCards.js'; +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js'; +import { + CCTV_OVERLAY_SOURCE_OPTIONS, + CARD_VIEW_MARGIN, + CCTV_AMBIENT_CARD_DRAIN_CAP, + CARD_GAP_PX, + CARD_FETCH_TICK_MS, +} from './policy.js'; + +export function createCards({ state: layerState, services, parts, source }) { + /** + * 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}} + */ + + function setCctvCardPresentationOptions({ + activeCameraCardEnabled = false, + } = {}) { + layerState._activeCameraCardEnabled = activeCameraCardEnabled === true; + if (layerState._enabled) pushAmbientCardEntries(); + layerState._viewer?.scene?.requestRender?.(); + + return { activeCameraCardEnabled: layerState._activeCameraCardEnabled }; + } + + // --------------------------------------------------------------------------- + // Ambient card tier (2026-07-29 design — spec: + // docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md) + // --------------------------------------------------------------------------- + + /** Returns (creating on demand) the stable frame slot for a camera id. */ + + function ensureCardFrameSlot(cameraId) { + let slot = layerState._cardFrameSlots.get(cameraId); + if (!slot) { + slot = createFrameSlot(); + layerState._cardFrameSlots.set(cameraId, slot); + } + return slot; + } + + /** + * Rebuilds the ambient card selection: horizon + in-view projection of the + * catalog (pure math — no scene queries), the zoom-budgeted nearest-first + * LOD pick, greedy screen-space declutter, and the eviction-grace pass that + * keeps budget-edge cards alive across small camera moves (zero-flicker). + * Runs on camera.moveEnd, enable, activation change, and the one-shot + * geometry-drain completion — NEVER per frame. The active camera is excluded + * from the ambient selection/quota. By default its monitor plane is the sole + * active representation; the optional protected-card path is applied only by + * `pushAmbientCardEntries`. Camera icons are never touched here (cards + * annotate markers, they don't replace them). + */ + + function refreshAmbientCards() { + if ( + !layerState._enabled || + !layerState._viewer || + layerState._viewer.isDestroyed() || + !layerState._records.length + ) { + layerState._cctvOverlayHost.setEntries( + CCTV_OVERLAY_SOURCE_ID, + [], + CCTV_OVERLAY_SOURCE_OPTIONS, + ); + return; + } + const scene = layerState._viewer.scene; + const carto = layerState._viewer.camera.positionCartographic; + const viewerLat = carto ? Cesium.Math.toDegrees(carto.latitude) : 0; + const viewerLon = carto ? Cesium.Math.toDegrees(carto.longitude) : 0; + // The active camera is excluded from ambient selection ALWAYS (not just + // after its async activation settles). It is published separately in the + // protected lane below, and grace never applies to it. + const activeId = layerState._activeCameraId; + const occluder = horizonOccluder(layerState._viewer.camera); + const width = scene.canvas.clientWidth || scene.canvas.width || 0; + const height = scene.canvas.clientHeight || scene.canvas.height || 0; + const marginX = width * CARD_VIEW_MARGIN; + const marginY = height * CARD_VIEW_MARGIN; + + const candidates = []; + const screenById = new Map(); + for (const record of layerState._records) { + const id = record.camera.id; + if (id === activeId || !record.position) continue; + let inView = false; + let sx = NaN; + let sy = NaN; + if (occluder.isPointVisible(record.position)) { + const screen = scene.cartesianToCanvasCoordinates(record.position); + if ( + screen && + Number.isFinite(screen.x) && + Number.isFinite(screen.y) && + screen.x >= -marginX && + screen.x <= width + marginX && + screen.y >= -marginY && + screen.y <= height + marginY + ) { + inView = true; + sx = screen.x; + sy = screen.y; + screenById.set(id, { sx, sy }); + } + } + candidates.push({ + id, + distanceKm: parts.model.haversineKm( + viewerLat, + viewerLon, + record.camera.lat, + record.camera.lon, + ), + inView, + isVideo: parts.model.isVideoFeedType( + parts.model.normalizeFeedType(record.camera.feedType), + ), + sx, + sy, + }); + } + + // Owner finding 4: current card holders rank with the 20% incumbency + // distance discount, so a small camera move never batch-swaps the ring. + // Item C: passing the viewport dims + per-candidate screen anchors routes + // the budget fill through the screen-distribution grid, so periphery + // cells hold cards instead of everything clustering at screen center. + const { cardIds, budgets } = selectCctvLod(candidates, { + cameraHeightM: carto?.height, + incumbentIds: layerState._cardIds, + viewW: width, + viewH: height, + }); + // Like the cold-fill burst, card density yields to the staggered geometry + // drain: painting the raised 20/28/40 budget per frame starves the + // frame-paced mesh-floor queue on weak GPUs (qa-cctv-v2 N=800 drain-budget + // regression). During the initial load the budget holds at the low tier; + // full density arrives the moment the drain completes (which triggers its + // own refreshAmbientCards pass). + const cardLimit = layerState._geoLoading + ? Math.min(budgets.cardLimit, CCTV_AMBIENT_CARD_DRAIN_CAP) + : budgets.cardLimit; + const decluttered = declutterCctvCards( + cardIds + .filter((id) => screenById.has(id)) + .slice(0, cardLimit) + .map((id, index) => ({ + id, + ...screenById.get(id), + // Priority carrier, not kilometers: declutter sorts ascending on + // this field, and the selection's order (distribution + incumbency) + // must survive — a periphery cell-winner must not be re-outranked + // by central proximity when two anchors contest the min separation. + distanceKm: index, + })), + { limit: cardLimit }, + ); + // Owner finding 2: grace must never apply to the active camera — drop any + // lingering grace entry and keep it out of the retained-card baseline. + if (activeId) { + layerState._cardIds.delete(activeId); + layerState._cardGraceState.delete(activeId); + } + const retention = applyEvictionGrace({ + selectedIds: decluttered, + builtIds: [...layerState._cardIds], + graceState: layerState._cardGraceState, + nowMs: Date.now(), + cardLimit: budgets.cardLimit, + }); + layerState._cardIds = new Set(retention.keepIds); + layerState._cardGraceState = retention.graceState; + + // Bounded thumbnail LRU: live cards (grace included) never lose their + // persisted frame — that persistence IS the no-flicker guarantee. The + // hover card's slot is protected too while the gesture lasts. + const keepFrames = new Set(layerState._cardIds); + if (layerState._hoverCardId) keepFrames.add(layerState._hoverCardId); + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) + keepFrames.add(layerState._activeCameraId); + const drops = planFrameCachePrune( + [...layerState._cardFrameSlots].map(([id, slot]) => ({ + id, + stamp: slot.stamp, + })), + keepFrames, + ); + for (const id of drops) layerState._cardFrameSlots.delete(id); + + pushAmbientCardEntries(); + } + + /** + * Builds and publishes the card entry list from the current kept set and the + * hover-summoned pin. The hover entry is budget-exempt and high-priority. The + * active camera is absent by default; `activeCameraCardEnabled` retains the + * migrated protected-publication path as an explicit product option. If the + * LOD selection adopts the hovered camera it remains a normal budgeted entry + * that keeps the pin while the gesture lasts. + */ + + function pushAmbientCardEntries() { + const entries = []; + let rank = 0; + const push = (id, { pinned = false, active = false } = {}) => { + const record = layerState._recordById.get(id); + if (!record?.position) return; + entries.push( + createCctvThumbnailOverlayEntry({ + id, + position: record.position, + gapPx: CARD_GAP_PX, + title: record.camera.name, + frameSlot: ensureCardFrameSlot(id), + rank: rank++, + pinned, + active, + }), + ); + }; + for (const id of layerState._cardIds) + push(id, { pinned: id === layerState._hoverCardId }); + if ( + layerState._hoverCardId && + !layerState._cardIds.has(layerState._hoverCardId) && + layerState._hoverCardId !== layerState._activeCameraId + ) { + push(layerState._hoverCardId, { pinned: true }); + } + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) { + push(layerState._activeCameraId, { active: true }); + } + layerState._cctvOverlayHost.setEntries( + CCTV_OVERLAY_SOURCE_ID, + entries, + CCTV_OVERLAY_SOURCE_OPTIONS, + ); + } + + /** + * Fetches one paced static frame and settles it into the stable slot via the + * pure persistence rule (applyFrameResult): success replaces the thumbnail, + * failure leaves the drawn frame untouched. The frame is downscaled once + * into a 2x-thumb offscreen canvas; the renderer reads the slot live. + * @param {Object} record - Camera record. + * @param {Object} slot - The camera's stable frame slot. + * @param {number} refreshMs - Source cadence (also keys the frame-URL tick). + * @param {Object} [options] + * @param {boolean} [options.userGesture] - Hover fast-track (item B): the + * launch bypasses the pacer gate, so its spacing sample would pollute the + * pacing telemetry — skip the min-spacing sample only. The launch still + * stamps `_cardLastFetchAt`, so the pacer waits a full interval after it. + */ + + function fetchCardFrame( + record, + slot, + refreshMs, + { userGesture = false } = {}, + ) { + if (typeof document !== 'undefined' && document.hidden && !userGesture) + return; + const now = Date.now(); + const cameraId = record.camera.id; + layerState._cardFetchInFlightCount += 1; + layerState._cardFetchPendingIds.add(cameraId); + if (layerState._cardLastFetchAt > 0 && !userGesture) { + const spacing = now - layerState._cardLastFetchAt; + // NOTE: cold-fill bursts legitimately push this to ~250 ms — read it + // together with the ambientCards.fetchMode telemetry. + layerState._cardMinFetchSpacingMs = + layerState._cardMinFetchSpacingMs == null + ? spacing + : Math.min(layerState._cardMinFetchSpacingMs, spacing); + } + layerState._cardLastFetchAt = now; + layerState._cardFetchCount += 1; + + const image = new Image(); + layerState._cardFetchImages.add(image); + const settle = (ok) => { + image.onload = null; + image.onerror = null; + if (layerState._cardFetchImages.delete(image)) { + layerState._cardFetchInFlightCount = Math.max( + 0, + layerState._cardFetchInFlightCount - 1, + ); + layerState._cardFetchPendingIds.delete(cameraId); + } + let frame = null; + if (ok) { + try { + const canvas = document.createElement('canvas'); + canvas.width = CCTV_FRAME_CANVAS_W; + canvas.height = CCTV_FRAME_CANVAS_H; + canvas + .getContext('2d') + .drawImage(image, 0, 0, canvas.width, canvas.height); + frame = canvas; + } catch { + frame = null; + } + } + Object.assign( + slot, + applyFrameResult(slot, { ok: !!frame, frame }, Date.now()), + ); + layerState._viewer?.scene?.requestRender?.(); + }; + image.onload = () => settle(true); + image.onerror = () => settle(false); + image.src = parts.frames.frameUrlFor(record.camera, refreshMs); + } + + /** Starts the card-frame pacer (idempotent; policy-gated per tick). */ + + function startCardFrameLoop() { + if (layerState._cardFetchTimer) return; + layerState._cardFetchTimer = setInterval( + parts.model.cardFrameTick, + CARD_FETCH_TICK_MS, + ); + } + + /** Stops the pacer and detaches all in-flight fetch handlers. */ + + function stopCardFrameLoop() { + if (layerState._cardFetchTimer) { + clearInterval(layerState._cardFetchTimer); + layerState._cardFetchTimer = 0; + } + for (const image of layerState._cardFetchImages) { + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + layerState._cardFetchImages.clear(); + layerState._cardFetchPendingIds.clear(); + layerState._cardFetchInFlightCount = 0; + layerState._cardFetchMode = 'steady'; + } + + /** + * Complete ambient-tier teardown (layer disable/destroy): pacer + in-flight + * handlers, shared-host source entries, card set, grace state, thumbnail + * cache, and fetch telemetry. Nothing leaks across a toggle. + */ + + function teardownAmbientCards() { + stopCardFrameLoop(); + layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID); + layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false); + parts.hover.clearHoverCard(); + layerState._cameraMoving = false; + layerState._cardIds = new Set(); + layerState._cardGraceState = new Map(); + layerState._cardFrameSlots = new Map(); + layerState._cardFetchCount = 0; + layerState._cardLastFetchAt = 0; + layerState._cardMinFetchSpacingMs = null; + } + function handleVisibilityChange() { + if (!document.hidden) return; + for (const image of layerState._cardFetchImages) { + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + layerState._cardFetchImages.clear(); + layerState._cardFetchPendingIds.clear(); + layerState._cardFetchInFlightCount = 0; + } + return { + handleVisibilityChange, + setCctvCardPresentationOptions, + ensureCardFrameSlot, + refreshAmbientCards, + pushAmbientCardEntries, + fetchCardFrame, + startCardFrameLoop, + stopCardFrameLoop, + teardownAmbientCards, + }; +} diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js new file mode 100644 index 000000000..4cc66727c --- /dev/null +++ b/src/layers/cctv/catalog.js @@ -0,0 +1,210 @@ +import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; + +export function createCatalog({ state: layerState, services, parts, source }) { + const { CITY_POIS } = services.locations; + + /** + * Builds the initial camera catalog from CAMERA_SEEDS definitions. + * Each seed is resolved against its city's POI coordinates, offset, and + * passed through ensureCameraPose to populate derived fields. + * @returns {Object[]} Array of fully-initialized camera objects. + */ + + function seedCatalog() { + const catalog = []; + for (const seed of CAMERA_SEEDS) { + const city = CITY_POIS[seed.cityId]; + const poi = city?.pois?.[seed.poiIndex]; + if (!city || !poi) continue; + const { latOffset, lonOffset } = parts.model.offsetDegrees( + poi.lat, + seed.offsetNorthM || 0, + seed.offsetEastM || 0, + ); + const camera = { + id: seed.id, + name: seed.label, + cityId: seed.cityId, + city: city.name, + provider: 'OSM Camera Grid', + sourceKind: 'seed', + feedType: 'image', + feedConfigured: false, + headingConfidence: 'medium', + lat: poi.lat + latOffset, + lon: poi.lon + lonOffset, + headingDeg: parts.model.normalizeHeading( + seed.headingDeg ?? poi.heading ?? 0, + ), + fovDeg: parts.model.clamp(seed.fovDeg ?? 70, 20, 120), + rangeM: parts.model.clamp(seed.rangeM ?? 700, 260, 1800), + mountHeightM: parts.model.clamp(seed.elevationM ?? 22, 8, 80), + groundElevationM: Number(city.groundElevation) || 0, + absoluteHeightM: + (Number(city.groundElevation) || 0) + + parts.model.clamp(seed.elevationM ?? 22, 8, 80), + pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), + }; + parts.model.ensureCameraPose(camera); + catalog.push(camera); + } + return catalog; + } + + /** + * Looks up a city ID from CITY_POIS by exact or partial name match. + * @param {string} cityName + * @returns {string|null} Matching city ID or null. + */ + + function cityIdByName(cityName) { + const probe = String(cityName || '') + .trim() + .toLowerCase(); + if (!probe) return null; + for (const [cityId, city] of Object.entries(CITY_POIS)) { + if (city.name.toLowerCase() === probe) return cityId; + } + for (const [cityId, city] of Object.entries(CITY_POIS)) { + if ( + city.name.toLowerCase().includes(probe) || + probe.includes(city.name.toLowerCase()) + ) + return cityId; + } + return null; + } + + /** + * Fetches configured camera sources from the backend. + * @returns {Promise} Array of raw source objects, or empty on failure. + */ + + async function loadCameraSources() { + try { + const signal = layerState._sourceAbort?.signal; + const data = await source.getCatalog({ signal }); + signal?.throwIfAborted(); + if (!Array.isArray(data?.sources)) return []; + return data.sources; + } catch (error) { + if (error?.name === 'AbortError') throw error; + return []; + } + } + + /** + * Merges raw backend sources with seed data to produce the final camera catalog. + * Seeds provide fallback values for heading, FOV, range, etc. when not specified + * by the source. Each camera is passed through ensureCameraPose. + * @param {Object[]} rawSources - Raw source objects from the backend. + * @returns {Object[]} Array of fully-initialized camera objects. + */ + + function buildCatalogFromSources(rawSources) { + const sources = Array.isArray(rawSources) ? rawSources : []; + if (!sources.length) return []; + + const seeded = seedCatalog(); + const seedById = new Map(seeded.map((camera) => [camera.id, camera])); + + const catalog = []; + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + const id = String(source.id || '').trim(); + if (!id) continue; + const seed = seedById.get(id); + const cityId = + String(source.cityId || '').trim() || + cityIdByName(source.city) || + seed?.cityId || + ''; + const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null; + + const lat = parts.model.safeNumber(source.lat, seed?.lat ?? NaN); + const lon = parts.model.safeNumber(source.lon, seed?.lon ?? NaN); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + const sourceHeading = parts.model.safeNumber(source.headingDeg, NaN); + const headingDeg = parts.model.normalizeHeading( + Number.isFinite(sourceHeading) + ? sourceHeading + : (seed?.headingDeg ?? parts.model.headingFromId(id)), + ); + const fovDeg = parts.model.clamp( + parts.model.safeNumber(source.fovDeg, seed?.fovDeg ?? 74), + 20, + 125, + ); + const rangeM = parts.model.clamp( + parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700), + 220, + 2200, + ); + const mountHeightM = parts.model.clamp( + parts.model.safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24), + 6, + 120, + ); + const pitchDeg = parts.model.clamp( + parts.model.safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17), + -55, + -2, + ); + const groundElevationM = parts.model.safeNumber( + source.groundElevationM, + city?.groundElevation ?? seed?.groundElevationM ?? 0, + ); + const feedType = parts.model.normalizeFeedType( + source.feedType || source.type || 'image', + ); + const headingConfidence = String( + source.headingConfidence || (seed ? 'high' : 'low'), + ).toLowerCase(); + // CAL badge input (design §3b passthrough): hand-authored file/env source + // entries may carry poseSource:'curated'. Austin Open Data rows never set + // this — they stay RAW PRIOR until a human manually calibrates them. + const poseSource = + source.poseSource === 'curated' ? 'curated' : seed?.poseSource || null; + + const camera = { + id, + name: String(source.name || seed?.name || id), + cityId, + city: String(source.city || city?.name || seed?.city || 'Global'), + provider: String( + source.provider || seed?.provider || 'Configured CCTV Source', + ), + sourceKind: String( + source.sourceKind || + source.kind || + (source.url ? 'configured' : 'seed'), + ).toLowerCase(), + feedType, + feedConfigured: typeof source.url === 'string' && !!source.url.trim(), + lat, + lon, + headingDeg, + headingConfidence, + fovDeg, + rangeM, + mountHeightM, + groundElevationM, + absoluteHeightM: groundElevationM + mountHeightM, + pitchDeg, + license: String(source.license || source.licenseNote || ''), + poseSource, + }; + parts.model.ensureCameraPose(camera); + catalog.push(camera); + } + + return catalog; + } + return { + seedCatalog, + cityIdByName, + loadCameraSources, + buildCatalogFromSources, + }; +} diff --git a/src/layers/cctv/controls.js b/src/layers/cctv/controls.js new file mode 100644 index 000000000..5db8f04fe --- /dev/null +++ b/src/layers/cctv/controls.js @@ -0,0 +1,352 @@ +import { + DEFAULT_UPDATE_INTERVAL_MS, + MIN_AUTO_HOP_SEC, + MAX_AUTO_HOP_SEC, + DEFAULT_CAMERA_CALIBRATION, +} from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { CCTV_ACTIVATION_RESULT } = services.activation; + + const methods = { + id: 'cctv', + + name: 'CCTV', + + icon: '📹', + + source: 'CCTV + Street View fallback', + + updateInterval: DEFAULT_UPDATE_INTERVAL_MS, + + /** + * Applies runtime parameter changes: coverage/projection toggles, auto-hop + * settings, camera selection, and calibration patches/resets. + * @param {Object} [params={}] - Parameter object. + * @param {boolean} [params.showCoverage] - Back-compat coverage toggle (true→'on', false→'off'). + * @param {'off'|'on'|'viewshed'} [params.coverageMode] - Full coverage-mode API. + * @param {boolean} [params.showProjection] - Toggle projection overlay visibility. + * @param {boolean} [params.autoHop] - Enable/disable auto-hop. + * @param {number} [params.autoHopSec] - Auto-hop interval in seconds. + * @param {string} [params.selectedCameraId] - Camera ID to activate. + * @param {Object} [params.calibration] - Calibration config: `patch` edits + * the live pose (save-gated — no persistence), `save` persists the current + * calibration as manual, `reset` restores the base prior. + * @param {boolean} [params.calibrationMode] - Toggle the ADJUST gizmo. + * @param {boolean} [params.focusSelected] - Fly to the active camera. + * @param {number} [params.focusDurationSec] - Fly-to duration. + */ + setParams(params = {}) { + if (typeof params.showCoverage === 'boolean') { + layerState._coverageMode = parts.model.normalizeCoverageMode( + params.showCoverage, + layerState._coverageMode, + ); + } + if (typeof params.coverageMode === 'string') { + layerState._coverageMode = parts.model.normalizeCoverageMode( + params.coverageMode, + layerState._coverageMode, + ); + } + if (typeof params.showProjection === 'boolean') { + layerState._showProjection = params.showProjection; + if (layerState._showProjection) { + const active = parts.selection.getActiveRecord(); + if (active) parts.projection.ensureProjectionRuntime(active); + parts.projection.startProjectionLoop(); + } + } + if (typeof params.autoHop === 'boolean') { + layerState._autoHop = params.autoHop; + if (params.autoHop) layerState._autoHopSuspended = false; + } + if ( + typeof params.autoHopSec === 'number' && + Number.isFinite(params.autoHopSec) + ) { + layerState._autoHopSec = parts.model.clamp( + Math.round(params.autoHopSec), + MIN_AUTO_HOP_SEC, + MAX_AUTO_HOP_SEC, + ); + } + if ( + typeof params.selectedCameraId === 'string' && + layerState._recordById.has(params.selectedCameraId) + ) { + parts.selection.setActiveCamera(params.selectedCameraId); + } + if (params.calibration && typeof params.calibration === 'object') { + const calibrationCfg = params.calibration; + const targetCameraId = + typeof calibrationCfg.cameraId === 'string' && calibrationCfg.cameraId + ? calibrationCfg.cameraId + : layerState._activeCameraId; + const targetRecord = targetCameraId + ? layerState._recordById.get(targetCameraId) + : null; + if (targetRecord) { + if (calibrationCfg.reset) { + // RESET: back to the base prior, delete the persisted entry, clear + // the dirty flag (semantics unchanged from v2). + targetRecord.camera.calibration = + parts.calibration.normalizeCalibration( + DEFAULT_CAMERA_CALIBRATION, + ); + targetRecord.camera.calSource = null; + targetRecord.calDirty = false; + parts.model.ensureCameraPose(targetRecord.camera); + layerState._calibrationById.delete(targetCameraId); + parts.calibration.saveCalibrationStore(); + // Reset returns to the base lat/lon, so resolve that anchor once. + parts.ground.resolveCommittedGroundAnchor(targetRecord); + parts.frames.refreshProjectionImage(targetRecord, true); + } + if ( + calibrationCfg.patch && + typeof calibrationCfg.patch === 'object' + ) { + // Save-gated persistence (design §3e): a patch edits the LIVE pose + // only. The store — and the CALIBRATED badge's `calSource` — move + // exclusively on the explicit `save` action below. (§9.1 range-slider + // clamp override + B9b re-ground live inside applyCalibrationPatch.) + parts.calibration.applyCalibrationPatch( + targetRecord, + calibrationCfg.patch, + ); + } + if (calibrationCfg.save) { + // SAVE CAL: persist the current in-memory calibration with manual + // provenance. Saving an all-default calibration clears the entry + // (a no-op calibration is not a calibration). + if ( + parts.calibration.isDefaultCalibration( + targetRecord.camera.calibration, + ) + ) { + targetRecord.camera.calSource = null; + layerState._calibrationById.delete(targetCameraId); + } else { + targetRecord.camera.calSource = 'manual'; + layerState._calibrationById.set(targetCameraId, { + values: { ...targetRecord.camera.calibration }, + source: 'manual', + savedAt: Date.now(), + }); + } + targetRecord.calDirty = false; + parts.calibration.saveCalibrationStore(); + } + } + } + if (typeof params.calibrationMode === 'boolean') { + layerState._calibrationMode = params.calibrationMode; + if (layerState._calibrationMode) { + parts.calibration.ensureGizmo(); + layerState._gizmo?.setEnabled(true); + // ADJUST mode: gizmo drags mutate entity geometry from pointer events, + // which don't trigger renders in requestRenderMode. (perf wave 2) + holdContinuousRender('cctv-adjust'); + } else { + releaseContinuousRender('cctv-adjust'); + layerState._gizmo?.setEnabled(false); + } + } + if (params.focusSelected && layerState._activeCameraId) { + parts.navigation.focusCamera( + layerState._activeCameraId, + Number(params.focusDurationSec) || 1.8, + ); + } + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }, + + /** + * Returns the current runtime parameters including toggle states, + * active camera, and calibration values. + * @returns {Object} + */ + getParams() { + const active = parts.selection.getActiveRecord(); + return { + showCoverage: layerState._coverageMode !== 'off', + coverageMode: layerState._coverageMode, + showProjection: layerState._showProjection, + calibrationMode: layerState._calibrationMode, + autoHop: layerState._autoHop, + autoHopSec: layerState._autoHopSec, + selectedCameraId: active?.camera.id || null, + calibration: active?.camera + ? { + cameraId: active.camera.id, + values: { + ...parts.calibration.normalizeCalibration( + active.camera.calibration, + ), + }, + } + : null, + }; + }, + + /** + * Returns a sampled list of camera positions for the detection overlay system. + * @param {Object} [options={}] + * @param {number} [options.maxCount] - Maximum number of objects to return. + * @param {number} [options.seed] - Offset seed for deterministic stride sampling. + * @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || layerState._records.length === 0) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : layerState._records.length; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max( + 1, + Math.ceil(layerState._records.length / maxCount), + ); + const start = seed % stride; + + const objects = []; + for (let i = start; i < layerState._records.length; i += stride) { + const camera = layerState._records[i].camera; + objects.push({ + position: layerState._records[i].position, + sourceId: camera.id, + id: `CAM-${camera.id}`, + type: 'CAM', + }); + if (objects.length >= maxCount) break; + } + return objects; + }, + + /** + * Returns basic layer statistics, including initial-load progress while + * the staggered geometry queue is draining. + * @returns {{ count: number, lastUpdate: number|null, error: string|null, loading: boolean, loadingLoaded: number, loadingTotal: number }} + */ + getStats() { + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + loading: layerState._geoLoading, + loadingLoaded: Math.min( + layerState._geoLoadDone, + layerState._geoLoadTotal, + ), + loadingTotal: layerState._geoLoadTotal, + }; + }, + + /** + * Registers a callback that receives the full UI state on every change. + * The callback is invoked immediately with the current state. + * @param {Function} callback - Listener function receiving the UI state object. + * @returns {Function} Unsubscribe function. + */ + subscribe(callback) { + if (typeof callback !== 'function') return () => {}; + layerState._listeners.add(callback); + callback(parts.presentation.uiState()); + return () => { + layerState._listeners.delete(callback); + }; + }, + + /** + * Returns the current UI state snapshot without subscribing. + * @returns {Object} + */ + getUIState() { + return parts.presentation.uiState(); + }, + + /** + * Opts the active camera into or out of protected thumbnail publication. + * The default is false: the monitor plane remains the sole active-camera + * representation while ambient and hover-pinned cards continue unchanged. + * @param {Object} [options] + * @param {boolean} [options.activeCameraCardEnabled=false] + * @returns {{activeCameraCardEnabled:boolean}} + */ + setCardPresentationOptions(options = {}) { + return parts.cards.setCctvCardPresentationOptions(options); + }, + + /** + * Selects a camera by ID and optionally flies to it. + * @param {string} cameraId - Camera ID to select. + * @param {Object} [options={}] + * @param {boolean} [options.focus] - If true, fly the viewer to the camera. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {boolean} True if the camera was found and selected. + */ + selectCamera(cameraId, options = {}) { + const result = parts.selection.setActiveCamera(cameraId); + if (result === CCTV_ACTIVATION_RESULT.NOT_FOUND) return false; + if (options.focus) { + parts.navigation.focusCamera(cameraId, options.durationSec || 1.8); + } + return true; + }, + + /** + * Flies the viewer to a specific camera. + * @param {string} cameraId - Camera ID to focus on. + * @param {number} [durationSec=2.2] - Flight duration in seconds. + * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. + */ + focusCamera(cameraId, durationSec = 2.2) { + return parts.navigation.focusCamera(cameraId, durationSec); + }, + + /** + * Cycles the active camera forward or backward by `step` positions in the catalog. + * @param {number} [step=1] - Number of positions to advance (negative to go back). + * @param {Object} [options={}] + * @param {boolean} [options.focus] - If true, fly to the new camera. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {string|null} The newly active camera ID, or null if catalog is empty. + */ + cycleCamera(step = 1, options = {}) { + if (!layerState._records.length) return null; + const current = parts.selection.getActiveRecord(); + const nextIdx = parts.navigation.cctvCycleIndex( + layerState._records.findIndex((record) => record === current), + step, + layerState._records.length, + ); + const nextId = layerState._records[nextIdx].camera.id; + parts.selection.setActiveCamera(nextId); + if (options.focus) { + parts.navigation.focusCamera(nextId, options.durationSec || 1.8); + } + return nextId; + }, + + /** + * Selects and flies to the camera nearest the current viewer position. + * @param {Object} [options={}] + * @param {boolean} [options.focus=true] Whether to fly after selection. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {string|null} The nearest camera ID, or null if none found. + */ + focusNearest(options = {}) { + const nearest = parts.navigation.nearestCameraIdToViewer(); + if (!nearest) return null; + parts.selection.setActiveCamera(nearest); + if (options.focus !== false) { + parts.navigation.focusCamera(nearest, options.durationSec || 1.8); + } + return nearest; + }, + }; + + return { methods }; +} diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js new file mode 100644 index 000000000..d137b74b2 --- /dev/null +++ b/src/layers/cctv/frames.js @@ -0,0 +1,368 @@ +import { + FRAME_SIGNATURE_W, + FRAME_SIGNATURE_H, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + PROJECTION_TEXTURE_SWAP_MS, + ACTIVE_FRAME_REFRESH_MS, + FRAME_ENDPOINT, + MEDIA_ENDPOINT, + PROJECTION_ACTIVE_REFRESH_MS, + PROJECTION_IDLE_REFRESH_MS, + PLACEHOLDER_REPAINT_MS, +} from './policy.js'; + +export function createFrames({ state: layerState, services, parts, source }) { + /** + * FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw + * pixel buffer, no DOM) so it is unit-testable. + * + * Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would + * cost a third more work to mix in a constant. + * + * @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel. + * @returns {number|null} Unsigned 32-bit signature, or null for empty input. + */ + + function frameSignatureFromPixels(data) { + if (!data || typeof data.length !== 'number' || data.length < 4) + return null; + let hash = 0x811c9dc5; + for (let i = 0; i < data.length; i += 4) { + hash = Math.imul(hash ^ data[i], 0x01000193); + hash = Math.imul(hash ^ data[i + 1], 0x01000193); + hash = Math.imul(hash ^ data[i + 2], 0x01000193); + } + return hash >>> 0; + } + + /** + * Signature of the runtime's freshly decoded frame, via a reused 64x36 + * scratch canvas. + * + * @param {Object} runtime - Projection runtime holding the decoded `.image`. + * @returns {number|null} Signature, or null when it cannot be computed (the + * caller then treats the frame as changed — the pre-2026-07-30 behavior). + */ + + function projectionFrameSignature(runtime) { + const image = runtime?.image; + if (!image) return null; + if (!runtime.signatureCtx) { + const canvas = document.createElement('canvas'); + canvas.width = FRAME_SIGNATURE_W; + canvas.height = FRAME_SIGNATURE_H; + runtime.signatureCanvas = canvas; + runtime.signatureCtx = canvas.getContext('2d', { + willReadFrequently: true, + }); + } + const ctx = runtime.signatureCtx; + if (!ctx) return null; + try { + ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); + ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); + return frameSignatureFromPixels( + ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data, + ); + } catch { + // Tainted canvas (a cross-origin source served without CORS) or a decode + // race. Returning null means "assume changed", so behavior degrades to + // the unconditional redraw this optimization replaced. + return null; + } + } + + /** + * Blits the latest projection canvas into the next of two alternating + * offscreen buffer canvases and returns it (H5). + * + * Two buffers are required because Cesium's Material image path re-uploads a + * canvas texture only when the uniform receives a NEW object reference — + * redrawing the same canvas in place is invisible to the GPU. Alternating + * references forces a texture recreate, which at <=1Hz and 1080p is cheap. + * + * @param {Object} runtime - Projection runtime with `.canvas`. + * @returns {HTMLCanvasElement|null} The freshly painted buffer, or null. + */ + + function paintNextProjectionBuffer(runtime) { + if (!runtime?.canvas) return null; + if (!runtime.buffers) { + runtime.buffers = [0, 1].map(() => { + const buffer = document.createElement('canvas'); + buffer.width = PROJECTION_CANVAS_WIDTH; + buffer.height = PROJECTION_CANVAS_HEIGHT; + return buffer; + }); + runtime.bufferIndex = 0; + } + runtime.bufferIndex = (runtime.bufferIndex + 1) % 2; + const buffer = runtime.buffers[runtime.bufferIndex]; + const ctx = buffer.getContext('2d'); + if (!ctx) return null; + ctx.clearRect(0, 0, buffer.width, buffer.height); + ctx.drawImage(runtime.canvas, 0, 0); + return buffer; + } + + /** + * Pushes fresh pixels into the monitor plane material. Called every + * projection tick. + * + * Video feeds are skipped entirely — their HTMLVideoElement uniform is + * updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap + * the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS. + * + * @param {Object} record - Camera record with an initialized projection runtime. + */ + + function refreshProjectionTextures(record) { + const runtime = record?.projection; + if (!runtime || runtime.mode === 'video') return; + const now = Date.now(); + if ( + now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) < + PROJECTION_TEXTURE_SWAP_MS + ) + return; + + const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial); + if (!planeShowing) return; + + // Only swap when the canvas content actually changed since the last swap. + // Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED + // canvas re-uploads the texture for nothing, and each material image + // reassignment is a flash opportunity on the live plane (owner field test + // 2026-07-04: intermittent white flashes on the monitor plane). + if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return; + + const buffer = paintNextProjectionBuffer(runtime); + if (!buffer) return; + runtime.lastTextureSwapAt = now; + runtime.lastSwappedCanvasStamp = runtime.canvasStamp; + runtime.planeMaterial.image = buffer; + } + + /** + * Builds the URL for fetching a camera frame image from the backend. + * Includes a tick parameter to control cache invalidation cadence. + * @param {Object} camera - Camera object. + * @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing. + * @returns {string} Frame URL. + */ + + function frameUrlFor(...args) { + return source.getFrameUrl(...args); + } + + /** + * Builds the URL for fetching a camera's video/media stream. + * @param {Object} camera - Camera object. + * @returns {string} Media URL. + */ + + function mediaUrlFor(...args) { + return source.getMediaUrl(...args); + } + + /** + * Paints a placeholder frame onto the projection canvas when no live feed + * image or video is available. Shows camera name, city, and status text + * over a dark gradient with tactical border lines. + * @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas. + * @param {Object} camera - Camera object for label info. + * @param {Object|null} [health=null] - Health state for status message. + */ + + function paintProjectionPlaceholder(ctx, camera, health = null) { + if (!ctx) return; + const w = PROJECTION_CANVAS_WIDTH; + const h = PROJECTION_CANVAS_HEIGHT; + ctx.clearRect(0, 0, w, h); + const g = ctx.createLinearGradient(0, 0, w, h); + g.addColorStop(0, '#05111a'); + g.addColorStop(1, '#01070c'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + + const label = String(camera?.name || 'CCTV'); + const city = String(camera?.city || 'GLOBAL'); + const status = String( + health?.message || health?.status || camera?.feedType || 'NO FEED', + ).toUpperCase(); + + ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)'; + ctx.lineWidth = 2; + ctx.strokeRect(18, 18, w - 36, h - 36); + ctx.strokeRect(36, 36, w - 72, h - 72); + + ctx.fillStyle = 'rgba(170, 242, 255, 0.95)'; + ctx.font = '600 32px "JetBrains Mono", monospace'; + ctx.fillText(label.slice(0, 42), 46, 74); + ctx.fillStyle = 'rgba(127, 216, 231, 0.8)'; + ctx.font = '500 24px "JetBrains Mono", monospace'; + ctx.fillText(city.toUpperCase(), 46, 112); + ctx.font = '500 21px "JetBrains Mono", monospace'; + ctx.fillText(status.slice(0, 58), 46, h - 42); + } + + /** + * Triggers a new frame fetch for an image-mode projection if the refresh + * interval has elapsed. Active cameras refresh more frequently than idle ones. + * @param {Object} record - Camera record. + * @param {boolean} [force=false] - Bypass the interval check. + */ + + function refreshProjectionImage(record, force = false) { + const runtime = record?.projection; + if (!runtime || runtime.mode !== 'image' || !runtime.image) return; + // Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas + // nobody can see. The refresh interval re-fills naturally on return. + if (typeof document !== 'undefined' && document.hidden && !force) return; + // Do not replace an in-flight URL on the 10-second refresh boundary. Slow + // providers otherwise leave cancelled server requests behind and the plane + // can remain permanently pending. The proxy bounds each attempt; load/error + // clears this latch so the next normal tick can refresh. + if (runtime.imageLoading) return; + const now = Date.now(); + const refreshMs = + record.camera.id === layerState._activeCameraId + ? PROJECTION_ACTIVE_REFRESH_MS + : PROJECTION_IDLE_REFRESH_MS; + if (!force && now - runtime.lastImageRefreshAt < refreshMs) return; + runtime.lastImageRefreshAt = now; + + const frameUrl = frameUrlFor(record.camera, refreshMs); + const sep = frameUrl.includes('?') ? '&' : '?'; + runtime.imageLoading = true; + runtime.imageReady = false; + runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`; + } + + /** + * Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS. + * The projection loop runs at RAF cadence — unthrottled, a pending feed would + * re-fill the 1080p canvas (gradient + text) on every single frame. + * @param {Object} record - Camera record. + * @param {Object} runtime - Projection runtime. + * @param {Object|null} health - Health state for status text. + */ + + function paintPlaceholderThrottled(record, runtime, health) { + const now = Date.now(); + if ( + now - parts.model.safeNumber(runtime.lastPlaceholderPaintAt, 0) < + PLACEHOLDER_REPAINT_MS + ) + return; + runtime.lastPlaceholderPaintAt = now; + runtime.drawnImageStamp = -1; + // The placeholder overwrites the canvas, so the last real frame is no longer + // on it. Drop the signature or an identical frame returning after an outage + // would be skipped as "unchanged" and leave the placeholder on the plane. + runtime.lastFrameSignature = null; + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + paintProjectionPlaceholder(runtime.ctx, record.camera, health); + } + + /** + * Draws the current frame (video or image) onto the projection canvas. + * Falls back to the placeholder if the media source is not yet ready. + * Image feeds only repaint when a NEW image finished loading (stamp check): + * re-blitting an unchanged 1080p image every RAF tick is pure waste since + * texture uploads are already throttled to 1Hz buffer swaps. + * @param {Object} record - Camera record with an initialized projection runtime. + */ + + function drawProjectionFrame(record) { + const runtime = record?.projection; + if (!runtime || !runtime.ctx) return; + + const health = layerState._healthById.get(record.camera.id) || null; + + if (runtime.mode === 'video' && runtime.video) { + const video = runtime.video; + if ( + video.readyState >= 2 && + video.videoWidth > 0 && + video.videoHeight > 0 + ) { + runtime.ctx.clearRect( + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.ctx.drawImage( + video, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + return; + } + paintPlaceholderThrottled(record, runtime, health); + return; + } + + refreshProjectionImage(record); + if (runtime.image && runtime.imageReady) { + if (runtime.drawnImageStamp !== runtime.imageStamp) { + // The frame URL carries a 10s cache-buster tick, so a fresh Image + // DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider + // actually published a new picture — measured 2026-07-30: a London + // camera republished once in 5 minutes, an Austin one not at all. + // Redrawing regardless bumped canvasStamp, which forced a buffer swap + // and a fresh 1920x1080 texture upload; the plane renders its white + // base color (planeMaterial color = WHITE, alpha .95) for the frame or + // two Cesium needs to rebind, which IS the periodic white flash from the + // owner field tests (2026-07-04 and 2026-07-30). + const signature = projectionFrameSignature(runtime); + runtime.drawnImageStamp = runtime.imageStamp; + if (signature !== null && signature === runtime.lastFrameSignature) { + // Identical pixels — leave the canvas, and therefore the bound + // texture, completely alone. No canvasStamp bump, no swap, no flash. + return; + } + runtime.lastFrameSignature = signature; + runtime.ctx.clearRect( + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.ctx.drawImage( + runtime.image, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + runtime.lastPlaceholderPaintAt = 0; + } + return; + } + // A refresh is in flight (imageReady=false): keep the last good frame on + // the canvas instead of flashing the placeholder. Placeholder only paints + // when nothing has ever been drawn for this camera. + if (runtime.drawnImageStamp === -1) { + paintPlaceholderThrottled(record, runtime, health); + } + } + return { + frameSignatureFromPixels, + projectionFrameSignature, + paintNextProjectionBuffer, + refreshProjectionTextures, + frameUrlFor, + mediaUrlFor, + paintProjectionPlaceholder, + refreshProjectionImage, + paintPlaceholderThrottled, + drawProjectionFrame, + }; +} diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js new file mode 100644 index 000000000..221648a3a --- /dev/null +++ b/src/layers/cctv/geometry.js @@ -0,0 +1,719 @@ +import * as Cesium from 'cesium'; +import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js'; +import { + PROJECTION_VERT_ASPECT, + FRUSTUM_GROUND_CLEARANCE_M, + PROBE_MIN_RANGE_M, + PROBE_CLEARANCE_M, + COVERAGE_NEIGHBOR_RADIUS_KM, + COVERAGE_NEIGHBOR_LIMIT, + IDLE_COVERAGE_COLOR, +} from './policy.js'; + +export function createGeometry({ state: layerState, services, parts, source }) { + const { warmGroundFloor, cachedGroundFloor } = services.ground; + const { sampleMeshFloorCells } = services.mesh; + + /** + * V2 core geometry (design §2a): computes the pitched frustum pyramid — mount + * point, far-cap (monitor plane) center, and the 4 far-plane corners — purely + * from the calibrated pose + a caller-supplied ground altitude. ZERO scene + * queries: ground sampling happens in the caller (one-shot snap), and the + * obstruction probe passes its clamp in as `rangeOverrideM`. + * + * Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges): + * mountAlt = groundAltM + mountHeightM + * capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch) + * halfW = R·tan(hFov/2) + * vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2) + * upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading + * corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset + * The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated + * pitch must never bury the plane's anchor); corners derive rigidly from the + * clamped center so the wireframe rays always terminate on the plane's + * corners — the bottom pair may dip below ground (tiles occlude it). + * + * @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg, + * rangeM, mountHeightM. + * @param {number} groundAltM - Ground altitude at the mount (metres). + * @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the + * effective range (never lengthens it). + * @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number, + * mount: {lat:number,lon:number,alt:number}, + * capCenter: {lat:number,lon:number,alt:number}, + * corners: { tl: Object, tr: Object, br: Object, bl: Object }, + * topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }} + */ + + function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) { + const ground = parts.model.safeNumber(groundAltM, 0); + const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700)); + const override = parts.model.safeNumber(rangeOverrideM, NaN); + const R = + Number.isFinite(override) && override > 0 + ? Math.min(poseRange, override) + : poseRange; + const pitch = parts.model.toRad( + parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89), + ); + const hFov = parts.model.toRad( + parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160), + ); + const heading = parts.model.safeNumber(camera.headingDeg, 0); + const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24); + + const horiz = R * Math.cos(pitch); + const vert = R * Math.sin(pitch); + const capLL = parts.model.projectPoint( + camera.lat, + camera.lon, + heading, + horiz, + ); + const capAlt = mountAlt + vert; + + const halfW = R * Math.tan(hFov / 2); + const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT); + const halfH = R * Math.tan(vFovRad / 2); + + // In-plane "up" of the pitched cap, decomposed into a vertical part and a + // horizontal part along the heading (pitch < 0 tilts the cap's top forward). + const upVert = Math.cos(pitch) * halfH; + const upHoriz = -Math.sin(pitch) * halfH; + + const capL = parts.model.projectPoint( + capLL.lat, + capLL.lon, + heading - 90, + halfW, + ); + const capR = parts.model.projectPoint( + capLL.lat, + capLL.lon, + heading + 90, + halfW, + ); + // Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch + // never buries the plane's anchor — then derive the corners RIGIDLY from the + // lifted center. Clamping each corner independently flattened the wireframe + // into a ground-hugging fan while the rigid plane kept its height (owner + // field test 2026-07-04): the corner rays must always terminate exactly on + // the monitor plane's corners. The bottom pair may dip below ground; the 3D + // tiles occlude that portion, exactly as they do for the plane itself. + const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M; + const capAltClamped = Math.max(minAlt, capAlt); + const corner = (base, sign) => { + const ll = parts.model.projectPoint( + base.lat, + base.lon, + heading, + sign * upHoriz, + ); + return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert }; + }; + + const topCenter = corner(capLL, 1); + return { + rangeM: R, + vFovDeg: Cesium.Math.toDegrees(vFovRad), + halfW, + halfH, + mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt }, + capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped }, + corners: { + tl: corner(capL, 1), + tr: corner(capR, 1), + br: corner(capR, -1), + bl: corner(capL, -1), + }, + topCenter, + groundAltM: ground, + }; + } + + /** + * Resolves the obstruction probe's effective-range clamp just short of a hit, + * with the field-derived 12 m floor used by the original H6 monitor. + * @param {number} rangeM Nominal camera range. + * @param {number} hitDistanceM Distance to the first obstruction. + * @returns {number|null} Clamp range, or null when the hit does not shorten it. + */ + + function activationProbeClampRange(rangeM, hitDistanceM) { + const nominalRange = Number(rangeM); + const hitDistance = Number(hitDistanceM); + if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null; + if ( + !Number.isFinite(hitDistance) || + hitDistance <= 0 || + hitDistance >= nominalRange + ) + return null; + return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M); + } + + /** + * Converts a computeFrustumGeometry result into the Cartesian3 positions the + * entities consume. Fresh objects per call (geometry updates are rare — + * slider/save/activation — and entities must never share scratch objects). + * @param {Object} geometry - Result of computeFrustumGeometry. + * @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3, + * tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3, + * bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }} + */ + + function frustumCartesians(geometry) { + const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt); + return { + mount: at(geometry.mount), + capCenter: at(geometry.capCenter), + tl: at(geometry.corners.tl), + tr: at(geometry.corners.tr), + br: at(geometry.corners.br), + bl: at(geometry.corners.bl), + label: Cesium.Cartesian3.fromDegrees( + geometry.topCenter.lon, + geometry.topCenter.lat, + geometry.topCenter.alt + 1.2, + ), + }; + } + + /** + * Unit ECEF direction of the frustum view axis (heading/pitch) at a position. + * Used by the plane orientation and the activation obstruction probe — both + * need the UNCLAMPED axis, so it comes from the pose, not from clamped points. + * @param {Object} camera - Camera pose (headingDeg, pitchDeg). + * @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame. + * @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the + * frame's in-plane "up" (both unit, mutually perpendicular). + */ + + function frustumFrameEcef(camera, atPos) { + const h = parts.model.toRad(camera.headingDeg); + const p = parts.model.toRad(camera.pitchDeg); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos); + const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3()); + // ENU components: view axis + the perpendicular "up" of the pitched cap. + const dirEnu = new Cesium.Cartesian3( + Math.sin(h) * Math.cos(p), + Math.cos(h) * Math.cos(p), + Math.sin(p), + ); + const upEnu = new Cesium.Cartesian3( + -Math.sin(p) * Math.sin(h), + -Math.sin(p) * Math.cos(h), + Math.cos(p), + ); + return { + dir: Cesium.Matrix3.multiplyByVector( + rot, + dirEnu, + new Cesium.Cartesian3(), + ), + up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()), + }; + } + + /** + * Recomputes the pure frustum geometry from the camera pose + the given ground + * altitude and writes it into the scene: billboard position, the 5 wireframe + * polylines (4 corner rays + closed far-plane rectangle), and the monitor + * plane placement. This is the ONLY place v2 geometry is written — called on + * slider input / save / activation / the one-shot ground snap, never per frame. + * @param {Object} record - Camera record. + * @param {number} groundAltM - Ground altitude at the mount (metres). + */ + + function applyFrustumGeometry(record, groundAltM) { + const geometry = computeFrustumGeometry( + record.camera, + groundAltM, + record.probeClampRangeM, + ); + const positions = frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + record.position = positions.mount; + record.camera.absoluteHeightM = geometry.mount.alt; + if (record.billboard) { + record.billboard.position = positions.mount; + } + if (record.coverageEntities?.length >= 5) { + record.coverageEntities[0].polyline.positions = [ + positions.mount, + positions.tl, + ]; + record.coverageEntities[1].polyline.positions = [ + positions.mount, + positions.tr, + ]; + record.coverageEntities[2].polyline.positions = [ + positions.mount, + positions.br, + ]; + record.coverageEntities[3].polyline.positions = [ + positions.mount, + positions.bl, + ]; + record.coverageEntities[4].polyline.positions = [ + positions.tl, + positions.tr, + positions.br, + positions.bl, + positions.tl, + ]; + } + // A live viewshed volume tracks its wireframe: rebuild from the SAME fresh + // positions (weld invariant). Only records currently showing a volume pay + // this (6 triangles, synchronous — trivial even during slider/gizmo drags). + // Tint derives from the live active id, not the cached viewshedActiveTint — + // during an activation switch this runs BEFORE refreshCoverageStyles, and + // the cache is stale for exactly that window. + if (record.viewshedPrimitive) { + rebuildViewshedVolume( + record, + record.camera.id === layerState._activeCameraId, + ); + } + parts.projection.updatePlanePlacement(record); + // Gizmo handles track the pose they manipulate: refresh when the ACTIVE + // camera's geometry rewrites (incl. during its own drag). + if ( + layerState._gizmo?.isEnabled() && + record.camera.id === layerState._activeCameraId + ) { + layerState._gizmo.refresh(); + } + } + + /** + * Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground + * resolution (Task 5, spec §2). Per regime: + * + * - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM + * floor because mesh floors are regime-disabled. The exact Re:Earth prior + * remains the immediate fallback while that coarse cell warms. + * - `google-3d` (photoreal): the shared mesh-floor sampler may refine the + * DEM cell once, subject to its existing tiles-ready, distance, + * camera-height, and acceptance gates. Geometry reads only + * `cachedGroundFloor`, never a CCTV-owned point sample. + * + * v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No + * timer, no deadband: this function is called only from the staggered + * geometry queue (the enable-time drain + update()'s one-shot tiles-ready + * completion re-enqueue), from explicit pose-edit call sites, and from the + * map-stack regime-change handler. + * @param {Object} record - Camera record. + * @param {Object} [options={}] + * @param {boolean} [options.sampleGround=true] - When false, skip shared + * mesh-floor refinement and use the cached/prior ground instead. + */ + + function updateRecordGeometry(record, options = {}) { + const sampleGround = options.sampleGround !== false; + const regime = parts.ground.currentSurfaceRegime(); + const point = { lat: record.camera.lat, lon: record.camera.lon }; + warmGroundFloor([point]); + + if (regime === 'terrain-globe') { + const cachedFloor = cachedGroundFloor(point.lat, point.lon); + const ground = Number.isFinite(cachedFloor) + ? cachedFloor + : parts.ground.groundPriorAltFor(record); + record.groundSamples['terrain-globe'] = ground; + record.groundResolved['terrain-globe'] = true; + applyFrustumGeometry(record, ground); + return; + } + + // Photoreal regime. Sampling is delegated to the shared coarse-cell + // sampler. It remains event-driven, one-shot per cell, and keeps its + // existing acceptance window; CCTV adds no rooftop rejection policy. + if (sampleGround && parts.model.projectionTilesReady()) { + record.groundMeshSampleRequestCount = + (record.groundMeshSampleRequestCount || 0) + 1; + const viewerCarto = layerState._viewer?.camera?.positionCartographic; + const excludeObjects = [...(record.coverageEntities || [])]; + if (record.billboard) excludeObjects.push(record.billboard); + if (record.projection?.planeEntity) + excludeObjects.push(record.projection.planeEntity); + sampleMeshFloorCells(layerState._viewer?.scene, [point], { + excludeObjects: excludeObjects.filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + const cachedFloor = cachedGroundFloor(point.lat, point.lon); + const ground = Number.isFinite(cachedFloor) + ? cachedFloor + : parts.ground.groundAltFor(record, 'google-3d'); + applyFrustumGeometry(record, ground); + + record.groundResolved['google-3d'] = Number.isFinite(cachedFloor); + if (Number.isFinite(cachedFloor)) { + record.groundSamples['google-3d'] = ground; + } + } + + /** + * Determines which camera coverage overlays should be visible based on + * proximity to the active camera. Limits visibility to + * COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM. + * @param {Object|null} activeRecord - The active camera record. + * @returns {Set} Set of visible camera IDs. + */ + + function buildCoverageVisibleSet(activeRecord) { + if (!layerState._records.length) return new Set(); + // Coverage emphasis is relative to a selected camera. Without one, do not + // invent an arbitrary catalog-order cohort. + if (!activeRecord) return new Set(); + + const ranked = layerState._records.map((record) => { + if (record === activeRecord) { + return { record, distKm: -1 }; + } + return { + record, + distKm: parts.model.haversineKm( + activeRecord.camera.lat, + activeRecord.camera.lon, + record.camera.lat, + record.camera.lon, + ), + }; + }); + + ranked.sort((a, b) => a.distKm - b.distKm); + + const primary = ranked.filter( + (entry) => + entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1, + ); + const fallback = ranked; + const chosen = ( + primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback + ) + .slice(0, COVERAGE_NEIGHBOR_LIMIT) + .map((entry) => entry.record.camera.id); + return new Set(chosen); + } + + /** + * Removes (and destroys) a record's viewshed volume primitive, if any. + * @param {Object} record - Camera record. + */ + + function destroyViewshedVolume(record) { + if (!record) return; + if (record.viewshedPrimitive && layerState._viewer) { + layerState._viewer.scene.primitives.remove(record.viewshedPrimitive); + } + record.viewshedPrimitive = null; + } + + /** + * (Re)builds a record's translucent viewshed volume from its CURRENT + * frustumPositions — the same 5 Cartesians the wireframe draws, so the volume + * is welded to the cone by construction. Called only where the wireframe + * already rewrites (style refresh on mode/visible-set/active changes, + * applyFrustumGeometry on pose edits) — no new update cadence, zero scene + * queries (viewshed design §3b). + * @param {Object} record - Camera record. + * @param {boolean} isActive - Active camera gets the brighter fill. + */ + + function rebuildViewshedVolume(record, isActive) { + destroyViewshedVolume(record); + if ( + !layerState._viewer || + !record?.frustumPositions || + !record.viewshedColors + ) + return; + const color = isActive + ? record.viewshedColors.fillActive + : record.viewshedColors.fill; + const primitive = createFrustumVolumePrimitive( + record.frustumPositions, + color, + ); + // QA tag: the harness counts viewshed volumes by this marker. + primitive._gevViewshed = record.camera.id; + record.viewshedPrimitive = + layerState._viewer.scene.primitives.add(primitive); + record.viewshedActiveTint = !!isActive; + } + + /** + * Counts how many other cameras have overlapping coverage with the target. + * Overlap is approximated by comparing inter-camera distance against the + * combined range of both cameras (scaled by 0.92). + * @param {Object} targetRecord - Camera record to check. + * @returns {number} Number of overlapping neighbors. + */ + + function coverageNeighborCount(targetRecord) { + if (!targetRecord) return 0; + let count = 0; + for (const record of layerState._records) { + if (record === targetRecord) continue; + const dKm = parts.model.haversineKm( + targetRecord.camera.lat, + targetRecord.camera.lon, + record.camera.lat, + record.camera.lon, + ); + const overlapKm = + ((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92; + if (dKm <= overlapKm) count++; + } + return count; + } + + /** + * §9.1 activation obstruction probe (LOCKED owner decision): on camera + * ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis + * (mount → cap-center direction). If it hits the tiles closer than the pose + * range, clamp the plane's effective range just short of the first hit so the + * "big and dramatic" true end cap never clips into downtown buildings. + * + * This is the ONLY raycast in the whole CCTV subsystem — once per activation, + * never per-frame (the zero-raycast invariant applies to steady state). The + * per-camera range slider overrides the clamp: a user-set rangeScale skips the + * probe entirely. Probe failure/miss keeps the unclamped range. + * @param {Object} record - Camera record being activated. + */ + + function runActivationObstructionProbe(record) { + record.probeClampRangeM = null; + const scene = layerState._viewer?.scene; + if (!scene || typeof scene.pickFromRay !== 'function') return; + const camera = record.camera; + const rangeScale = parts.calibration.normalizeCalibration( + camera.calibration, + ).rangeScale; + if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp + try { + const mountAlt = parts.ground.groundAltFor(record) + camera.mountHeightM; + const mountPos = Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + mountAlt, + ); + const { dir } = frustumFrameEcef(camera, mountPos); + // Exclude everything the layer itself draws so the probe can only hit the + // world (3D tiles), not our own billboards/polylines/planes. + const exclude = [layerState._billboards, ...layerState._coverageEntities]; + for (const runtime of layerState._projectionEntities) { + if (runtime?.planeEntity) exclude.push(runtime.planeEntity); + } + const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); + if (!hit?.position) return; + const dist = Cesium.Cartesian3.distance(mountPos, hit.position); + record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); + } catch { + // probe failure → keep the unclamped range + } + } + + /** + * Clears a deactivated camera's temporary obstruction clamp and rewrites its + * geometry through the normal single-range path. + * @param {Object|null} record Camera runtime record being deactivated. + * @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite. + * @returns {boolean} Whether a clamp was cleared. + */ + + function clearProbeClampOnDeactivation(record, rewriteGeometry) { + if (!record || !Number.isFinite(record.probeClampRangeM)) return false; + record.probeClampRangeM = null; + rewriteGeometry?.(record); + return true; + } + + /** + * Creates the five Cesium polyline entities that visualize a camera's pitched + * frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane + * rectangle. Entity ids stay in the `cctv--` scheme (pick-owner + * regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap. + * @param {Object} record - Camera record. + * @returns {Cesium.Entity[]} Array of five coverage entities. + */ + + function buildCoverageEntities(record) { + const { camera } = record; + // Prefer the record's already-refined geometry. Lazy creation commonly + // happens after the staggered ground pass; recomputing from the catalog + // prior here would regress the camera to its pre-sampled datum. + let geometry = record.frustumGeometry; + let positions = record.frustumPositions; + if (!geometry || !positions) { + geometry = computeFrustumGeometry( + camera, + parts.ground.groundPriorAltFor(record), + record.probeClampRangeM, + ); + positions = frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + record.position = positions.mount; + } + + const addPolyline = (role, linePositions) => + layerState._viewer.entities.add({ + id: `cctv-${camera.id}-${role}`, + properties: { cctvCameraId: camera.id }, + polyline: { + positions: linePositions, + width: 1.2, + material: IDLE_COVERAGE_COLOR, + }, + }); + + const entities = [ + addPolyline('ray-tl', [positions.mount, positions.tl]), + addPolyline('ray-tr', [positions.mount, positions.tr]), + addPolyline('ray-br', [positions.mount, positions.br]), + addPolyline('ray-bl', [positions.mount, positions.bl]), + addPolyline('cap', [ + positions.tl, + positions.tr, + positions.br, + positions.bl, + positions.tl, + ]), + ]; + + entities[0]._coverageRole = 'edge'; + entities[1]._coverageRole = 'edge'; + entities[2]._coverageRole = 'edge'; + entities[3]._coverageRole = 'edge'; + entities[4]._coverageRole = 'cap'; + return entities; + } + + /** + * Materializes coverage entities for eligible records exactly once. + * The helper is dependency-injected so unit tests can prove the enable policy + * without constructing Cesium entities. + * + * @param {Object[]} records CCTV runtime records. + * @param {(record: Object) => boolean} [isEligible] Eligibility predicate. + * @param {(record: Object) => Object[]} buildEntities Coverage builder. + * @returns {Object[]} Newly created entities across all eligible records. + */ + + function materializeCctvCoverageEntities( + records, + isEligible = () => true, + buildEntities, + ) { + const created = []; + if (typeof buildEntities !== 'function') return created; + for (const record of Array.isArray(records) ? records : []) { + if (!record || record.coverageEntities?.length || !isEligible(record)) + continue; + const entities = buildEntities(record); + record.coverageEntities = Array.isArray(entities) + ? entities.filter(Boolean) + : []; + created.push(...record.coverageEntities); + } + return created; + } + + /** Materializes one active camera's coverage set. */ + + function materializeCctvActiveCoverageEntities(record, buildEntities) { + return materializeCctvCoverageEntities([record], () => true, buildEntities); + } + + /** Materializes only records in the current coverage-visible ID set. */ + + function materializeCctvVisibleCoverageEntities( + records, + visibleIds, + buildEntities, + ) { + const eligibleIds = + visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []); + return materializeCctvCoverageEntities( + records, + (record) => eligibleIds.has(record.camera?.id), + buildEntities, + ); + } + + /** Registers newly built coverage entities with the layer-global collection. */ + + function registerCoverageEntities(created) { + layerState._coverageEntities.push(...created); + return created; + } + + function ensureActiveCoverageEntities(record) { + return registerCoverageEntities( + materializeCctvActiveCoverageEntities(record, buildCoverageEntities), + ); + } + + function ensureVisibleCoverageEntities(records, visibleIds) { + return registerCoverageEntities( + materializeCctvVisibleCoverageEntities( + records, + visibleIds, + buildCoverageEntities, + ), + ); + } + + /** + * Removes all coverage entities and projection runtimes from the scene. + */ + + function destroyCoverageEntities() { + if (!layerState._viewer) return; + for (const entity of layerState._coverageEntities) { + layerState._viewer.entities.remove(entity); + } + layerState._coverageEntities = []; + + for (const record of layerState._records) { + destroyViewshedVolume(record); + } + + for (const runtime of layerState._projectionEntities) { + parts.projection.destroyProjectionRuntime(runtime); + } + layerState._projectionEntities = []; + } + return { + computeFrustumGeometry, + activationProbeClampRange, + frustumCartesians, + frustumFrameEcef, + applyFrustumGeometry, + updateRecordGeometry, + buildCoverageVisibleSet, + destroyViewshedVolume, + rebuildViewshedVolume, + coverageNeighborCount, + runActivationObstructionProbe, + clearProbeClampOnDeactivation, + buildCoverageEntities, + materializeCctvCoverageEntities, + materializeCctvActiveCoverageEntities, + materializeCctvVisibleCoverageEntities, + registerCoverageEntities, + ensureActiveCoverageEntities, + ensureVisibleCoverageEntities, + destroyCoverageEntities, + }; +} diff --git a/src/layers/cctv/geometryQueue.js b/src/layers/cctv/geometryQueue.js new file mode 100644 index 000000000..5583e36af --- /dev/null +++ b/src/layers/cctv/geometryQueue.js @@ -0,0 +1,339 @@ +import * as Cesium from 'cesium'; +import { + GEO_PROGRESS_NOTIFY_INTERVAL_MS, + GEO_PROGRESS_NOTIFY_BATCH_LIMIT, + GEO_TRACKING_BATCH_SIZE, + GEO_TRACKING_BATCH_DELAY_MS, + GEO_LOAD_BATCH_SIZE, + GEO_LOAD_BATCH_DELAY_MS, +} from './policy.js'; + +export function createGeometryQueue({ + state: layerState, + services, + parts, + source, +}) { + /** + * Stops the staggered geometry-load queue and optionally clears progress + * counters (kept when pausing mid-flight is not needed — we always clear). + * @param {boolean} [clearProgress=true] + */ + + function stopGeometryLoadQueue(clearProgress = true) { + if (layerState._geoQueueTimer) { + clearTimeout(layerState._geoQueueTimer); + layerState._geoQueueTimer = 0; + } + layerState._geoQueue = []; + layerState._geoProgressNotifier = null; + if (clearProgress) { + layerState._geoLoading = false; + layerState._geoLoadTotal = 0; + layerState._geoLoadDone = 0; + } + } + + /** + * Creates the notification coalescer used by a staggered geometry drain. + * Progress emits after roughly 300 ms or ten batches, whichever comes first; + * finish always emits once even when the last progress tick just fired. + * + * @param {Function} notify Notification callback. + * @param {Object} [options={}] Testable timing options. + * @param {() => number} [options.now] Monotonic clock returning milliseconds. + * @param {number} [options.intervalMs] Maximum progress-notification cadence. + * @param {number} [options.batchLimit] Maximum batches between progress ticks. + * @returns {{ progress: () => boolean, finish: () => void }} Drain notifier. + */ + + function createGeometryProgressNotifier(notify, options = {}) { + const now = typeof options.now === 'function' ? options.now : Date.now; + const intervalMs = Number.isFinite(options.intervalMs) + ? Math.max(0, options.intervalMs) + : GEO_PROGRESS_NOTIFY_INTERVAL_MS; + const batchLimit = Number.isFinite(options.batchLimit) + ? Math.max(1, Math.floor(options.batchLimit)) + : GEO_PROGRESS_NOTIFY_BATCH_LIMIT; + let lastNotifyAt = now(); + let batchesSinceNotify = 0; + + return { + progress() { + batchesSinceNotify += 1; + const current = now(); + if ( + current - lastNotifyAt < intervalMs && + batchesSinceNotify < batchLimit + ) { + return false; + } + batchesSinceNotify = 0; + lastNotifyAt = current; + notify?.(); + return true; + }, + finish() { + batchesSinceNotify = 0; + lastNotifyAt = now(); + notify?.(); + }, + }; + } + + /** + * Processes one geometry-queue batch and routes progress/completion through + * the callbacks shared by production and the unit drain harness. + * + * @param {Object} options Batch inputs. + * @param {Object[]} options.queue Mutable record queue. + * @param {number} options.batchSize Maximum records to visit. + * @param {(record: Object) => void} options.visit Per-record geometry work. + * @param {() => void} options.progress Coalesced progress publication. + * @param {() => void} options.complete Unconditional completion publication. + * @returns {boolean} True when more records remain. + */ + + function processCctvGeometryQueueBatch({ + queue, + batchSize, + visit, + progress, + complete, + }) { + const safeQueue = Array.isArray(queue) ? queue : []; + const take = Number.isFinite(batchSize) + ? Math.max(1, Math.floor(batchSize)) + : 1; + const batch = safeQueue.splice(0, take); + for (const record of batch) visit?.(record); + if (safeQueue.length) { + progress?.(); + return true; + } + complete?.(); + return false; + } + + /** + * Selects per-batch geometry-drain pacing from current camera ownership. + * Called for every batch so releasing tracking immediately restores normal + * throughput without restarting the queue. + * + * @param {Object} [ownership={}] Current camera-ownership state. + * @param {*} [ownership.trackedEntity] Cesium tracked entity, if any. + * @param {boolean} [ownership.cockpitActive] Whether cockpit owns the camera. + * @returns {{ batchSize: number, delayMs: number }} Drain pacing. + */ + + function cctvGeometryDrainPacing({ + trackedEntity = null, + cockpitActive = false, + } = {}) { + if (trackedEntity || cockpitActive) { + return { + batchSize: GEO_TRACKING_BATCH_SIZE, + delayMs: GEO_TRACKING_BATCH_DELAY_MS, + }; + } + return { batchSize: GEO_LOAD_BATCH_SIZE, delayMs: GEO_LOAD_BATCH_DELAY_MS }; + } + + /** + * Processes one tracking-aware geometry-drain batch. Ownership is read inside + * every call so a mid-drain tracking/cockpit transition changes the very next + * batch's size and delay. + * + * @param {Object} options Batch inputs. + * @param {Object[]} options.queue Mutable record queue. + * @param {() => Object} [options.readOwnership] Current camera ownership. + * @param {(record: Object) => void} options.visit Per-record geometry work. + * @param {() => void} options.progress Coalesced progress publication. + * @param {() => void} options.complete Unconditional completion publication. + * @returns {{ hasMore: boolean, batchSize: number, delayMs: number }} Batch result and pacing. + */ + + function processCctvGeometryDrainBatch({ + queue, + readOwnership, + visit, + progress, + complete, + }) { + const pacing = cctvGeometryDrainPacing(readOwnership?.() || {}); + const hasMore = processCctvGeometryQueueBatch({ + queue, + batchSize: pacing.batchSize, + visit, + progress, + complete, + }); + return { hasMore, ...pacing }; + } + + /** + * Moves the current active record to the front of a live drain queue. + * @param {Object[]} queue Mutable geometry queue. + * @param {Object|null} activeRecord Current active CCTV record. + * @returns {boolean} Whether the queue order changed. + */ + + function prioritizeActiveCctvGeometryRecord(queue, activeRecord) { + if (!Array.isArray(queue) || !activeRecord) return false; + const index = queue.indexOf(activeRecord); + if (index <= 0) return false; + queue.splice(index, 1); + queue.unshift(activeRecord); + return true; + } + + /** + * Processes one batch (GEO_LOAD_BATCH_SIZE records) of the geometry queue: + * full ground-sampled coverage geometry per record, then yields back to the + * event loop before the next batch so tile rendering never stalls. When the + * initial-load pass completes it clears the loading flag and refreshes styles. + */ + + function processGeometryBatch() { + layerState._geoQueueTimer = 0; + if (!layerState._viewer) { + stopGeometryLoadQueue(); + return; + } + // Active-camera-first is re-established every batch because the operator + // can select a new camera while a long catalog drain is in flight. + prioritizeActiveCctvGeometryRecord( + layerState._geoQueue, + parts.selection.getActiveRecord(), + ); + const batchResult = processCctvGeometryDrainBatch({ + queue: layerState._geoQueue, + readOwnership: () => ({ + trackedEntity: layerState._viewer.trackedEntity, + cockpitActive: + typeof document !== 'undefined' && + document.body?.classList.contains('cockpit-mode'), + }), + visit: (record) => { + try { + parts.geometry.updateRecordGeometry(record); + } catch (err) { + console.warn( + '[Data:CCTV] geometry refresh error:', + err?.message || err, + ); + } + if ( + layerState._geoLoading && + layerState._geoLoadDone < layerState._geoLoadTotal + ) { + layerState._geoLoadDone += 1; + } + }, + progress: () => layerState._geoProgressNotifier?.progress(), + complete: () => { + const wasInitialLoad = layerState._geoLoading; + layerState._geoLoading = false; + if (wasInitialLoad) { + layerState._geoLoadDone = layerState._geoLoadTotal; + if (layerState._enabled) { + parts.rendering.refreshCoverageStyles(); + // Geometry refinement may have replaced record.position objects — the + // one-shot drain completion re-anchors the card entries (event-driven, + // not a per-frame or timer pass). + parts.cards.refreshAmbientCards(); + } + } + // Completion is never coalesced: subscribers must observe the final + // loading state even if the last progress tick just happened. + layerState._geoProgressNotifier?.finish(); + layerState._geoProgressNotifier = null; + }, + }); + if (batchResult.hasMore) { + layerState._geoQueueTimer = setTimeout( + processGeometryBatch, + batchResult.delayMs, + ); + return; + } + } + + /** + * Appends records to the geometry queue (no progress tracking) and starts + * the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion + * pass (records left `!groundResolved` by an enable-time drain that ran while + * tiles were still streaming) so it shares the same stagger machinery as the + * initial load. Fires at most once per enable — never on a recurring timer. + * @param {Object[]} records - Camera records needing geometry refresh. + */ + + function enqueueGeometryRefresh(records) { + for (const record of records) { + if (!layerState._geoQueue.includes(record)) { + layerState._geoQueue.push(record); + } + } + if (!layerState._geoQueueTimer && layerState._geoQueue.length) { + layerState._geoProgressNotifier = createGeometryProgressNotifier( + parts.presentation.notifyListeners, + ); + layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0); + } + } + + /** + * Starts the initial staggered load: orders all records active-camera-first, + * then by distance from the current viewer position (nearest first, so + * cameras likely in view refine before off-screen ones), and exposes + * loaded/total progress through uiState()/getStats() while running. + */ + + function startGeometryLoadQueue() { + stopGeometryLoadQueue(); + // Fresh drain → fresh one-shot completion pass: re-arm the tiles-ready + // latch so update() can complete any records this drain leaves unresolved. + layerState._tilesReadyReenqueued = false; + if (!layerState._records.length) return; + const active = parts.selection.getActiveRecord(); + const carto = layerState._viewer?.camera?.positionCartographic; + const refLat = carto + ? Cesium.Math.toDegrees(carto.latitude) + : (active?.camera.lat ?? 0); + const refLon = carto + ? Cesium.Math.toDegrees(carto.longitude) + : (active?.camera.lon ?? 0); + const pending = layerState._records + .filter((record) => record !== active) + .map((record) => ({ + record, + distKm: parts.model.haversineKm( + refLat, + refLon, + record.camera.lat, + record.camera.lon, + ), + })) + .sort((a, b) => a.distKm - b.distKm) + .map((entry) => entry.record); + layerState._geoQueue = active ? [active, ...pending] : pending; + layerState._geoLoadTotal = layerState._geoQueue.length; + layerState._geoLoadDone = 0; + layerState._geoLoading = true; + layerState._geoProgressNotifier = createGeometryProgressNotifier( + parts.presentation.notifyListeners, + ); + layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0); + } + return { + stopGeometryLoadQueue, + createGeometryProgressNotifier, + processCctvGeometryQueueBatch, + cctvGeometryDrainPacing, + processCctvGeometryDrainBatch, + prioritizeActiveCctvGeometryRecord, + processGeometryBatch, + enqueueGeometryRefresh, + startGeometryLoadQueue, + }; +} diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js new file mode 100644 index 000000000..4e6439306 --- /dev/null +++ b/src/layers/cctv/ground.js @@ -0,0 +1,283 @@ +export function createGround({ state: layerState, services, parts, source }) { + const { resolveGroundFloorCells } = services.ground; + const { resolveEllipsoidalGround } = services.terrain; + + /** + * 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'} + */ + + function surfaceRegimeKey(globeShow) { + return globeShow === false ? 'google-3d' : 'terrain-globe'; + } + + /** + * Task 5: the surface regime the scene is CURRENTLY rendering, derived live + * from `globe.show` (mapStackController's `_activatePhotoreal` / + * `_activateGlobeStack` flip exactly this flag). Reading scene state directly + * — rather than caching the map-stack id — means the regime is correct even + * for stack changes this module never got an event for. + * @returns {'google-3d'|'terrain-globe'} + */ + + function currentSurfaceRegime() { + return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show); + } + + /** + * Task 5: the record's ellipsoidal ground PRIOR (Re:Earth point-height batch, + * `record.groundPrior.ellipsoid`), falling back to the catalog's fabricated + * orthometric `groundElevationM` only when the prior batch hasn't landed yet. + * This is the value that replaces every previous + * `Number(camera.groundElevationM) || 0` ground fallback — it alone lifts + * London's cameras from the fabricated 15 m to ~52.7 m ellipsoidal in every + * regime, on first paint. + * @param {Object} record - Camera record. + * @returns {number} Ellipsoidal ground altitude in metres. + */ + + function groundPriorAltFor(record) { + const prior = record?.groundPrior?.ellipsoid; + return Number.isFinite(prior) + ? prior + : Number(record?.camera?.groundElevationM) || 0; + } + + /** + * Task 5: whether the record's one-shot ground resolution has completed for + * the given regime (per-regime latch — replaces the old boolean + * `groundResolved`). + * @param {Object} record - Camera record. + * @param {string} [regime] - Defaults to the current surface regime. + * @returns {boolean} + */ + + function isGroundResolved(record, regime = currentSurfaceRegime()) { + return record?.groundResolved?.[regime] === true; + } + + /** + * Ground altitude used for pure geometry recomputes: the given regime's + * cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor + * in google-3d, the DEM/prior in terrain-globe) when it exists, else the prior + * itself. Never queries the scene. + * @param {Object} record - Camera record. + * @param {string} [regime] - Defaults to the current surface regime. + * @returns {number} Ground altitude in metres. + */ + + function groundAltFor(record, regime = currentSurfaceRegime()) { + const cached = record?.groundSamples?.[regime]; + return Number.isFinite(cached) ? cached : groundPriorAltFor(record); + } + + /** + * Re-arms a record for a fresh one-shot ground resolution after a GENUINE + * pose change (an explicit user select/move or manual calibration edit). + * Clears the CURRENT regime's resolved latch so the next real pass in + * updateRecordGeometry always applies, then the record re-freezes. Never + * called on a timer. + * + * The cached `groundSamples` entries are deliberately KEPT (only the latch is + * cleared). They are the record's "has ever resolved" memory: the B9c + * fallback guard in updateRecordGeometry reads the google-3d entry so a + * rearmed camera whose tiles are mid-stream (e.g. select → flyTo → + * tilesLoaded false) is not yanked back to prior/catalog heights before its + * fresh shared floor lands. In the terrain-globe regime the re-arm is + * effectively free: the next pass re-latches from the DEM/prior with zero + * scene queries. + * @param {Object} record - Camera record. + */ + + function rearmGroundResolution(record) { + if (!record) return; + record.groundResolved[currentSurfaceRegime()] = false; + } + + /** + * Resolves a user-moved ground anchor exactly once at commit. The synchronous + * pass uses a warm shared floor immediately, or preserves the pre-drag floor + * while the new cell is cold. A revision and coordinate check prevent an + * older asynchronous release from rewriting a newer edit. + * @param {Object} record - Camera record whose lat/lon just committed. + */ + + function resolveCommittedGroundAnchor(record) { + if (!record?.camera) return; + record.calibrationGroundResolveCount = + (record.calibrationGroundResolveCount || 0) + 1; + const revision = (record.calibrationGroundRevision || 0) + 1; + record.calibrationGroundRevision = revision; + const point = { lat: record.camera.lat, lon: record.camera.lon }; + + rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + if (isGroundResolved(record)) return; + + resolveGroundFloorCells([point]).then(() => { + if (layerState._recordById.get(record.camera.id) !== record) return; + if (record.calibrationGroundRevision !== revision) return; + if (record.camera.lat !== point.lat || record.camera.lon !== point.lon) + return; + rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }); + } + + /** + * Task 5: batches every catalog camera's coords through the Re:Earth + * ellipsoidal ground resolver (`/api/terrain/heights` proxy — network-cached, + * chunked, geoid fallback; NOT a scene query). The catalog's orthometric + * `groundElevationM` rides along as `sourceOrthometricM` so the geoid + * fallback chain is meaningful where the catalog value is real (Caltrans). + * Never rejects — a total failure resolves null and geometry stays on + * catalog fallbacks (no worse than pre-Task-5). + * @param {Object[]} catalog - Camera objects (post-ensureCameraPose). + * @returns {Promise|null>} + */ + + async function resolveGroundPriors(catalog) { + try { + const coords = catalog.map((camera) => { + const ortho = Number(camera.groundElevationM); + return { + lat: camera.lat, + lon: camera.lon, + ...(Number.isFinite(ortho) ? { sourceOrthometricM: ortho } : {}), + }; + }); + return await resolveEllipsoidalGround(coords); + } catch (error) { + console.warn( + '[Data:CCTV] ground-prior batch failed (keeping catalog fallbacks):', + error?.message || error, + ); + return null; + } + } + + /** + * Task 5: applies a LATE-arriving ground-prior batch (init's bounded race + * lost — cold proxy cache / slow upstream). Pure recomputes only, no scene + * queries: + * - terrain-globe regime: the prior IS the resolution → re-run the + * resolution (updateRecordGeometry latches it) for every record. + * - google-3d regime: records still awaiting a shared floor move from the + * catalog fallback onto the exact prior; records already holding a shared + * mesh/DEM floor keep it untouched. + * Guarded per record against a torn-down/re-inited layer (records are only + * touched while they are still the live catalog entries). + * @param {Object[]} records - The record array captured at init time. + * @param {Array<{ellipsoid:number, source:string}>} priors - Aligned by index. + */ + + function applyLateGroundPriors(records, priors) { + if (!Array.isArray(records) || !Array.isArray(priors)) return; + let applied = 0; + for (let i = 0; i < records.length && i < priors.length; i++) { + const record = records[i]; + const prior = priors[i]; + if (!record || !prior || !Number.isFinite(prior.ellipsoid)) continue; + // Stale-record guard: init() may have re-run (destroy/init cycle) while + // the batch was in flight — only touch records still live in the map. + if (layerState._recordById.get(record.camera.id) !== record) continue; + record.groundPrior = prior; + // Keep the cheap pre-enable altitude consistent for records whose + // geometry hasn't been applied yet (applyFrustumGeometry overwrites it). + if (!record.frustumPositions) { + record.camera.absoluteHeightM = + prior.ellipsoid + record.camera.mountHeightM; + } + const regime = currentSurfaceRegime(); + if (regime === 'terrain-globe') { + // Prior IS the resolution — re-latch onto the fresh value. + parts.geometry.updateRecordGeometry(record, { sampleGround: false }); + applied += 1; + } else if (!Number.isFinite(record.groundSamples['google-3d'])) { + // Still awaiting the shared floor: snap interim geometry onto the exact + // prior (pure recompute; the shared cell may refine later). + parts.geometry.applyFrustumGeometry(record, prior.ellipsoid); + applied += 1; + } + } + if (applied) parts.presentation.notifyListeners(); + } + + /** + * Task 5: surface-regime change handler ('gev:map-stack-changed' + * CustomEvent, dispatched by main.js from MapStackController.onChange). The + * surface HEIGHT at a camera differs between regimes (a photogrammetric + * deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so + * on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and + * no-ops): + * 1. every record's geometry recomputes IMMEDIATELY from the new regime's + * resolution — cached sample if that regime has one, else the Re:Earth + * prior (never blank, zero scene queries); + * 2. entering google-3d re-arms the one-shot tiles-ready completion latch so + * update()'s existing event-driven machinery refines records that never + * took their sample, through the same staggered queue. + * Event-driven only — never called on a timer. + */ + + function handleMapStackChanged() { + if (!layerState._viewer || !layerState._records.length) return; + const regime = currentSurfaceRegime(); + if (regime === layerState._lastAppliedRegime) return; + layerState._lastAppliedRegime = regime; + + for (const record of layerState._records) { + if (regime === 'terrain-globe') { + // Prior IS the resolution — latch it (zero scene queries). + record.groundSamples['terrain-globe'] = groundPriorAltFor(record); + record.groundResolved['terrain-globe'] = true; + } + const ground = groundAltFor(record, regime); + // Skip the entity rewrite when the applied ground already matches (e.g. + // entering google-3d before any sample: prior → prior is a no-op). + if ( + record.frustumGeometry && + Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001 + ) { + continue; + } + parts.geometry.applyFrustumGeometry(record, ground); + } + + if (regime === 'google-3d') { + // Fresh google-3d session: let update()'s ONE-SHOT completion pass + // re-enqueue records without an accepted sample once the (re-shown) + // tileset reports tilesLoaded. Records already sampled in a previous + // google-3d session keep their cached resolution — 0 new samples, well + // under the ≤1-per-(camera, session) ceiling. + layerState._tilesReadyReenqueued = false; + } + parts.presentation.notifyListeners(); + } + return { + surfaceRegimeKey, + currentSurfaceRegime, + groundPriorAltFor, + isGroundResolved, + groundAltFor, + rearmGroundResolution, + resolveCommittedGroundAnchor, + resolveGroundPriors, + applyLateGroundPriors, + handleMapStackChanged, + }; +} diff --git a/src/layers/cctv/health.js b/src/layers/cctv/health.js new file mode 100644 index 000000000..979e997cf --- /dev/null +++ b/src/layers/cctv/health.js @@ -0,0 +1,41 @@ +import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js'; + +export function createHealth({ state: layerState, services, parts, source }) { + /** + * Fetches per-camera health status from the backend and updates _healthById. + * Rate-limited to HEALTH_SYNC_INTERVAL_MS unless forced. + * @param {boolean} [force=false] - Bypass the interval check. + */ + + async function syncHealthState(force = false) { + const now = Date.now(); + if (!force && now - layerState._lastHealthSyncAt < HEALTH_SYNC_INTERVAL_MS) + return; + layerState._lastHealthSyncAt = now; + + try { + const signal = layerState._sourceAbort?.signal; + const data = await source.getHealth({ signal }); + signal?.throwIfAborted(); + const rows = Array.isArray(data?.cameras) ? data.cameras : []; + const next = new Map(); + for (const row of rows) { + const id = String(row?.id || '').trim(); + if (!id) continue; + next.set(id, { + status: String(row.status || '').toLowerCase() || 'unknown', + sourceKind: String( + row.sourceKind || row.feedType || '', + ).toLowerCase(), + label: String(row.label || row.provider || ''), + message: String(row.message || ''), + updatedAt: parts.model.safeNumber(row.updatedAt, now), + }); + } + layerState._healthById = next; + } catch { + // keep previous health map + } + } + return { syncHealthState }; +} diff --git a/src/layers/cctv/hover.js b/src/layers/cctv/hover.js new file mode 100644 index 000000000..cdd3375bf --- /dev/null +++ b/src/layers/cctv/hover.js @@ -0,0 +1,127 @@ +import { + CCTV_CARD_FETCH_BURST_LIMIT, + frameFetchDue, +} from '../../data/cctvCards.js'; +import { staticFrameRefreshMs } from '../../data/cctvLod.js'; +import { HOVER_PICK_THROTTLE_MS, HOVER_RELEASE_MS } from './policy.js'; + +export function createHover({ state: layerState, services, parts, source }) { + /** + * Throttled MOUSE_MOVE hover pass (owner round 2, item B): pointing at a + * camera icon that has no card summons its card immediately. This is + * EVENT-DRIVEN picking on a user gesture, not steady-state work — the + * ≥120 ms throttle caps it at ~8 scene.pick calls/s while the pointer is + * actually moving (a still pointer costs nothing), so it can never approach + * per-frame cost. Skipped while the camera is in motion, while ADJUST mode + * owns the pointer (gizmo drags), and while the layer is disabled. + * @param {Cesium.Cartesian2} position - Pointer position (CSS px). + */ + + function handleHoverMove(position) { + if ( + !layerState._enabled || + layerState._cameraMoving || + layerState._calibrationMode || + !position + ) + return; + if (!layerState._viewer || layerState._viewer.isDestroyed()) return; + const now = Date.now(); + if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return; + layerState._hoverLastPickAt = now; + let picked = null; + try { + picked = layerState._viewer.scene.pick(position); + } catch { + picked = null; + } + const cameraId = parts.selection.extractPickedCameraId(picked); + if (cameraId && cameraId === layerState._hoverCardId) { + // Still on the hovered camera — keep the card alive. + cancelHoverRelease(); + return; + } + const record = cameraId ? layerState._recordById.get(cameraId) : null; + // Hovering the active camera or a camera that already has a card is a + // no-op; video feeds stay icon-only until activated (ambient tier is + // stills-only — same rule as the LOD selection). + const eligible = + !!record && + cameraId !== layerState._activeCameraId && + !layerState._cardIds.has(cameraId) && + !parts.model.isVideoFeedType( + parts.model.normalizeFeedType(record.camera.feedType), + ); + if (eligible) { + cancelHoverRelease(); + layerState._hoverCardId = cameraId; + // Immediacy: the pinned entry publishes now — chrome may paint before + // the first frame arrives (documented exception in cctvCards.js). + parts.cards.pushAmbientCardEntries(); + hoverFetchCardFrame(record); + } else if (layerState._hoverCardId) { + // Pointer left the hovered icon: linger ~1 s, then release. + scheduleHoverRelease(); + } + } + + /** Cancels a pending hover-card release. */ + + function cancelHoverRelease() { + if (layerState._hoverReleaseTimer) { + clearTimeout(layerState._hoverReleaseTimer); + layerState._hoverReleaseTimer = 0; + } + } + + /** + * Schedules the hover card's release ~1 s after unhover. On release the pin + * drops; the card stays only if the LOD selection has adopted the camera + * (it is then a normal budgeted entry in `_cardIds`). + */ + + function scheduleHoverRelease() { + if (layerState._hoverReleaseTimer) return; + layerState._hoverReleaseTimer = setTimeout(() => { + layerState._hoverReleaseTimer = 0; + layerState._hoverCardId = null; + parts.cards.pushAmbientCardEntries(); + }, HOVER_RELEASE_MS); + } + + /** Clears the hover-card state (teardown / activation of the hovered camera). */ + + function clearHoverCard() { + cancelHoverRelease(); + layerState._hoverCardId = null; + layerState._hoverLastPickAt = 0; + } + + /** + * Fast-tracked frame fetch for the hover-summoned card: launches + * immediately, bypassing the pacer's launch-spacing gate (a single + * user-gesture fetch is fine even during the geometry drain), but still + * respecting the per-camera failure backoff / freshness check + * (frameFetchDue), the no-double-fetch pending set, and the burst in-flight + * cap of 4. + * @param {Object} record - The hovered camera's record. + */ + + function hoverFetchCardFrame(record) { + const cameraId = record.camera.id; + if (layerState._cardFetchPendingIds.has(cameraId)) return; + if (layerState._cardFetchInFlightCount >= CCTV_CARD_FETCH_BURST_LIMIT) + return; + const slot = parts.cards.ensureCardFrameSlot(cameraId); + const refreshMs = staticFrameRefreshMs(record.camera); + if (!frameFetchDue(slot, refreshMs, Date.now())) return; + parts.cards.fetchCardFrame(record, slot, refreshMs, { userGesture: true }); + } + return { + handleHoverMove, + cancelHoverRelease, + scheduleHoverRelease, + clearHoverCard, + hoverFetchCardFrame, + }; +} diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js new file mode 100644 index 000000000..712783144 --- /dev/null +++ b/src/layers/cctv/index.js @@ -0,0 +1,130 @@ +import { createCalibration } from './calibration.js'; +import { createTesting } from './testing.js'; +import { createProjection } from './projection.js'; +import { createCards } from './cards.js'; +import { createModel } from './model.js'; +import { createGround } from './ground.js'; +import { createGeometry } from './geometry.js'; +import { createCatalog } from './catalog.js'; +import { createFrames } from './frames.js'; +import { createRendering } from './rendering.js'; +import { createGeometryQueue } from './geometryQueue.js'; +import { createSelection } from './selection.js'; +import { createHover } from './hover.js'; +import { createNavigation } from './navigation.js'; +import { createPresentation } from './presentation.js'; +import { createLifecycle } from './lifecycle.js'; +import { createHealth } from './health.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createCctvLayer({ services, source }) { + if ( + !['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every( + (key) => typeof source?.[key] === 'function', + ) + ) + throw new TypeError('A camera source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.calibration = createCalibration(context); + parts.testing = createTesting(context); + parts.projection = createProjection(context); + parts.cards = createCards(context); + parts.model = createModel(context); + parts.ground = createGround(context); + parts.geometry = createGeometry(context); + parts.catalog = createCatalog(context); + parts.frames = createFrames(context); + parts.rendering = createRendering(context); + parts.geometryQueue = createGeometryQueue(context); + parts.selection = createSelection(context); + parts.hover = createHover(context); + parts.navigation = createNavigation(context); + parts.presentation = createPresentation(context); + parts.lifecycle = createLifecycle(context); + parts.health = createHealth(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + calibrationPatchMovesAnchor: + parts.calibration.calibrationPatchMovesAnchor, + _pushAmbientCardEntriesForTest: + parts.testing._pushAmbientCardEntriesForTest, + _setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest, + createCctvProjectionOverlayEntry: + parts.projection.createCctvProjectionOverlayEntry, + setCctvCardPresentationOptions: + parts.cards.setCctvCardPresentationOptions, + surfaceRegimeKey: parts.ground.surfaceRegimeKey, + normalizeCoverageMode: parts.model.normalizeCoverageMode, + readCalibrationStoreV2: parts.calibration.readCalibrationStoreV2, + writeCalibrationStoreV2: parts.calibration.writeCalibrationStoreV2, + deriveCalBadge: parts.calibration.deriveCalBadge, + computeFrustumGeometry: parts.geometry.computeFrustumGeometry, + activationProbeClampRange: parts.geometry.activationProbeClampRange, + frameSignatureFromPixels: parts.frames.frameSignatureFromPixels, + _createCctvProjectionPlaneForTest: + parts.testing._createCctvProjectionPlaneForTest, + _updateCctvProjectionPlaneForTest: + parts.testing._updateCctvProjectionPlaneForTest, + applyCctvFocusDeemphasis: parts.rendering.applyCctvFocusDeemphasis, + createGeometryProgressNotifier: + parts.geometryQueue.createGeometryProgressNotifier, + processCctvGeometryQueueBatch: + parts.geometryQueue.processCctvGeometryQueueBatch, + cctvGeometryDrainPacing: parts.geometryQueue.cctvGeometryDrainPacing, + processCctvGeometryDrainBatch: + parts.geometryQueue.processCctvGeometryDrainBatch, + prioritizeActiveCctvGeometryRecord: + parts.geometryQueue.prioritizeActiveCctvGeometryRecord, + processGeometryBatch: parts.geometryQueue.processGeometryBatch, + hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals, + refreshCoverageStyles: parts.rendering.refreshCoverageStyles, + clearProbeClampOnDeactivation: + parts.geometry.clearProbeClampOnDeactivation, + cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation, + bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture, + setActiveCamera: parts.selection.setActiveCamera, + deactivateActiveCamera: parts.selection.deactivateActiveCamera, + cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects, + materializeCctvCoverageEntities: + parts.geometry.materializeCctvCoverageEntities, + materializeCctvActiveCoverageEntities: + parts.geometry.materializeCctvActiveCoverageEntities, + materializeCctvVisibleCoverageEntities: + parts.geometry.materializeCctvVisibleCoverageEntities, + _extractPickedCameraIdForTest: + parts.testing._extractPickedCameraIdForTest, + _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, + focusCctvRecord: parts.navigation.focusCctvRecord, + maybeAutoHop: parts.navigation.maybeAutoHop, + cctvCycleIndex: parts.navigation.cctvCycleIndex, + }, + ); +} +export { + CCTV_CALIBRATION_STORAGE_KEY_V1, + CCTV_CALIBRATION_STORAGE_KEY_V2, + FRUSTUM_GROUND_CLEARANCE_M, + CCTV_FOCUS_RESULT, + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export { createCctvSource } from './source.js'; + +export { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; +export { + CCTV_OVERLAY_SOURCE_ID, + createCctvThumbnailOverlayEntry, + createFrameSlot, +} from '../../data/cctvCards.js'; diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js new file mode 100644 index 000000000..0947ccdb6 --- /dev/null +++ b/src/layers/cctv/ingestion.js @@ -0,0 +1,48 @@ +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + /** + * Periodic update tick: syncs health state and runs auto-hop logic. Ground + * geometry is NOT resampled here — v2 grounds each camera once via the + * staggered load queue (see startGeometryLoadQueue/updateRecordGeometry) + * and never resamples on a timer. The ONE exception is a one-shot + * completion pass: the enable-time drain can run while 3D tiles are still + * streaming (each such pass keeps the fabricated catalog height and leaves + * the record `!groundResolved`), so the FIRST tick that sees + * projectionTilesReady() re-enqueues those records once — each then takes + * its single real sample and freezes (design §4). Guarded by a boolean + * latch (`_tilesReadyReenqueued`), NOT a timer loop: after it fires, no + * tick ever samples anything again. + */ + async update() { + if (!layerState._enabled) return; + const now = Date.now(); + layerState._lastUpdate = now; + if ( + !layerState._tilesReadyReenqueued && + parts.model.projectionTilesReady() + ) { + layerState._tilesReadyReenqueued = true; + // Per-regime resolution (Task 5): only records unresolved for the + // CURRENT surface regime need the completion pass. On globe stacks + // projectionTilesReady() is false while a (hidden) Google tileset + // exists, so this latch effectively fires for the google-3d regime — + // terrain-globe records resolve from the prior in their drain pass. + const unresolved = layerState._records.filter( + (record) => !parts.ground.isGroundResolved(record), + ); + if (unresolved.length) + parts.geometryQueue.enqueueGeometryRefresh(unresolved); + } + await parts.health.syncHealthState(); + parts.navigation.maybeAutoHop(now); + parts.presentation.notifyListeners(); + }, + }; + + return { methods }; +} diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js new file mode 100644 index 000000000..a679f3234 --- /dev/null +++ b/src/layers/cctv/lifecycle.js @@ -0,0 +1,461 @@ +import * as Cesium from 'cesium'; +import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js'; +import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js'; +import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js'; +import { + GROUND_PRIOR_INIT_WAIT_MS, + CAMERA_ICON, + IDLE_CAMERA_COLOR, +} from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { activateCctvCameraFromWorldClick } = services.activation; + const { resolvePickId, registerPickOwner, unregisterPickOwner } = + services.picking; + const { onFocusTargetAppear } = services.focus; + const { releaseContinuousRender } = services.render; + + /** Resets all module-scoped runtime state to initial values. */ + + function clearRuntimeState() { + parts.geometryQueue.stopGeometryLoadQueue(); + // Idempotent — also covers a re-init without a prior destroy(). + parts.cards.teardownAmbientCards(); + parts.projection.clearProjectionOverlay(); + layerState._records = []; + layerState._recordById = new Map(); + layerState._healthById = new Map(); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastHealthSyncAt = 0; + layerState._lastError = null; + layerState._lastFocusStyleAt = 0; + layerState._activeFocusStyleCount = 0; + // FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh + // init re-discovers against the current scene primitives. + layerState._activeTileset = null; + // Task 5: the applied-regime tracker is record-set-scoped — a fresh init + // recomputes it against the then-current scene. + layerState._lastAppliedRegime = null; + } + const methods = { + /** + * Initializes the CCTV layer: loads camera sources, builds the catalog, + * restores calibration from localStorage, creates billboards, sets up click + * handling, and performs initial health sync. Coverage entities stay lazy. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + async init(viewer) { + layerState._sourceAbort?.abort(); + const sourceAbort = new AbortController(); + layerState._sourceAbort = sourceAbort; + if (typeof document !== 'undefined') + document.addEventListener( + 'visibilitychange', + parts.cards.handleVisibilityChange, + ); + layerState._viewer = viewer; + clearRuntimeState(); + layerState._enabled = false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = false; + layerState._lastHopAt = 0; + layerState._lastViewContext = ''; + layerState._calibrationById = parts.calibration.loadCalibrationStore(); + + layerState._billboards = new Cesium.BillboardCollection(); + layerState._viewer.scene.primitives.add(layerState._billboards); + registerSpriteCollection('cctv', layerState._billboards); + + const sources = await parts.catalog.loadCameraSources(); + const catalogFromSources = parts.catalog.buildCatalogFromSources(sources); + const catalog = catalogFromSources.length + ? catalogFromSources + : parts.catalog.seedCatalog(); + + // Viewshed color identity (design §3a): golden-angle hue over the + // id-SORTED catalog index — deterministic across sessions for a stable + // catalog, maximally separated for neighboring cameras. + const hueIndexById = new Map( + catalog + .map((camera) => camera.id) + .sort() + .map((id, index) => [id, index]), + ); + + for (const camera of catalog) { + const savedEntry = layerState._calibrationById.get(camera.id); + if (savedEntry) { + camera.calibration = parts.calibration.normalizeCalibration( + savedEntry.values, + ); + camera.calSource = savedEntry.source; + } + parts.model.ensureCameraPose(camera); + } + + // Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth + // ellipsoidal ground-prior resolver (network-cached — NOT a scene query; + // the catalog's orthometric groundElevationM feeds the geoid fallback + // chain). Bounded wait: a warm proxy cache resolves in milliseconds, so + // records are normally built WITH their prior (correct first paint in + // every regime); a cold/slow upstream loses the race and the batch + // applies post-hoc via applyLateGroundPriors instead of hanging init. + const priorsPromise = parts.ground.resolveGroundPriors(catalog); + const priors = await Promise.race([ + priorsPromise, + new Promise((resolve) => + setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS), + ), + ]); + sourceAbort.signal.throwIfAborted(); + + for (let i = 0; i < catalog.length; i++) { + const camera = catalog[i]; + // Ellipsoidal ground prior (or null while the batch is still in + // flight). Geometry falls back to the catalog value only until the + // batch lands. + const groundPrior = priors?.[i] || null; + // Cheap first-pass altitude from the ellipsoidal prior (catalog value + // only as the pre-prior fallback) — the staggered geometry queue + // refines with sampled tile heights after enable so the init path + // never raycasts the scene once per camera. + const priorGround = Number.isFinite(groundPrior?.ellipsoid) + ? groundPrior.ellipsoid + : Number(camera.groundElevationM) || 0; + camera.absoluteHeightM = priorGround + camera.mountHeightM; + const position = Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + camera.absoluteHeightM, + ); + const billboard = layerState._billboards.add({ + id: camera.id, + image: CAMERA_ICON, + position, + color: IDLE_CAMERA_COLOR, + width: 24, + height: 24, + // Field-test fix (2026-07-06): always-on-top. The old finite value + // (1800 m) re-engaged the depth test at far zoom, where the COARSE + // far-LOD Google-3D mesh sits above the true ground and swallowed + // ground-anchored icons ("submerged" pills over SF). Far-side-of-globe + // icons are handled by refreshHorizonCulling() (the flights-layer + // EllipsoidalOccluder pattern), not by the depth test. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42), + }); + + const record = { + camera, + position, + billboard, + coverageEntities: [], + projection: null, + // Task 5 (height-datum fix): regime-aware ground resolution state. + // groundPrior — { ellipsoid, source } from the Re:Earth batch + // (null until a late batch lands). The prior applies in EVERY + // regime and is the terrain-globe resolution outright. + // groundResolved — PER-REGIME one-shot latch (regime key → + // boolean): true once this record's resolution completed for that + // regime; such records are excluded from the completion pass so + // their geometry freezes. Re-armed only on a genuine pose change, + // explicit user select/move, or a surface-regime change — never + // on the 10s timer. + // groundSamples — PER-REGIME resolved ground (regime key → + // metres): the accepted one-shot scene sample in google-3d, the + // mirrored prior in terrain-globe. Kept across re-arms as the + // "has ever resolved" memory for the B9c mid-stream guard. + // frustumPositions — cached Cartesians for pure recomputes (so + // plane placement never re-derives geometry it already has). + groundPrior, + groundResolved: {}, + groundSamples: {}, + frustumGeometry: null, + frustumPositions: null, + // §9.1 activation obstruction probe result: effective-range clamp so + // the far-cap plane never clips into the tiles. Null = unclamped. + // Reset + re-probed on every activation; cleared when the user takes + // the range slider (slider overrides the clamp). + probeClampRangeM: null, + // Viewshed (design §3a/§3b): per-camera color identity + the volume + // primitive handle (exists only in viewshed mode for the visible set). + viewshedColors: viewshedColors( + cameraHue(hueIndexById.get(camera.id) ?? 0), + ), + viewshedPrimitive: null, + viewshedActiveTint: false, + }; + layerState._records.push(record); + layerState._recordById.set(camera.id, record); + } + + layerState._count = layerState._records.length; + if (layerState._records.length > 0) { + // Projection runtime + first frame fetch are deferred to enable() so + // initializing the catalog stays render-cheap. + layerState._activeCameraId = layerState._records[0].camera.id; + } + + // Task 5: if the prior batch lost init's bounded race, apply it post-hoc + // when it lands (pure recomputes — applyLateGroundPriors guards against + // a torn-down/re-inited catalog). + if (!priors) { + const initRecords = layerState._records.slice(); + priorsPromise + .then((late) => { + if (late) parts.ground.applyLateGroundPriors(initRecords, late); + }) + .catch(() => {}); + } + + // Task 5: track the surface regime the initial geometry was computed for + // and listen for map-stack changes (main.js re-dispatches + // MapStackController.onChange as this CustomEvent). The handler compares + // regimes itself, so 'switching'/'error' emissions and same-regime stack + // swaps (bing→osm) no-op. + layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime(); + if (!layerState._mapStackListener && typeof window !== 'undefined') { + layerState._mapStackListener = () => + parts.ground.handleMapStackChanged(); + window.addEventListener( + 'gev:map-stack-changed', + layerState._mapStackListener, + ); + } + + // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with + // the billboards' always-on-top depth setting) + one initial pass so the + // first paint is already culled. + if (!layerState._horizonCullListener) { + // Ambient cards piggyback the same settle event: moveEnd-driven + // reselection only, never per frame (refreshAmbientCards no-ops while + // the layer is disabled). + layerState._horizonCullListener = () => { + layerState._cameraMoving = false; + parts.rendering.refreshHorizonCulling(); + parts.cards.refreshAmbientCards(); + }; + layerState._viewer.camera.moveEnd.addEventListener( + layerState._horizonCullListener, + ); + } + if (!layerState._moveStartListener) { + // Item B: hover picking pauses while the camera is in motion. + layerState._moveStartListener = () => { + layerState._cameraMoving = true; + }; + layerState._viewer.camera.moveStart.addEventListener( + layerState._moveStartListener, + ); + } + parts.rendering.refreshHorizonCulling(); + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + layerState._viewer.scene.canvas, + ); + parts.selection.bindCctvWorldClickGesture( + layerState._clickHandler, + (click) => { + if (!layerState._enabled) return; + const picked = layerState._viewer.scene.pick(click.position); + const cameraId = parts.selection.extractPickedCameraId(picked); + if (cameraId) { + activateCctvCameraFromWorldClick( + cameraId, + parts.selection.setActiveCamera, + ); + return; + } + // Any identified scene object owns this click even if its layer does not + // register a shared pick predicate. This keeps selectable siblings ahead + // of an overlapping CCTV card while ID-less globe/terrain/tile surfaces + // remain eligible for true empty-space deselection. + const pickedId = resolvePickId(picked); + if (pickedId !== null) return; + // Item A (owner round 2): the scene pick found no camera — try the + // painted ambient cards. The cards canvas is pointer-events:none (this + // handler owns the events), so a click landing on a card's rect selects + // its camera exactly like a click on the icon. Cesium click positions + // and the recorded rects are both CSS px — direct comparison. + const cardId = layerState._cctvOverlayHost.hitTest( + click.position.x, + click.position.y, + { sourceId: CCTV_OVERLAY_SOURCE_ID }, + )?.entryId; + if (cardId && layerState._recordById.has(cardId)) { + activateCctvCameraFromWorldClick( + cardId, + parts.selection.setActiveCamera, + ); + return; + } + if ( + parts.selection.cctvEmptyClickDeselects(picked, { + activeCameraId: layerState._activeCameraId, + calibrationMode: layerState._calibrationMode, + }) + ) { + parts.selection.deactivateActiveCamera(); + } + }, + { + // Item B: hover summons a card on a cardless camera icon. The gesture + // classifier owns MOUSE_MOVE too, so chain hover work through its seam + // instead of replacing the travel accumulator's handler. + onMouseMove: (movement) => + parts.hover.handleHoverMove(movement?.endPosition), + }, + ); + + await parts.health.syncHealthState(true); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + restoreSpriteOrder(layerState._viewer); + console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras'); + }, + + /** + * Enables the layer: shows entities, starts the projection loop, and kicks + * the staggered geometry-load queue. Heavy work (per-camera ground + * sampling) is deferred/batched so the frame budget never collapses at + * enable time. + */ + enable() { + layerState._enabled = true; + layerState._lastUpdate = Date.now(); + // Pick-ownership (H2): camera billboards use the camera id directly; + // coverage polyline entities use `cctv--` entity ids. + registerPickOwner('cctv', (pickedId) => { + if (layerState._recordById.has(pickedId)) return true; + if ( + typeof pickedId === 'string' && + pickedId.startsWith(GIZMO_ID_PREFIX) + ) + return true; + const coverage = + /^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec( + pickedId, + ); + return Boolean(coverage && layerState._recordById.has(coverage[1])); + }); + if (!layerState._activeCameraId && layerState._records.length) { + layerState._activeCameraId = layerState._records[0].camera.id; + layerState._autoHopSuspended = false; + } + const activeRecord = parts.selection.getActiveRecord(); + if (activeRecord) { + parts.projection.ensureProjectionRuntime(activeRecord); + parts.frames.refreshProjectionImage(activeRecord, true); + } + parts.geometryQueue.startGeometryLoadQueue(); + parts.rendering.refreshCoverageStyles(); + parts.projection.startProjectionLoop(); + // The projection loop self-stops when idle; a focus target appearing + // (user starts tracking a contact) is the one edge it can't see while + // stopped, so re-arm on it. Removed on disable. + layerState._removeFocusAppearListener?.(); + layerState._removeFocusAppearListener = onFocusTargetAppear(() => + parts.projection.startProjectionLoop(), + ); + // Ambient card tier: shared host source + policy-gated frame pacer + the + // initial selection pass (moveEnd drives every later reselection). + layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, true); + parts.cards.startCardFrameLoop(); + parts.cards.refreshAmbientCards(); + parts.presentation.notifyListeners(); + restoreSpriteOrder(layerState._viewer); + }, + + /** Disables the layer: hides entities, stops the projection loop and load queue. */ + disable() { + layerState._enabled = false; + unregisterPickOwner('cctv'); + // ADJUST mode does not survive a layer toggle — predictable re-entry. + layerState._calibrationMode = false; + releaseContinuousRender('cctv-adjust'); + layerState._gizmo?.setEnabled(false); + layerState._removeFocusAppearListener?.(); + layerState._removeFocusAppearListener = null; + parts.projection.stopProjectionLoop(); + parts.geometryQueue.stopGeometryLoadQueue(); + // Ambient cards tear down COMPLETELY on disable (owner design point 6): + // source entries, pacer timer, in-flight handlers, and caches. + parts.cards.teardownAmbientCards(); + parts.rendering.hideCctvVisuals(); + parts.presentation.notifyListeners(); + }, + + /** + * Tears down the layer: destroys click handler, projection loop, coverage + * entities, billboards, and clears all runtime state and subscribers. + * @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref). + */ + destroy(viewer) { + layerState._sourceAbort?.abort(); + if (typeof document !== 'undefined') + document.removeEventListener( + 'visibilitychange', + parts.cards.handleVisibilityChange, + ); + unregisterPickOwner('cctv'); + if (layerState._mapStackListener && typeof window !== 'undefined') { + window.removeEventListener( + 'gev:map-stack-changed', + layerState._mapStackListener, + ); + layerState._mapStackListener = null; + } + const teardownViewer = viewer || layerState._viewer; + if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) { + teardownViewer.camera.moveEnd.removeEventListener( + layerState._horizonCullListener, + ); + layerState._horizonCullListener = null; + } + if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) { + teardownViewer.camera.moveStart.removeEventListener( + layerState._moveStartListener, + ); + layerState._moveStartListener = null; + } + if (layerState._gizmo) { + layerState._gizmo.destroy(); + layerState._gizmo = null; + } + layerState._calibrationMode = false; + releaseContinuousRender('cctv-adjust'); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (teardownViewer?.scene?.screenSpaceCameraController) { + teardownViewer.scene.screenSpaceCameraController.enableInputs = true; + } + parts.projection.stopProjectionLoop(); + parts.geometryQueue.stopGeometryLoadQueue(); + parts.cards.teardownAmbientCards(); + parts.geometry.destroyCoverageEntities(); + if (layerState._billboards && teardownViewer) { + teardownViewer.scene.primitives.remove(layerState._billboards); + layerState._billboards = null; + } + clearRuntimeState(); + layerState._viewer = null; + layerState._enabled = false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = false; + // Clear existing subscribers rather than replacing the Set — + // replacing would silently orphan any unsubscribe() closures + layerState._listeners.clear(); + }, + }; + + return { clearRuntimeState, methods }; +} diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js new file mode 100644 index 000000000..48c8cddd4 --- /dev/null +++ b/src/layers/cctv/model.js @@ -0,0 +1,566 @@ +import * as Cesium from 'cesium'; +import { staticFrameRefreshMs } from '../../data/cctvLod.js'; +import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js'; +import { DEFAULT_CAMERA_CALIBRATION } from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + const { focusPassIsNeeded, getFocusTarget } = services.focus; + + /** + * 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)); + } + + /** + * 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 element rather than an . + * @param {string} feedType + * @returns {boolean} + */ + + function isVideoFeedType(feedType) { + return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm'; + } + + /** + * Coerces a value to a finite number or returns the fallback. + * @param {*} value + * @param {number} [fallback=NaN] + * @returns {number} + */ + + function safeNumber(value, fallback = NaN) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + /** + * Derives a deterministic heading from a camera ID string via a simple hash. + * Produces one of 16 evenly-spaced headings (0, 22.5, 45, ..., 337.5). + * @param {string} id + * @returns {number} Heading in degrees [0, 360). + */ + + function headingFromId(id) { + const text = String(id || ''); + let acc = 0; + for (let i = 0; i < text.length; i++) { + acc = (acc * 33 + text.charCodeAt(i)) >>> 0; + } + return normalizeHeading((acc % 16) * 22.5); + } + + /** + * Rounds a value to the nearest multiple of `step`. + * @param {number} value + * @param {number} [step=0.1] + * @returns {number} + */ + + function quantize(value, step = 0.1) { + return Math.round(value / step) * step; + } + + /** + * Normalizes a coverage-mode request (viewshed design §3b). Accepts the three + * mode strings plus booleans for `setParams({showCoverage})` back-compat + * (true → 'on', false → 'off'); anything else keeps the current mode. + * @param {*} value - Requested mode ('off'|'on'|'viewshed') or boolean. + * @param {'off'|'on'|'viewshed'} current - Mode to keep when the request is invalid. + * @returns {'off'|'on'|'viewshed'} + */ + + function normalizeCoverageMode(value, current) { + if (value === true) return 'on'; + if (value === false) return 'off'; + if (value === 'off' || value === 'on' || value === 'viewshed') return value; + return current; + } + + /** + * Converts north/east metre offsets to lat/lon degree deltas at a given latitude. + * Uses the equirectangular approximation (111320 m/deg). + * @param {number} latDeg - Reference latitude (degrees). + * @param {number} northMeters - Offset northward (metres). + * @param {number} eastMeters - Offset eastward (metres). + * @returns {{ latOffset: number, lonOffset: number }} Degree deltas. + */ + + function offsetDegrees(latDeg, northMeters, eastMeters) { + const latOffset = northMeters / 111320; + const lonDivisor = Math.max(0.15, Math.cos(toRad(latDeg))); + const lonOffset = eastMeters / (111320 * lonDivisor); + return { latOffset, lonOffset }; + } + + /** + * Returns `window.localStorage` when it is safely accessible, else null. + * Split out so store IO can be exercised under plain node:test with an + * injected storage-like object (getItem/setItem/removeItem) instead. + * @returns {Storage|null} + */ + + function safeWindowLocalStorage() { + if (typeof window === 'undefined') return null; + try { + // NB: the window.localStorage property ACCESS itself throws SecurityError + // under "block all cookies", so it has to live inside the try (M11). + return window.localStorage || null; + } catch { + return null; + } + } + + /** + * Initializes or recomputes a camera's derived pose fields from its base pose + * and calibration offsets. Also sets intrinsics, extrinsics, and anchor. + * + * On first call for a camera, captures the raw values as `basePose`. + * Subsequent calls re-derive lat/lon/heading/pitch/fov/range by applying + * calibration deltas to the frozen base pose. + * + * Note: the old score-based quality system (`confidenceFromScore`, seeded + * `camera.quality.score`) is retired — panel trust signal is now the 3-state + * CAL badge (`deriveCalBadge`, driven by `calSource`/`poseSource`), not a + * fabricated confidence score. + * + * @param {Object} camera - Mutable camera record. + */ + + function ensureCameraPose(camera) { + if (!camera) return; + if (!camera.basePose) { + camera.basePose = { + lat: safeNumber(camera.lat, 0), + lon: safeNumber(camera.lon, 0), + headingDeg: normalizeHeading(safeNumber(camera.headingDeg, 0)), + pitchDeg: clamp(safeNumber(camera.pitchDeg, -17), -70, 10), + fovDeg: clamp(safeNumber(camera.fovDeg, 74), 20, 130), + rangeM: clamp(safeNumber(camera.rangeM, 700), 120, 5000), + mountHeightM: clamp(safeNumber(camera.mountHeightM, 24), 2, 240), + }; + } + + const nextCalibration = parts.calibration.normalizeCalibration( + camera.calibration || DEFAULT_CAMERA_CALIBRATION, + ); + camera.calibration = nextCalibration; + + const base = camera.basePose; + const offsets = offsetDegrees( + base.lat, + nextCalibration.offsetNorthM, + nextCalibration.offsetEastM, + ); + camera.lat = base.lat + offsets.latOffset; + camera.lon = base.lon + offsets.lonOffset; + camera.headingDeg = normalizeHeading( + base.headingDeg + nextCalibration.headingDeg, + ); + camera.pitchDeg = clamp(base.pitchDeg + nextCalibration.pitchDeg, -70, 10); + camera.fovDeg = clamp(base.fovDeg + nextCalibration.fovDeg, 20, 130); + camera.rangeM = clamp(base.rangeM * nextCalibration.rangeScale, 120, 5000); + camera.mountHeightM = clamp( + base.mountHeightM + nextCalibration.heightM, + 2, + 240, + ); + + camera.intrinsics = { + fovDeg: camera.fovDeg, + principalPoint: [0.5, 0.5], + }; + camera.extrinsics = { + headingDeg: camera.headingDeg, + pitchDeg: camera.pitchDeg, + rollDeg: 0, + heightM: camera.mountHeightM, + }; + camera.anchor = { + lat: camera.lat, + lon: camera.lon, + elevM: safeNumber(camera.groundElevationM, 0), + targetLatLon: camera.anchor?.targetLatLon || null, + }; + } + + /** + * Projects a point along a bearing from a given lat/lon by a distance. + * Uses the spherical-earth direct geodesic formula (R = 6371 km). + * @param {number} latDeg - Origin latitude (degrees). + * @param {number} lonDeg - Origin longitude (degrees). + * @param {number} bearingDeg - Azimuth from north (degrees). + * @param {number} distanceM - Distance in metres. + * @returns {{ lat: number, lon: number }} Destination in degrees. + */ + + function projectPoint(latDeg, lonDeg, bearingDeg, distanceM) { + const angular = distanceM / 6371000; + const bearing = toRad(bearingDeg); + const lat1 = toRad(latDeg); + const lon1 = toRad(lonDeg); + + const sinLat2 = + Math.sin(lat1) * Math.cos(angular) + + Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing); + const lat2 = Math.asin(sinLat2); + + const y = Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1); + const x = Math.cos(angular) - Math.sin(lat1) * sinLat2; + const lon2 = lon1 + Math.atan2(y, x); + + return { + lat: Cesium.Math.toDegrees(lat2), + lon: Cesium.Math.toDegrees(lon2), + }; + } + + /** + * Computes the great-circle distance between two points using the haversine formula. + * @param {number} lat1 - Latitude of point 1 (degrees). + * @param {number} lon1 - Longitude of point 1 (degrees). + * @param {number} lat2 - Latitude of point 2 (degrees). + * @param {number} lon2 - Longitude of point 2 (degrees). + * @returns {number} Distance in kilometres. + */ + + function haversineKm(lat1, lon1, lat2, lon2) { + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + /** + * Computes the area of a circular sector (camera FOV wedge). + * @param {number} rangeM - Radius in metres. + * @param {number} fovDeg - Field of view in degrees. + * @returns {number} Area in km^2. + */ + + function sectorAreaKm2(rangeM, fovDeg) { + const theta = toRad(clamp(fovDeg, 12, 170)); + const areaM2 = 0.5 * rangeM * rangeM * theta; + return areaM2 / 1_000_000; + } + + /** + * Returns a coarse grid key describing the viewer's current position and zoom + * level. Used to detect meaningful view changes for auto-hop camera switching. + * @returns {string} Grid key in the form "zoomBucket:latGrid:lonGrid". + */ + + function currentViewContext() { + const carto = layerState._viewer?.camera?.positionCartographic; + if (!carto) return 'none'; + const lat = Cesium.Math.toDegrees(carto.latitude); + const lon = Cesium.Math.toDegrees(carto.longitude); + const alt = carto.height || 0; + const zoomBucket = + alt < 1500 + ? 'street' + : alt < 12000 + ? 'city' + : alt < 75000 + ? 'regional' + : 'global'; + const grid = + zoomBucket === 'street' + ? 0.045 + : zoomBucket === 'city' + ? 0.24 + : zoomBucket === 'regional' + ? 1.0 + : 4.5; + return `${zoomBucket}:${Math.floor(lat / grid)}:${Math.floor(lon / grid)}`; + } + + /** + * Reports whether the active Google Photorealistic 3D Tileset (if any) has + * finished loading the tiles in view. Shared mesh-floor sampling is gated on + * this so a one-shot cell never bakes in a miss from still-streaming tiles. + * Discovers + caches the tileset lazily from scene + * primitives (the CCTV module holds only a `_viewer` reference). When no + * tileset is present (OSM fallback) this returns true so ground sampling is + * not permanently blocked. + * + * Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`, + * i.e. a globe stack is active) must NOT report ready — Cesium 1.138's + * the shared sampler can only inspect *visible* 3D tilesets, so a sample taken + * against the hidden Google tileset would silently miss. + * + * @returns {boolean} True when tiles are loaded AND visible (or no tileset + * exists to wait on). + */ + + function projectionTilesReady() { + if ( + !layerState._activeTileset || + layerState._activeTileset.isDestroyed?.() + ) { + layerState._activeTileset = null; + const primitives = layerState._viewer?.scene?.primitives; + if (primitives && typeof primitives.get === 'function') { + for (let i = 0; i < primitives.length; i++) { + const p = primitives.get(i); + if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) { + layerState._activeTileset = p; + break; + } + } + } + } + if (!layerState._activeTileset) return true; + if (layerState._activeTileset.show === false) return false; + return layerState._activeTileset.tilesLoaded === true; + } + + /** + * Orientation quaternion for the monitor plane entity: local +Z is the plane + * normal, pointing BACK along the view axis toward the mount so the textured + * front face reads correctly from the natural viewpoint (focusCamera flies the + * viewer to look along the camera heading). Local +X = viewer-right, +Y = + * frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry — + * computed only on slider/save/activation, never per frame (§2b). + * @param {Object} camera - Camera pose. + * @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF. + * @returns {Cesium.Quaternion} + */ + + function planeOrientationFor(camera, capCenterPos) { + const frame = parts.geometry.frustumFrameEcef(camera, capCenterPos); + const right = Cesium.Cartesian3.cross( + frame.dir, + frame.up, + new Cesium.Cartesian3(), + ); + const normal = Cesium.Cartesian3.negate(frame.dir, new Cesium.Cartesian3()); + const m = new Cesium.Matrix3( + right.x, + frame.up.x, + normal.x, + right.y, + frame.up.y, + normal.y, + right.z, + frame.up.z, + normal.z, + ); + return Cesium.Quaternion.fromRotationMatrix(m); + } + + /** + * Starts the requestAnimationFrame loop that drives projection canvas updates + * (frame draw + texture swap) for the active camera. + */ + /** + * The projection rAF has real work only while a camera is actively projected + * or a focus fade is in flight — otherwise it burned a wakeup + style poll + * every rendered frame for the whole enabled lifetime of the layer. The tick + * self-stops when idle; every state edge that creates work re-arms it + * (enable, setActiveCamera, showProjection, focus-target appearance). + * (perf wave 1) + * @returns {boolean} Whether the loop currently has work. + */ + + function projectionLoopIsNeeded() { + if (!layerState._enabled || !layerState._viewer) return false; + if (layerState._showProjection && parts.selection.getActiveRecord()) + return true; + return focusPassIsNeeded( + getFocusTarget(), + layerState._activeFocusStyleCount, + ); + } + + /** + * Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per + * tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill + * — any selected card still missing its FIRST frame — bursts up to 4 + * in-flight fetches at 250 ms spacing so arriving in a new area populates + * in a few seconds instead of 16-32 s; once every selected card has a first + * frame the layer drops back to the salvaged steady-state gate (single + * flight, one request per second). Priority: frameless cards first in ring + * order (nearest-first — they're what makes a card appear at all), then the + * stalest refresh-overdue card by its source cadence (staticFrameRefreshMs). + * Failures back off per camera (frameFetchDue) instead of hammering a dead + * source, so a dead upstream never eats the burst slots. + */ + + function cardFrameTick() { + if ( + !layerState._enabled || + (!layerState._cardIds.size && + !(layerState._activeCameraCardEnabled && layerState._activeCameraId)) + ) + return; + const now = Date.now(); + let frameless = null; + let stalest = null; + let coldFill = false; + const consider = (id) => { + const record = layerState._recordById.get(id); + if (!record) return; + const slot = parts.cards.ensureCardFrameSlot(id); + if (layerState._cardFetchPendingIds.has(id)) { + // An in-flight first-frame fetch keeps cold-fill mode active without + // being re-launchable. + if (!(slot.stamp > 0)) coldFill = true; + return; + } + const refreshMs = staticFrameRefreshMs(record.camera); + if (!frameFetchDue(slot, refreshMs, now)) return; + if (!(slot.stamp > 0)) { + coldFill = true; + if (!frameless) frameless = { record, slot, refreshMs }; + return; + } + if (!stalest || slot.stamp < stalest.slot.stamp) { + stalest = { record, slot, refreshMs }; + } + }; + // The optional protected active card stays outside the 40-card ambient + // quota and uses the same source-owned pacing/retry/cache lifecycle. + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) + consider(layerState._activeCameraId); + for (const id of layerState._cardIds) consider(id); + const policy = cardFetchPolicy({ + // The cold-fill burst yields to the staggered geometry drain: 4 concurrent + // image fetch+decodes mid-drain starve the mesh-floor queue on weak GPUs + // (qa-cctv-v2 drain-budget regression). Steady 1/s trickle still runs; + // the burst fires the moment the drain completes. + coldFill: coldFill && !layerState._geoLoading, + inFlight: layerState._cardFetchInFlightCount, + sinceLastLaunchMs: + layerState._cardLastFetchAt > 0 + ? now - layerState._cardLastFetchAt + : Infinity, + }); + layerState._cardFetchMode = policy.mode; + if (!policy.launch) return; + const pick = frameless || stalest; + if (pick) + parts.cards.fetchCardFrame(pick.record, pick.slot, pick.refreshMs); + } + + /** + * Hidden-state gate (perf wave 2): detach in-flight card frame decodes when + * the document hides — a hidden canvas has no reader, and image decode is + * the expensive half. New fetches are gated at fetchCardFrame; the steady + * pacer refills naturally on return. Owned by the initialized layer and detached on destruction. + */ + + /** + * Reports whether selecting a record must run the full activation path. + * Disable re-arms the still-active record so its obstruction probe runs again + * on its next real activation after the temporary clamp is cleared. + * @param {string} cameraId Requested camera ID. + * @param {string|null} activeCameraId Current active camera ID. + * @param {Object|null} record Requested camera runtime record. + * @returns {boolean} Whether activation work must run. + */ + + function cctvRecordNeedsActivation(cameraId, activeCameraId, record) { + return cameraId !== activeCameraId || record?.activationDone !== true; + } + return { + toRad, + normalizeHeading, + clamp, + normalizeSignedAngle, + angularDeltaAbs, + normalizeFeedType, + isVideoFeedType, + safeNumber, + headingFromId, + quantize, + normalizeCoverageMode, + offsetDegrees, + safeWindowLocalStorage, + ensureCameraPose, + projectPoint, + haversineKm, + sectorAreaKm2, + currentViewContext, + projectionTilesReady, + planeOrientationFor, + projectionLoopIsNeeded, + cardFrameTick, + cctvRecordNeedsActivation, + }; +} diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js new file mode 100644 index 000000000..6c576bb4f --- /dev/null +++ b/src/layers/cctv/navigation.js @@ -0,0 +1,157 @@ +import * as Cesium from 'cesium'; +import { CCTV_FOCUS_RESULT } from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Finds the camera closest to the Cesium viewer's current position. + * @returns {string|null} Camera ID of the nearest camera, or null. + */ + + function nearestCameraIdToViewer() { + const carto = layerState._viewer?.camera?.positionCartographic; + if (!carto || !layerState._records.length) return null; + const lat = Cesium.Math.toDegrees(carto.latitude); + const lon = Cesium.Math.toDegrees(carto.longitude); + + let best = null; + for (const record of layerState._records) { + const distKm = parts.model.haversineKm( + lat, + lon, + record.camera.lat, + record.camera.lon, + ); + if (!best || distKm < best.distKm) { + best = { id: record.camera.id, distKm }; + } + } + return best?.id || null; + } + + /** + * Flies the Cesium viewer camera to frame the specified CCTV camera, + * looking along its heading from above. + * @param {Cesium.Viewer|null} viewer Cesium viewer that owns the camera. + * @param {Object|null} record CCTV camera runtime record. + * @param {number} [duration=2.2] - Flight duration in seconds. + * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. + */ + + function focusCctvRecord(viewer, record, duration = 2.2) { + if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA; + if ( + typeof document !== 'undefined' && + document.body?.classList.contains('cockpit-mode') + ) { + console.debug('[Data:CCTV] focus ignored while cockpit owns the camera'); + return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE; + } + if (viewer.trackedEntity) { + console.debug( + '[Data:CCTV] focus ignored while a tracked entity owns the camera', + ); + return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; + } + const { camera } = record; + const range = Math.max(280, camera.rangeM * 1.18); + viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + record.position, + Math.max(40, camera.rangeM * 0.36), + ), + { + offset: new Cesium.HeadingPitchRange( + parts.model.toRad(camera.headingDeg), + parts.model.toRad(-22), + range, + ), + duration: Math.max(0.2, duration || 0), + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + ); + return CCTV_FOCUS_RESULT.FOCUSED; + } + + function focusCamera(cameraId, duration = 2.2) { + return focusCctvRecord( + layerState._viewer, + layerState._recordById.get(cameraId), + duration, + ); + } + + /** + * Advances to the next camera if auto-hop is enabled and the hop interval + * has elapsed. If the viewer has panned to a new region since the last hop, + * snaps to the nearest camera instead of cycling sequentially. + * @param {number} nowMs - Current timestamp in milliseconds. + */ + + function maybeAutoHop(nowMs) { + if ( + !layerState._autoHop || + layerState._autoHopSuspended || + !layerState._enabled || + layerState._records.length < 2 + ) + return; + if (nowMs - layerState._lastHopAt < layerState._autoHopSec * 1000) return; + + const viewKey = parts.model.currentViewContext(); + const viewChanged = viewKey !== layerState._lastViewContext; + layerState._lastViewContext = viewKey; + + if (viewChanged) { + const nearest = nearestCameraIdToViewer(); + if (nearest && nearest !== layerState._activeCameraId) { + // Use setActiveCamera so the full activation path runs (obstruction + // probe, projection runtime, geometry rewrite) — previously bypassed + // with a bare assignment + parts.selection.setActiveCamera(nearest); + layerState._lastHopAt = nowMs; + return; + } + } + + const nextIdx = cctvCycleIndex( + layerState._records.findIndex( + (record) => record.camera.id === layerState._activeCameraId, + ), + 1, + layerState._records.length, + ); + parts.selection.setActiveCamera(layerState._records[nextIdx].camera.id); + layerState._lastHopAt = nowMs; + } + + /** + * Resolves a catalog cycle target, including the explicit no-selection state. + * NEXT from null selects the first record; PREV selects the last. + * @param {number} currentIdx + * @param {number} step + * @param {number} count + * @returns {number} + */ + + function cctvCycleIndex(currentIdx, step, count) { + const total = Number.isFinite(count) ? Math.floor(count) : 0; + if (total <= 0) return -1; + const delta = Number.isFinite(step) ? Math.trunc(step) : 1; + if (!Number.isFinite(currentIdx) || currentIdx < 0) { + return delta < 0 ? total - 1 : 0; + } + return (((Math.floor(currentIdx) + delta) % total) + total) % total; + } + return { + nearestCameraIdToViewer, + focusCctvRecord, + focusCamera, + maybeAutoHop, + cctvCycleIndex, + }; +} diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js new file mode 100644 index 000000000..ff9aa2465 --- /dev/null +++ b/src/layers/cctv/policy.js @@ -0,0 +1,481 @@ +import * as Cesium from 'cesium'; +import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; +import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; + +// --------------------------------------------------------------------------- +// API endpoints +// --------------------------------------------------------------------------- + +export const FRAME_ENDPOINT = '/api/cctv/frame'; + +export const SOURCE_ENDPOINT = '/api/cctv/sources'; + +export const HEALTH_ENDPOINT = '/api/cctv/health'; + +export const MEDIA_ENDPOINT = '/api/cctv/media'; + +// --------------------------------------------------------------------------- +// Timing and geometry constants +// --------------------------------------------------------------------------- + +export const DEFAULT_UPDATE_INTERVAL_MS = 10000; + +export const MIN_AUTO_HOP_SEC = 8; + +export const MAX_AUTO_HOP_SEC = 90; + +export const HEALTH_SYNC_INTERVAL_MS = 7000; + +export const ACTIVE_FRAME_REFRESH_MS = 10000; + +export const IDLE_FRAME_REFRESH_MS = 60000; + +export const PROJECTION_ACTIVE_REFRESH_MS = 10000; + +export const PROJECTION_IDLE_REFRESH_MS = 60000; + +export const PROJECTION_CANVAS_WIDTH = 1920; + +export 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. + +export const FRAME_SIGNATURE_W = 64; + +export const FRAME_SIGNATURE_H = 36; + +export const COVERAGE_NEIGHBOR_LIMIT = 14; + +export 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. + +export const GEO_LOAD_BATCH_SIZE = 4; + +export const GEO_LOAD_BATCH_DELAY_MS = 120; + +export const GEO_TRACKING_BATCH_SIZE = 2; + +export const GEO_TRACKING_BATCH_DELAY_MS = 250; + +export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300; + +export 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. + +export 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). + +export const PROJECTION_TEXTURE_SWAP_MS = 1000; + +export 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', +}); + +// §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). + +export const PROBE_CLEARANCE_M = 4; + +export 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. + +export const GROUND_PRIOR_INIT_WAIT_MS = 8000; + +/** Default calibration offsets — all zeroed, range scale 1x. */ + +export const DEFAULT_CAMERA_CALIBRATION = Object.freeze({ + offsetNorthM: 0, + offsetEastM: 0, + headingDeg: 0, + pitchDeg: 0, + fovDeg: 0, + rangeScale: 1, + heightM: 0, +}); + +/** Base64-encoded SVG camera icon for billboard rendering. */ + +export 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. + */ + +export 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 +// --------------------------------------------------------------------------- + +export const IDLE_CAMERA_COLOR = + Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88); + +export const ACTIVE_CAMERA_COLOR = + Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); + +export const IDLE_COVERAGE_COLOR = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); + +export const IDLE_COVERAGE_CENTER_MUTED = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2); + +export const IDLE_COVERAGE_EDGE_MUTED = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18); + +export const ACTIVE_COVERAGE_EDGE = + Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58); + +export 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. + +export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL = + Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18); + +export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL = + Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26); + +export const PLANE_OUTLINE_COLOR = + Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55); + +/** + * Card budget while the staggered geometry drain is running — the raised + * 20/28/40 tiers resume when loading completes (see refreshAmbientCards). + */ + +export 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. + +export 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). + +export const CARD_VIEW_MARGIN = 0.06; + +/** Card leader gap: clears the 24px icon (12px half + breathing room). */ + +export const CARD_GAP_PX = 16; + +export 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, +}); + +// 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). */ + +export const HOVER_PICK_THROTTLE_MS = 120; + +/** How long the hover card lingers after the pointer leaves the icon. */ + +export const HOVER_RELEASE_MS = 1_000; diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js new file mode 100644 index 000000000..6a8d632e4 --- /dev/null +++ b/src/layers/cctv/presentation.js @@ -0,0 +1,207 @@ +import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; +import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; + +export function createPresentation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Builds a single-line summary string for the active camera, including city, + * heading, FOV, coverage area, overlap count, projection mode, alignment + * confidence, source type, and view context. + * @returns {string} Summary text separated by mid-dots. + */ + + function buildSummaryText() { + const active = parts.selection.getActiveRecord(); + if (!active) { + return layerState._records.length + ? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE` + : 'No cameras available in catalog.'; + } + + const area = parts.model.sectorAreaKm2( + active.camera.rangeM, + active.camera.fovDeg, + ); + const overlapCount = parts.geometry.coverageNeighborCount(active); + const viewKey = parts.model.currentViewContext(); + const viewBand = viewKey.split(':')[0] || 'global'; + const health = layerState._healthById.get(active.camera.id) || null; + const calBadge = parts.calibration.deriveCalBadge(active.camera); + + return [ + `${active.camera.city.toUpperCase()} CCTV`, + `${active.camera.name.toUpperCase()}`, + `HDG ${Math.round(active.camera.headingDeg)}°`, + `FOV ${Math.round(active.camera.fovDeg)}°`, + `COVERAGE ${area.toFixed(2)}km²`, + overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', + `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, + layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null, + `CAL ${calBadge.replace('-', ' ').toUpperCase()}`, + health?.sourceKind + ? `SRC ${String(health.sourceKind).toUpperCase()}` + : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, + `${viewBand.toUpperCase()} CONTEXT`, + ] + .filter(Boolean) + .join(' · '); + } + + /** + * Builds a public-facing camera state object for UI consumption. + * Includes all pose, calibration, CAL badge, projection, and feed metadata. + * @param {Object} record - Camera record. + * @param {string|null} [activeId=null] - Active camera ID for the `active` flag. + * @returns {Object} Public camera state. + */ + + function getPublicCameraState(record, activeId = null) { + const resolvedActiveId = + activeId || parts.selection.getActiveRecord()?.camera.id || null; + const camera = record.camera; + const health = layerState._healthById.get(camera.id) || null; + const isActive = camera.id === resolvedActiveId; + const refreshMs = isActive + ? ACTIVE_FRAME_REFRESH_MS + : IDLE_FRAME_REFRESH_MS; + return { + id: camera.id, + name: camera.name, + city: camera.city, + provider: camera.provider, + lat: camera.lat, + lon: camera.lon, + headingDeg: camera.headingDeg, + pitchDeg: camera.pitchDeg, + fovDeg: camera.fovDeg, + rangeM: camera.rangeM, + elevationM: camera.absoluteHeightM, + mountHeightM: camera.mountHeightM, + active: isActive, + feedType: camera.feedType, + sourceKind: + health?.sourceKind || + camera.sourceKind || + (camera.feedConfigured ? 'configured' : 'seed'), + sourceStatus: health?.status || 'unknown', + sourceMessage: health?.message || '', + sourceLabel: health?.label || camera.provider || '', + calibration: { + ...parts.calibration.normalizeCalibration(camera.calibration), + }, + // Save-gated persistence (design §3e): true while the live pose carries + // edits that have not been SAVEd (or RESET). Drives the CAL · EDITED chip. + calDirty: !!record.calDirty, + // Deterministic QA seam: counts commit-grade anchor resolutions (E/N drag + // release, numeric E/N edit, or reset), never transient gizmo moves. + groundResolveCount: record.calibrationGroundResolveCount || 0, + // Per-record QA seam for proving transient gizmo moves never enter the + // shared mesh-floor sampler while unrelated catalog cells finish. + groundMeshSampleRequestCount: record.groundMeshSampleRequestCount || 0, + // Datum QA seam: expose the immutable Re:Earth ellipsoidal prior + // separately from the currently applied frustum ground. Google-3D may + // legitimately refine the latter to the rendered mesh, so callers must + // not infer the prior by subtracting mount height from live geometry. + groundPriorM: Number.isFinite(record.groundPrior?.ellipsoid) + ? record.groundPrior.ellipsoid + : null, + intrinsics: camera.intrinsics ? { ...camera.intrinsics } : null, + extrinsics: camera.extrinsics ? { ...camera.extrinsics } : null, + anchor: camera.anchor ? { ...camera.anchor } : null, + // Panel-only trust signal (design §3b, amended by LOCKED §9.2/§9.3): no + // in-world rendering reads this, no score-based quality math backs it. + calBadge: parts.calibration.deriveCalBadge(camera), + poseSource: camera.poseSource || null, + basePose: camera.basePose ? { ...camera.basePose } : null, + frameUrl: parts.frames.frameUrlFor(camera, refreshMs), + mediaUrl: parts.frames.mediaUrlFor(camera), + }; + } + + /** + * Assembles the full UI state payload containing layer toggles, camera list, + * active camera details, summary text, and error state. + * @returns {Object} Complete UI state for subscribers. + */ + + function uiState() { + const active = parts.selection.getActiveRecord(); + const activeId = active?.camera.id || null; + const payload = { + enabled: layerState._enabled, + // Compat boolean + the full tri-state (viewshed design §3b). + showCoverage: layerState._coverageMode !== 'off', + coverageMode: layerState._coverageMode, + showProjection: layerState._showProjection, + calibrationMode: layerState._calibrationMode, + autoHop: layerState._autoHop, + autoHopSuspended: layerState._autoHopSuspended, + autoHopSec: layerState._autoHopSec, + count: layerState._count, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + loading: { + active: layerState._geoLoading, + loaded: Math.min(layerState._geoLoadDone, layerState._geoLoadTotal), + total: layerState._geoLoadTotal, + }, + // Ambient card tier telemetry (QA harnesses assert the fetch pacing — + // minFrameFetchSpacingMs reads together with fetchMode: cold-fill bursts + // legitimately reach ~250 ms, steady state stays >=1000 ms). + ambientCards: { + count: layerState._cardIds.size, + limit: CCTV_AMBIENT_CARD_MAX, + frameFetches: layerState._cardFetchCount, + minFrameFetchSpacingMs: layerState._cardMinFetchSpacingMs, + fetchMode: layerState._cardFetchMode, + fetchesInFlight: layerState._cardFetchInFlightCount, + // Item B QA seam: the hover-summoned pinned card, if any. + hoverId: layerState._hoverCardId, + }, + activeCameraId: activeId, + activeCamera: active ? getPublicCameraState(active, activeId) : null, + cameras: layerState._records.map((record) => + getPublicCameraState(record, activeId), + ), + summary: buildSummaryText(), + }; + return payload; + } + + /** Dispatches the current UI state to all registered subscriber callbacks. */ + + function notifyListeners() { + const payload = uiState(); + for (const callback of layerState._listeners) { + try { + callback(payload); + } catch (error) { + console.warn('[Data:CCTV] listener error:', error); + } + } + } + + /** + * Throttled notifyListeners for transient (mid-drag) calibration patches — + * the panel re-render is DOM-heavy, so live gizmo drags publish state at + * ≤10 Hz while the in-world geometry still tracks every processed move. + */ + + function notifyListenersThrottled() { + const now = Date.now(); + if (now - layerState._lastTransientNotifyAt < 100) return; + layerState._lastTransientNotifyAt = now; + notifyListeners(); + } + return { + buildSummaryText, + getPublicCameraState, + uiState, + notifyListeners, + notifyListenersThrottled, + }; +} diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js new file mode 100644 index 000000000..1ea937961 --- /dev/null +++ b/src/layers/cctv/projection.js @@ -0,0 +1,385 @@ +import * as Cesium from 'cesium'; +import { + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, + PLANE_OUTLINE_COLOR, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, +} from './policy.js'; + +export function createProjection({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + /** + * 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. + */ + + 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, + }; + } + + /** + * Re-derives the monitor plane entity's placement (position, orientation, + * dimensions) + label from the record's current frustum geometry, so the plane + * always caps the wireframe exactly (corner rays terminate on its corners). + * No-op when the record has no plane runtime (idle neighbors have no plane). + * @param {Object} record - Camera record. + */ + + function updatePlanePlacement(record) { + const runtime = record?.projection; + if (!runtime?.planeEntity) return; + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + runtime.planeEntity.position = positions.capCenter; + runtime.planeEntity.orientation = parts.model.planeOrientationFor( + record.camera, + positions.capCenter, + ); + if (runtime.planeEntity.plane) { + runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2( + geometry.halfW * 2, + geometry.halfH * 2, + ); + } + if (runtime.labelPosition) { + Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); + } + } + + /** + * Clear the active monitor-plane label source and ownership marker. + */ + + function clearProjectionOverlay() { + layerState._cctvOverlayHost.clearSource(CCTV_PROJECTION_OVERLAY_SOURCE_ID); + layerState._cctvOverlayHost.setVisible( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + false, + ); + layerState._projectionOverlayOwnerId = null; + } + + /** + * Shows/hides the monitor plane and its associated shared-host label. + * @param {Object} runtime - Projection runtime. + * @param {boolean} visible + */ + + function setPlaneVisible(runtime, visible) { + if (!runtime) return; + if (runtime.planeEntity) runtime.planeEntity.show = !!visible; + if (visible && runtime.overlayEntry && runtime.cameraId) { + if (layerState._projectionOverlayOwnerId !== runtime.cameraId) { + layerState._cctvOverlayHost.setEntries( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + [runtime.overlayEntry], + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, + ); + layerState._cctvOverlayHost.setVisible( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + true, + ); + layerState._projectionOverlayOwnerId = runtime.cameraId; + } + } else if (layerState._projectionOverlayOwnerId === runtime.cameraId) { + clearProjectionOverlay(); + } + } + + /** Create the native monitor plane plus its cached host-label presentation. */ + + function createProjectionPlane(record, runtime, geometry, positions) { + runtime.labelPosition ||= new Cesium.Cartesian3(); + Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); + runtime.cameraId = String(record.camera.id); + runtime.overlayEntry = createCctvProjectionOverlayEntry({ + cameraId: runtime.cameraId, + name: record.camera.name, + position: () => runtime.labelPosition, + }); + runtime.planeEntity = layerState._viewer.entities.add({ + id: `cctv-${record.camera.id}-plane`, + properties: { cctvCameraId: record.camera.id }, + show: false, + position: positions.capCenter, + orientation: parts.model.planeOrientationFor( + record.camera, + positions.capCenter, + ), + plane: { + plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0), + dimensions: new Cesium.Cartesian2( + geometry.halfW * 2, + geometry.halfH * 2, + ), + material: runtime.planeMaterial, + outline: true, + outlineColor: PLANE_OUTLINE_COLOR, + }, + }); + return runtime.planeEntity; + } + + /** + * Creates the projection runtime for a camera record: an offscreen canvas, + * the monitor plane plus associated host label, and either an + * or element depending on the feed type. + * + * The plane is the only projection representation (v2): the frustum's far cap, + * perpendicular to the view axis (§2b — never billboarded; a wall primitive + * can't pitch, the plane can). It is textured with the live frame: video + * element direct, canvas double-buffer otherwise. + * + * @param {Object} record - Camera record. + * @returns {Object|null} Projection runtime, or null if no viewer. + */ + + function createProjectionRuntime(record) { + if (!layerState._viewer) return null; + const canvas = document.createElement('canvas'); + canvas.width = PROJECTION_CANVAS_WIDTH; + canvas.height = PROJECTION_CANVAS_HEIGHT; + const ctx = canvas.getContext('2d', { alpha: true }); + + const feedType = parts.model.normalizeFeedType(record.camera.feedType); + const mode = parts.model.isVideoFeedType(feedType) ? 'video' : 'image'; + const runtime = { + mode, + canvas, + ctx, + image: null, + video: null, + planeEntity: null, + cameraId: String(record.camera.id), + labelPosition: new Cesium.Cartesian3(), + overlayEntry: null, + planeMaterial: null, + buffers: null, + bufferIndex: 0, + lastTextureSwapAt: 0, + lastImageRefreshAt: 0, + imageReady: false, + imageLoading: false, + imageStamp: 0, + drawnImageStamp: -1, + // Signature of the pixels currently ON the canvas, plus the reused 64x36 + // scratch used to compute it. null = "nothing known", which always redraws. + lastFrameSignature: null, + signatureCanvas: null, + signatureCtx: null, + lastPlaceholderPaintAt: 0, + // canvasStamp increments on every canvas write (frame blit / placeholder + // paint); lastSwappedCanvasStamp trails it so refreshProjectionTextures + // only re-uploads the plane texture when there is genuinely new content. + canvasStamp: 1, + lastSwappedCanvasStamp: 0, + }; + + parts.frames.paintProjectionPlaceholder(ctx, record.camera); + + if (mode === 'video') { + const video = document.createElement('video'); + video.muted = true; + video.loop = true; + video.autoplay = true; + video.playsInline = true; + video.crossOrigin = 'anonymous'; + video.preload = 'auto'; + video.src = parts.frames.mediaUrlFor(record.camera); + video.addEventListener('canplay', () => { + video.play().catch(() => {}); + }); + runtime.video = video; + } else { + const img = new Image(); + img.decoding = 'async'; + img.crossOrigin = 'anonymous'; + img.onload = () => { + runtime.imageLoading = false; + runtime.imageReady = true; + runtime.imageStamp = Date.now(); + }; + img.onerror = () => { + runtime.imageLoading = false; + runtime.imageReady = false; + }; + runtime.image = img; + } + + // Monitor plane = the frustum's far cap: video feeds bind the video element + // directly (Cesium updates video-backed entity materials per frame); image + // feeds start on the placeholder canvas and switch to double-buffer swaps + // at <=1Hz. + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + runtime.planeMaterial = new Cesium.ImageMaterialProperty({ + image: mode === 'video' && runtime.video ? runtime.video : canvas, + transparent: true, + color: Cesium.Color.WHITE.withAlpha(0.95), + }); + createProjectionPlane(record, runtime, geometry, positions); + + return runtime; + } + + /** + * Lazily initializes the projection runtime for a record if it doesn't exist yet. + * @param {Object} record - Camera record. + * @returns {Object|null} The record's projection runtime. + */ + + function ensureProjectionRuntime(record) { + if (!record) return null; + if (record.projection) return record.projection; + const runtime = createProjectionRuntime(record); + record.projection = runtime; + if (runtime) { + layerState._projectionEntities.push(runtime); + } + return runtime; + } + + /** + * Tears down a projection runtime: stops video playback, removes the monitor + * plane, and clears its host label if it owns the active source. + * @param {Object} runtime - Projection runtime to destroy. + */ + + function destroyProjectionRuntime(runtime) { + if (!runtime) return; + if (runtime.video) { + runtime.video.pause(); + runtime.video.removeAttribute('src'); + runtime.video.load(); + } + if (runtime.planeEntity && layerState._viewer) { + layerState._viewer.entities.remove(runtime.planeEntity); + runtime.planeEntity = null; + } + if (layerState._projectionOverlayOwnerId === runtime.cameraId) + clearProjectionOverlay(); + runtime.overlayEntry = null; + runtime.labelPosition = null; + runtime.planeMaterial = null; + } + + function startProjectionLoop() { + if (layerState._projectionRaf) return; + if (!parts.model.projectionLoopIsNeeded()) return; + // The armed projection loop uploads video textures / runs focus fades per + // frame — the scene must render continuously while it runs. Released when + // the tick self-stops. (perf wave 2) + holdContinuousRender('cctv-projection'); + + const tick = () => { + if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) { + layerState._projectionRaf = 0; + releaseContinuousRender('cctv-projection'); + return; + } + + parts.rendering.refreshCctvFocusStyles(performance.now()); + + const active = parts.selection.getActiveRecord(); + if (layerState._enabled && layerState._showProjection && active) { + ensureProjectionRuntime(active); + if (active.projection?.video) { + active.projection.video.play().catch(() => {}); + } + if (active.projection) { + parts.frames.drawProjectionFrame(active); + parts.frames.refreshProjectionTextures(active); + } + } + + layerState._projectionRaf = requestAnimationFrame(tick); + }; + + layerState._projectionRaf = requestAnimationFrame(tick); + } + + /** Cancels the projection animation loop. */ + + function stopProjectionLoop() { + if (layerState._projectionRaf) { + cancelAnimationFrame(layerState._projectionRaf); + layerState._projectionRaf = 0; + } + releaseContinuousRender('cctv-projection'); + } + + /** + * Pauses video playback on all non-active camera projections and resumes + * the active one (if projection is enabled). + * @param {string|null} activeId - ID of the currently active camera. + */ + + function pauseInactiveProjectionFeeds(activeId) { + for (const record of layerState._records) { + if (!record.projection?.video) continue; + if ( + record.camera.id === activeId && + layerState._enabled && + layerState._showProjection + ) { + record.projection.video.play().catch(() => {}); + } else { + record.projection.video.pause(); + } + } + } + return { + createCctvProjectionOverlayEntry, + updatePlanePlacement, + clearProjectionOverlay, + setPlaneVisible, + createProjectionPlane, + createProjectionRuntime, + ensureProjectionRuntime, + destroyProjectionRuntime, + startProjectionLoop, + stopProjectionLoop, + pauseInactiveProjectionFeeds, + }; +} diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js new file mode 100644 index 000000000..8c4dbb9be --- /dev/null +++ b/src/layers/cctv/rendering.js @@ -0,0 +1,312 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { + ACTIVE_CAMERA_COLOR, + IDLE_CAMERA_COLOR, + ACTIVE_COVERAGE_CENTER, + IDLE_COVERAGE_CENTER_MUTED, + ACTIVE_COVERAGE_CENTER_DEPTHFAIL, + ACTIVE_COVERAGE_EDGE, + IDLE_COVERAGE_EDGE_MUTED, + ACTIVE_COVERAGE_EDGE_DEPTHFAIL, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + /** + * Focus modulation rides the layer's existing animation loop; no additional + * scene listener is installed. Only camera-icon alpha changes here — coverage + * geometry and monitor-plane styling retain their established cadence. + */ + + function refreshCctvFocusStyles(nowMs) { + nowMs = focusNowMs(nowMs); + const target = getFocusTarget(); + if ( + !layerState._enabled || + !layerState._viewer || + !focusPassIsNeeded(target, layerState._activeFocusStyleCount) + ) + return; + if (nowMs - layerState._lastFocusStyleAt < 80) return; + layerState._lastFocusStyleAt = nowMs; + const scene = layerState._viewer.scene; + const camera = layerState._viewer.camera; + const result = applyCctvFocusDeemphasis({ + records: layerState._records, + target, + previousActiveCount: layerState._activeFocusStyleCount, + nowMs, + screenPositionFor: (position) => + Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + position, + layerState._scratchFocusScreen, + ), + cameraDistanceFor: (position) => + Cesium.Cartesian3.distance(camera.positionWC, position), + baseColorFor: (record) => + record.camera.id === layerState._activeCameraId + ? ACTIVE_CAMERA_COLOR + : IDLE_CAMERA_COLOR, + }); + layerState._activeFocusStyleCount = result.activeCount; + } + + /** + * Apply the gated CCTV focus pass through the production color path. + * @param {object} input + * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} + */ + + function applyCctvFocusDeemphasis({ + records, + target, + previousActiveCount = 0, + nowMs, + screenPositionFor, + cameraDistanceFor, + baseColorFor, + 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; + if (!bb) continue; + const position = bb.position; + // CCTV never publishes a tracked focus target, so every icon is ambient. + const focus = advanceSpriteFocus(bb, { + // Keep hidden icons in the state/release pass so the active count cannot + // drop while a stale dim alpha remains waiting to reappear. + screenPosition: + bb.show === false || !position ? null : screenPositionFor(position), + cameraDistance: position ? cameraDistanceFor(position) : Number.NaN, + nowMs, + target, + params, + spriteHalfWidthPx: (bb.width || 24) * (bb.scale || 1) * 0.5, + spriteHalfHeightPx: (bb.height || 24) * (bb.scale || 1) * 0.5, + }); + transitioning ||= focus.transitioning; + if (focus.active) activeCount += 1; + const base = baseColorFor(record); + const alpha = base.alpha * focus.factor; + if (focusAlphaNeedsWrite(bb.color?.alpha, alpha, params)) { + // Order-independent narrow amendment to always-visible icons: CCTV + // contacts retain a non-zero floor while yielding near the tracked target. + bb.color = base.withAlpha(alpha); + writes += 1; + } + } + return { writes, transitioning, activeCount, ran: true }; + } + + /** + * Updates visual styles (colors, widths, visibility) for all camera billboards, + * coverage polylines, viewshed volumes, and projection entities based on which + * camera is active, whether the layer is enabled, and the current + * coverage-mode/projection toggle states. + */ + /** + * Field-test fix (2026-07-06): horizon-culls camera billboards, mirroring the + * flights layer's EllipsoidalOccluder pass. With the Cesium globe hidden + * (Google-3D regime) nothing writes far-side depth, and the billboards are now + * always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge + * fix), so without this pass London's cluster would shine through the planet + * from a US viewpoint. Pure math over ≤ catalog-size points; runs on + * camera.moveEnd + init only (event-driven — no steady-state work). + */ + + function refreshHorizonCulling() { + if ( + !layerState._viewer || + layerState._viewer.isDestroyed() || + !layerState._records.length + ) + return; + const occluder = horizonOccluder(layerState._viewer.camera); + for (const record of layerState._records) { + const bb = record.billboard; + if (!bb) continue; + const visible = occluder.isPointVisible(bb.position); + if (bb.show !== visible) bb.show = visible; + } + } + + /** + * Hides every per-record CCTV visual without recomputing coverage membership or + * styles. Disabling makes every visibility branch false, so a direct sweep is + * sufficient; live viewshed primitives still require explicit destruction. + * + * @param {Object[]} records CCTV runtime records. + * @param {(record: Object) => void} destroyVolume Viewshed teardown callback. + * @param {string|null} [activeCameraId=null] Active camera whose activation probe must be re-armed. + */ + + function hideCctvRecordVisuals( + records, + destroyVolume, + activeCameraId = null, + ) { + for (const record of Array.isArray(records) ? records : []) { + if (record) { + record.probeClampRangeM = null; + if (record.camera?.id === activeCameraId) record.activationDone = false; + } + for (const entity of record?.coverageEntities || []) entity.show = false; + if (record?.viewshedPrimitive) destroyVolume?.(record); + if (record?.projection?.planeEntity) + record.projection.planeEntity.show = false; + } + } + + function hideCctvVisuals() { + hideCctvRecordVisuals( + layerState._records, + parts.geometry.destroyViewshedVolume, + layerState._activeCameraId, + ); + parts.projection.clearProjectionOverlay(); + if (layerState._billboards) layerState._billboards.show = false; + parts.projection.pauseInactiveProjectionFeeds(null); + } + + /** Applies coverage visibility/style state and lazily builds eligible sets. */ + + function refreshCoverageStyles() { + const activeRecord = parts.selection.getActiveRecord(); + parts.geometry.ensureActiveCoverageEntities(activeRecord); + const activeId = activeRecord?.camera.id || null; + const coverageVisible = + parts.geometry.buildCoverageVisibleSet(activeRecord); + const coverageOn = layerState._coverageMode !== 'off'; + const viewshedOn = layerState._coverageMode === 'viewshed'; + if (coverageOn) { + parts.geometry.ensureVisibleCoverageEntities( + layerState._records, + coverageVisible, + ); + } + for (const record of layerState._records) { + const isActive = record.camera.id === activeId; + if (record.billboard) { + record.billboard.color = isActive + ? ACTIVE_CAMERA_COLOR + : IDLE_CAMERA_COLOR; + record.billboard.scale = isActive ? 1.25 : 1.0; + // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard + // (set at creation) — see the field-test far-zoom submerge fix there. + } + + if (layerState._enabled && layerState._showProjection && isActive) { + parts.projection.ensureProjectionRuntime(record); + } + // One live plane in the world at a time (§2c): only the active camera's + // far cap carries the monitor plane; idle neighbors get the faint + // wireframe only. + const planeShowing = !!( + layerState._enabled && + layerState._showProjection && + isActive + ); + + const inVisibleSet = coverageVisible.has(record.camera.id); + for (const entity of record.coverageEntities || []) { + // The frustum wireframe is part of the projection representation — + // force it on for the active camera and let it read through geometry + // via depthFailMaterial (polylines have no disableDepthTestDistance). + entity.show = !!( + layerState._enabled && + ((coverageOn && inVisibleSet) || planeShowing) + ); + if (!entity.polyline) continue; + // Viewshed mode swaps the cyan/green scheme for the camera's own hue so + // adjacent cones read as distinct coverage claims (design §3b); the + // active camera keeps its width/alpha emphasis in both schemes. + const hue = viewshedOn ? record.viewshedColors : null; + if (entity._coverageRole === 'cap') { + entity.polyline.material = hue + ? isActive + ? hue.lineActive + : hue.line + : isActive + ? ACTIVE_COVERAGE_CENTER + : IDLE_COVERAGE_CENTER_MUTED; + entity.polyline.width = isActive ? 2.2 : 1.0; + entity.polyline.depthFailMaterial = planeShowing + ? hue + ? hue.line.withAlpha(0.26) + : ACTIVE_COVERAGE_CENTER_DEPTHFAIL + : undefined; + } else { + entity.polyline.material = hue + ? isActive + ? hue.lineActive + : hue.line.withAlpha(0.6) + : isActive + ? ACTIVE_COVERAGE_EDGE + : IDLE_COVERAGE_EDGE_MUTED; + entity.polyline.width = isActive ? 1.8 : 0.9; + entity.polyline.depthFailMaterial = planeShowing + ? hue + ? hue.line.withAlpha(0.18) + : ACTIVE_COVERAGE_EDGE_DEPTHFAIL + : undefined; + } + } + + // Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the + // visible set. Rebuild on active-tint flips (rare); otherwise leave the + // primitive alone so idle refreshes never churn geometry. + const wantVolume = !!( + layerState._enabled && + viewshedOn && + inVisibleSet && + record.frustumPositions + ); + if (wantVolume) { + if ( + !record.viewshedPrimitive || + record.viewshedActiveTint !== isActive + ) { + parts.geometry.rebuildViewshedVolume(record, isActive); + } + } else if (record.viewshedPrimitive) { + parts.geometry.destroyViewshedVolume(record); + } + + if (record.projection) { + parts.projection.setPlaneVisible(record.projection, planeShowing); + } + } + + if (layerState._billboards) + layerState._billboards.show = !!layerState._enabled; + parts.projection.pauseInactiveProjectionFeeds(activeId); + } + return { + refreshCctvFocusStyles, + applyCctvFocusDeemphasis, + refreshHorizonCulling, + hideCctvRecordVisuals, + hideCctvVisuals, + refreshCoverageStyles, + }; +} diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js new file mode 100644 index 000000000..c89937a9a --- /dev/null +++ b/src/layers/cctv/selection.js @@ -0,0 +1,258 @@ +import { + bindTrackingClickGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import * as Cesium from 'cesium'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { CCTV_ACTIVATION_RESULT } = services.activation; + const { resolvePickId } = services.picking; + + /** + * Returns the camera record for the currently active camera. A stale ID falls + * back to the first record, but an intentional null remains an honest + * deselected state. + * @returns {Object|null} Active camera record, or null when none is active. + */ + + function getActiveRecord() { + if (!layerState._activeCameraId) return null; + if (layerState._recordById.has(layerState._activeCameraId)) { + return layerState._recordById.get(layerState._activeCameraId); + } + // Sync _activeCameraId when falling back to first record to prevent ID mismatch + const fallback = layerState._records[0] || null; + if (fallback && fallback.camera?.id) { + layerState._activeCameraId = fallback.camera.id; + } + return fallback; + } + + /** + * Bind CCTV activation to clean taps while preserving the layer's hover-move + * callback on the shared Cesium handler. Drag-like and long-press gestures do + * not reach camera activation or focus dispatch. + * @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler. + * @param {(click: Object) => void} onClick - Accepted CCTV click callback. + * @param {Object} [options] - Gesture test seams and optional onMouseMove hook. + * @returns {void} + */ + + function bindCctvWorldClickGesture(handler, onClick, options = {}) { + bindTrackingClickGesture( + handler, + (click, gesture) => { + if (!isTrackingClickGesture(gesture)) return; + onClick(click); + }, + options, + ); + } + + /** + * Sets the active camera by ID, initializes its projection runtime, refreshes + * its frame, and updates styles. + * @param {string} cameraId - ID of the camera to activate. + * @returns {'activated'|'unchanged'|'not-found'} Discriminated activation result. + */ + + function setActiveCamera(cameraId) { + if (!cameraId || !layerState._recordById.has(cameraId)) + return CCTV_ACTIVATION_RESULT.NOT_FOUND; + const record = layerState._recordById.get(cameraId); + const previousActiveRecord = getActiveRecord(); + // Re-selecting the already-active camera is a no-op: re-running the + // activation path re-probes and rewrites the plane entity's geometry, and + // that async primitive rebuild visibly flashes the monitor plane (owner + // field test 2026-07-04 — every click ON the plane picks its own camera). + // `activationDone` distinguishes a real activation from the enable()-time + // default `_activeCameraId` assignment, which never ran this path. + if ( + !parts.model.cctvRecordNeedsActivation( + cameraId, + layerState._activeCameraId, + record, + ) + ) { + return CCTV_ACTIVATION_RESULT.UNCHANGED; + } + layerState._activeCameraId = cameraId; + layerState._autoHopSuspended = false; + // A real activation creates projection work — wake the self-stopping loop. + parts.projection.startProjectionLoop(); + if (previousActiveRecord && previousActiveRecord !== record) { + parts.geometry.clearProbeClampOnDeactivation( + previousActiveRecord, + (previous) => { + parts.geometry.applyFrustumGeometry( + previous, + parts.ground.groundAltFor(previous), + ); + }, + ); + } + // The clicked camera leaves the ambient quota immediately (never graced). + // Shipped behavior publishes no card for it because the monitor plane is + // now the active representation; the opt-in protected-card path republishes + // it synchronously through refreshAmbientCards() below. + layerState._cardIds.delete(cameraId); + layerState._cardGraceState.delete(cameraId); + // Activating the hovered camera consumes the transient pin; the active + // monitor plane replaces it from this moment. + if (layerState._hoverCardId === cameraId) parts.hover.clearHoverCard(); + // If the record's geometry refinement is still queued, jump it to the front + // so the newly active camera resolves before idle neighbors. + const queueIdx = layerState._geoQueue.indexOf(record); + if (queueIdx > 0) { + layerState._geoQueue.splice(queueIdx, 1); + layerState._geoQueue.unshift(record); + } + // §9.1: one obstruction probe per activation, BEFORE the geometry pass so + // the range clamp lands in the same rewrite. A FIRST-EVER activation (no + // real ground sample yet) probes from the catalog-prior altitude — if that + // prior is off, the clamp is measured from a shifted origin, but a + // re-activation after the one-shot snap lands re-probes from real ground, + // so it self-corrects. + parts.geometry.runActivationObstructionProbe(record); + // Coverage is activation-lazy even while the mode is OFF: the active + // camera's projection representation must be ready without materializing + // any idle neighbor. The following geometry rewrite welds these entities to + // the current sampled positions. + parts.geometry.ensureActiveCoverageEntities(record); + parts.projection.ensureProjectionRuntime(record); + parts.frames.refreshProjectionImage(record, true); + // FIX B9b: an explicit user (re)select re-arms one real ground sample for the + // newly-active camera, then freezes. Idle neighbors keep their resolved state. + // FIX B9c: if tiles are ready this real pass applies + re-resolves + // immediately; if tiles are still streaming, updateRecordGeometry's fallback + // guard recomputes purely from the cached real ground (the pose + the probe + // clamp above still land — no scene queries) and the record stays unresolved + // until update()'s one-shot tiles-ready completion pass re-grounds it. + parts.ground.rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + record.activationDone = true; + parts.rendering.refreshCoverageStyles(); + // The newly active camera leaves the ambient ring (its monitor plane takes + // over); the freed slot re-fills on this same pass. + parts.cards.refreshAmbientCards(); + // ADJUST mode follows the active camera. + layerState._gizmo?.refresh(); + parts.presentation.notifyListeners(); + return CCTV_ACTIVATION_RESULT.ACTIVATED; + } + + /** + * Clears the active CCTV camera in place without moving the viewer or + * disabling the layer. The normal selection-refresh path releases the active + * projection, emphasis, and probe state while ambient cards remain available. + * @returns {boolean} True when a camera was deactivated. + */ + + function deactivateActiveCamera() { + const record = layerState._activeCameraId + ? layerState._recordById.get(layerState._activeCameraId) + : null; + if (!record) return false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = true; + record.activationDone = false; + parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { + parts.geometry.applyFrustumGeometry( + previous, + parts.ground.groundAltFor(previous), + ); + }); + parts.rendering.refreshCoverageStyles(); + parts.cards.refreshAmbientCards(); + layerState._gizmo?.refresh(); + parts.presentation.notifyListeners(); + return true; + } + + /** + * True only for a clean click that is empty from CCTV's perspective: an + * active camera exists, ADJUST does not own the pointer, and the scene pick + * carries no canonical object ID. Any identified scene object is non-empty, + * including selectable siblings that do not participate in the pick registry. + * @param {Object|null} picked - `scene.pick()` result. + * @param {Object} [context] + * @param {string|null} [context.activeCameraId] + * @param {boolean} [context.calibrationMode] + * @returns {boolean} + */ + + function cctvEmptyClickDeselects( + picked, + { activeCameraId = null, calibrationMode = false } = {}, + ) { + if (!activeCameraId || calibrationMode) return false; + return resolvePickId(picked) === null; + } + + /** + * Extracts a camera ID from a Cesium pick result by checking billboard IDs, + * primitive IDs, and entity cctvCameraId properties. + * @param {Object|null} picked - Result from scene.pick(). + * @returns {string|null} Camera ID, or null if the pick is not a CCTV entity. + */ + + function extractPickedCameraId(picked) { + if (!picked) return null; + + const entity = picked.id?.properties + ? picked.id + : picked.primitive?.id?.properties + ? picked.primitive.id + : null; + const maybeProp = entity?.properties?.cctvCameraId; + if (maybeProp) { + const value = + typeof maybeProp.getValue === 'function' + ? maybeProp.getValue(Cesium.JulianDate.now()) + : maybeProp; + const record = + typeof value === 'string' ? layerState._recordById.get(value) : null; + const ownsCoverageEntity = Boolean( + record?.coverageEntities?.includes(entity), + ); + const ownsProjectionEntity = + record?.projection?.planeEntity === entity || + layerState._projectionEntities.some( + (runtime) => + runtime?.cameraId === value && runtime.planeEntity === entity, + ); + if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value; + } + + // Camera billboard IDs are intentionally the upstream camera ID, so a + // sibling may legitimately use the same string. A bare ID match is not + // ownership proof: require this layer's billboard collection or the exact + // billboard stored on the record. + const directId = + typeof picked.id === 'string' + ? picked.id + : typeof picked.primitive?.id === 'string' + ? picked.primitive.id + : null; + const record = + directId === null ? null : layerState._recordById.get(directId); + if (!record) return null; + return picked.primitive === layerState._billboards || + picked.primitive === record.billboard + ? directId + : null; + } + return { + getActiveRecord, + bindCctvWorldClickGesture, + setActiveCamera, + deactivateActiveCamera, + cctvEmptyClickDeselects, + extractPickedCameraId, + }; +} diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js new file mode 100644 index 000000000..7fba9a9ca --- /dev/null +++ b/src/layers/cctv/source.js @@ -0,0 +1,55 @@ +import { + ACTIVE_FRAME_REFRESH_MS, + FRAME_ENDPOINT, + MEDIA_ENDPOINT, +} from './policy.js'; +function safeNumber(value, fallback = NaN) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} +function frameUrlFor(camera, refreshMs = ACTIVE_FRAME_REFRESH_MS) { + const cadenceMs = Math.max( + 1000, + safeNumber(refreshMs, ACTIVE_FRAME_REFRESH_MS), + ); + const tick = Math.floor(Date.now() / cadenceMs); + const params = new URLSearchParams({ + label: camera.name, + city: camera.city, + lat: camera.lat.toFixed(6), + lon: camera.lon.toFixed(6), + heading: String(Math.round(camera.headingDeg)), + fov: String(Math.round(camera.fovDeg)), + pitch: String(Math.round(camera.pitchDeg || -10)), + ts: String(tick), + }); + return `${FRAME_ENDPOINT}/${encodeURIComponent(camera.id)}?${params.toString()}`; +} +function mediaUrlFor(camera) { + return `${MEDIA_ENDPOINT}/${encodeURIComponent(camera.id)}?ts=${Math.floor(Date.now() / 15000)}`; +} +/** Supply catalog/health records and the existing registered camera URL families. */ +export function createCctvSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + async function read(path, key, { signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(path, { cache: 'no-store', signal }); + if (!response.ok) throw new Error('Camera source HTTP ' + response.status); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.[key])) + throw new Error('Malformed camera ' + key + ' snapshot'); + return payload; + } + return { + getCatalog(options) { + return read('/api/cctv/sources', 'sources', options); + }, + getHealth(options) { + return read('/api/cctv/health', 'cameras', options); + }, + getFrameUrl: frameUrlFor, + getMediaUrl: mediaUrlFor, + }; +} diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs new file mode 100644 index 000000000..f0e3b0cc3 --- /dev/null +++ b/src/layers/cctv/source.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCctvSource, createCctvLayer } from './index.js'; + +const camera = { + id: 'pack/camera ?x', + name: 'Camera & road', + city: 'Austin', + lat: 30.267, + lon: -97.744, + headingDeg: 45, + fovDeg: 60, + pitchDeg: -12, +}; + +test('camera catalog and health use fixed source routes and caller cancellation', async () => { + const calls = []; + const source = createCctvSource({ + fetchImpl: async (path, options) => { + calls.push({ path, options }); + return new Response( + JSON.stringify( + path.endsWith('/sources') ? { sources: [] } : { cameras: [] }, + ), + ); + }, + }); + const controller = new AbortController(); + await source.getCatalog({ signal: controller.signal }); + await source.getHealth({ signal: controller.signal }); + assert.deepEqual( + calls.map((call) => call.path), + ['/api/cctv/sources', '/api/cctv/health'], + ); + for (const { options } of calls) { + assert.equal(options.signal, controller.signal); + assert.equal(options.cache, 'no-store'); + } +}); + +test('camera sources reject malformed snapshots and failures', async () => { + for (const method of ['getCatalog', 'getHealth']) { + const malformed = createCctvSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects(malformed[method](), /Malformed camera/); + const denied = createCctvSource({ + fetchImpl: async () => new Response('', { status: 403 }), + }); + await assert.rejects(denied[method](), /HTTP 403/); + } +}); + +test('cancellation while reading a camera response body prevents publication', async () => { + const controller = new AbortController(); + const source = createCctvSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { sources: [] }; + }, + }), + }); + await assert.rejects(source.getCatalog({ signal: controller.signal }), { + name: 'AbortError', + }); +}); + +test('frame and media URLs preserve registered camera identity and encoded metadata', () => { + const source = createCctvSource(); + const frame = new URL(source.getFrameUrl(camera), 'https://example.test'); + const media = new URL(source.getMediaUrl(camera), 'https://example.test'); + assert.equal( + frame.pathname, + '/api/cctv/frame/' + encodeURIComponent(camera.id), + ); + assert.equal( + media.pathname, + '/api/cctv/media/' + encodeURIComponent(camera.id), + ); + assert.equal(frame.searchParams.get('label'), camera.name); + assert.equal(frame.searchParams.get('city'), camera.city); + assert.equal(frame.searchParams.get('lat'), '30.267000'); + assert.equal(frame.searchParams.get('lon'), '-97.744000'); + assert.equal(frame.searchParams.get('heading'), '45'); + assert.equal(frame.searchParams.get('pitch'), '-12'); + assert.deepEqual([...media.searchParams.keys()], ['ts']); +}); + +test('camera construction is inert and destruction cancels a pending catalog and its visibility listener', async (t) => { + const original = globalThis.document; + const listeners = new Set(); + globalThis.document = { + addEventListener(type, handler) { + if (type === 'visibilitychange') listeners.add(handler); + }, + removeEventListener(type, handler) { + if (type === 'visibilitychange') listeners.delete(handler); + }, + }; + t.after(() => { + globalThis.document = original; + }); + const noop = () => {}; + const services = { + overlays: { + clearOverlaySource: noop, + hitTestWorldOverlay: noop, + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + }, + sprites: { registerSpriteCollection: noop }, + activation: {}, + locations: {}, + picking: { unregisterPickOwner: noop }, + terrain: {}, + ground: {}, + mesh: {}, + focus: {}, + render: { releaseContinuousRender: noop }, + }; + let resolveCatalog; + let signal; + const source = { + ...createCctvSource(), + getCatalog(options) { + signal = options.signal; + return new Promise((resolve) => { + resolveCatalog = resolve; + }); + }, + }; + const a = createCctvLayer({ services, source }); + const b = createCctvLayer({ services, source }); + assert.equal(listeners.size, 0); + const viewer = { + scene: { primitives: { add: (value) => value, remove: () => true } }, + }; + const initializing = a.init(viewer); + assert.equal(listeners.size, 1); + assert.equal(signal.aborted, false); + a.destroy(viewer); + assert.equal(listeners.size, 0); + assert.equal(signal.aborted, true); + resolveCatalog({ sources: [] }); + await assert.rejects(initializing, { name: 'AbortError' }); + assert.equal(a.getStats().count, 0); + assert.equal(b.getStats().count, 0); +}); diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js new file mode 100644 index 000000000..b78d18b73 --- /dev/null +++ b/src/layers/cctv/state.js @@ -0,0 +1,208 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { + clearOverlaySource, + hitTestWorldOverlay, + setOverlayEntries, + setOverlaySourceVisible, + } = services.overlays; + const layerState = {}; + + // --------------------------------------------------------------------------- + // Module-scoped mutable state + // --------------------------------------------------------------------------- + + layerState._viewer = null; + + layerState._billboards = null; + + layerState._records = []; + + layerState._recordById = new Map(); + + layerState._coverageEntities = []; + + layerState._projectionEntities = []; + + layerState._enabled = false; + + layerState._activeCameraId = null; + + layerState._coverageMode = 'on'; + // 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes) + + layerState._showProjection = true; + + layerState._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. + + layerState._autoHopSuspended = false; + + layerState._autoHopSec = 18; + + layerState._lastHopAt = 0; + + layerState._lastViewContext = ''; + + layerState._clickHandler = null; + + layerState._count = 0; + + layerState._lastUpdate = null; + + layerState._lastHealthSyncAt = 0; + + layerState._lastError = null; + + layerState._healthById = new Map(); + + layerState._calibrationById = new Map(); + + layerState._listeners = new Set(); + + layerState._projectionRaf = 0; + + layerState._removeFocusAppearListener = null; + + layerState._lastFocusStyleAt = 0; + + /** Icons whose animated emphasis remains outside the 1.0 deadband. */ + + layerState._activeFocusStyleCount = 0; + + layerState._scratchFocusScreen = new Cesium.Cartesian2(); + + // Staggered geometry-load queue state (see startGeometryLoadQueue). + + layerState._geoQueue = []; + + layerState._geoQueueTimer = 0; + + layerState._geoLoading = false; + + layerState._geoLoadTotal = 0; + + layerState._geoLoadDone = 0; + + layerState._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. + + layerState._tilesReadyReenqueued = false; + + // Calibration ADJUST mode (viewshed/gizmo design §3c): while true, the active + // camera renders the direct-manipulation gizmo. Reset on layer disable. + + layerState._calibrationMode = false; + + layerState._gizmo = null; + + layerState._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. + + layerState._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. + + layerState._lastAppliedRegime = null; + + // Task 5: window listener handle for the 'gev:map-stack-changed' CustomEvent + // main.js dispatches from MapStackController's onChange (removed in destroy). + + layerState._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. + + layerState._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. + + layerState._cardIds = new Set(); + + /** @type {Map} */ + + layerState._cardGraceState = new Map(); + + /** @type {Map} */ + + layerState._cardFrameSlots = new Map(); + + layerState._cardFetchTimer = 0; + + /** In-flight card-frame fetch count (burst allows up to 4, steady is 1). */ + + layerState._cardFetchInFlightCount = 0; + + /** @type {Set} in-flight fetches, detached on teardown. */ + + layerState._cardFetchImages = new Set(); + + /** @type {Set} camera ids with an in-flight fetch (no double-fetch). */ + + layerState._cardFetchPendingIds = new Set(); + + layerState._cardFetchCount = 0; + + layerState._cardLastFetchAt = 0; + + layerState._cardMinFetchSpacingMs = null; + + /** Pacer mode telemetry: 'burst' during cold fill, 'steady' after. */ + + layerState._cardFetchMode = 'steady'; + + layerState.DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({ + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + }); + + layerState._cctvOverlayHost = layerState.DEFAULT_CCTV_OVERLAY_HOST; + + layerState._projectionOverlayOwnerId = null; + + /** + * Product presentation option. Shipped behavior keeps the active camera's + * thumbnail absent because its monitor plane is the active representation. + */ + + layerState._activeCameraCardEnabled = false; + + /** Camera id currently holding the hover-summoned pinned card (or null). */ + + layerState._hoverCardId = null; + + layerState._hoverReleaseTimer = 0; + + layerState._hoverLastPickAt = 0; + + // True between camera.moveStart and moveEnd — hover picking pauses while the + // camera is in motion (picks during a flight would fight the reselection). + + layerState._cameraMoving = false; + + layerState._moveStartListener = null; + layerState._sourceAbort = null; + return layerState; +} diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js new file mode 100644 index 000000000..5c1e67956 --- /dev/null +++ b/src/layers/cctv/testing.js @@ -0,0 +1,120 @@ +import * as Cesium from 'cesium'; + +export function createTesting({ state: layerState, services, parts, source }) { + /** Test seam: republishes host entries through the real push path, so tests + * can observe the pristine module default without touching the setter. */ + + function _pushAmbientCardEntriesForTest() { + parts.cards.pushAmbientCardEntries(); + } + + /** Test seam for exercising real layer lifecycle paths without a DOM host. */ + + function _setCctvOverlayHostForTest(host = null) { + layerState._cctvOverlayHost = host + ? { ...layerState.DEFAULT_CCTV_OVERLAY_HOST, ...host } + : layerState.DEFAULT_CCTV_OVERLAY_HOST; + layerState._projectionOverlayOwnerId = null; + } + + /** + * Create the production monitor-plane/host-label pair without media setup. + * @param {Object} viewer Cesium viewer seam. + * @param {Object} record CCTV runtime record. + * @returns {Object} Projection runtime. + */ + + function _createCctvProjectionPlaneForTest(viewer, record) { + layerState._viewer = viewer; + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + const runtime = { + cameraId: String(record.camera.id), + planeEntity: null, + labelPosition: new Cesium.Cartesian3(), + overlayEntry: null, + planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE), + }; + parts.projection.createProjectionPlane( + record, + runtime, + geometry, + positions, + ); + record.projection = runtime; + return runtime; + } + + /** + * Exercise the production geometry-to-plane-and-label cache update. + * @param {Object} record CCTV runtime record. + */ + + function _updateCctvProjectionPlaneForTest(record) { + parts.projection.updatePlanePlacement(record); + } + + /** Test-only seam for the CCTV ownership proof used by the world-click route. */ + + function _extractPickedCameraIdForTest(picked) { + return parts.selection.extractPickedCameraId(picked); + } + + /** + * Primes the minimum module state needed to exercise the production coverage + * refresh path in unit tests. + * @param {Object} [options={}] Test state values. + * @param {Object|null} [options.viewer] Viewer-like entity owner. + * @param {Object[]} [options.records] Seeded CCTV records. + * @param {string|null} [options.activeCameraId] Active record id. + * @param {boolean} [options.enabled=true] Layer enabled state. + * @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode. + * @param {boolean} [options.showProjection=false] Projection visibility. + * @returns {void} + */ + + function _setCctvCoverageStateForTest({ + viewer = null, + records = [], + activeCameraId = null, + enabled = true, + coverageMode = 'on', + showProjection = false, + } = {}) { + layerState._viewer = viewer; + layerState._records = Array.isArray(records) ? records : []; + layerState._recordById = new Map( + layerState._records + .filter((record) => record?.camera?.id) + .map((record) => [record.camera.id, record]), + ); + layerState._coverageEntities = []; + layerState._projectionEntities = []; + layerState._billboards = null; + layerState._activeCameraId = activeCameraId; + layerState._autoHopSuspended = false; + layerState._enabled = !!enabled; + layerState._coverageMode = parts.model.normalizeCoverageMode( + coverageMode, + 'on', + ); + layerState._showProjection = !!showProjection; + } + return { + _pushAmbientCardEntriesForTest, + _setCctvOverlayHostForTest, + _createCctvProjectionPlaneForTest, + _updateCctvProjectionPlaneForTest, + _extractPickedCameraIdForTest, + _setCctvCoverageStateForTest, + }; +} diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js new file mode 100644 index 000000000..f4fd12bd8 --- /dev/null +++ b/src/layers/earthquakes/index.js @@ -0,0 +1,233 @@ +import * as Cesium from 'cesium'; +import { + EARTHQUAKE_OVERLAY_SOURCE_ID, + EARTHQUAKE_OVERLAY_COHORT_LIMIT, + EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, + depthColor, + createEarthquakeOverlayEntry, + selectEarthquakeOverlayCohort, + mapAnalystRecord, +} from './model.js'; +export * from './model.js'; +export { createUsgsEarthquakeSource } from './source.js'; + +/** Own one earthquake display and its refresh lifecycle. */ +export function createEarthquakesLayer({ source, overlayHost } = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Earthquakes require a snapshot source'); + if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); + let _viewer = null; + let _request = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + + const layer = { + id: 'earthquakes', + name: 'Earthquakes (24h)', + icon: '🌋', + source: 'USGS', + updateInterval: 60000, + + init(viewer) { + if (_viewer) throw new Error('Earthquake layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('earthquakes'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + console.log('[Data:Earthquakes] Initialized'); + }, + + enable(viewer) { + _enabled = true; + // No continuous-render hold: the discs are static geometry now, so the + // layer has no per-frame animator to keep the render loop alive for. + if (_dataSource) _dataSource.show = true; + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + }, + + disable(viewer) { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + }, + + async update(viewer) { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + const nextEntities = []; + let count = 0; + const overlayEntries = []; + + for (const { + stableId, + usgsId, + lon, + lat, + depthKm, + mag, + place, + time, + } of rows) { + count++; + const baseRadius = Math.pow(2, mag) * 1000; + const color = depthColor(depthKm || 0); + const isSignificant = mag >= 5.0; + const fillAlpha = isSignificant ? 0.4 : 0.3; + const outlineAlpha = isSignificant ? 1.0 : 0.8; + + const position = Cesium.Cartesian3.fromDegrees(lon, lat); + nextEntities.push( + new Cesium.Entity({ + id: `earthquake:${stableId}`, + position, + ellipse: { + // Static axes — see the module header. A CallbackProperty here + // re-tessellates the clamped ground geometry every frame. + semiMajorAxis: baseRadius, + semiMinorAxis: baseRadius, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(fillAlpha), + ), + outline: true, + outlineColor: color.withAlpha(outlineAlpha), + outlineWidth: isSignificant ? 3 : 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + properties: { + // Analyst seam (additive): the USGS event id (e.g. "us7000abcd"). + usgsId, + mag, + place, + time, + depth: depthKm, + }, + }), + ); + overlayEntries.push( + createEarthquakeOverlayEntry({ + id: String(stableId), + position, + magnitude: mag, + accent: color.toCssColorString(), + }), + ); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + if (_enabled) { + overlayHost.setEntries( + EARTHQUAKE_OVERLAY_SOURCE_ID, + selectEarthquakeOverlayCohort(overlayEntries), + { + cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT, + collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, + moving: false, + }, + ); + } + + _count = count; + _lastUpdate = Date.now(); + _lastError = null; + console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:Earthquakes] Fetch error:', e); + _lastError = e?.message || 'Earthquake source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + _viewer = null; + _enabled = false; + overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + /** + * Snapshot the layer's in-memory earthquake records as plain JSON-safe + * objects for the analyst query engine. On-demand only (called at most + * once per spoken query) — zero per-frame cost, no listeners, no caching. + * Returns [] while the layer is disabled or empty. + * @param {number} [maxCount=2000] - Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const entities = _dataSource.entities.values; + if (!entities.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const now = Cesium.JulianDate.now(); + const result = []; + for (const entity of entities) { + if (result.length >= limit) break; + const cartesian = entity.position + ? entity.position.getValue(now) + : null; + const carto = cartesian + ? Cesium.Cartographic.fromCartesian(cartesian) + : null; + const p = entity.properties; + result.push( + mapAnalystRecord( + { + id: p?.usgsId?.getValue(now) ?? null, + mag: p?.mag?.getValue(now), + place: p?.place?.getValue(now), + time: p?.time?.getValue(now), + depth: p?.depth?.getValue(now), + lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, + lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, + }, + result.length, + ), + ); + } + return result; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js new file mode 100644 index 000000000..b679b7e35 --- /dev/null +++ b/src/layers/earthquakes/model.js @@ -0,0 +1,150 @@ +import * as Cesium from 'cesium'; +export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; +export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; +export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; + +/** + * Color by depth: + * - Shallow (<70km): Red + * - Intermediate (70-300km): Orange + * - Deep (>300km): Yellow + */ +export function depthColor(depthKm) { + if (depthKm < 70) return Cesium.Color.RED; + if (depthKm < 300) return Cesium.Color.ORANGE; + return Cesium.Color.YELLOW; +} + +/** + * Build the source-owned presentation for one ambient magnitude label. + * Magnitude formatting deliberately remains here instead of moving into the + * shared renderer. + * @param {object} input + * @param {string} input.id Stable USGS or deterministic fallback id. + * @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse. + * @param {number} input.magnitude USGS magnitude. + * @param {string} input.accent Source-owned depth-band color. + * @returns {object} + */ +export function createEarthquakeOverlayEntry({ + id, + position, + magnitude, + accent, +}) { + const mag = Number(magnitude); + return { + id: String(id), + position, + variant: 'label', + title: `M${mag.toFixed(1)}`, + accent, + priority: Math.round(mag * 1000), + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} + +/** Keep the largest events, with stable identity as the tie-break. */ +export function selectEarthquakeOverlayCohort( + entries, + limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT, +) { + const cap = Math.max( + 0, + Math.min(EARTHQUAKE_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); +} + +/** + * Map one earthquake's raw plain values to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. Falls back to an index-based id + * when the USGS event id is absent. + * @param {Object|null|undefined} raw - Plain values pulled off the entity: + * {id, mag, place, time, depth, lat, lon}. + * @param {number} [index=0] - Position in the snapshot (fallback id only). + * @returns {{id: string, magnitude: number|null, depthKm: number|null, + * lat: number|null, lon: number|null, timeMs: number|null, place: string|null}} + */ +export function mapAnalystRecord(raw, index = 0) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + return { + id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`, + magnitude: num(raw?.mag), + depthKm: num(raw?.depth), + lat: num(raw?.lat), + lon: num(raw?.lon), + timeMs: num(raw?.time), // USGS epoch ms + place: text(raw?.place), + }; +} + +/** Validate a complete feed before replacing the last good earthquake snapshot. */ +export function normalizeEarthquakeSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const [index, feature] of geojson.features.entries()) { + const coordinates = feature?.geometry?.coordinates; + const properties = feature?.properties; + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 || + !properties || + typeof properties !== 'object' || + Array.isArray(properties) || + (feature.geometry.type != null && feature.geometry.type !== 'Point') + ) + return null; + const [lon, lat, depthKm] = coordinates; + const mag = properties.mag; + if ( + !Number.isFinite(lon) || + Math.abs(lon) > 180 || + !Number.isFinite(lat) || + Math.abs(lat) > 90 || + (depthKm != null && !Number.isFinite(depthKm)) || + (mag != null && (!Number.isFinite(mag) || mag > 10)) + ) + return null; + // A missing magnitude cannot establish that this event meets M2.5+. + if (mag == null || mag < 2.5) continue; + const stableId = + feature.id == null || feature.id === '' + ? `event-${index + 1}` + : String(feature.id); + if (ids.has(stableId)) return null; + ids.add(stableId); + rows.push({ + stableId, + usgsId: feature.id ?? null, + lon, + lat, + depthKm: depthKm ?? null, + mag, + place: typeof properties.place === 'string' ? properties.place : null, + time: Number.isFinite(properties.time) ? properties.time : null, + }); + } + return rows; +} diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs new file mode 100644 index 000000000..9951935de --- /dev/null +++ b/src/layers/earthquakes/ownership.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createEarthquakesLayer } from './index.js'; +import { createUsgsEarthquakeSource } from './source.js'; +function harness(source) { + const sources = []; + const events = []; + const viewer = { + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createEarthquakesLayer({ + source, + overlayHost: { + setEntries(...args) { + events.push(args); + }, + setVisible() {}, + clearSource() {}, + }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, events }; +} +const row = { + stableId: 'event-a', + usgsId: 'event-a', + lon: 30, + lat: 20, + depthKm: 3, + mag: 4, + place: 'Fixture', + time: 1000, +}; +test('late refresh cannot publish after disable, re-enable, or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + assert.equal(h.events.length, 0); + h.layer.destroy(h.viewer); + } +}); +test('two displays own separate data sources and destruction', async () => { + const a = harness({ getSnapshot: async () => [row] }); + const b = harness({ getSnapshot: async () => [] }); + await a.layer.update(a.viewer); + await b.layer.update(b.viewer); + assert.equal(a.layer.getStats().count, 1); + assert.equal(b.layer.getStats().count, 0); + a.layer.destroy(); + assert.equal(a.sources.length, 0); + assert.equal(b.sources.length, 1); + b.layer.destroy(); +}); +test('USGS body completion honors cancellation even with an uncooperative transport', async () => { + const abort = new AbortController(); + const source = createUsgsEarthquakeSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + abort.abort(); + return { features: [] }; + }, + }), + }); + await assert.rejects(source.getSnapshot({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/earthquakes/source.js b/src/layers/earthquakes/source.js new file mode 100644 index 000000000..fe074e139 --- /dev/null +++ b/src/layers/earthquakes/source.js @@ -0,0 +1,20 @@ +import { normalizeEarthquakeSnapshot } from './model.js'; +const API_URL = + 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'; +/** Request and validate a complete USGS snapshot before it can replace displayed events. */ +export function createUsgsEarthquakeSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(API_URL, { signal }); + if (!response.ok) throw new Error(`USGS HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + const rows = normalizeEarthquakeSnapshot(payload); + if (!rows) throw new Error('Malformed USGS response'); + return rows; + }, + }; +} diff --git a/src/layers/firms/anchors.js b/src/layers/firms/anchors.js new file mode 100644 index 000000000..8b740bdab --- /dev/null +++ b/src/layers/firms/anchors.js @@ -0,0 +1,98 @@ +// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections +// (owner field finding 2026-07-21: at close/oblique zoom over high country, +// fire dots anchored at ellipsoid height 0 read as buried inside the terrain +// ~1-2 km below the visible surface). +// +// The fix rides the height-datum ship's machinery end to end: anchors read +// the SAME shared ground floor every other ground-adjacent consumer uses +// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM +// cell), and cold cells warm through the same batched, chunked, cached +// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts); +// fires are static, and floor cells latch warm for the session, so each +// rendered detection costs at most one DEM lookup EVER — and detections +// sharing a ~111 m coarse cell share that lookup. + +/** @constant {number} Metres above the resolved floor for a fire anchor. + * The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel; + * a few metres of lift biases "slightly above the visible surface" (owner + * principle) without a visible hover on the screen-sized sprite. */ +export const FIRE_ANCHOR_LIFT_M = 5; + +/** + * Synchronous anchor height for one detection: shared ground floor + lift + * when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render + * exactly as before until their floor lands). Warm-cache read only; never + * triggers network or sampling. + * @param {number} lat + * @param {number} lon + * @returns {number} Ellipsoidal anchor height in metres. + */ +export function createFireAnchors({ + cachedGroundFloor, + resolveGroundFloorCells, +}) { + function fireAnchorHeight(lat, lon) { + const floor = cachedGroundFloor(lat, lon); + return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0; + } + + /** @type {?Promise} Tail of the batch chain — batches run strictly + * sequentially so overlapping renders can't stack concurrent requests on + * the single dev-server proxy (same courtesy as terrainHeights' sequential + * chunking). Each queued batch re-filters against the warm cache when it + * actually runs, so cells resolved by an earlier batch are never refetched. */ + let _chain = null; + + /** + * Batched warm of the ground-floor cells behind a rendered detection set. + * Fire-and-forget safe (never rejects). Resolves `true` only when at least + * one requested point actually gained a warm floor — a failed resolve (proxy + * down caches only geoid fallbacks, which do NOT count as a floor) resolves + * `false`, so a render → warm → re-render chain terminates instead of + * looping; the next natural rebuild retries and the cache self-heals. + * @param {Array<{lat: number, lon: number}>} points - Rendered detections. + * @returns {Promise} Whether any requested point warmed. + */ + function warmFireAnchorFloors(points) { + const cold = collectCold(points); + if (!cold.length) return Promise.resolve(false); + const prev = _chain; + const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold); + _chain = run.then( + () => true, + () => false, + ); + return run; + } + + /** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */ + function collectCold(points) { + if (!Array.isArray(points)) return []; + const cold = []; + for (const p of points) { + if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; + if (cachedGroundFloor(p.lat, p.lon) != null) continue; + cold.push({ lat: p.lat, lon: p.lon }); + } + return cold; + } + + /** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */ + async function resolveBatch(points) { + const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null); + if (!cold.length) return false; + try { + await resolveGroundFloorCells(cold); + } catch { + /* resolver is best-effort and never throws; belt and braces */ + } + return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null); + } + + /** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */ + function _resetFireAnchorsForTest() { + _chain = null; + } + + return { fireAnchorHeight, warmFireAnchorFloors, _resetFireAnchorsForTest }; +} diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js new file mode 100644 index 000000000..6b8a72ab6 --- /dev/null +++ b/src/layers/firms/cards.js @@ -0,0 +1,135 @@ +import * as Cesium from 'cesium'; +import { + FIRMS_OVERLAY_SOURCE_ID, + FIRMS_AMBIENT_COHORT_LIMIT, +} from '../../data/firmsLabels.js'; +import { MAX_AMBIENT_LABELS, LABEL_VIEW_MARGIN_PX } from './policy.js'; + +export function createCards({ + layerState, + services, + components, + config, + feed, +}) { + const { overlayHost } = config; + + /** + * Rebuild the card entries for the shared world-overlay host: the + * selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS + * ambient cards picked by a greedy screen-space declutter — walk the + * priority-ranked candidates, project each with + * SceneTransforms.worldToWindowCoordinates, and accept only candidates + * ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose + * projection fails are dropped silently. SELECTION runs here, on LOD/ + * viewport rebuilds and camera moveEnd — never per frame; RENDERING is + * the host, which re-projects the accepted entries every frame so + * cards track the camera smoothly. + */ + + function rebuildAmbientLabels() { + if (!layerState._viewer) return; + const scene = layerState._viewer.scene; + const now = Date.now(); + const entries = []; + /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ + const accepted = []; + // worldToWindowCoordinates happily projects points on the FAR side of the + // planet, so without this the ≤18 ambient slots get spent on fires the + // overlay host will then horizon-cull at paint time (`horizonCull: true` + // in applyFirmsOverlayPolicy) — near-side fires silently lose their cards. + // Shared with the sprite pass (fireHorizonOccluder) so cards and sprites + // never disagree about which hemisphere a detection is on. + const occluder = components.rendering.fireHorizonOccluder(); + const beyondHorizon = (position) => + occluder ? occluder.isPointVisible(position) !== true : false; + layerState._fireByCardId.clear(); + + if (layerState._selectedFire) { + const selectedCard = components.model.buildSelectedFireCard( + layerState._selectedFire, + now, + ); + layerState._fireByCardId.set(selectedCard.id, layerState._selectedFire); + entries.push(selectedCard); + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + components.model.firePosition(layerState._selectedFire), + layerState.scratchWindowCoord, + ); + // Seed the accepted list so ambient cards keep clear of the detail card. + // A selected fire behind the limb is not painted, so it must not reserve + // screen space either. + if ( + screen && + !beyondHorizon( + components.model.fireCullPosition(layerState._selectedFire), + ) + ) { + accepted.push({ x: screen.x, y: screen.y }); + } + } + + const width = scene.canvas.clientWidth; + const height = scene.canvas.clientHeight; + let ambientCount = 0; + + for (const candidate of layerState._labelCandidates) { + if (ambientCount >= MAX_AMBIENT_LABELS) break; + if (candidate.fire && candidate.fire === layerState._selectedFire) + continue; + // Spend the bounded cohort only on the visible hemisphere. At global + // LOD, far-side cells can still project on-canvas; accepting those first + // starves front-side cards before the host applies its authoritative cull. + if (beyondHorizon(candidate.cullPosition || candidate.position)) continue; + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + candidate.position, + layerState.scratchWindowCoord, + ); + if (!screen) continue; // projection failed (e.g. behind camera) — drop silently + if ( + screen.x < -LABEL_VIEW_MARGIN_PX || + screen.x > width + LABEL_VIEW_MARGIN_PX || + screen.y < -LABEL_VIEW_MARGIN_PX || + screen.y > height + LABEL_VIEW_MARGIN_PX + ) + continue; + if (!components.model.screenSeparated(accepted, screen)) continue; + accepted.push({ x: screen.x, y: screen.y }); + ambientCount += 1; + const card = candidate.fire + ? components.model.buildFireCard(candidate, now) + : components.model.buildCellCard(candidate, now); + if (candidate.fire) layerState._fireByCardId.set(card.id, candidate.fire); + entries.push(card); + } + + overlayHost.setEntries( + FIRMS_OVERLAY_SOURCE_ID, + entries.map((entry) => { + const card = components.model.applyFirmsOverlayPolicy( + entry, + layerState._labelLodDistance, + ); + if (!card.interactive) return card; + return { + ...card, + accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`, + activate: () => { + const fire = layerState._fireByCardId.get(card.id); + if (!fire) return false; + components.selection.selectAndFocusFire(fire); + return true; + }, + }; + }), + { + cohortLimit: FIRMS_AMBIENT_COHORT_LIMIT, + collisionCapacity: FIRMS_AMBIENT_COHORT_LIMIT, + moving: false, + }, + ); + } + return { rebuildAmbientLabels }; +} diff --git a/src/layers/firms/index.js b/src/layers/firms/index.js new file mode 100644 index 000000000..080755872 --- /dev/null +++ b/src/layers/firms/index.js @@ -0,0 +1,42 @@ +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createRendering } from './rendering.js'; +import { createCards } from './cards.js'; +import { createSelection } from './selection.js'; +import { createViewport } from './viewport.js'; +import { createLifecycle } from './lifecycle.js'; +import { createQueries } from './queries.js'; +import { createFirmsState } from './state.js'; + +export function createFirmsHelpers({ services }) { + const layerState = createFirmsState({ services, config: {} }); + return createModel({ layerState, services, config: {}, components: {} }); +} + +/** Compose one fire layer with explicit source and scene operations. */ +export function createFirmsHeatmapLayer({ services, feed, ...config }) { + if (typeof feed?.getSnapshot !== 'function') + throw new TypeError('Fires require a snapshot source'); + const layerState = createFirmsState({ services, config }); + const components = {}; + const context = { layerState, services, config, components, feed }; + components.model = createModel(context); + components.ingestion = createIngestion(context); + components.rendering = createRendering(context); + components.cards = createCards(context); + components.selection = createSelection(context); + components.viewport = createViewport(context); + components.lifecycle = createLifecycle(context); + components.queries = createQueries(context); + return Object.assign( + {}, + components.queries.methods, + components.lifecycle.methods, + components.ingestion.methods, + ); +} + +export { createFirmsSource } from './source.js'; +export { createFireAnchors, FIRE_ANCHOR_LIFT_M } from './anchors.js'; +export * from '../../data/firmsAdapt.js'; +export * from '../../data/firmsLabels.js'; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js new file mode 100644 index 000000000..e363fe72b --- /dev/null +++ b/src/layers/firms/ingestion.js @@ -0,0 +1,111 @@ +import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; +import { fireDetectionKey } from '../../data/firmsLabels.js'; + +export function createIngestion({ + layerState, + services, + components, + config, + feed, +}) { + const { clearSelectedEntityContextForLayer } = services.context; + const { id } = config; + + /** Replace a validated snapshot while preserving source freshness and selection identity. */ + + async function loadHeatmap() { + if (!layerState._dataSource || !layerState._enabled) return; + layerState.request?.abort(); + const request = new AbortController(); + layerState.request = request; + layerState._loading = true; + + try { + const payload = await feed.getSnapshot({ signal: request.signal }); + if ( + request.signal.aborted || + layerState.request !== request || + !layerState._enabled + ) + return; + if (payload.keyRequired) { + layerState._keyRequired = true; + layerState._error = null; + layerState._stale = false; + return; + } + layerState._keyRequired = false; + layerState._error = null; + layerState._stale = Boolean(payload?.stale); + const previousSelection = layerState._selectedFire; + layerState._selectedFire = null; + layerState._fires = adaptFirmsRecords(payload?.fires); + layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells + layerState._firesByFrp = [...layerState._fires].sort( + (a, b) => b.frp - a.frp, + ); + layerState._count = layerState._fires.length; + // Data age, not response age: a stale proxy payload truthfully reads old. + layerState._lastUpdate = Number.isFinite(payload?.fetchedAt) + ? payload.fetchedAt + : Date.now(); + // Settle the previous selection BEFORE the LOD rebuild. renderCurrentLod + // runs refreshContextRegistrations(), which deletes every context record + // not in the new top-N — including the one the store still points at. + // Clearing after that deletion fails the ownership guard inside + // clearSelectedEntityContextForLayer and emits nothing at all, so an + // eviction-aware readout never hears that its subject is gone. + const reselected = findMatchingFire(previousSelection); + if (!reselected && previousSelection) { + // The selected detection is not in the new payload: it left the feed + // rather than being deselected. Eviction-aware readouts hold their + // last-known values for this instead of tearing down. + clearSelectedEntityContextForLayer(id, { evicted: true }); + } + components.rendering.renderCurrentLod(true); + if (reselected) components.selection.selectFire(reselected, false); + } catch (error) { + if ( + request.signal.aborted || + layerState.request !== request || + !layerState._enabled + ) + return; + console.warn(`[Data:${id}] FIRMS live load failed:`, error); + layerState._error = 'live feed unavailable'; + } finally { + if (layerState.request === request) { + layerState.request = null; + layerState._loading = false; + } + } + } + + /** + * Find the record in the freshly-loaded set matching a previous selection. + * Identity is the detection itself (lat/lon/acquisition time) — indices + * are regenerated every fetch, so object/index identity cannot be used. + * @param {?Object} previous - Previously selected fire record. + * @returns {?Object} Matching new record. + */ + + function findMatchingFire(previous) { + if (!previous) return null; + const key = fireDetectionKey(previous); + return ( + layerState._fires.find((fire) => fireDetectionKey(fire) === key) || null + ); + } + const methods = { + async update() { + if (layerState._destroyed || !layerState._enabled || layerState._loading) + return; + // Scheduled 10-minute poll (and the manager's immediate first update): + // refetch live fires through the proxy and re-render. Viewport-driven + // re-renders between polls are handled by the LOD watcher. + await loadHeatmap(); + }, + }; + + return { loadHeatmap, findMatchingFire, methods }; +} diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js new file mode 100644 index 000000000..7e0b42a00 --- /dev/null +++ b/src/layers/firms/lifecycle.js @@ -0,0 +1,119 @@ +import * as Cesium from 'cesium'; +import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; + +export function createLifecycle({ + layerState, + services, + components, + config, + feed, +}) { + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { restoreSpriteOrderOnEnable } = services.sprites; + const { clearSelectedEntityContextForLayer } = services.context; + const { id, overlayHost } = config; + + const methods = { + init(viewer) { + if (layerState._destroyed) return; + layerState._viewer = viewer; + layerState._dataSource = new Cesium.CustomDataSource(id); + layerState._dataSource.show = false; + viewer.dataSources.add(layerState._dataSource); + }, + + async enable(viewer) { + if (layerState._destroyed) return; + layerState._enabled = true; + layerState._viewer = viewer; + if (!layerState._dataSource) this.init(viewer); + if (layerState._dataSource) layerState._dataSource.show = true; + if (layerState._billboards) { + // The camera can have moved anywhere while the layer was off: moveEnd + // was not being listened to, and the preRender watcher is inert while + // disabled AND while the (untimed) refetch below is in flight. So the + // retained per-sprite show flags describe the OLD viewpoint. Re-cull + // BEFORE the collection becomes visible — otherwise re-enabling at a + // new location flashes far-side fires through the planet (and keeps + // near-side ones hidden) until the fetch resolves. + components.rendering.refreshHorizonCulling(); + layerState._billboards.show = true; + } + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + components.viewport.installLodWatcher(); + components.viewport.installMoveEndWatcher(); + components.selection.installClickHandler(); + registerPickOwner(id, (pickedId) => + layerState._pickIndexById.has(pickedId), + ); + if (!layerState._fires.length && !layerState._loading) + await components.ingestion.loadHeatmap(); + if ( + layerState._enabled && + !layerState._destroyed && + layerState._viewer === viewer + ) + restoreSpriteOrderOnEnable('firms', viewer); + }, + + disable() { + layerState.request?.abort(); + layerState.request = null; + layerState._loading = false; + layerState._enabled = false; + components.selection.clearFireSelection(); + if (layerState._dataSource) layerState._dataSource.show = false; + if (layerState._billboards) layerState._billboards.show = false; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + clearSelectedEntityContextForLayer(id); + components.selection.removeClickHandler(); + components.viewport.removeMoveEndWatcher(); + unregisterPickOwner(id); + components.viewport.removeLodWatcher(); + }, + + destroy(viewer = layerState._viewer) { + layerState.request?.abort(); + layerState.request = null; + layerState._loading = false; + if (layerState._destroyed) return; + layerState._destroyed = true; + layerState._enabled = false; + components.viewport.removeLodWatcher(); + components.viewport.removeMoveEndWatcher(); + components.selection.removeClickHandler(); + unregisterPickOwner(id); + if (layerState._dataSource && viewer) { + viewer.dataSources.remove(layerState._dataSource, true); + } + layerState._dataSource = null; + components.rendering.removeDetectionCollections(viewer); + components.selection.clearContextRegistrations(); + clearSelectedEntityContextForLayer(id); + layerState._fires = []; + layerState._firesByFrp = []; + layerState._cellCacheByGrid.clear(); + layerState._count = 0; + layerState._cellCount = 0; + layerState._lastUpdate = null; + layerState._keyRequired = false; + layerState._stale = false; + layerState._error = null; + layerState._currentLodId = null; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + layerState._currentLodIndex = -1; + layerState._lastViewRect = null; + layerState._selectedFire = null; + layerState._labelCandidates = []; + layerState._labelLodDistance = 0; + layerState._pickIndexById.clear(); + layerState._fireByCardId.clear(); + layerState._cullPositions.length = 0; + layerState._camSnapValid = false; + }, + }; + + return { methods }; +} diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js new file mode 100644 index 000000000..563a2d735 --- /dev/null +++ b/src/layers/firms/model.js @@ -0,0 +1,532 @@ +import * as Cesium from 'cesium'; +import { + satelliteShortName, + fireDetectionKey, + accentForSeverity, +} from '../../data/firmsLabels.js'; +import { + VIEW_PADDING, + DETECTION_COLOR_STOPS, + CULL_LIFT_THRESHOLD_M, + CULL_LIFT_M, + LABEL_MIN_SEP_PX, +} from './policy.js'; + +export function createModel({ + layerState, + services, + components, + config, + feed, +}) { + const { fireAnchorHeight } = services.anchors; + + /** + * Map one internal fire record (firmsAdapt.js shape) to a plain JSON-safe + * analyst record (analyst query engine seam). Pure — no Cesium types. + * Missing/unknown fields are null, never NaN/undefined. The id reuses the + * layer's FIRE-##### pick-id convention (index keys pick ids and + * context-store ids, so the two stay consistent). + * @param {Object|null|undefined} fire - Internal fire record. + * @returns {{id: string, lat: number|null, lon: number|null, frp: number|null, + * confidence: number|null, satellite: string|null, acqTime: number|null}} + */ + + function mapAnalystRecord(fire) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + return { + id: `FIRE-${String(fire?.index ?? 0).padStart(5, '0')}`, + lat: num(fire?.lat), + lon: num(fire?.lon), + frp: num(fire?.frp), + confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence) + satellite: text(fire?.satellite) || text(fire?.sensor), + acqTime: + Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null + }; + } + + /** + * Expand a view rectangle by VIEW_PADDING per side and convert to degree + * bounds with an explicit anti-meridian wrap flag. Returns null for + * near-global views where clipping would be pointless. + * @param {Cesium.Rectangle} rect - Camera view rectangle (radians). + * @returns {?{west: number, south: number, east: number, north: number, wraps: boolean}} + */ + + function paddedDegreeBounds(rect) { + const width = Cesium.Rectangle.computeWidth(rect); + const height = Cesium.Rectangle.computeHeight(rect); + if (width >= Math.PI * 1.85) return null; + const padLon = width * VIEW_PADDING; + const padLat = height * VIEW_PADDING; + const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat); + const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat); + let west = rect.west - padLon; + let east = rect.east + padLon; + let wraps; + if (width + padLon * 2 >= Cesium.Math.TWO_PI) { + west = -Math.PI; + east = Math.PI; + wraps = false; + } else { + west = Cesium.Math.negativePiToPi(west); + east = Cesium.Math.negativePiToPi(east); + wraps = west > east; + } + return { + west: Cesium.Math.toDegrees(west), + south: Cesium.Math.toDegrees(south), + east: Cesium.Math.toDegrees(east), + north: Cesium.Math.toDegrees(north), + wraps, + }; + } + + /** Point-in-bounds test in degrees, anti-meridian aware. */ + + function boundsContainPoint(bounds, lat, lon) { + if (lat < bounds.south || lat > bounds.north) return false; + if (bounds.wraps) return lon >= bounds.west || lon <= bounds.east; + return lon >= bounds.west && lon <= bounds.east; + } + + /** Cell-rectangle/bounds intersection test in degrees, anti-meridian aware. */ + + function cellIntersectsBounds(cell, gridDegrees, bounds) { + if ( + cell.latCell + gridDegrees < bounds.south || + cell.latCell > bounds.north + ) + return false; + const west = cell.lonCell; + const east = cell.lonCell + gridDegrees; + if (bounds.wraps) return east >= bounds.west || west <= bounds.east; + return east >= bounds.west && west <= bounds.east; + } + + function heatScore(cell) { + return ( + cell.intensity + cell.count * 0.8 + cell.night * 0.6 + cell.maxFrp * 0.12 + ); + } + + function heatColor(value, alpha) { + if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); + if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); + return Cesium.Color.YELLOW.withAlpha(alpha); + } + + /** + * Pick a sprite color stop from FRP + confidence, reusing the same + * yellow → orange → red thresholds as the aggregated heat cells. + * @param {Object} fire - Detection record. + * @returns {{name: string, color: Cesium.Color}} + */ + + function detectionColorStop(fire) { + const heat = Math.min( + 1, + Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, + ); + if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; + if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; + return DETECTION_COLOR_STOPS[2]; + } + + /** FRP → core marker pixel size, clamped to 8..28px. */ + + function frpPixelSize(frp) { + return Math.max( + 8, + Math.min(28, Math.round(8 + Math.sqrt(Math.max(0, frp)) * 2)), + ); + } + + /** Quantize a core size to a 2px bucket so the sprite cache stays tiny. */ + + function sizeBucket(coreSize) { + return Math.max(8, Math.min(28, Math.round(coreSize / 2) * 2)); + } + + /** + * Build (and cache) a radial-glow sprite: hot near-white center fading + * through the stop color to a transparent edge. The glow lives in the + * sprite itself because global bloom defaults OFF. + * @param {{name: string, color: Cesium.Color}} stop - Color stop. + * @param {number} corePx - Bucketed core size in pixels. + * @returns {string} PNG data URL. + */ + + function glowSprite(stop, corePx) { + const key = `${stop.name}:${corePx}`; + const cached = layerState.glowSpriteCache.get(key); + if (cached) return cached; + + const dimension = corePx * 2; + const canvas = document.createElement('canvas'); + canvas.width = dimension; + canvas.height = dimension; + const context = canvas.getContext('2d'); + if (!context) return ''; + const radius = dimension / 2; + const rgb = [ + Math.round(stop.color.red * 255), + Math.round(stop.color.green * 255), + Math.round(stop.color.blue * 255), + ].join(','); + const gradient = context.createRadialGradient( + radius, + radius, + 0, + radius, + radius, + radius, + ); + gradient.addColorStop(0, 'rgba(255,255,235,0.95)'); + gradient.addColorStop(0.25, `rgba(${rgb},0.9)`); + gradient.addColorStop(0.55, `rgba(${rgb},0.35)`); + gradient.addColorStop(1, `rgba(${rgb},0)`); + context.fillStyle = gradient; + context.fillRect(0, 0, dimension, dimension); + + const dataUrl = canvas.toDataURL('image/png'); + layerState.glowSpriteCache.set(key, dataUrl); + return dataUrl; + } + + /** + * Lazily-cached Cartesian3 anchor at the shared ground floor (DEM/mesh cell + * + lift) when the floor is warm, else ellipsoid height 0 (owner field + * finding 2026-07-21: height-0 anchors read as buried under high terrain at + * close/oblique zoom). Warm-cache read only — floors are warmed in batch by + * renderDetections for the close band; everything else (cell-band context + * registrations, detectable objects) just rides whatever is already warm. + * Fires are static and floor cells latch, so each detection re-allocates at + * most once per floor state (cold 0 → warm DEM). Rendering stays depth-test + * free either way (contacts are ALWAYS visible — never below the surface, + * slightly above is fine). + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function firePosition(fire) { + const height = fireAnchorHeight(fire.lat, fire.lon); + if (!fire.position || fire.positionHeight !== height) { + fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height); + fire.positionHeight = height; + } + return fire.position; + } + + /** + * Occlusion-test anchor for one detection. Returns the render position + * outright for anchors comfortably above the ellipsoid (the common case) and + * a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This + * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} + * is what the sprite and the card are drawn at. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireCullPosition(fire) { + const position = firePosition(fire); + if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; + if (!fire.cullPosition) { + fire.cullPosition = Cesium.Cartesian3.fromDegrees( + fire.lon, + fire.lat, + CULL_LIFT_M, + ); + } + return fire.cullPosition; + } + + /** + * Occlusion-test anchor for an aggregated heat cell. Cell centers are built at + * height 0 (exactly on the ellipsoid), which the occluder treats as a limb + * boundary case, so they get the same lift as sub-ellipsoid fire anchors. + * @param {number} lon - Cell center longitude in degrees. + * @param {number} lat - Cell center latitude in degrees. + * @returns {Cesium.Cartesian3} + */ + + function cellCullPosition(lon, lat) { + return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M); + } + + /** + * Horizon-cull a billboard collection against an ellipsoid occluder. + * + * The Cesium globe is hidden in this app (Google 3D Tiles render the planet), + * so nothing writes far-side depth — and these sprites are additionally + * always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never + * swallowed by terrain it sits on). Without an explicit occluder pass, fires on + * the opposite side of the Earth shine through the planet at tilted mid-altitude + * views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes. + * + * Pure and allocation-free: reads `billboard.position`, writes `billboard.show` + * only when it actually flips (assigning `show` dirties the collection's vertex + * buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without + * a WebGL scene. + * + * @param {?{length: number, get: function(number): (Object|undefined)}} billboards + * Billboard collection (or collection-shaped stub). + * @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder. + * @param {?Array} [cullPositions=null] Index-aligned lifted + * occlusion-test anchors (see {@link fireCullPosition}); falls back to the + * billboard's own render position wherever an entry is absent. + * @returns {number} Count of billboards left visible (0 when nothing to test). + */ + + function applyHorizonCull(billboards, occluder, cullPositions = null) { + if (!billboards || typeof billboards.get !== 'function') return 0; + if (typeof occluder?.isPointVisible !== 'function') return 0; + const total = Number(billboards.length) || 0; + let visibleCount = 0; + for (let i = 0; i < total; i += 1) { + const billboard = billboards.get(i); + if (!billboard) continue; + const point = (cullPositions && cullPositions[i]) || billboard.position; + const visible = occluder.isPointVisible(point) === true; + if (billboard.show !== visible) billboard.show = visible; + if (visible) visibleCount += 1; + } + return visibleCount; + } + + /** + * True when `screen` is at least LABEL_MIN_SEP_PX away from every accepted + * screen position (greedy declutter accept test). + * @param {Array<{x: number, y: number}>} accepted - Accepted label positions. + * @param {Cesium.Cartesian2} screen - Candidate window coordinates. + * @returns {boolean} + */ + + function screenSeparated(accepted, screen) { + const minSq = LABEL_MIN_SEP_PX * LABEL_MIN_SEP_PX; + 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; + } + + /** + * Card model for a click-selected fire — the full-detail card, drawn last + * (on top) and never distance-faded by the overlay. + * Exported for unit tests. + * @param {Object} fire - Detection record. + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildSelectedFireCard(fire, nowMs) { + const meta = [`${confidenceBucket(fire.confidence)} conf`]; + if (fire.acqMs > 0) { + const age = formatAge(nowMs - fire.acqMs); + if (age) meta.push(`${age} ago`); + } + const sat = satelliteShortName(fire.satellite); + meta.push( + sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a', + ); + return { + id: `selected-fire:${fireDetectionKey(fire)}`, + actionable: true, + position: firePosition(fire), + // Host-side horizon test uses this instead of the render anchor. + cullPosition: fireCullPosition(fire), + gapPx: frpPixelSize(fire.frp), + accent: accentForSeverity(detectionColorStop(fire).name), + title: `FIRE · ${formatFrp(fire.frp)} MW`, + details: [ + meta.join(' · '), + formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''), + ], + selected: true, + priority: Number.MAX_SAFE_INTEGER, + }; + } + + /** + * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail + * "high · 14h · N20". Ages are computed against each detection's acquisition + * time — with the live feed everything reads under 24 h (and a stale cache + * reads truthfully old). Exported for unit tests. + * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildFireCard(candidate, nowMs) { + const fire = candidate.fire; + const meta = [confidenceBucket(fire.confidence)]; + if (fire.acqMs > 0) { + const age = formatAge(nowMs - fire.acqMs); + if (age) meta.push(age); + } + const sat = satelliteShortName(fire.satellite) || fire.sensor; + if (sat) meta.push(sat); + return { + id: `fire:${fireDetectionKey(fire)}`, + actionable: true, + position: candidate.position, + cullPosition: candidate.cullPosition || candidate.position, + gapPx: frpPixelSize(fire.frp), + accent: accentForSeverity(detectionColorStop(fire).name), + title: `▲ ${formatFrp(fire.frp)} MW`, + details: [meta.join(' · ')], + selected: false, + priority: Number(fire.frp) || 0, + }; + } + + /** + * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail + * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized + * score is only known at renderCells time). Exported for unit tests. + * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildCellCard(candidate, nowMs) { + const cell = candidate.cell; + const noun = cell.count === 1 ? 'FIRE' : 'FIRES'; + const parts = [`max ${formatFrp(cell.maxFrp)} MW`]; + if (cell.newestAcqMs > 0) { + const age = formatAge(nowMs - cell.newestAcqMs); + if (age) parts.push(`new ${age}`); + } + return { + id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`, + position: candidate.position, + cullPosition: candidate.cullPosition || candidate.position, + gapPx: 10, + accent: candidate.accent || accentForSeverity('yellow'), + title: `${cell.count} ${noun}`, + details: [parts.join(' · ')], + selected: false, + priority: Number(cell.maxFrp) || 0, + }; + } + + /** + * Add the host-owned layout/fade/collision fields to a source-formatted FIRMS + * card. Selected fires share the collision domain so their protected rect + * excludes ambient cards, but bypass both the 18-card cohort and distance fade. + * @param {Object} card Source-formatted card. + * @param {number} fadeDistance Current LOD fade distance in metres. + * @returns {Object} + */ + + function applyFirmsOverlayPolicy(card, fadeDistance) { + const selected = card?.selected === true; + const rawGap = Number(card?.gapPx) || 10; + const gapPx = Math.max(12, rawGap + 8); + return { + ...card, + variant: selected ? 'selected' : 'card', + protected: selected, + collisionGroup: 'ambient-card', + cardStyle: 'tactical', + gapPx, + leaderOffsetPx: Math.max(2, gapPx - 6), + verticalOnly: true, + viewportMargin: 4, + maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance, + distanceFadeStartRatio: 0.7, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + // Aggregate cells omit actionable because they do not identify one fire. + interactive: card?.actionable === true, + }; + } + + /** + * Heat-normalized cell score → severity accent, mirroring heatColor's + * red/orange/yellow thresholds. + * @param {number} normalized - 0..1 normalized heat score. + * @returns {string} "r, g, b" accent string. + */ + + function cellAccent(normalized) { + if (normalized > 0.72) return accentForSeverity('red'); + if (normalized > 0.42) return accentForSeverity('orange'); + return accentForSeverity('yellow'); + } + + /** Coordinate line like "30.512°N 75.831°E". */ + + function formatLatLon(lat, lon) { + const latPart = `${Math.abs(lat).toFixed(3)}°${lat >= 0 ? 'N' : 'S'}`; + const lonPart = `${Math.abs(lon).toFixed(3)}°${lon >= 0 ? 'E' : 'W'}`; + return `${latPart} ${lonPart}`; + } + + function formatFrp(frp) { + return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1); + } + + /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ + + function formatAge(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; + const hours = deltaMs / 3600000; + if (hours < 1) return '<1h'; + if (hours < 48) return `${Math.round(hours)}h`; + return `${Math.round(hours / 24)}d`; + } + + /** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */ + + function formatAgoMinutes(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now'; + const minutes = Math.floor(deltaMs / 60000); + if (minutes < 1) return '<1m ago'; + if (minutes < 90) return `${minutes}m ago`; + return `${Math.round(minutes / 60)}h ago`; + } + + /** Normalized 0..1 confidence → low/nominal/high display bucket. */ + + function confidenceBucket(confidence) { + if (confidence >= 0.75) return 'high'; + if (confidence >= 0.45) return 'nominal'; + return 'low'; + } + return { + mapAnalystRecord, + paddedDegreeBounds, + boundsContainPoint, + cellIntersectsBounds, + heatScore, + heatColor, + detectionColorStop, + frpPixelSize, + sizeBucket, + glowSprite, + firePosition, + fireCullPosition, + cellCullPosition, + applyHorizonCull, + screenSeparated, + buildSelectedFireCard, + buildFireCard, + buildCellCard, + applyFirmsOverlayPolicy, + cellAccent, + formatLatLon, + formatFrp, + formatAge, + formatAgoMinutes, + confidenceBucket, + }; +} diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js new file mode 100644 index 000000000..673aab871 --- /dev/null +++ b/src/layers/firms/policy.js @@ -0,0 +1,97 @@ +import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; +import * as Cesium from 'cesium'; + +/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ + +export const REFRESH_INTERVAL_MS = 600_000; + +/** + * LOD bands keyed by camera height. `cells` bands render aggregated + * ground-clamped heat rectangles; `detections` bands (< ~750km plus the + * local band) render individual fire detections as glow-sprite billboards. + * Labels in every band go through the shared screen-space greedy declutter + * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. + */ + +export const LOD_LEVELS = [ + { + id: 'global', + minHeight: 9000000, + mode: 'cells', + gridDegrees: 2.0, + maxCells: 1800, + labelDistance: 12000000, + }, + { + id: 'regional', + minHeight: 3000000, + mode: 'cells', + gridDegrees: 1.0, + maxCells: 3600, + labelDistance: 8500000, + }, + { + id: 'local', + minHeight: 750000, + mode: 'detections', + maxDetections: 2500, + labelDistance: 4500000, + }, + { + id: 'close', + minHeight: 0, + mode: 'detections', + maxDetections: 3000, + labelDistance: 1800000, + }, +]; + +export const LOD_CHECK_MS = 650; + +/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ + +export const LOD_HYSTERESIS = 0.1; + +/** Padding fraction applied to the camera view rectangle before clipping. */ + +export const VIEW_PADDING = 0.3; + +/** Number of top-FRP detections registered in the shared context store. */ + +export const CONTEXT_TOP_N = 50; + +/** Hard cap on ambient (non-selected) labels, regardless of zoom/density. */ + +export const MAX_AMBIENT_LABELS = FIRMS_AMBIENT_COHORT_LIMIT; + +/** Min screen-space separation between accepted labels, in CSS pixels. */ + +export const LABEL_MIN_SEP_PX = 150; + +/** Candidates projecting outside the canvas by more than this are skipped. */ + +export const LABEL_VIEW_MARGIN_PX = 16; + +/** + * Anchors below this height get a separate LIFTED point for occlusion tests + * (never for rendering). Mirrors the flights layer's `cullPosition` idiom + * (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor + * + lift, and in negative-geoid coastal regions that lands a few tens of + * metres BELOW the WGS84 ellipsoid — EllipsoidalOccluder then judges such a + * point "beyond the horizon" while its true-surface neighbours stay visible, + * so near-limb fires would blink out for a datum reason. + */ + +export const CULL_LIFT_THRESHOLD_M = 10; + +/** Height of the lifted occlusion-test point (flights uses the same 12 m). */ + +export const CULL_LIFT_M = 12; + +/** Color stops shared by cell heat fills and detection glow sprites. */ + +export const DETECTION_COLOR_STOPS = [ + { name: 'red', color: Cesium.Color.RED }, + { name: 'orange', color: Cesium.Color.ORANGE }, + { name: 'yellow', color: Cesium.Color.YELLOW }, +]; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js new file mode 100644 index 000000000..d1dcb3f5e --- /dev/null +++ b/src/layers/firms/queries.js @@ -0,0 +1,174 @@ +import { fireDetectionKey } from '../../data/firmsLabels.js'; +import { REFRESH_INTERVAL_MS } from './policy.js'; + +export function createQueries({ + layerState, + services, + components, + config, + feed, +}) { + const { id, name, icon, source } = config; + + const methods = { + /** Return the selected detection as a plain record for application consumers. */ + getSelectedInfo() { + const fire = layerState._selectedFire; + if (!fire) return null; + return { + id: fireDetectionKey(fire), + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, + latitude: fire.lat, + longitude: fire.lon, + frp: fire.frp, + confidencePct: Number.isFinite(fire.confidence) + ? fire.confidence * 100 + : null, + observedAt: + Number.isFinite(fire.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, + source: fire.sensor || fire.satellite || null, + }; + }, + + id, + + name, + + icon, + + source, + + // Live layer: the manager calls update() every 10 minutes while enabled, + // which refetches through the /api/firms proxy (the proxy's 30 min TTL — + // not this interval — is what protects the upstream FIRMS quota). + updateInterval: REFRESH_INTERVAL_MS, + + /** + * Layer stats for the data panel. Degraded feed states surface through + * `error` (established qa-failstate pattern: a dead feed must never look + * like a healthy empty layer) with a matching human `loadingLabel`: + * 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy + * served past-TTL cache, 'KEY REQUIRED' keyless. + */ + getStats() { + const now = Date.now(); + const staleText = layerState._lastUpdate + ? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}` + : 'STALE'; + let loadingLabel = ''; + if (layerState._loading) { + loadingLabel = layerState._fires.length + ? 'refreshing...' + : 'loading...'; + } else if (layerState._keyRequired) { + loadingLabel = 'KEY REQUIRED'; + } else if (layerState._stale) { + loadingLabel = staleText; + } else if (layerState._error) { + loadingLabel = layerState._error; + } else if (layerState._lastUpdate) { + loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`; + } + return { + count: layerState._count, + cells: layerState._cellCount, + lastUpdate: layerState._lastUpdate, + loading: layerState._loading, + stale: layerState._stale, + error: layerState._keyRequired + ? 'KEY REQUIRED' + : layerState._stale + ? staleText + : layerState._error, + loadingLabel, + }; + }, + + /** + * Strongest currently-loaded detection (by FRP) for voice targeting + * ("take me to the biggest fire"). + * @returns {{latitude: number, longitude: number, frp: number, label: string}|null} + */ + getStrongestFire() { + const strongest = layerState._firesByFrp.length + ? layerState._firesByFrp[0] + : null; + if (!strongest) return null; + return { + latitude: strongest.lat, + longitude: strongest.lon, + frp: strongest.frp, + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, + }; + }, + + /** + * Detection-overlay seam, shaped like the other layers' detectable + * objects (traffic/flights). NOT yet registered in initDetection's layer + * list — wiring fires through the detection/label-arbiter pipeline is a + * deliberate post-PR#1 task; when that happens the arbiter replaces this + * layer's greedy declutter as the SELECTOR and the shared overlay remains + * the renderer. Walks the FRP-sorted index so the strongest fires come first. + * @param {{maxCount?: number}} [options] + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || !layerState._firesByFrp.length) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : 250; + const result = []; + for (const fire of layerState._firesByFrp) { + result.push({ + position: components.model.firePosition(fire), + id: `FIRE-${String(fire.index).padStart(5, '0')}`, + type: 'FIRE', + }); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory fire records as plain JSON-safe + * objects for the analyst query engine. Walks the FRP-sorted index so + * truncation keeps the STRONGEST fires (200k+ detections can be live — + * the cap is load-bearing, not cosmetic). 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 (!layerState._enabled || !layerState._firesByFrp.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const fire of layerState._firesByFrp) { + result.push(components.model.mapAnalystRecord(fire)); + if (result.length >= limit) break; + } + return result; + }, + + /** Test seam that binds the production click path and indexes. */ + _bindInteractionForTest(viewer, fires = []) { + layerState._viewer = viewer; + layerState._enabled = true; + layerState._fires = fires; + layerState._firesByFrp = [...fires]; + layerState._pickIndexById.clear(); + layerState._labelLodDistance = 1e7; + layerState._labelCandidates = fires.map((fire) => { + layerState._pickIndexById.set(`firms-${fire.index}`, fire); + return { fire, position: components.model.firePosition(fire) }; + }); + components.cards.rebuildAmbientLabels(); + components.selection.installClickHandler(); + return layerState._clickHandler; + }, + }; + + return { methods }; +} diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js new file mode 100644 index 000000000..8ae445f91 --- /dev/null +++ b/src/layers/firms/rendering.js @@ -0,0 +1,355 @@ +import * as Cesium from 'cesium'; +import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; + +export function createRendering({ + layerState, + services, + components, + config, + feed, +}) { + const { warmFireAnchorFloors } = services.anchors; + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { overlayHost } = config; + + /** + * Rebuild the render for the current LOD band and viewport. Skips work + * when neither the (hysteresis-damped) LOD band nor the padded view + * rectangle changed meaningfully. + * @param {boolean} force - Rebuild even if LOD/view appear unchanged. + * @returns {boolean} True when a rebuild actually ran. Callers use this to + * avoid a second horizon walk in the same tick: a rebuild already culls + * its freshly-added sprites, an early-out does not. + */ + + function renderCurrentLod(force = false) { + if ( + !layerState._dataSource || + !layerState._fires.length || + !layerState._viewer + ) + return false; + const lodIndex = components.viewport.selectLodIndex( + components.viewport.cameraHeight(), + ); + const lod = LOD_LEVELS[lodIndex]; + const viewRect = components.viewport.computeViewRect(); + if ( + !force && + lodIndex === layerState._currentLodIndex && + !components.viewport.viewChangedEnough(viewRect) + ) + return false; + layerState._currentLodIndex = lodIndex; + layerState._currentLodId = lod.id; + layerState._lastViewRect = viewRect + ? Cesium.Rectangle.clone(viewRect) + : null; + const bounds = viewRect + ? components.model.paddedDegreeBounds(viewRect) + : null; + + if (lod.mode === 'detections') { + renderDetections(lod, bounds); + } else { + renderCells(aggregateFires(lod, bounds), lod, bounds); + } + return true; + } + + /** + * Bin all detections into grid cells, then viewport-clip BEFORE the + * top-N cap so fires in view never lose their slot to higher-scoring + * cells on other continents. `bounds === null` (sky/horizon or + * near-global view) falls back to the global top-N behavior. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {Array} Cells sorted by heat score, capped. + */ + + function aggregateFires(lod, bounds) { + const gridDegrees = lod.gridDegrees; + + let sorted = layerState._cellCacheByGrid.get(gridDegrees); + if (!sorted) { + const cells = new Map(); + for (const fire of layerState._fires) { + const latCell = Math.floor(fire.lat / gridDegrees) * gridDegrees; + const lonCell = Math.floor(fire.lon / gridDegrees) * gridDegrees; + const key = `${latCell.toFixed(3)}:${lonCell.toFixed(3)}`; + // confidence is normalized 0..1 — weight ×4 preserves the old 0..100×0.04 scale. + const intensity = Math.max( + 1, + fire.frp * 0.18 + fire.confidence * 4 + fire.brightness * 0.01, + ); + const existing = cells.get(key) || { + latCell, + lonCell, + count: 0, + intensity: 0, + maxFrp: 0, + night: 0, + newestAcqMs: 0, + }; + existing.count += 1; + existing.intensity += intensity; + existing.maxFrp = Math.max(existing.maxFrp, fire.frp); + existing.newestAcqMs = Math.max(existing.newestAcqMs, fire.acqMs); + if (fire.night) existing.night += 1; + cells.set(key, existing); + } + // Heat-sorted once; per-render clipping below preserves the order. + sorted = [...cells.values()].sort( + (a, b) => components.model.heatScore(b) - components.model.heatScore(a), + ); + layerState._cellCacheByGrid.set(gridDegrees, sorted); + } + + if (!bounds) return sorted.slice(0, lod.maxCells); + const clipped = []; + for (const cell of sorted) { + if (!components.model.cellIntersectsBounds(cell, gridDegrees, bounds)) + continue; + clipped.push(cell); + if (clipped.length >= lod.maxCells) break; + } + return clipped; + } + + /** + * Render aggregated heat cells (global/regional bands) as ground-clamped + * rectangles. Cell labels do NOT live on the entities anymore — they go + * through the same unclamped, screen-space-decluttered label pipeline as + * detections (see {@link rebuildAmbientLabels}). + * @param {Array} cells - Aggregated cells, heat-sorted descending. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderCells(cells, lod, bounds) { + ensureDetectionCollections(); + layerState._dataSource.entities.removeAll(); + if (layerState._billboards) layerState._billboards.removeAll(); + layerState._pickIndexById.clear(); + layerState._cullPositions.length = 0; + layerState._cellCount = cells.length; + const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + + layerState._labelCandidates = []; + layerState._labelLodDistance = lod.labelDistance; + + for (const cell of cells) { + const score = components.model.heatScore(cell); + const normalized = Math.min(1, Math.sqrt(score / maxScore)); + const alpha = 0.16 + normalized * 0.5; + const color = components.model.heatColor(normalized, alpha); + const centerLon = cell.lonCell + lod.gridDegrees / 2; + const centerLat = cell.latCell + lod.gridDegrees / 2; + const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); + + layerState._dataSource.entities.add({ + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees( + cell.lonCell, + cell.latCell, + cell.lonCell + lod.gridDegrees, + cell.latCell + lod.gridDegrees, + ), + material: new Cesium.ColorMaterialProperty(color), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + position, + properties: { + count: cell.count, + intensity: score, + maxFrp: cell.maxFrp, + night: cell.night, + }, + }); + + // Cells arrive heat-sorted, so candidate order doubles as label priority. + // Accent is resolved here because `normalized` only exists in this walk. + layerState._labelCandidates.push({ + position, + cullPosition: components.model.cellCullPosition(centerLon, centerLat), + fire: null, + cell, + accent: components.model.cellAccent(normalized), + }); + } + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + components.selection.topFiresWithinBounds(bounds), + ); + } + + /** + * Render individual detections (local/close bands) as pre-baked + * radial-glow sprite billboards sized by FRP, capped per viewport by + * highest FRP, depth test disabled. The 3D Tiles mesh is never sampled + * per point; instead, the CLOSE band (where terrain parallax is actually + * visible) batch-warms the shared cached DEM floor for its rendered + * subset and re-renders once floors land, so anchors sit on the terrain + * (firePosition). The local band keeps cold anchors at height 0 — at + * ≥750 km camera height the divergence is invisible and warming a + * continent-wide viewport would waste the DEM proxy. + * Labels are not built here; candidates feed {@link rebuildAmbientLabels}. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderDetections(lod, bounds) { + ensureDetectionCollections(); + layerState._dataSource.entities.removeAll(); + if (!layerState._billboards) return; + layerState._billboards.removeAll(); + layerState._pickIndexById.clear(); + layerState._cullPositions.length = 0; + + let candidates; + if (bounds) { + // Walk the pre-sorted FRP index and early-exit at the cap — the old + // collect-then-sort touched and sorted every in-view detection per + // rebuild (tens of thousands mid-zoom; field-test round 1 chug). + candidates = []; + for (const fire of layerState._firesByFrp) { + if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon)) + continue; + candidates.push(fire); + if (candidates.length >= lod.maxDetections) break; + } + } else { + // Sky/horizon view: fall back to the globally strongest detections. + candidates = layerState._firesByFrp.slice(0, lod.maxDetections); + } + + layerState._cellCount = candidates.length; + layerState._labelCandidates = []; + layerState._labelLodDistance = lod.labelDistance; + + for (const fire of candidates) { + const coreSize = components.model.frpPixelSize(fire.frp); + const position = components.model.firePosition(fire); + const cullPosition = components.model.fireCullPosition(fire); + const pickId = `firms-${fire.index}`; + layerState._pickIndexById.set(pickId, fire); + layerState._cullPositions.push(cullPosition); + layerState._billboards.add({ + id: pickId, + position, + image: components.model.glowSprite( + components.model.detectionColorStop(fire), + components.model.sizeBucket(coreSize), + ), + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + // FRP-sorted walk: candidate order doubles as label priority. + layerState._labelCandidates.push({ + position, + cullPosition, + fire, + cell: null, + }); + } + + // Freshly-added sprites default to show=true; cull the far side now so a + // rebuild never flashes through-the-planet detections while the camera + // sits still (the moveEnd/preRender hooks only fire on camera motion). + refreshHorizonCulling(); + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + function ensureDetectionCollections() { + if (!layerState._viewer || layerState._billboards) return; + layerState._billboards = new Cesium.BillboardCollection({ + scene: layerState._viewer.scene, + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + layerState._billboards.show = layerState._enabled; + layerState._viewer.scene.primitives.add(layerState._billboards); + registerSpriteCollection('firms', layerState._billboards); + restoreSpriteOrder(layerState._viewer); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled); + } + + function removeDetectionCollections(viewer) { + const scene = viewer?.scene || layerState._viewer?.scene; + if (scene && !scene.isDestroyed?.()) { + try { + if (layerState._billboards) + scene.primitives.remove(layerState._billboards); + } catch { + /* already torn down */ + } + } + layerState._billboards = null; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + } + + /** + * Shared horizon occluder positioned at the current camera, or null when + * there is no usable camera yet (init order / headless stubs). + * `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium; + * both are probed so a partial stub (either shape) still gets a real + * occluder rather than silently degrading to "nothing is ever occluded". + * @returns {?Cesium.EllipsoidalOccluder} + */ + + function fireHorizonOccluder() { + const camera = layerState._viewer?.camera?.positionWC + ? layerState._viewer.camera + : layerState._viewer?.scene?.camera; + if (!camera?.positionWC) return null; + return horizonOccluder(camera); + } + + /** + * Hide detection sprites that sit beyond the ellipsoid horizon (see + * {@link applyHorizonCull}). Runs on the cadences that already exist in this + * layer — the throttled LOD preRender watcher (camera-moved only), camera + * moveEnd, and each detection rebuild — never as a new per-frame pass. + * No-ops in the `cells` bands, where the collection is empty. + */ + + function refreshHorizonCulling() { + if (!layerState._billboards || !layerState._billboards.length) return; + components.model.applyHorizonCull( + layerState._billboards, + fireHorizonOccluder(), + layerState._cullPositions, + ); + } + return { + renderCurrentLod, + aggregateFires, + renderCells, + renderDetections, + ensureDetectionCollections, + removeDetectionCollections, + fireHorizonOccluder, + refreshHorizonCulling, + }; +} diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js new file mode 100644 index 000000000..4cec52c69 --- /dev/null +++ b/src/layers/firms/selection.js @@ -0,0 +1,251 @@ +import { + FIRMS_OVERLAY_SOURCE_ID, + fireDetectionKey, +} from '../../data/firmsLabels.js'; +import * as Cesium from 'cesium'; +import { CONTEXT_TOP_N } from './policy.js'; + +export function createSelection({ + layerState, + services, + components, + config, + feed, +}) { + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { requestWorldFocus } = services.focus; + const { + selectEntityContext, + clearSelectedEntityContextForLayer, + getContextStore, + registerEntityContext, + } = services.context; + const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config; + + /** + * Install the LEFT_CLICK handler for fire selection (enable-time only, + * mirroring the flight layers). Clicking a fire sprite selects it and + * surfaces it in the shared context store; clicking empty space clears + * the selection. Picks owned by sibling layers are left alone. + */ + + function installClickHandler() { + if (layerState._clickHandler || !layerState._viewer) return; + layerState._clickHandler = screenSpaceEventHandlerFactory( + layerState._viewer, + ); + layerState._clickHandler.setInputAction((click) => { + const picked = layerState._viewer.scene.pick(click.position); + const fire = pickedFire(picked); + if (fire) { + selectAndFocusFire(fire); + return; + } + // A pick that belongs to a sibling layer (e.g. an aircraft) is not + // "empty space" — leave the selection alone and let that layer handle it. + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer(id, pickedId)) return; + } + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { + sourceId: FIRMS_OVERLAY_SOURCE_ID, + }, + ); + if (cardHit) { + const carded = layerState._fireByCardId.get(cardHit.entryId); + if (carded) selectAndFocusFire(carded); + return; + } + clearFireSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + } + + /** Select one stable detection and request one UI-owned camera transfer. */ + + function selectAndFocusFire(fire) { + selectFire(fire); + requestWorldFocus({ + kind: 'fire', + id: fireDetectionKey(fire), + label: 'FIRE', + position: components.model.firePosition(fire), + }); + } + + /** + * Resolve a scene pick to one of this layer's fire records, or null. + * BillboardCollection picks surface our id either on picked.primitive.id + * or directly on picked.id depending on the CesiumJS pick path. + * @param {*} picked - Result of scene.pick(). + * @returns {?Object} Fire record. + */ + + function pickedFire(picked) { + if (!picked) return null; + const primitiveId = picked.primitive?.id; + if ( + typeof primitiveId === 'string' && + layerState._pickIndexById.has(primitiveId) + ) { + return layerState._pickIndexById.get(primitiveId); + } + if ( + typeof picked.id === 'string' && + layerState._pickIndexById.has(picked.id) + ) { + return layerState._pickIndexById.get(picked.id); + } + return null; + } + + /** + * Select a fire: show its detail label and mark it selected in the shared + * context store so voice "what's selected" resolves to it. + * @param {Object} fire - Detection record. + */ + + function selectFire(fire, publishSelection = true) { + layerState._selectedFire = fire; + try { + registerFireContext(fire); + layerState._contextIds.add(fireDetectionKey(fire)); + if (publishSelection) selectEntityContext(fire.contextEntity); + } catch { + // context store unavailable — the selection label still works + } + components.cards.rebuildAmbientLabels(); + } + + function clearFireSelection() { + if (!layerState._selectedFire) return; + layerState._selectedFire = null; + clearSelectedEntityContextForLayer(id); + components.cards.rebuildAmbientLabels(); + } + + /** + * Register the top detections of the current render set in the shared + * context store (voice "what's burning here") and drop stale entries + * from the previous rebuild. + * @param {Array} fires - FRP-ranked detections to register. + */ + + function refreshContextRegistrations(fires) { + let store = null; + try { + store = getContextStore(); + } catch { + return; + } + + const nextIds = new Set(); + for (const fire of fires) { + nextIds.add(registerFireContext(fire)); + } + // The click-selected fire must stay queryable ("what's selected") even + // when it falls outside the top-N context slice. + if (layerState._selectedFire) { + nextIds.add(registerFireContext(layerState._selectedFire)); + } + + for (const staleId of layerState._contextIds) { + if (!nextIds.has(staleId)) store.entities.delete(staleId); + } + layerState._contextIds = nextIds; + } + + /** + * Register one fire in the shared context store (idempotent) and return + * its record id. + * @param {Object} fire - Detection record. + * @returns {string} Context record id. + */ + + function registerFireContext(fire) { + const recordId = fireDetectionKey(fire); + if (!fire.contextEntity) { + fire.contextEntity = { + show: true, + }; + } + // Refreshed every registration: the anchor re-grounds onto the DEM when + // its floor cell warms, and voice targeting must project the same point + // the sprite renders at. + fire.contextEntity.__localBaseCartesian = + components.model.firePosition(fire); + registerEntityContext(fire.contextEntity, { + id: recordId, + layerId: id, + layerName: name, + source: 'NASA FIRMS', + dataSource: layerState._dataSource, + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, + latitude: fire.lat, + longitude: fire.lon, + properties: { + frp: fire.frp, + confidence: components.model.confidenceBucket(fire.confidence), + age: + fire.acqMs > 0 + ? components.model.formatAge(Date.now() - fire.acqMs) + : 'unknown', + sensor: fire.sensor || 'unknown', + }, + }); + return recordId; + } + + function clearContextRegistrations() { + if (!layerState._contextIds.size) return; + try { + const store = getContextStore(); + for (const recordId of layerState._contextIds) + store.entities.delete(recordId); + } catch { + // store unavailable — nothing to clean + } + layerState._contextIds = new Set(); + } + + /** + * Top-FRP detections within the given bounds (or globally when bounds is + * null). Walks the pre-sorted FRP index so it stays a single cheap pass. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @param {number} limit - Max detections returned. + * @returns {Array} + */ + + function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) { + if (!bounds) return layerState._firesByFrp.slice(0, limit); + const top = []; + for (const fire of layerState._firesByFrp) { + if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon)) + continue; + top.push(fire); + if (top.length >= limit) break; + } + return top; + } + return { + installClickHandler, + removeClickHandler, + selectAndFocusFire, + pickedFire, + selectFire, + clearFireSelection, + refreshContextRegistrations, + registerFireContext, + clearContextRegistrations, + topFiresWithinBounds, + }; +} diff --git a/src/layers/firms/source.js b/src/layers/firms/source.js new file mode 100644 index 000000000..9ee4ebc6c --- /dev/null +++ b/src/layers/firms/source.js @@ -0,0 +1,29 @@ +/** Construct the existing live-fire endpoint without making a request. */ +export function createFirmsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/firms', { + signal, + cache: 'no-store', + }); + let payload; + try { + payload = await response.json(); + } catch { + /* status below remains authoritative */ + } + signal?.throwIfAborted(); + if (!response.ok) { + if (response.status === 503 && payload?.error === 'no_key') + return { keyRequired: true }; + throw new Error(`FIRMS HTTP ${response.status}`); + } + if (!Array.isArray(payload?.fires)) + throw new Error('Malformed fire snapshot'); + return payload; + }, + }; +} diff --git a/src/layers/firms/source.test.mjs b/src/layers/firms/source.test.mjs new file mode 100644 index 000000000..f67bcdd9b --- /dev/null +++ b/src/layers/firms/source.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFirmsSource } from './source.js'; + +test('a malformed successful response is never accepted as an empty fire snapshot', async () => { + for (const payload of [{}, { fires: null }, { fires: {} }]) { + const source = createFirmsSource({ + fetchImpl: async () => ({ ok: true, json: async () => payload }), + }); + await assert.rejects(source.getSnapshot(), /Malformed fire snapshot/); + } +}); +test('optional-key guidance is distinct from denial or upstream failure', async () => { + for (const status of [401, 403, 429, 500, 503]) { + const source = createFirmsSource({ + fetchImpl: async () => ({ + ok: false, + status, + json: async () => ({ error: 'no_key' }), + }), + }); + if (status === 503) + assert.deepEqual(await source.getSnapshot(), { keyRequired: true }); + else + await assert.rejects( + source.getSnapshot(), + new RegExp(`FIRMS HTTP ${status}`), + ); + } +}); +test('response-body completion honors cancellation without replacing records', async () => { + const abort = new AbortController(); + const source = createFirmsSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + abort.abort(); + return { fires: [] }; + }, + }), + }); + await assert.rejects(source.getSnapshot({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js new file mode 100644 index 000000000..48ae11db2 --- /dev/null +++ b/src/layers/firms/state.js @@ -0,0 +1,115 @@ +import * as Cesium from 'cesium'; + +export function createFirmsState({ services, config }) { + const layerState = {}; + + /** Pre-baked radial-glow sprites keyed by `:`. */ + + layerState.glowSpriteCache = new Map(); + + layerState.scratchViewRect = new Cesium.Rectangle(); + + layerState.scratchCenterA = new Cesium.Cartographic(); + + layerState.scratchCenterB = new Cesium.Cartographic(); + + layerState.scratchWindowCoord = new Cesium.Cartesian2(); + + layerState._viewer = null; + + layerState._dataSource = null; + + layerState._billboards = null; + + layerState._enabled = false; + + layerState._destroyed = false; + + layerState._loading = false; + + /** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */ + + layerState._keyRequired = false; + + /** True when the proxy served a cached payload past TTL (upstream failing). */ + + layerState._stale = false; + + /** Surfaced error string when the live fetch failed outright. */ + + layerState._error = null; + + layerState._fires = []; + + layerState._firesByFrp = []; + + layerState._count = 0; + + layerState._cellCount = 0; + + layerState._lastUpdate = null; + + layerState._currentLodId = null; + + layerState._currentLodIndex = -1; + + layerState._lastViewRect = null; + + layerState._preRenderRemover = null; + + layerState._moveEndSettleRemover = null; + + layerState._lastLodCheck = 0; + + layerState._contextIds = new Set(); + + layerState._clickHandler = null; + + layerState._moveEndRemover = null; + + layerState._selectedFire = null; + + /** Priority-ordered label candidates from the last rebuild (detections or cells). */ + + layerState._labelCandidates = []; + + layerState._labelLodDistance = 0; + + /** pick id string -> fire record, for the currently rendered sprites. */ + + layerState._pickIndexById = new Map(); + + /** Actionable card id -> current detection record painted for that id. */ + + layerState._fireByCardId = new Map(); + + /** + * Occlusion-test anchors index-aligned with the billboard collection (the + * collection is cleared and refilled in one ordered pass per rebuild, and + * nothing else adds to it). Lifted where the render anchor sits at/below + * the ellipsoid — see {@link fireCullPosition}. + * @type {Array} + */ + + layerState._cullPositions = []; + + /** + * gridDegrees -> heat-sorted full cell list. The full-grid aggregation is + * viewport-independent but walks every detection (~200k live), so it is + * computed once per grid size per data refresh; renders only clip + cap + * (field-test round 1: intermediate-zoom chug during LOD rebuilds). + * @type {Map>} + */ + + layerState._cellCacheByGrid = new Map(); + + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ + + layerState._camSnapValid = false; + + layerState._camPos = new Cesium.Cartesian3(); + + layerState._camDir = new Cesium.Cartesian3(); + layerState.request = null; + return layerState; +} diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js new file mode 100644 index 000000000..932fe2e75 --- /dev/null +++ b/src/layers/firms/viewport.js @@ -0,0 +1,216 @@ +import * as Cesium from 'cesium'; +import { + LOD_LEVELS, + LOD_HYSTERESIS, + VIEW_PADDING, + LOD_CHECK_MS, +} from './policy.js'; + +export function createViewport({ + layerState, + services, + components, + config, + feed, +}) { + const { governorRequestRender } = services.render; + + /** + * Ambient labels are placed via screen-space projection, so they need a + * re-declutter after the camera settles — on moveEnd, never per frame. + */ + + function installMoveEndWatcher() { + if (layerState._moveEndRemover || !layerState._viewer) return; + layerState._moveEndRemover = + layerState._viewer.camera.moveEnd.addEventListener(() => { + if (!layerState._enabled) return; + // Settled-camera exactness for the horizon pass (the throttled preRender + // watcher can last have run up to LOD_CHECK_MS before the camera + // stopped). Runs ahead of the `_loading` gate on purpose: the sprites on + // screen during a refresh are the ones that need culling. + components.rendering.refreshHorizonCulling(); + if (layerState._loading) return; + components.cards.rebuildAmbientLabels(); + }); + } + + function removeMoveEndWatcher() { + if (layerState._moveEndRemover) { + layerState._moveEndRemover(); + layerState._moveEndRemover = null; + } + } + + function cameraHeight() { + return ( + layerState._viewer?.camera?.positionCartographic?.height ?? + Number.POSITIVE_INFINITY + ); + } + + /** Raw LOD index for a camera height (0 = global ... 3 = close). */ + + function rawLodIndex(height) { + const index = LOD_LEVELS.findIndex((level) => height >= level.minHeight); + return index === -1 ? LOD_LEVELS.length - 1 : index; + } + + /** + * LOD selection with ±10% hysteresis around band floors: a band switch + * only happens once the camera clears the boundary by the margin, so a + * slow zoom oscillating on an edge cannot trigger a rebuild every check. + * @param {number} height - Camera height in meters. + * @returns {number} LOD index to render. + */ + + function selectLodIndex(height) { + const raw = rawLodIndex(height); + if (layerState._currentLodIndex < 0 || raw === layerState._currentLodIndex) + return raw; + let index = layerState._currentLodIndex; + // Zooming out: enter a coarser band only after clearing its floor by +10%. + while ( + index > raw && + height >= LOD_LEVELS[index - 1].minHeight * (1 + LOD_HYSTERESIS) + ) + index -= 1; + // Zooming in: leave the current band only after dropping 10% below its floor. + while ( + index < raw && + height <= LOD_LEVELS[index].minHeight * (1 - LOD_HYSTERESIS) + ) + index += 1; + return index; + } + + /** Current camera view rectangle, or null when looking at sky/horizon. */ + + function computeViewRect() { + try { + const rect = layerState._viewer?.camera?.computeViewRectangle( + layerState._viewer?.scene?.globe?.ellipsoid, + layerState.scratchViewRect, + ); + return rect || null; + } catch { + return null; + } + } + + /** + * True when the camera view rectangle drifted far enough from the last + * rendered one that the padding is at risk of being consumed (pan) or + * the footprint changed notably (zoom within a band). + * @param {?Cesium.Rectangle} viewRect - Current view rectangle. + * @returns {boolean} + */ + + function viewChangedEnough(viewRect) { + if (!viewRect) return false; // sky/horizon — keep the current render + if (!layerState._lastViewRect) return true; // previous render was global fallback + const lastWidth = Cesium.Rectangle.computeWidth(layerState._lastViewRect); + const lastHeight = Cesium.Rectangle.computeHeight(layerState._lastViewRect); + const width = Cesium.Rectangle.computeWidth(viewRect); + const height = Cesium.Rectangle.computeHeight(viewRect); + if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true; + if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true; + const lastCenter = Cesium.Rectangle.center( + layerState._lastViewRect, + layerState.scratchCenterA, + ); + const center = Cesium.Rectangle.center(viewRect, layerState.scratchCenterB); + const latShift = Math.abs(center.latitude - lastCenter.latitude); + let lonShift = Math.abs(center.longitude - lastCenter.longitude); + if (lonShift > Math.PI) lonShift = Cesium.Math.TWO_PI - lonShift; + return ( + latShift > lastHeight * VIEW_PADDING * 0.6 || + lonShift > lastWidth * VIEW_PADDING * 0.6 + ); + } + + function installLodWatcher() { + if (layerState._preRenderRemover || !layerState._viewer) return; + // The LOD sweep is throttle-gated inside preRender. When the camera + // settles just inside the throttle window, the final rebuild would wait + // for a frame that idle mode never produces — schedule one. (perf wave 2) + if (!layerState._moveEndSettleRemover) { + layerState._moveEndSettleRemover = + layerState._viewer.camera.moveEnd.addEventListener(() => { + if (!layerState._enabled) return; + setTimeout( + () => governorRequestRender('firms-lod-settle'), + LOD_CHECK_MS + 40, + ); + }); + } + layerState._preRenderRemover = + layerState._viewer.scene.preRender.addEventListener(() => { + // NOTE: `_loading`/empty-data are deliberately NOT part of this guard. + // A refresh has no timeout, and sprites from the previous payload stay + // on screen throughout it — so the horizon pass below must keep running + // while a fetch is in flight. Only the rebuild is gated on fresh data. + if (!layerState._enabled) return; + const now = performance.now(); + if (now - layerState._lastLodCheck < LOD_CHECK_MS) return; + layerState._lastLodCheck = now; + // Idle camera: two scratch compares and out — no view-rect math, no + // aggregation, no allocation while the user is parked. + const camera = layerState._viewer.camera; + if ( + layerState._camSnapValid && + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + layerState._camPos, + 0, + 0.5, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + layerState._camDir, + 0, + 1e-7, + ) + ) { + return; + } + Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos); + Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir); + layerState._camSnapValid = true; + // The camera moved, so the horizon moved. A rebuild culls its own fresh + // sprites (renderDetections); an early-out — same LOD band and view + // rect, or a refresh in flight — does not, so cull here instead. + // Exactly ONE occlusion walk per tick either way, bounded by the + // idle-gate + LOD_CHECK_MS throttle above: a parked camera costs + // nothing, a moving one pays one ≤maxDetections show-flip walk at + // ~1.5 Hz. + const rebuilt = + layerState._fires.length && !layerState._loading + ? components.rendering.renderCurrentLod(false) + : false; + if (!rebuilt) components.rendering.refreshHorizonCulling(); + }); + } + + function removeLodWatcher() { + if (layerState._preRenderRemover) { + layerState._preRenderRemover(); + layerState._preRenderRemover = null; + } + if (layerState._moveEndSettleRemover) { + layerState._moveEndSettleRemover(); + layerState._moveEndSettleRemover = null; + } + } + return { + installMoveEndWatcher, + removeMoveEndWatcher, + cameraHeight, + rawLodIndex, + selectLodIndex, + computeViewRect, + viewChangedEnough, + installLodWatcher, + removeLodWatcher, + }; +} diff --git a/src/layers/flights/controller.js b/src/layers/flights/controller.js new file mode 100644 index 000000000..5e04c53e1 --- /dev/null +++ b/src/layers/flights/controller.js @@ -0,0 +1,26 @@ +import * as Cesium from 'cesium'; + +export function createController({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _abortActiveUpdates() { + for (const controller of flightState._activeUpdateControllers) + controller.abort(); + flightState._activeUpdateControllers.clear(); + } + + function _flightQuery(viewer) { + const cartographic = viewer?.camera?.positionCartographic; + return cartographic + ? { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + } + : {}; + } + return { _abortActiveUpdates, _flightQuery }; +} diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js new file mode 100644 index 000000000..935fb301e --- /dev/null +++ b/src/layers/flights/enrichment.js @@ -0,0 +1,240 @@ +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import * as Cesium from 'cesium'; +import { + ENRICH_MAX_INFLIGHT, + ENRICH_DISPATCH_GAP_MS, + ENRICH_AMBIENT_BUDGET_CEIL, + ENRICH_AMBIENT_REFILL_TOKENS, + ENRICH_AMBIENT_REFILL_WINDOW_MS, + ENRICH_AMBIENT_PER_SWEEP, +} from './policy.js'; + +export function createEnrichment({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _enqueueEnrich(key, query, onData, priority = false) { + if (flightState.lifetime.signal.aborted) return; + if (flightState._enrichSeen.has(key)) return; + flightState._enrichSeen.add(key); + const job = { query, onData }; + // Priority (tracked / model-eligible) goes to the FRONT so a deep ambient + // backlog can never delay the plane the user just clicked or zoomed into. + if (priority) flightState._enrichQueue.unshift(job); + else flightState._enrichQueue.push(job); + _drainEnrich(); + } + + function _drainEnrich() { + if (flightState.lifetime.signal.aborted) return; + while ( + flightState._enrichActive < ENRICH_MAX_INFLIGHT && + flightState._enrichQueue.length + ) { + // Drip: at most one dispatch per ENRICH_DISPATCH_GAP_MS. When the gap + // hasn't elapsed yet, park a single wake-up timer and stop — completions + // and enqueues in the meantime re-enter here harmlessly. + const wait = + ENRICH_DISPATCH_GAP_MS - + (Date.now() - flightState._enrichLastDispatchMs); + if (wait > 0) { + if (!flightState._enrichDripTimer) { + flightState._enrichDripTimer = setTimeout(() => { + flightState._enrichDripTimer = null; + _drainEnrich(); + }, wait); + } + return; + } + flightState._enrichLastDispatchMs = Date.now(); + const job = flightState._enrichQueue.shift(); + flightState._enrichActive += 1; + const lifetime = flightState.lifetime; + Promise.resolve() + .then(() => { + lifetime.signal.throwIfAborted(); + return flightState._source.getEnrichment?.(job.query, { + signal: lifetime.signal, + }); + }) + .then((data) => { + if (!lifetime.signal.aborted && data && data.found) job.onData(data); + }) + .catch(() => { + /* enrichment never surfaces errors */ + }) + .finally(() => { + if (lifetime.signal.aborted) return; + flightState._enrichActive -= 1; + _drainEnrich(); + }); + } + } + + function _requestTypeEnrichment(icao24, priority = false) { + if (!/^[0-9a-f]{6}$/i.test(icao24)) return; + _enqueueEnrich( + `t:${icao24}`, + { kind: 'type', id: icao24.toLowerCase() }, + (data) => { + const meta = flightState._flightData.get(icao24); + if (!meta) return; // evicted while the lookup was in flight + meta.typeCode = data.typeCode || meta.typeCode; + meta.typeName = data.typeName || meta.typeName; + meta.registration = data.registration || meta.registration; + if (meta.typeCode) { + const klass = classifyAircraft({ + typeCode: meta.typeCode, + category: meta.category, + }); + if (klass !== meta.klass) { + meta.klass = klass; + const bb = flightState._billboards.get(icao24); + if (bb) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + // Hangar fleet: the class's GLB/scale may have changed — resync the + // live model, any in-flight load, and the tracked standalone model. + parts.rendering._syncModelToClass(icao24); + } + } + if (icao24 === flightState._trackedIcao && flightState._trackedEntity) + parts.tracking._updateTrackedLabelModel(icao24); + }, + priority, + ); + } + + function _requestRouteEnrichment(icao24) { + const cs = String(flightState._flightData.get(icao24)?.callsign || '') + .trim() + .toUpperCase(); + if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve + _enqueueEnrich( + `r:${cs}`, + { kind: 'route', id: cs }, + (data) => { + const meta = flightState._flightData.get(icao24); + if (!meta) return; + meta.airline = data.airline || meta.airline; + if (data.origin && data.destination) + meta.route = { origin: data.origin, destination: data.destination }; + if (icao24 === flightState._trackedIcao && flightState._trackedEntity) + parts.tracking._updateTrackedLabelModel(icao24); + }, + true, + ); // route lookups only fire for the TRACKED plane — front of the queue + } + + /** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the + * bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens, + * windowMs} — they cannot wait out a real 5-minute window. Read lazily each + * refill so a pre-boot override (or a mid-run windowMs swap) applies. + * Production never sets this; the constants above are the defaults. */ + + function _ambientBudgetKnobs() { + const o = + (typeof window !== 'undefined' && window.__GEV_ENRICH_AMBIENT_QA) || null; + return { + ceil: + Number.isFinite(o?.ceil) && o.ceil > 0 + ? o.ceil + : ENRICH_AMBIENT_BUDGET_CEIL, + refillTokens: + Number.isFinite(o?.refillTokens) && o.refillTokens > 0 + ? o.refillTokens + : ENRICH_AMBIENT_REFILL_TOKENS, + windowMs: + Number.isFinite(o?.windowMs) && o.windowMs > 0 + ? o.windowMs + : ENRICH_AMBIENT_REFILL_WINDOW_MS, + }; + } + + /** Advance the token bucket: add refillTokens per FULLY elapsed window since + * the anchor, clamp at the ceiling, and move the anchor forward by the whole + * windows consumed (while the bucket sits full this still advances, so idle + * time never banks more than one bucket's worth of burst). */ + + function _refillAmbientBudget(nowMs) { + const { ceil, refillTokens, windowMs } = _ambientBudgetKnobs(); + if (!flightState._enrichAmbientRefillAnchorMs) { + flightState._enrichAmbientRefillAnchorMs = nowMs; + return; + } + const windows = Math.floor( + (nowMs - flightState._enrichAmbientRefillAnchorMs) / windowMs, + ); + if (windows <= 0) return; + flightState._enrichAmbientBudget = Math.min( + ceil, + flightState._enrichAmbientBudget + windows * refillTokens, + ); + flightState._enrichAmbientRefillAnchorMs += windows * windowMs; + } + + function _sweepAmbientEnrichment() { + _refillAmbientBudget(Date.now()); + if ( + flightState._enrichAmbientBudget <= 0 || + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + try { + const camera = flightState._viewer.camera; + const camPos = camera.positionWC; + const occluder = horizonOccluder(camera); + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + const cand = []; + for (const [icao24, bb] of flightState._billboards) { + if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session + if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only + if (flightState._flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) + if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + if ( + cull.computeVisibility(flightState._scratchModelBS) === + Cesium.Intersect.OUTSIDE + ) + continue; // off-screen + cand.push([ + icao24, + Cesium.Cartesian3.distanceSquared(camPos, bb.position), + ]); + } + cand.sort((a, b) => a[1] - b[1]); // nearest first — what the user is looking at resolves first + const n = Math.min( + cand.length, + ENRICH_AMBIENT_PER_SWEEP, + flightState._enrichAmbientBudget, + ); + for (let i = 0; i < n; i++) { + flightState._enrichAmbientBudget -= 1; + _requestTypeEnrichment(cand[i][0]); // non-priority: fills the back of the queue + } + } catch { + /* ambient enrichment is best-effort — never disturb the poll loop */ + } + } + return { + _enqueueEnrich, + _drainEnrich, + _requestTypeEnrichment, + _requestRouteEnrichment, + _ambientBudgetKnobs, + _refillAmbientBudget, + _sweepAmbientEnrichment, + }; +} diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js new file mode 100644 index 000000000..9a6f9da66 --- /dev/null +++ b/src/layers/flights/evidence.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { FOCUS_EVIDENCE_DEV } from './policy.js'; + +export function createEvidence({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** Resolve a JSON-safe evidence position into ECEF. DEV-only caller. */ + + function _focusEvidencePosition(record) { + const cartesian = record?.cartesian; + if ( + Array.isArray(cartesian) && + cartesian.length >= 3 && + cartesian.slice(0, 3).every(Number.isFinite) + ) { + return Cesium.Cartesian3.fromElements( + cartesian[0], + cartesian[1], + cartesian[2], + ); + } + if ( + !Number.isFinite(record?.longitude) || + !Number.isFinite(record?.latitude) + ) + return null; + return Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000, + ); + } + + /** Replace the real fleet with deterministic explicit-position contacts. */ + + function _setFocusEvidenceAircraft(records = []) { + if ( + !FOCUS_EVIDENCE_DEV || + !flightState._billboardCollection || + !flightState._viewer + ) + return { ok: false, count: 0 }; + if (flightState._trackedIcao) parts.tracking._clearTracking(); + parts.rendering._releaseModels(); + for (const bb of flightState._billboards.values()) + flightState._billboardCollection.remove(bb); + flightState._billboards.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._missingPolls.clear(); + flightState._focusEvidenceIds.clear(); + + for (const record of Array.isArray(records) ? records : []) { + const id = String(record?.id || '') + .trim() + .toLowerCase(); + const position = _focusEvidencePosition(record); + if (!id || !position) continue; + const klass = record.klass || 'airliner'; + const altitudeM = Number.isFinite(record.altitudeM) + ? record.altitudeM + : Cesium.Cartographic.fromCartesian(position)?.height || 3_000; + const meta = { + callsign: String(record.callsign || id).toUpperCase(), + altitude: altitudeM, + renderAltitudeM: altitudeM, + velocity: Number.isFinite(record.velocityMps) ? record.velocityMps : 0, + true_track: Number.isFinite(record.trackDeg) ? record.trackDeg : 90, + klass, + onGround: false, + wasAirborne: true, + turnRateDps: 0, + lastContactEpochMs: Date.now(), + rawLat: record.latitude ?? null, + rawLon: record.longitude ?? null, + cullPosition: null, + }; + flightState._flightData.set(id, meta); + flightState._focusEvidenceIds.add(id); + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon(parts.rendering._iconKind(id, klass)), + width: 20, + height: 20, + scale: parts.rendering._fleetBillboardScale(id, klass), + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: parts.rendering._fleetBillboardColor(id), + sizeInMeters: false, + scaleByDistance: parts.rendering._normalBillboardScaleByDistance(), + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id, + show: true, + }); + flightState._billboards.set(id, bb); + } + flightState._count = flightState._billboards.size; + flightState._lastFleetTickMs = 0; + flightState._viewer.scene.requestRender?.(); + return { ok: true, count: flightState._count }; + } + + /** Update explicit evidence positions without rebuilding billboards. */ + + function _moveFocusEvidenceAircraft(records = []) { + if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 }; + let moved = 0; + for (const record of Array.isArray(records) ? records : []) { + const id = String(record?.id || '') + .trim() + .toLowerCase(); + if (!flightState._focusEvidenceIds.has(id)) continue; + const position = _focusEvidencePosition(record); + const bb = flightState._billboards.get(id); + if (!position || !bb) continue; + bb.position = position; + const meta = flightState._flightData.get(id); + if (meta) { + if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; + if (Number.isFinite(record.velocityMps)) + meta.velocity = record.velocityMps; + } + moved += 1; + } + flightState._lastFleetTickMs = 0; + flightState._viewer?.scene?.requestRender?.(); + return { ok: true, moved }; + } + + /** JSON-safe visual snapshot for the evidence report. */ + + function _focusEvidenceSnapshot() { + if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; + return [...flightState._focusEvidenceIds].map((id) => { + const bb = flightState._billboards.get(id); + const screen = bb?.position + ? Cesium.SceneTransforms.worldToWindowCoordinates( + flightState._viewer.scene, + bb.position, + ) + : null; + return { + id, + show: bb?.show === true, + scale: bb?.scale ?? null, + alpha: bb?.color?.alpha ?? null, + x: screen?.x ?? null, + y: screen?.y ?? null, + cameraDistanceM: bb?.position + ? Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + bb.position, + ) + : null, + }; + }); + } + return { + _focusEvidencePosition, + _setFocusEvidenceAircraft, + _moveFocusEvidenceAircraft, + _focusEvidenceSnapshot, + }; +} diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js new file mode 100644 index 000000000..c652ca45b --- /dev/null +++ b/src/layers/flights/index.js @@ -0,0 +1,41 @@ +import { createFlightState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createMotion } from './motion.js'; +import { createTracking } from './tracking.js'; +import { createController } from './controller.js'; +import { createEnrichment } from './enrichment.js'; +import { createIngestion } from './ingestion.js'; +import { createLifecycle } from './lifecycle.js'; +import { createEvidence } from './evidence.js'; +import { createTesting } from './testing.js'; +import { createQueries } from './queries.js'; +/** Compose one civil-flight layer with application-owned scene services. */ +export function createCivilFlightLayer({ + source, + services, + resolveAsset = (url) => url, +} = {}) { + const flightState = createFlightState({ source, services }); + const parts = {}; + const layer = {}; + const context = { flightState, services, parts, layer, resolveAsset }; + parts.rendering = createRendering(context); + parts.motion = createMotion(context); + parts.tracking = createTracking(context); + parts.controller = createController(context); + parts.enrichment = createEnrichment(context); + parts.ingestion = createIngestion(context); + parts.lifecycle = createLifecycle(context); + parts.evidence = createEvidence(context); + parts.testing = createTesting(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { TRACKED_MODEL_MAX_PX } from './policy.js'; diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js new file mode 100644 index 000000000..7a0a44731 --- /dev/null +++ b/src/layers/flights/ingestion.js @@ -0,0 +1,661 @@ +import * as Cesium from 'cesium'; +import { stickyNumber, stickyText } from '../../data/aircraftMeta.js'; +import { + geoidSurfaceLastResortM, + pickRenderAltitudeM, +} from '../../data/renderAltitude.js'; +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + GROUND_FLOOR_CLAMP_RADIUS_KM, + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './policy.js'; + +export function createIngestion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = + services.militaryRegistry; + const { geoidHeight } = services.geoid; + const { cachedGroundFloor, floorAltitudeM, warmGroundFloor } = + services.groundFloor; + const { sampleMeshFloorCells } = services.meshFloor; + const flightsLayer = layer; + + const methods = { + /** + * Fetch the latest aircraft state vectors from the OpenSky proxy and + * reconcile them with the billboard collection. + * + * Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors + * with exponential-ish backoff. On success, adds/updates/removes + * billboards and position history, triggers lerp blending for the + * tracked aircraft, and updates its label text. + * + * @param {Cesium.Viewer} viewer + * @returns {Promise} + */ + async update(viewer, { signal = null } = {}) { + const nowMs = Date.now(); + const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + ids: new Set(), + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + if (flightState._retryAt && nowMs < flightState._retryAt) { + flightState._backoff = true; + return; + } + + const resourceController = new AbortController(); + flightState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + const snapshot = await flightState._source.getSnapshot( + parts.controller._flightQuery(viewer || flightState._viewer), + { signal: updateSignal }, + ); + updateSignal.throwIfAborted(); + flightState._lastStatus = snapshot.status ?? 200; + const usableStates = snapshot.records; + const sourceEpochMs = snapshot.observedAtMs; + const sourceAgeMs = snapshot.ageMs; + const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; + flightState._backoff = sourceStale; + flightState._retryAt = 0; + flightState._lastError = sourceStale + ? sourceAgeMs == null + ? 'Source snapshot time unavailable' + : `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old` + : null; + flightState._lastSource = snapshot.source; + flightState._lastCoverage = snapshot.coverage; + flightsLayer.source = flightState._lastSource; + const currentIcaos = new Set(); + const acceptedSnapshotIcaos = new Set(); + const now = Cesium.JulianDate.now(); + // Field-test round 3 (2026-07-06, Austin fleet-underground): viewer + // subpoint + collected floor cells for the viewer-proximate low-contact + // clamp below — one carto read per poll, one batch warm after the loop. + const viewerCarto = + (viewer || flightState._viewer)?.camera?.positionCartographic || null; + const viewerLatDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : null; + const viewerLonDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : null; + const floorWarmPoints = []; + + // Classification and display policy consume source-independent observations. + refreshMilitaryRegistryIfStale(); + for (const observation of usableStates) { + const { + id: icao24, + callsign, + originCountry: origin_country, + longitude: lon, + latitude: lat, + baroAltitudeM: baro_alt, + onGround: on_ground, + speedMps: velocity, + courseDeg: true_track, + ellipsoidAltitudeM: geo_alt, + category, + verticalRateMps: vertical_rate, + } = observation; + acceptedSnapshotIcaos.add(icao24); + const onGround = on_ground === true; + + // Known-military aircraft: the dedicated military layer wins + // (icon + track + click) while it is enabled — suppress the + // OpenSky duplicate entirely (except a currently tracked one, + // which hands off on untrack). + const isMil = isMilitaryIcao(icao24); + if (isMil && parts.tracking._militaryLayerSuppresses(icao24)) { + const dupe = flightState._billboards.get(icao24); + if (dupe) { + flightState._billboardCollection.remove(dupe); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._missingPolls.delete(icao24); + flightState._geoidNCache.delete(icao24); + } + continue; + } + + currentIcaos.add(icao24); + flightState._missingPolls.delete(icao24); + // Sticky merge: OpenSky intermittently drops callsign/velocity/track for + // aircraft it still positions — hold last-known-good instead of + // regressing to the ICAO hex / a 0° (north) heading. Bounded by the + // MISSING_POLL_LIMIT eviction below, which deletes the whole entry. + const prevMeta = flightState._flightData.get(icao24); + // Grounded planes with no baro reading sit at 0 m, not the 10 km + // airborne default (a parked plane must never float). + // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky + // barometric/MSL altitude read by labels (FL/altitude readout), + // route-plausibility, and follow-camera range heuristics. It is + // NEVER overwritten or renamed. Where the aircraft actually RENDERS + // on the ellipsoidal globe is a SEPARATE value (renderAltitudeM, + // below) — geo_altitude when OpenSky reports it (already WGS84 + // ellipsoidal), else baro+geoid as a visual fallback, else ground + // surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM, + // never `alt` directly. + const alt = stickyNumber( + baro_alt, + prevMeta?.altitude, + onGround ? 0 : 10000, + ); + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = flightState._geoidNCache.get(icao24); + if (geoidN === undefined && flightState._geoidReady) { + geoidN = geoidHeight(lat, lon); + flightState._geoidNCache.set(icao24, geoidN); + } + + // on_ground surface prior: ONLY synchronous warm-cache reads here — + // never a per-aircraft network fetch inside the poll loop (see the + // batch resolve call below, which fills this cache for NEXT poll). A + // Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied): + // the grounded surface is the round-4 choke point and nothing else — + // rendered-mesh cell first, real (never fallback-poisoned) DEM cell + // second. The old exact-5-decimal warm chain is GONE: it minted a new + // key per parked-jitter poll for every grounded contact ON EARTH, + // hammering Re:Earth into the very failures that poisoned the cache. + let surfaceM = null; + if (onGround) { + surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell) + // Taxiing crosses into a fresh cold cell every poll — always one + // step ahead of the warm batch — so fall back to LAST poll's cell + // (warmed by last poll's batch; aprons are flat across adjacent + // 111 m cells). Round-5 verify caught taxiing contacts stuck at + // the geoid without this (round 2's lesson, at cell granularity). + if ( + surfaceM == null && + Number.isFinite(prevMeta?.rawLat) && + Number.isFinite(prevMeta?.rawLon) + ) { + surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon); + } + // Grounded contacts near the viewer feed the floor warm/sampler + // (the only ones whose exact height is visible; far contacts are + // subpixel and always-on-top anyway). + if ( + viewerLatDeg != null && + parts.queries._approxDistanceKm( + viewerLatDeg, + viewerLonDeg, + lat, + lon, + ) <= GROUND_FLOOR_CLAMP_RADIUS_KM + ) { + floorWarmPoints.push({ lat, lon }); + } + // Last synchronous resort for a BRAND-NEW grounded contact with NO + // altitude data at all (nothing warm yet, not even the coarse + // cell): the geoid surface. At the sea-level airports where most + // grounded traffic sits, geoidN IS the local ellipsoidal ground to + // within metres — instantly right — and at elevated fields it is + // far less wrong than the raw 0 m ellipsoid default for the one + // poll until the coarse cell warms. STRICTLY gated on "no geo, no + // baro": a reported baro already reflects the field elevation, and + // pickRenderAltitudeM's surfaceM branch would let this crude guess + // outrank it (caught by the ground-3d track regression). + // + // 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds + // one more gate — a contact that already HAS a render height keeps + // it. Leaving surfaceM null routes it through the sentinel path + // below, which holds that height. + if (surfaceM == null) { + surfaceM = geoidSurfaceLastResortM({ + geoAltM: geo_alt, + baroAltM: baro_alt, + priorRenderM: prevMeta?.renderAltitudeM, + geoidN, + }); + } + } + + const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null; + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: Number.isFinite(baro_alt) ? baro_alt : null, + onGround, + surfaceM, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks, + // in priority order: + // (1) hold the previous geoid-corrected render height if we have one — + // a one-poll baro dropout must NOT snap the plane down by the geoid + // undulation N (~46 m in London) and back up next poll. `alt` stays + // sticky for labels, so holding the last render height keeps the two + // layers consistent through the gap. + // (2) otherwise the SAME default policy `alt` already uses, so the two + // never disagree on the genuine "no data yet" case (a never-reported + // aircraft has no prior render height, so it lands here unchanged). + let renderAltitudeM; + if (pickedAltM != null) { + renderAltitudeM = pickedAltM; + } else if (Number.isFinite(prevMeta?.renderAltitudeM)) { + renderAltitudeM = prevMeta.renderAltitudeM; + } else { + renderAltitudeM = alt; + } + // Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3): + // floor a low airborne contact's render height at the local coarse + // ground so it can never dive below the mesh. Round 3 (Austin + // fleet-underground): baro can read BELOW an elevated field — SWA696 + // showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi + // traffic that OpenSky hasn't flagged on_ground yet renders from that + // baro, so the whole fleet sat buried at AUS in 2D. Clamping every + // global contact would need unbounded terrain resolution; instead the + // clamp covers the TRACKED contact (always) plus every low contact + // within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones + // whose burial is visible. Cells warm in one batch after the loop. + // Airborne only (grounded planes keep the surface-cache path above). + if ( + !onGround && + renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M && + (icao24 === flightState._trackedIcao || + (viewerLatDeg != null && + parts.queries._approxDistanceKm( + viewerLatDeg, + viewerLonDeg, + lat, + lon, + ) <= GROUND_FLOOR_CLAMP_RADIUS_KM)) + ) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + floorWarmPoints.push({ lat, lon }); + } + + const position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + renderAltitudeM, + ); + // Landing/takeoff transition: the on_ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Either flip direction retires the model's ground snap: a departing plane + // flies free of it, a landing plane earns a fresh sample where it rolls out. + if (groundFlipped) flightState._groundSnap.forget(icao24); + + // Store flight metadata for click-to-track labels + const cat = stickyNumber(category, prevMeta?.category, null); + const meta = { + sourceReference: observation.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + altitude: alt, + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitude` — never rename/replace it (labels, + // FL readout, route-plausibility, and follow-camera range math all + // still read `altitude`/baro). + geoAltitudeM, + renderAltitudeM, + onGround, + // Round 7: sticky airborne history — the landed fast-cull only + // applies to contacts that actually flew this session. + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Round 6: lifted occlusion-test point for contacts rendering + // at/below the ellipsoid (fleet pass reads it — see the occluder + // note there). Null for the overwhelmingly common airborne case. + cullPosition: + renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) + : null, + velocity: stickyNumber(velocity, prevMeta?.velocity, 0), + true_track: stickyNumber(true_track, prevMeta?.true_track, 0), + category: cat, + // An adsbdb-enriched type code outranks the coarse OpenSky category. + klass: classifyAircraft({ + typeCode: prevMeta?.typeCode ?? null, + category: cat, + }), + turnRateDps: prevMeta?.turnRateDps || 0, + verticalRate: stickyNumber( + vertical_rate, + prevMeta?.verticalRate, + null, + ), + // Analyst seam: OpenSky origin_country (state[2]) — additive, sticky + // like callsign so a transient blank row doesn't blank the field. + originCountry: + stickyText(origin_country, prevMeta?.originCountry) || null, + // OpenSky distinguishes the last position epoch from the last + // transponder message. The fleet coast horizon uses this actual + // contact time so a temporarily old position does not hard-freeze + // while fresh velocity/track messages are still arriving. + lastContactEpochMs: stickyNumber( + observation.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + // adsbdb enrichment — written by the enrichment callbacks, carried across polls: + typeCode: prevMeta?.typeCode ?? null, + typeName: prevMeta?.typeName ?? null, + registration: prevMeta?.registration ?? null, + airline: prevMeta?.airline ?? null, + route: prevMeta?.route ?? null, + // The RAW poll fix lat/lon (this tick's OpenSky state-vector + // coords, pre-dead-reckon) — kept distinct from the continuously + // dead-reckoned billboard position for any consumer that needs the + // actual reported fix. + rawLat: lat, + rawLon: lon, + }; + flightState._flightData.set(icao24, meta); + + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch + // (time_position), not client receipt time — OpenSky positions arrive + // 5-15s stale and receipt-time stamping is what caused the + // back/forward oscillation. Only append when the fix actually + // advances, so stale repeats don't create zero-dt segments. + const fixEpochMs = + Number.isFinite(observation.positionTimeMs) && + observation.positionTimeMs > 0 + ? observation.positionTimeMs + : Date.now(); + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.velocity, + track: meta.true_track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F1) + // so the visible trail keeps growing while tracked. Ground traffic + // appends nothing — a touchdown freezes the existing trail. + // Round 2 (owner): ground traffic appends too — taxi history stays + // live after touchdown (grounded flights positions are already + // surface-clamped via the surfaceM chain, so the ground leg drapes). + if (isTracked) parts.tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = + parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + // Apply fresh kinematics only from a forward synthetic fix. Mutating + // the historical fix reprojects the entire stale interval and snaps + // the rendered aircraft when course or speed changes late. + const kinematicsChanged = + newest.velocity !== meta.velocity || + newest.track !== meta.true_track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.velocity, + track: meta.true_track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) + parts.tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Reclassify if the category resolved/changed (first extended poll); + // a ground flip (landing/takeoff) re-scales the SAME billboard in + // place — the transition is a restyle, never a removal. + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + if (!isTracked) { + // Refresh affiliation hue without clobbering the tick-owned + // freshness × focus × horizon alpha composition. + bb.color = parts.rendering + ._fleetBillboardColor(icao24) + .withAlpha(bb.color?.alpha ?? 1); + } + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Poll-path class change (category updates): same model resync rule + // as the enrichment path — the class's GLB/scale may have changed. + if (prevMeta?.klass !== meta.klass) + parts.rendering._syncModelToClass(icao24); + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon( + parts.rendering._iconKind(icao24, meta.klass), + ), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: parts.rendering._fleetBillboardScale(icao24, meta.klass), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked + ? Cesium.Color.CYAN + : parts.rendering._fleetBillboardColor(icao24), + sizeInMeters: false, + scaleByDistance: + parts.rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + + // Takeoff while TRACKED: ground traffic drew no trail, so start one + // from the fresh airborne history (touchdown needs no action — the + // append gate above simply freezes the existing trail). + if (isTracked && groundFlipped && !meta.onGround) + parts.tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + parts.tracking._updateTrackedLabelModel(icao24); + } + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // OpenSky routinely drops aircraft for a single poll; immediate removal + // made planes blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if ( + !snapshot.complete && + Date.now() - + (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < + 300000 + ) + continue; + const misses = (flightState._missingPolls.get(icao24) || 0) + 1; + const limit = parts.queries._likelyLanded(icao24) + ? LANDED_MISSING_POLL_LIMIT + : MISSING_POLL_LIMIT; + if (misses < limit) { + flightState._missingPolls.set(icao24, misses); + if ( + icao24 === flightState._trackedIcao && + flightState._trackedEntity + ) { + // Honest readout: the tracked plane has no faded billboard (its + // entity owns the visual), so refresh the label — with icao24 now + // in _missingPolls, _trackedLabelText appends the STALE cue so + // last-known velocity/altitude aren't presented as live. + parts.tracking._updateTrackedLabelModel(icao24); + } + continue; + } + flightState._missingPolls.delete(icao24); + + // If the tracked flight is truly gone, clear tracking BEFORE deleting + // its state (M3 ordering, keep it): teardown reads the maps this loop + // is about to delete (billboard restore, DR cache reset), and we must + // never leave the camera mid-follow with stale tracking state. The + // camera is then RELEASED IN PLACE — it stays where the follow left + // it, fully free (owner decision 2026-07-02: no overview flyTo). + if (icao24 === flightState._trackedIcao) { + parts.tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._geoidNCache.delete(icao24); + flightState._displayFloorState.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Ambient type enrichment: give ON-SCREEN planes real types (bounded + // sweep — see _sweepAmbientEnrichment; internally fail-silent). + parts.enrichment._sweepAmbientEnrichment(); + + // 2026-08-19: the loop above only ever collects FIX cells, but a grounded + // contact renders across every cell its dead-reckoned position drifts + // through. Add those too, so the display clamp has data where the contact + // actually is instead of silently passing. + parts.motion._collectDisplayCorridorCells( + floorWarmPoints, + viewerLatDeg, + viewerLonDeg, + ); + + // Field-test round 3: one batch warm of the viewer-proximate low-contact + // floor cells collected in the loop (fire-and-forget, single-flight; + // read synchronously by NEXT poll's clamp — the military-layer pattern). + warmGroundFloor(floorWarmPoints); + // Round 4: sample the RENDERED mesh for those same cells (one-shot per + // cell, budget-capped, viewer-proximate, google-3d regime only). Own + // billboards/models are excluded so a vertical probe can't land on an + // aircraft instead of the pavement. + sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerLatDeg, + viewerLon: viewerLonDeg, + }); + + // Round 5: the grounded exact-key warm that used to live here is gone — + // see the note where _warmGroundedAircraftSurfaceCache was removed. The + // viewer-proximate coarse warm + mesh sampler above cover everything + // whose height is actually visible. + // Round 6: re-floor STALE grounded contacts. A parked plane whose + // transponder went quiet stops receiving poll updates, so a floor that + // warms AFTER its last fix never applied — it sat frozen at the geoid + // (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever). + // Grounded contacts are static, so lifting the stored fix + billboard + // in place is safe (the DR extrapolates a zero-velocity fix). + parts.motion._refloorStaleGroundedContacts(currentIcaos); + + flightState._count = flightState._billboards.size; + // Freshness belongs to the source snapshot, not the moment this browser + // received a cached 200 response. + flightState._lastUpdate = sourceEpochMs; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'accepted', + ids: acceptedSnapshotIcaos, + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Flights update aborted', 'AbortError'); + } + console.warn('[Data:Flights] Fetch error:', e); + flightState._backoff = true; + flightState._retryAt = + Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); + flightState._lastStatus = e?.status ?? null; + if (e?.source) { + flightState._lastSource = e.source; + this.source = flightState._lastSource; + } + flightState._lastError = + e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; + } finally { + flightState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js new file mode 100644 index 000000000..9dff82f89 --- /dev/null +++ b/src/layers/flights/lifecycle.js @@ -0,0 +1,336 @@ +import * as Cesium from 'cesium'; +import { PLANE_MODEL_URL } from './policy.js'; + +export function createLifecycle({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { clearFocusTarget } = services.focus; + const { + registerSpriteCollection, + restoreSpriteOrder, + restoreSpriteOrderOnEnable, + } = services.sprites; + const { onMilitaryLayerActiveChange } = services.militaryRegistry; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (flightState._viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + flightState._source = source; + flightState._lastSource = source.label || flightState._lastSource; + this.source = flightState._lastSource; + }, + + /** + * Initialize the flights layer. + * Creates the BillboardCollection, resets all state, and installs the + * click-to-track handler on the scene canvas. + * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. + */ + init(viewer) { + if (flightState._viewer) + throw new Error('Flight layer is already initialized'); + if (typeof flightState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + if (flightState.lifetime.signal.aborted) + flightState.lifetime = new AbortController(); + clearFocusTarget('flights'); + flightState._focusEvidenceIds.clear(); + flightState._viewer = viewer; + flightState._billboardCollection = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(flightState._billboardCollection); + registerSpriteCollection('flights', flightState._billboardCollection); + flightState._modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(flightState._modelCollection); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(PLANE_MODEL_URL), + asynchronous: false, + }) + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + try { + m.destroy(); + } catch { + /* gone */ + } + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + flightState._billboards = new Map(); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map(); + flightState._positionHistory = new Map(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._backoff = false; + flightState._retryAt = 0; + flightState._lastError = null; + flightState._lastStatus = null; + flightState._lastSource = flightState._source.label || 'Aircraft'; + flightState._lastCoverage = 'worldwide upstream snapshot'; + flightState._trackedIcao = null; + parts.tracking._resetTrackedSelectionState(); + flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; + flightState._cockpitContactMode = + document.body.classList.contains('cockpit-mode'); + flightState._cockpitNearContacts = new Set(); + if (!flightState._cockpitModeListener) { + flightState._cockpitModeListener = (event) => + parts.tracking._applyCockpitState(event?.detail); + window.addEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + } + // Fresh session — full bucket, anchor re-seeded on the first sweep. + flightState._enrichAmbientBudget = + parts.enrichment._ambientBudgetKnobs().ceil; + flightState._enrichAmbientRefillAnchorMs = 0; + + parts.tracking._installClickHandler(viewer); + + // React to Military-layer toggles IMMEDIATELY (suppress/restore sweep) + // instead of waiting out the 30 s poll (M2). + if (!flightState._milActiveChangeUnsub) { + flightState._milActiveChangeUnsub = onMilitaryLayerActiveChange( + parts.tracking._onMilitaryActiveChange, + ); + } + + restoreSpriteOrder(viewer); + + console.log('[Data:Flights] Initialized with billboard icons'); + }, + + /** + * Show the billboard collection and re-install the click handler. + * @param {Cesium.Viewer} viewer + */ + enable(viewer) { + if (flightState._billboardCollection) + flightState._billboardCollection.show = true; + holdContinuousRender('flights'); // per-frame animator (perf wave 2) + if (flightState._modelCollection) + flightState._modelCollection.show = true; + parts.tracking._setCockpitContactMode( + document.body.classList.contains('cockpit-mode'), + ); + // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop + // only reads geoidHeight() synchronously after this resolves (guarded by + // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. + if (!flightState._geoidReady) { + const lifetime = flightState.lifetime; + ensureGeoidReady() + .then(() => { + if (!lifetime.signal.aborted) flightState._geoidReady = true; + }) + .catch(() => { + /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ + }); + } + parts.tracking._installClickHandler(viewer); + registerPickOwner('flights', (pickedId) => + flightState._billboards.has(pickedId), + ); + // Force a fresh rotation pass on the first tick after re-enable + flightState._lastCamPoseSig = ''; + if (!flightState._preRenderRemove && viewer?.scene) { + flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering._fleetTick, + ); + } + if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { + flightState._trackedModelPreUpdateRemove = + viewer.scene.preUpdate.addEventListener( + parts.rendering._updateTrackedModel, + ); + } + if (!flightState._moveEndRemove && viewer?.camera) { + // Arrival polish (field test 2026-07-03: "planes look weird when you first + // come to them"): when a camera move SETTLES (voice fly-to, fast pan + // release), force a full rotation pass on the very next frame. The + // pose-signature gate alone can eat the settle — the final easing frames + // of a flight land inside one quantization bucket (10 m / 0.06°), leaving + // every icon wearing its last mid-flight rotation for up to + // ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs + // on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one + // extra rotation pass per completed camera gesture — nothing per-frame. + flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( + () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }, + ); + } + restoreSpriteOrderOnEnable('flights', viewer); + }, + + /** + * Hide all flight billboards and tear down click/keyboard handlers. + * Also clears any active flight tracking so the camera is released. + * @param {Cesium.Viewer} viewer + */ + disable(viewer) { + parts.controller._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._billboardCollection) + flightState._billboardCollection.show = false; + releaseContinuousRender('flights'); + parts.rendering._releaseModels(); + if (flightState._modelCollection) + flightState._modelCollection.show = false; + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + // Remove click handler + keydown listener while disabled to avoid + // intercepting input when the layer is off + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + unregisterPickOwner('flights'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + }, + + /** + * Fully tear down the flights layer — remove primitives, handlers, + * tracked entities, and clear all internal state maps. + * @param {Cesium.Viewer} viewer + */ + destroy(viewer) { + flightState.lifetime.abort(); + flightState._enrichActive = 0; + flightState._enrichLastDispatchMs = 0; + parts.controller._abortActiveUpdates(); + releaseContinuousRender('flights'); // direct-destroy path (perf wave 2 fix) + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + if (flightState._milActiveChangeUnsub) { + flightState._milActiveChangeUnsub(); + flightState._milActiveChangeUnsub = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + if (flightState._cockpitModeListener) { + window.removeEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + flightState._cockpitModeListener = null; + } + unregisterPickOwner('flights'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + parts.rendering._releaseModels(); + if (flightState._billboardCollection) { + viewer.scene.primitives.remove(flightState._billboardCollection); + flightState._billboardCollection = null; + } + if (flightState._modelCollection) { + viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models + flightState._modelCollection = null; + } + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + try { + flightState._preloadModel.destroy(); + } catch { + /* gone */ + } + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._detectionObjects.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._displayFloorState.clear(); + flightState._enrichQueue.length = 0; + flightState._enrichSeen.clear(); + if (flightState._enrichDripTimer) { + clearTimeout(flightState._enrichDripTimer); + flightState._enrichDripTimer = null; + } + flightState._missingPolls.clear(); + flightState._focusEvidenceIds.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._cockpitContactMode = false; + flightState._cockpitNearContacts = new Set(); + flightState._cockpitSubjectId = null; + flightState._trackingRefreshEpoch += 1; + flightState._lastTrackingRefreshOutcome = { + epoch: flightState._trackingRefreshEpoch, + status: 'destroyed', + ids: new Set(), + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling + flightState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js new file mode 100644 index 000000000..42de84532 --- /dev/null +++ b/src/layers/flights/motion.js @@ -0,0 +1,1108 @@ +import * as Cesium from 'cesium'; +import { + COURSE_HOLD_SPEED_MPS, + lerpAngleDeg, + speedRamp, + courseBetweenCartesians, + staleCoastLimitSeconds, + arcOffsetEnu, + courseSlewCapDps, + limitCourseStep, + corridorPathLatLon, +} from '../../data/motionModel.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + modelVisualAnchor, + modelAnchorWorld, +} from '../../data/modelVisualAnchor.js'; +import { + FOCUS_EVIDENCE_DEV, + RENDER_DELAY_SEC, + TRACKED_MODEL_MAX_PX, + TRACKED_MODEL_MIN_PX, + TRACKED_BILLBOARD_SCALE_BY_DISTANCE, + DR_CORRECTION_MS, + COURSE_SLEW_DT_MAX_SEC, + COURSE_MAX_DPS, + HELD_FLOOR_MAX_DRIFT_KM, + NEIGHBOR_FLOOR_PROBE_MS, + FLOOR_SEED_GRACE_MS, + FLOOR_EASE_MAX_STEP, + FLOOR_EASE_TAU_MS, + FLOOR_EASE_EPSILON_M, + DISPLAY_CORRIDOR_RADIUS_KM, + DISPLAY_CORRIDOR_LOOKAHEAD_SEC, + DISPLAY_CORRIDOR_CELL_BUDGET, + DISPLAY_CORRIDOR_FAIR_SHARE, +} from './policy.js'; + +export function createMotion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + cachedGroundFloor, + GROUND_FLOOR_LIFT_M, + neighborFloorM, + stickyFloorCell, + displayFloorHeightM, + coarseFloorCoord, + corridorFloorCells, + allocateCorridorCells, + } = services.groundFloor; + const { trackedModelScaleForPixelCap } = services.camera; + const { + nearFarScalarValueAtDistance, + clearFocusTarget, + publishFocusTargetFromCachedPosition, + } = services.focus; + + // Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal + // warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS + // jitter minted fresh keys every poll → thousands of upstream points per + // minute → Re:Earth proxy failures → geoid-fallback POISON cached at + // sea-level heights → sunken sprites/trails and rejected mesh samples. The + // coarse ~111 m floor cells (viewer-proximate, collected in the poll loop) + // are the only DEM warm the layer needs. + + /** + * Round 6: lifts STALE grounded contacts onto floors that warmed after + * their last feed fix. A parked plane whose transponder went quiet keeps + * coasting on its final meta — if that fix predated the floor warm, it sat + * frozen underground forever. Runs once per poll over the (bounded) flight + * map; touches only grounded, feed-absent, demonstrably-below-floor + * contacts, and patches the stored fix + billboard in place (zero-velocity + * DR renders the patched fix verbatim). + * @param {Set} currentIcaos - Contacts present in THIS poll (already + * floored by the live path — skipped here). + */ + + function _refloorStaleGroundedContacts(currentIcaos) { + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + info.cullPosition = null; // above the ellipsoid now (or floors say otherwise next poll) + const position = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const history = flightState._positionHistory.get(icao24); + const newest = history?.[history.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone(position, newest.position); + const bb = flightState._billboards.get(icao24); + if (bb) bb.position = position; + } + } + + /** + * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. + * + * Projects forward from the last known API fix using the aircraft's ground + * velocity and true_track heading. The ENU transform avoids repeated lat/lon + * trig, keeping the per-frame cost low. + * + * If this aircraft is the tracked target and a lerp is in progress (new API + * fix just arrived), the function first blends from the old dead-reckoned + * position toward the corrected fix before projecting forward. + * + * @param {string} icao24 - ICAO 24-bit transponder address of the aircraft. + * @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists. + */ + + function _deadReckon(icao24, result) { + const info = flightState._flightData.get(icao24); + if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { + const position = flightState._billboards.get(icao24)?.position; + flightState._drCourseDeg = info?.true_track || 0; + flightState._drSpeedMps = info?.velocity || 0; + flightState._drCourseHold = + flightState._drSpeedMps < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = false; + return position + ? Cesium.Cartesian3.clone(position, result || new Cesium.Cartesian3()) + : null; + } + const history = flightState._positionHistory.get(icao24); + if (!history || history.length === 0) { + flightState._drCourseDeg = null; + flightState._drSpeedMps = null; + flightState._drCourseHold = false; + flightState._drExtrapolating = false; + return null; + } + + const out = result || new Cesium.Cartesian3(); + // Render one poll interval behind real time so we interpolate between + // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); + + // Bracketing pair: interpolate — no extrapolation error, no snap-back. + for (let i = history.length - 1; i >= 1; i--) { + const a = history[i - 1]; + const b = history[i]; + if ( + Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && + Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) { + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + // Course of the DISPLAYED motion. The chord is only trustworthy when the + // segment covers real ground (at hover its direction is GPS jitter; on a + // slow tight turn it STEPS the whole per-segment turn at each boundary), + // so it is blended against the reported per-fix track by displayed + // ground speed — and the track is TIME-INTERPOLATED between the fixes so + // a slow turner's nose advances continuously through the segment instead + // of snapping once per poll. Helicopters always use the reported track + // (rotorcraft chords are noise-dominated at their typical speeds). + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const segSpeed = + span > 0 ? chordLenM / span : (info && info.velocity) || 0; + const fallbackTrack = (info && info.true_track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); + const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); + const chordCourse = + w > 0 ? courseBetweenCartesians(a.position, b.position) : null; + flightState._drCourseDeg = + chordCourse != null + ? lerpAngleDeg(trackCourse, chordCourse, w) + : trackCourse; + flightState._drSpeedMps = segSpeed; + flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = false; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + } + + const newest = history[history.length - 1]; + const elapsedSec = Cesium.JulianDate.secondsDifference( + renderTime, + newest.time, + ); + if (elapsedSec <= 0) { + // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking + // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing + // pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind + // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history + // fills, renderTime advances toward the first fix and the icon glides FORWARD into + // the bracketing interpolation above with NO freeze and NO backward snap. (Holding + // the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made + // the icon jump back ~one poll interval the instant interpolation took over.) + const oldest = history[0]; + const lookbackSec = Cesium.JulianDate.secondsDifference( + oldest.time, + renderTime, + ); // ≥ 0 + return _extrapolateFix( + oldest, + info, + -Math.min(lookbackSec, 60), + out, + (info && info.turnRateDps) || 0, + ); + } + + // Newest POSITION is older than renderTime. OpenSky can still be receiving + // fresh contact/kinematic messages for that aircraft; freezing at a hard + // 60 s-after-position boundary produced the visible stop → catch-up → stop + // cadence. Coast through the latest real contact plus a bounded grace + // window, with an absolute cap so a stale cached feed cannot drift forever. + const coastLimitSec = staleCoastLimitSeconds({ + // `epochMs` is captured once when the poll is normalized. Avoid allocating + // a Date per aircraft on every 12 Hz fleet tick. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, + // Permit one minute of contact grace but cap any cached-feed drift at + // five minutes. Source backoff is exposed separately as a STALE cue. + minimumSec: 60, + maximumSec: 300, + }); + return _extrapolateFix( + newest, + info, + Math.min(elapsedSec, coastLimitSec), + out, + (info && info.turnRateDps) || 0, + ); + } + + /** + * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a + * constant-rate-turn arc when `turnRateDps` is significant (straight line + * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` + * projects BACKWARD (before the fix) — used for warm-up, estimating where the + * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, + * up = +Z; heading 0 deg = north, 90 deg = east. Sets `_drCourseDeg` to the + * arc's instantaneous end course on every path. + * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). + */ + + function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { + const speed = Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.velocity) || 0; + const heading = Number.isFinite(fix.track) + ? fix.track + : (info && info.true_track) || 0; + flightState._drSpeedMps = speed; + flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = true; + if (speed === 0 || dt === 0) { + flightState._drCourseDeg = heading; + return Cesium.Cartesian3.clone(fix.position, out); + } + // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a + // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. + arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); + flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; + Cesium.Cartesian3.fromElements( + flightState._scratchArc.east, + flightState._scratchArc.north, + 0, + flightState._scratchOffset, + ); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchEnu, + ); + return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); + } + + /** + * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) + * predates its oldest real fix — i.e. _deadReckon is extrapolating backward, with no + * real history yet sitting BEHIND the displayed icon. The trail must draw nothing in + * this window (every accumulated point is ahead of the icon). + * @returns {boolean} + */ + + function _isTrackWarmingUp() { + if (!flightState._trackedIcao) return false; + const history = flightState._positionHistory.get(flightState._trackedIcao); + if (!history || history.length === 0) return true; + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + return Cesium.JulianDate.lessThan(renderTime, history[0].time); + } + + /** Resolve the selected aircraft's actual rendered square extent this frame. */ + + function _trackedFocusSizePx(icao24, position) { + const camera = flightState._viewer?.camera; + const scene = flightState._viewer?.scene; + if (!camera?.positionWC || !position || !scene) return 28; + const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); + if (parts.rendering._modelOwnsVisual(icao24)) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(icao24)?.klass, + ); + const scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM, + viewportHeightPx: scene.canvas.clientHeight, + fovyRad: camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + const focalLengthPx = + scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + const projectedDiameterPx = + (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; + return Math.max( + TRACKED_MODEL_MIN_PX, + Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx), + ); + } + + const billboard = flightState._trackedEntity?.billboard; + const time = flightState._viewer.clock.currentTime; + const width = billboard?.width?.getValue(time) ?? 28; + const height = billboard?.height?.getValue(time) ?? 28; + const scale = + billboard?.scale?.getValue(time) ?? + (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + const scaleByDistance = + billboard?.scaleByDistance?.getValue(time) ?? + TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); + return Math.max(width, height) * scale * distanceScale; + } + + /** + * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium + * frame number so the position / rotation / trail-head callbacks share ONE computation + * (and one reconciliation-state update) per frame. Returns a stable module holder, or + * null when the aircraft has no fix. + * @param {string} icao24 + * @returns {Cesium.Cartesian3|null} + */ + + function _trackedDisplayPosition(icao24) { + const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frame === flightState._cachedDRFrame && + icao24 === flightState._drReconcileIcao + ) + return flightState._cachedDRPosition; + + // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting + // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) + const sameTrack = + flightState._drReconcileValid && flightState._drReconcileIcao === icao24; + const raw = _deadReckon(icao24, flightState._scratchDrRaw); + flightState._cachedDRCourse = flightState._drCourseDeg; + flightState._cachedDRSpeedMps = flightState._drSpeedMps; + flightState._cachedDRHold = flightState._drCourseHold; + flightState._cachedDRFrame = frame; + flightState._drReconcileIcao = icao24; + if (!raw) { + flightState._cachedDRPosition = null; + flightState._drReconcileValid = false; + clearFocusTarget('flights', icao24); + return null; + } + + const nowMs = Date.now(); + const info = flightState._flightData.get(icao24); + if (sameTrack) { + const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); + const speed = (info && info.velocity) || 0; + // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. + const plausible = speed * dtSec * 4 + 25; + if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { + // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. + Cesium.Cartesian3.subtract( + flightState._drPrevDisplay, + raw, + flightState._drCorrection, + ); + flightState._drCorrectionStartMs = nowMs; + } + } else { + Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); + flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed + } + + const elapsed = nowMs - flightState._drCorrectionStartMs; + const factor = + elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; + let display = Cesium.Cartesian3.multiplyByScalar( + flightState._drCorrection, + factor, + flightState._trackedPosHolder, + ); + Cesium.Cartesian3.add(raw, display, display); + // Same display floor the fleet pass applies — otherwise selecting a correctly + // floored grounded billboard swapped it for an unfloored tracked entity and + // dropped the cyan target back under the mesh. Applied HERE, at the single + // point every VISUAL consumer's per-frame position is computed, so the + // readout anchor, detection bracket, trail head and follow-camera all read + // the one floored value (the anti-jitter contract forbids recomputing per + // consumer). NOT the single point for DATA: `_describeFlight` deliberately + // reports sensor truth — see the note there. Skipped while a tracked 3D model + // owns the visual: it rides groundSnap's one-shot sample and moving its input + // would force a re-sample (T7). + display = _floorGroundedDisplayPosition( + icao24, + info, + display, + parts.rendering._modelOwnsVisual(icao24), + nowMs, + ); + + Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); + Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); + flightState._drPrevMs = nowMs; + flightState._drReconcileValid = true; + flightState._cachedDRPosition = display; + const focusSizePx = _trackedFocusSizePx( + icao24, + flightState._cachedDRPosition, + ); + // Publish only the exact per-frame display cache the tracked entity/camera + // consumes. Re-running DR from a later frame phase recreates the historical + // target-vs-camera jitter bug. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'flights', + id: icao24, + scene: flightState._viewer?.scene, + camera: flightState._viewer?.camera, + displayPosition: flightState._cachedDRPosition, + widthPx: focusSizePx, + heightPx: focusSizePx, + }); + return display; + } + + /** The tracked plane's current display position WITHOUT recomputing — the exact value the + * follow-camera already settled on this frame (in the Viewer _onTick). getDetectableObjects + the + * readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would + * re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters + * against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when + * there's no valid fix for the tracked aircraft, so callers fall back to the billboard position. */ + + function _trackedDisplayCached() { + return flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao + ? flightState._cachedDRPosition + : null; + } + + /** + * The position the tracked aircraft is VISUALLY at this frame — the translation its + * 3D model is actually rendering with when the model owns the visual, otherwise the + * cached dead-reckoned position the billboard uses. + * + * This exists because a grounded plane's model rides a one-shot ground snap while its + * billboard deliberately stays at the reported (buried) altitude — a ~100 m vertical + * split at an inland airport. Anything anchored to the display position while the model + * is what you can see drifts below the aircraft and only converges as the coarse floor + * cell warms ("the buoy"), sometimes never. + * + * It reads `modelMatrix`, which `_updateTrackedModel` already wrote this frame — no + * sampling, no `_modelDisplayPosition` call from postRender, and no new dead reckoning, + * so the follow-camera anti-jitter contract on `gevDisplayPosition` is untouched. + */ + + function _trackedVisualCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + return modelVisualAnchor( + flightState._trackedModel.modelMatrix, + spec.visualCenterNative, + Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale, + flightState._trackedVisualPos, + ); + } + return _trackedDisplayCached(); + } + + /** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on + * the visual centre; only the trail moves to the model's lower-centre hardpoint. */ + /** The tracked model's rendered bounding radius (m), or 0 when no model of this + * contact is drawing. Carries Cesium's effective `computedScale`, which may be + * above `scale` to satisfy minimumPixelSize — the trail head has to be judged + * against the size the operator SEES, not the nominal one. */ + /** World-space origin of the tracked model, or null when no model of this + * contact is drawing. This is the centre the rendered bounding sphere is + * measured from, so the trail clip and the envelope agree on one frame. */ + + function _trackedModelCenterWorld() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return null; + return Cesium.Matrix4.getTranslation( + flightState._trackedModel.modelMatrix, + flightState._scratchTrailClip, + ); + } + + function _trackedModelEnvelopeM() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return 0; + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const scale = Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale; + return spec.nativeRadiusM * scale; + } + + function _trackedTrailCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + // Through the model's OWN render chain (modelVisualAnchor's hand-rolled + // half-correction put this offset on the lateral axis — see + // modelAnchorWorld). + return modelAnchorWorld( + flightState._trackedModel, + spec.trailAnchorNative, + flightState._trackedTrailPos, + ); + } + return _trackedDisplayCached(); + } + + /** Smoothed world course for the tracked aircraft this frame. Reads the + * frame-cached course (set by _trackedDisplayPosition — the follow-camera's + * own computation), NEVER re-runs _deadReckon. Safe to call more than once + * per frame: the second call sees dt≈0 and the limiter is a no-op. + * + * The smoothed value lives in the SHARED per-icao _displayCourse entry (see + * its declaration): on click the limiter continues from whatever nose the + * fleet pass was displaying, and on untrack the fleet pass continues from + * whatever nose this path last wrote — the tracked and fleet consumers of + * the same aircraft can never disagree across the handoff. */ + + function _trackedDisplayCourse() { + const info = flightState._flightData.get(flightState._trackedIcao); + const fallback = (info && info.true_track) || 0; + const cacheValid = + flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao && + flightState._cachedDRCourse != null; + const raw = cacheValid ? flightState._cachedDRCourse : fallback; + const nowMs = Date.now(); + const dt = flightState._trackedCourseMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._trackedCourseMs) / 1000, + ) + : 0; + flightState._trackedCourseMs = nowMs; + const prev = flightState._displayCourse.get(flightState._trackedIcao); + // Hover hold: at near-zero displayed speed both the chord and the reported + // track are noise — keep the last stable nose direction instead of chasing. + if (cacheValid && flightState._cachedDRHold && prev != null) return prev; + const cap = courseSlewCapDps( + cacheValid + ? flightState._cachedDRSpeedMps + : ((info && info.velocity) ?? NaN), + COURSE_MAX_DPS, + ); + const course = limitCourseStep(prev, raw, cap, dt); + flightState._displayCourse.set(flightState._trackedIcao, course); + return course; + } + + /** Reset reconciliation + per-frame cache when tracking stops or switches + * target. Deliberately does NOT touch _displayCourse: the smoothed course + * entry belongs to the AIRCRAFT (not to the tracked session) and must + * survive the handoff back to the fleet pass. */ + + function _resetTrackedDisplay() { + flightState._drReconcileValid = false; + flightState._drReconcileIcao = null; + flightState._cachedDRFrame = -1; + flightState._cachedDRPosition = null; + flightState._cachedDRCourse = null; + flightState._cachedDRSpeedMps = null; + flightState._cachedDRHold = false; + flightState._trackedCourseMs = 0; + } + + /** + * The floor to stand a grounded contact on while its own cell is unresolved. + * + * Owner directive (2026-08-21, after a Re:Earth outage buried a parked contact + * at a Texas field): "hold the last known altitude until a fresh one comes in. + * Never render otherwise." Two tiers, strongest first: + * own — a floor this contact's OWN cell resolved to while it stood there. + * Nothing weaker can improve on it, and its cell warming again is picked + * up by the live read, not here. Valid only within + * HELD_FLOOR_MAX_DRIFT_KM of where it was measured. + * neighbor — the LOWEST of at least two resolved ADJACENT cells (~111 m away, + * the same apron). Two at least, because one reading cannot be checked + * against anything, and the LOWEST because a high reading beside a cold + * cell is as likely to be a terminal roof as ground — see neighborFloorM. + * + * BOTH are validated measurements out of the shared floor cache. A third tier + * that read the rendered mesh directly, where no DEM existed to check it, was + * built and then REMOVED: run against a real GPU with the proxy down it + * recorded a coarse-LOD 20.6 m for ground that is really ~122 m (see the note + * in meshFloorSampler.js). Nothing in this chain is a guess. + * + * A neighbour hold keeps re-probing at the throttle so it can UPGRADE: a parked + * contact that first answered from one neighbour would otherwise keep it after + * a better one warmed. A held value is never dropped for nothing — a failed + * probe leaves the previous answer standing — and a re-probe that lands LOWER + * eases rather than steps (see the ease note in the clamp below). + * + * A REHYDRATED SEED is the one exception to the tier order. A floor parked by + * `_retireDisplayFloorState` and picked back up on a later re-ground was + * measured while the contact stood somewhere it no longer necessarily is: it is + * a memory, not a reading, and it outranks nothing. So `seeded` demotes an + * `own` floor below the neighbour tier — when two fresh adjacent cells can + * answer, they answer, and the seed is discarded. It serves only while nothing + * fresh contradicts it, which is exactly the flap case it exists for (a rotation + * outruns its own cells, so nothing nearby is warm either). Without this a + * contact that re-grounded 0.5 km away kept a 200 m floor while its new + * neighbourhood read 100 m and 105 m, and rendered 100 m in the air. + * + * @param {object} state - The contact's `_displayFloorState` entry (mutated). + * @param {{lat: number, lon: number}} cell - Cell the display is reading now. + * @param {number} nowMs - Tick clock. + * @returns {number|null} Floor to use, or null when nothing anywhere can answer. + */ + + function _heldDisplayFloorM(state, cell, nowMs) { + if ( + state.heldTier && + !( + Number.isFinite(state.heldM) && + state.heldCell && + parts.queries._approxDistanceKm( + cell.lat, + cell.lon, + state.heldCell.lat, + state.heldCell.lon, + ) <= HELD_FLOOR_MAX_DRIFT_KM + ) + ) { + // Out of range: the held value is no longer a measurement of anywhere this + // contact has been. Drop it rather than stretch it. + _dropHeldFloor(state); + } + if (state.heldTier === 'own' && !state.seeded) return state.heldM; + if ( + state.probeMs != null && + nowMs - state.probeMs < NEIGHBOR_FLOOR_PROBE_MS + ) + return state.heldM; + state.probeMs = nowMs; + const near = neighborFloorM(cell); + if (near != null) return _adoptHeldFloorM(state, near, cell, 'neighbor'); + return state.heldM; + } + + /** Records a held floor and the tier it came from; returns it. Every caller + * passes a floor measured for the cell the contact is reading NOW, so an + * adoption always clears the seed flag: live evidence has arrived. */ + + function _adoptHeldFloorM(state, floorM, cell, tier) { + state.heldM = floorM; + state.heldCell = cell; + state.heldTier = tier; + state.seeded = false; + return floorM; + } + + /** Forgets the held floor and everything that describes where it came from, + * leaving the rest of the contact's display state alone. */ + + function _dropHeldFloor(state) { + state.heldM = null; + state.heldCell = null; + state.heldTier = null; + state.seeded = false; + } + + /** Whether a parked seed has been away longer than the grace window. + * + * ONE judgement, asked from BOTH sides of the park, because neither side sees + * the whole story on its own. While the contact keeps reporting, the retire + * path asks it and drops the entry. But a contact can be parked and then make + * no calls at all — off the poll for a long-haul cruise, out of the corridor + * radius, tab hidden — and then re-ground; nothing ran in between, so an + * expiry checked only on the retire path never fires and an arbitrarily old + * measurement walks back in (measured: parked 198 s, still reused). The + * rehydration side therefore asks the same question against the wall clock + * before it clears `retiredMs`. + * @param {object} state @param {number} nowMs - Tick clock. */ + + function _seedExpired(state, nowMs) { + return ( + state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS + ); + } + + /** Retires a contact's display-floor state. Called the moment it stops being a + * grounded billboard — airborne, model-owned, or gone. + * + * The floor itself is kept as a rehydration seed for FLOOR_SEED_GRACE_MS (see + * above) and MARKED as one; everything that describes the contact's CURRENT + * rendering is cleared, so a re-ground recomputes from scratch and cannot be + * mistaken for a hold release. Nothing visual is touched, so the T7 + * model-ownership gate is unaffected. + * @param {string} icao24 @param {number} nowMs - Tick clock. */ + + function _retireDisplayFloorState(icao24, nowMs) { + const state = flightState._displayFloorState.get(icao24); + if (!state) return; + if (state.retiredMs == null) { + state.retiredMs = nowMs; + state.seeded = true; // what it answers with next is a memory, not a reading + state.heldActive = false; // a later landing is an arrival, not a release + state.easedM = null; + state.easeMs = null; + state.probeMs = null; // re-ground may probe immediately + state.out = null; + state.effectiveM = null; + Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO, state.in); // invalidate the memo + } else if (_seedExpired(state, nowMs)) { + flightState._displayFloorState.delete(icao24); // airborne long enough to be anywhere + } + } + + /** + * Floors a GROUNDED contact's DISPLAYED position onto the local ground. + * + * `renderAltitudeM` is chosen once per poll from the floor of the FIX's coarse + * cell. The position that renders is the dead-reckoned one, which drifts away + * from that fix for the whole segment — and for up to 300 s / several hundred + * metres while a ground contact coasts through its stale-feed grace. Across a + * graded apron (KAUS spans ~119–140 m ellipsoidal) that drift buries the + * sprite under the mesh it is now over; `scripts/qa-floor-verify.mjs` measured + * −15.5 m. A second, smaller share comes from the fix-time floor itself: a + * taxiing contact whose current cell is still cold falls back to the PREVIOUS + * fix's cell (see the grounded `surfaceM` chain) and nothing revisits that + * height once the cell warms — the stale re-floor sweep deliberately skips + * contacts present in the poll. Both are cured by reading the floor at the + * coordinate actually being displayed. + * + * Discipline: + * - READ-ONLY against the shared floor cache. No latch, no heal, no sampling. + * Keeping cold cells rare is `_collectDisplayCorridorCells`'s job, not this + * one's. A cold cell used to mean NO clamp at all, which was only safe while + * the un-clamped height was a real reading — and for a grounded contact with + * no altitude data it is not: the poll path's last resort is the geoid, tens + * of metres under the mesh at an inland field. When the cell cannot answer, + * `_heldDisplayFloorM` holds the last floor that DID (owner, 2026-08-21). + * Still never an invented surface: every tier is a measurement, and when + * none exists the position passes through as before. + * - Grounded contacts only. Airborne heights are the fix-time clamp's job. + * - NEVER when a 3D model owns the visual (T7): the model rides groundSnap's + * one-shot tileset sample and the billboard hides behind it, so clamping the + * hidden billboard would put a SECOND ground chain on one contact — and the + * one the operator is not looking at. (The original rationale was narrower: + * lifting the datum dragged groundSnap's input past its 50 m + * move-invalidation and forced a re-sample every frame. groundSnap now + * measures that distance on the ellipsoid, so a purely vertical change costs + * nothing; the gate stays for the reason above.) Same gate the military + * layer's grounded billboard lift uses. + * + * @param {string} icao24 - Contact key (owns one `_displayFloorState` entry). + * @param {object|null|undefined} info - `_flightData` record for this contact. + * @param {Cesium.Cartesian3|null} pos - Dead-reckoned display position. + * @param {boolean} modelOwnsVisual - Whether a 3D model is drawing this contact. + * @param {number} [nowMs] - Tick clock, passed by both callers so the release + * ease advances on the same clock the rest of the tick uses. + * @returns {Cesium.Cartesian3|null} `pos` itself when nothing moves (the common + * case — no allocation, no rebuild), otherwise the lifted position. + */ + + function _floorGroundedDisplayPosition( + icao24, + info, + pos, + modelOwnsVisual, + nowMs = Date.now(), + ) { + if (!pos || !info?.onGround || modelOwnsVisual) { + _retireDisplayFloorState(icao24, nowMs); + return pos; + } + const state = flightState._displayFloorState.get(icao24); + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchDisplayCarto, + ); + // Boundary hysteresis: a position jittering across a cell edge would flip + // floors at fleet-tick rate (see stickyFloorCell). + const cell = stickyFloorCell( + Cesium.Math.toDegrees(carto.latitude), + Cesium.Math.toDegrees(carto.longitude), + state?.cell, + ); + const floor = cachedGroundFloor(cell.lat, cell.lon); + const next = state || { + cell, + in: new Cesium.Cartesian3(), + out: null, + effectiveM: null, + heldM: null, + heldCell: null, + heldTier: null, + heldActive: false, + seeded: false, + probeMs: null, + easedM: null, + easeMs: null, + retiredMs: null, + }; + // Back on the ground. Judge the seed's AGE here, before `retiredMs` is + // cleared: a contact that made no calls while it was away never reached the + // retire path's own expiry branch, so this is the only place that can tell an + // hour-old measurement from a three-poll-old one. + if (_seedExpired(next, nowMs)) _dropHeldFloor(next); + // The seed (if any survived) is live again, and the drift bound plus the + // neighbour tier in the hold chain decide whether it still describes ground + // this contact is on. + next.retiredMs = null; + // The floor to clamp against: this cell when it has one, otherwise the last + // one that resolved for this contact (never the geoid the poll path fell to). + // Snapshot what the contact was standing on BEFORE the chain overwrites it — + // the ease decision below needs the previous value, and `_heldDisplayFloorM` + // adopts into the same fields. + const wasHeld = next.heldActive; + const stoodOnM = next.heldM; + let effective = floor; + if (Number.isFinite(effective)) { + _adoptHeldFloorM(next, effective, cell, 'own'); + next.heldActive = false; + } else { + effective = _heldDisplayFloorM(next, cell, nowMs); + next.heldActive = Number.isFinite(effective); + } + // The floor moved DOWN under a contact that was standing on a BORROWED one. + // Dropping it by that difference in a single tick is the snap the owner asked + // not to have, so approach it instead. Two ways in, and both need it: + // - the real floor arrives below the hold (releasing the hold); + // - a re-probe finds a LOWER neighbour than the one being held, which the + // 12 m spread bound can make a large step on a mesa edge (200 m held, a + // 120 m neighbour warms, the bounded answer is 132 m) — and can happen + // AGAIN while the first approach is still running. + // Scoped to a borrowed floor on purpose: an ordinary cell-to-cell change + // between two resolved floors is the existing path and keeps its timing. + if ( + next.easedM == null && + wasHeld && + Number.isFinite(stoodOnM) && + Number.isFinite(effective) && + effective < stoodOnM + ) { + next.easedM = stoodOnM; // start from where the contact is actually drawn + next.easeMs = nowMs; + } + if (next.easedM != null) { + if (!Number.isFinite(effective) || effective >= next.easedM) { + // Nothing to approach, or the floor rose: take it whole and stop. + next.easedM = null; + next.easeMs = null; + } else { + // Exponential approach from the DISPLAYED value. The target may have + // moved since last tick; that changes only where this is heading, never + // where it is, so there is no seam to jump across. + const dtMs = Math.max(0, nowMs - next.easeMs); + next.easeMs = nowMs; + const closed = Math.min( + FLOOR_EASE_MAX_STEP, + 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS), + ); + let value = next.easedM + (effective - next.easedM) * closed; + if (Math.abs(value - effective) <= FLOOR_EASE_EPSILON_M) { + value = effective; // arrive exactly, so a parked contact stops rebuilding + next.easedM = null; + next.easeMs = null; + } else { + next.easedM = value; + } + effective = value; + } + } + // Same input position AND the same EFFECTIVE floor ⇒ the same answer as last + // tick, so skip the rebuild. A clamped stationary contact (parked, coasting + // on a zero-velocity fix) hits this every tick; without it the identical + // Cartesian was rebuilt at ~12 Hz forever. The test is deliberately on the + // OUTPUT of the hold chain, not on its inputs: keying it to the raw cell + // floor let a parked contact whose cell never warmed return a memoized + // unresolved answer forever, so an adjacent-cell floor warming later was + // never adopted. One owned entry per grounded + // contact — O(grounded), dropped on eviction, on destroy, and the moment the + // contact stops being a grounded billboard. + if ( + state && + state.effectiveM === effective && + Cesium.Cartesian3.equals(pos, state.in) + ) { + return state.out || pos; + } + const lifted = displayFloorHeightM(carto.height, effective); + next.cell = cell; + next.effectiveM = effective; + Cesium.Cartesian3.clone(pos, next.in); + if (lifted == null) { + next.out = null; + } else { + // The cache OWNS its output: returning a shared scratch would let the next + // contact in the fleet loop overwrite a position already handed out. + next.out = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + lifted, + Cesium.Ellipsoid.WGS84, + next.out || new Cesium.Cartesian3(), + ); + } + if (!state) flightState._displayFloorState.set(icao24, next); + return next.out || pos; + } + + /** + * Adds the cells each grounded contact's DISPLAY is about to render over to + * this poll's floor warm/sample batch. + * + * The poll loop otherwise collects FIX cells only, so the clamp above has data + * exactly where the contact ISN'T. A contact taxiing at 10 m/s crosses a + * ~111 m cell every ~11 s while the batch runs once per 30 s poll, so it stays + * permanently ahead of its own floor data (the "taxiing cache" failure mode, at + * cell granularity) and the clamp silently passes. + * + * The corridor therefore follows the direction the display is actually MOVING, + * which is not always toward the fix: + * - INTERPOLATING between two fixes — the display is walking to the newest + * fix, so that fix is the endpoint (exact, no projection error). + * - EXTRAPOLATING — coasting past the newest fix on a stale feed, or the + * pre-history warm-up — the display travels along its course AWAY from that + * fix. Aiming at the fix here warms the BACKTRAIL while the contact stays + * one sampling cycle ahead and buried, so the endpoint is the position its + * own kinematics put it at two poll intervals from now. + * + * Runs EVERY poll, warm cells included, for the same reason the fix cells do: + * `warmGroundFloor` skips cells that already have a real DEM, but the mesh + * sampler needs to see a cell again AFTER its DEM prior lands, since a sample + * without that prior is rejected. Offering a cell once would leave it DEM-only + * for the session — at fields where the photogrammetric mesh sits well above + * bare earth that is still metres of burial. + * + * Budgeting is need-ranked and dedupe-first: cells this poll already collected + * cost NOTHING (a parked contact's corridor is its own fix cell, so it never + * competes), candidates are ordered by how many of their cells are actually + * cold, and each takes at most DISPLAY_CORRIDOR_FAIR_SHARE before anyone takes + * seconds. Insertion-order spending starved the contacts that needed it most. + * + * Purely additive to the existing batch: same fire-and-forget DEM resolve, same + * one-shot DEM-validated mesh sampler. No latch, no heal. + * + * @param {Array<{lat: number, lon: number}>} out - This poll's warm points. + * @param {number|null} viewerLat @param {number|null} viewerLon - Viewer subpoint. + */ + + function _collectDisplayCorridorCells(out, viewerLat, viewerLon) { + if (viewerLat == null || viewerLon == null) return; + // Cells the poll already collected (grounded + low-airborne fix cells). + const seen = new Set(); + for (const p of out) { + const c = coarseFloorCoord(p.lat, p.lon); + seen.add(`${c.lat},${c.lon}`); + } + + const candidates = []; + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround) continue; + // T7: a contact whose 3D model is the visual never reads a display floor. + if (parts.rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + if ( + parts.queries._approxDistanceKm( + viewerLat, + viewerLon, + info.rawLat, + info.rawLon, + ) > DISPLAY_CORRIDOR_RADIUS_KM + ) + continue; + const dr = _deadReckon(icao24, flightState._scratchCorridorPos); + if (!dr) continue; + // Read the sibling scratches IMMEDIATELY, before any other _deadReckon call. + const extrapolating = flightState._drExtrapolating; + const speedMps = Number.isFinite(flightState._drSpeedMps) + ? flightState._drSpeedMps + : info.velocity || 0; + const courseDeg = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : info.true_track || 0; + const c = Cesium.Cartographic.fromCartesian( + dr, + Cesium.Ellipsoid.WGS84, + flightState._scratchCorridorCarto, + ); + const lat = Cesium.Math.toDegrees(c.latitude); + const lon = Cesium.Math.toDegrees(c.longitude); + const cells = corridorFloorCells( + corridorPathLatLon({ + extrapolating, + displayLat: lat, + displayLon: lon, + courseDeg, + speedMps, + // Same turn the dead-reckon integrates — a sustained-turn taxi leaves a + // straight tangent within a few hundred metres. + turnRateDps: info.turnRateDps || 0, + fixLat: info.rawLat, + fixLon: info.rawLon, + lookaheadSec: DISPLAY_CORRIDOR_LOOKAHEAD_SEC, + }), + ); + let cold = 0; + for (const cell of cells) { + if (cachedGroundFloor(cell.lat, cell.lon) == null) cold += 1; + } + candidates.push({ cells, cold, speedMps }); + } + flightState._corridorEpoch += 1; + for (const cell of allocateCorridorCells( + candidates, + seen, + DISPLAY_CORRIDOR_CELL_BUDGET, + DISPLAY_CORRIDOR_FAIR_SHARE, + flightState._corridorEpoch, + )) { + out.push(cell); + } + } + return { + _refloorStaleGroundedContacts, + _deadReckon, + _extrapolateFix, + _isTrackWarmingUp, + _trackedFocusSizePx, + _trackedDisplayPosition, + _trackedDisplayCached, + _trackedVisualCached, + _trackedModelCenterWorld, + _trackedModelEnvelopeM, + _trackedTrailCached, + _trackedDisplayCourse, + _resetTrackedDisplay, + _heldDisplayFloorM, + _adoptHeldFloorM, + _dropHeldFloor, + _seedExpired, + _retireDisplayFloorState, + _floorGroundedDisplayPosition, + _collectDisplayCorridorCells, + }; +} diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs new file mode 100644 index 000000000..c951616bb --- /dev/null +++ b/src/layers/flights/ownership.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCivilFlightLayer } from './index.js'; +import { createFlightState } from './state.js'; +import { createEnrichment } from './enrichment.js'; + +function services() { + const names = [ + 'picking', + 'sprites', + 'trails', + 'aircraftPresentation', + 'camera', + 'militaryRegistry', + 'labels', + 'groundFloor', + 'meshFloor', + 'geoid', + 'focus', + 'readout', + 'context', + 'render', + 'recession', + ]; + return { + ...Object.fromEntries(names.map((name) => [name, {}])), + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + }; +} + +test('constructing independent flight layers performs no source request and isolates selection', () => { + let requests = 0; + const source = { + label: 'Fixture aircraft', + getSnapshot() { + requests++; + }, + }; + const first = createCivilFlightLayer({ source, services: services() }); + const second = createCivilFlightLayer({ source, services: services() }); + first.testing._armFlightTrackingRestoreForTest('abc123'); + assert.equal(first.testing._pendingFlightTrackingRestoreForTest(), 'abc123'); + assert.equal(second.testing._pendingFlightTrackingRestoreForTest(), null); + first.testing._setFlightTrackingRefreshOutcomeForTest({ ids: ['abc123'] }); + assert.notEqual(first.testing, second.testing); + assert.equal(first.getStats().source, 'Fixture aircraft'); + assert.equal(requests, 0); +}); + +test('a layer without a source fails before touching the viewer', () => { + const layer = createCivilFlightLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); +}); + +test('flight state owns distinct mutable records, scratch objects and floor samplers', () => { + const first = createFlightState({ services: services() }); + const second = createFlightState({ services: services() }); + for (const key of [ + '_flightData', + '_billboards', + '_positionHistory', + '_displayFloorState', + '_groundSnap', + '_scratchCarto', + '_models', + '_activeUpdateControllers', + 'lifetime', + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test('aborted enrichment cannot write into a later lifecycle or decrement its active count', async () => { + let resolve; + let signal; + const source = { + getEnrichment(query, options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }; + const flightState = createFlightState({ source, services: services() }); + const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + let writes = 0; + enrichment._enqueueEnrich( + 'test', + { kind: 'type', id: 'abc123' }, + () => writes++, + ); + await Promise.resolve(); + assert.equal(flightState._enrichActive, 1); + flightState.lifetime.abort(); + flightState.lifetime = new AbortController(); + flightState._enrichActive = 0; + resolve({ found: true }); + await new Promise((done) => setImmediate(done)); + assert.equal(signal.aborted, true); + assert.equal(writes, 0); + assert.equal(flightState._enrichActive, 0); +}); + +test('a synchronous enrichment failure stays bounded and releases its slot', async () => { + const flightState = createFlightState({ + services: services(), + source: { + getEnrichment() { + throw new Error('fixture failure'); + }, + }, + }); + const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + assert.doesNotThrow(() => + enrichment._enqueueEnrich('test', {}, () => assert.fail()), + ); + await new Promise((done) => setImmediate(done)); + assert.equal(flightState._enrichActive, 0); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js new file mode 100644 index 000000000..6ea4f0e77 --- /dev/null +++ b/src/layers/flights/policy.js @@ -0,0 +1,447 @@ +import * as Cesium from 'cesium'; + +export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; + +/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ + +export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); + +// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes +// taxiing and landing") ----------------------------------------------------------- +// Present-but-grounded planes are RENDERED instead of being skipped: same class +// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata +// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles +// the existing billboard in place, never a removal. Ground planes draw no trails +// and are excluded from the ambient enrichment sweep (click-to-enrich still +// works). In 3D mode they take model slots like airborne planes (owner decision +// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap +// (see _modelDisplayPosition). +// +// TINT: full-strength, same pipeline as airborne (white / amber-military / +// cyan-tracked). Day 1 shipped a slate-gray 50%-alpha "muted" ground tint; the +// owner killed it the same day ("just leave them as white, dude … in NYC I can +// barely see them, extremely grayed out"). "On the ground" reads from the ×0.8 +// scale + missing trail; "feed-dropped, coasting" stays the 45%-alpha stale +// fade — a full-alpha ground icon can never be confused with it. +/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ + +export const GROUND_SCALE = 0.8; + +// --- 3D model rendering (B3) --------------------------------------------------------- +// When enabled, aircraft render as 3D glTF models once the camera is below MODEL_ALT_CEIL_M +// (zoomed in); higher up they stay flat billboards. Eligibility is FRUSTUM-based (on-screen), and +// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each +// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). + +export const PLANE_MODEL_URL = '/models/airplane.glb'; + +export const MODEL_ALT_CEIL_M = 800000; +// m: below this camera altitude, draw 3D models (raised so it's easy to trigger) + +export const MODEL_MIN_PX = 24; +// floor so distant models stay visible WITHOUT ballooning into a giant +// min-pixel blob (was 54 — far planes at the All radius became white +// star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent + +export const TRACKED_MODEL_MIN_PX = 40; +// keep the glTF silhouette comparable to the selected 2D glyph at handoff + +export const TRACKED_MODEL_MAX_PX = 200; +// owner-selected close-range tracked-target feel + +export const MODEL_NATIVE_RADIUS_M = 34.41; + +export const MODEL_SCALE = 1; +// airplane.glb is transform-applied and baked to real-world meters +// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. + +export const MODEL_MAX = 150; +// 'proximity' cap (the planes immediately around you) + +export const MODEL_MAX_ALL = 350; +// 'all' cap (everything out to ~the horizon) +// Per-mode ADD / KEEP radii. The two modes differ by RADIUS, not just cap — otherwise they look +// IDENTICAL whenever fewer than a cap's worth of planes are in range (field bug: Proximity and All +// rendered the same). 'proximity' = a tight ring; 'all' = roughly to the horizon (state-scale). Each +// band has hysteresis (KEEP > ADD) so a plane doesn't release+reload its model when it straddles the +// add edge (the zoom/pan flicker). Beyond ADD a model would force-clamp to minimumPixelSize into a +// giant floating blob (the old 422 km airport-cluster bug), so far planes stay 2D dots; the cap + +// on-screen priority then spend the model slots on planes you can actually see. + +export const MODEL_PROX_ADD_M = 150000; +// proximity: model NEW planes within 150 km + +export const MODEL_PROX_KEEP_M = 185000; +// proximity: KEEP modeled planes out to 185 km + +export const COCKPIT_MODEL_MAX = 60; +// max concurrent GLBs in cockpit (never raises the map cap) + +export const MODEL_ALL_ADD_M = 400000; +// all: model NEW planes within 400 km (~to the horizon) + +export const MODEL_ALL_KEEP_M = 450000; +// all: KEEP modeled planes out to 450 km + +export const MODEL_HEADING_OFFSET_DEG = 180; +// airplane.glb nose is opposite Cesium heading-0 +// Owner launch-polish direction: models should read as clean light silhouettes, +// with only a weak diffuse contribution from the existing approved textures. + +export const MODEL_COLOR_BLEND_AMOUNT = 0.94; + +// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its +// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as +// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m +// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled +// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB +// by modelScale.test.mjs. + +export const MODEL_BELLY_OFFSET_NATIVE = 6.719; + +export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); + +/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */ + +export const ERROR_BACKOFF_INTERVAL = 20000; +// transient error retry +/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */ + +export const POSITION_HISTORY_LIMIT = 5; + +export const COCKPIT_CONTACT_SIZE_PX = 6; + +export const COCKPIT_CIVILIAN_COLOR = + Cesium.Color.fromCssColorString('#DCEEFF'); + +export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 3.0, + 8000000, + 0.5, +); + +// --------------------------------------------------------------------------- +// Track-history trail state (PRD WS-F F1/F4): a fading polyline behind the +// tracked aircraft. The accumulation array is intentionally SEPARATE from +// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) +// so the visible trail can grow to TRAIL_MAX_POINTS fixes. +// --------------------------------------------------------------------------- + +/** @constant {string} Civilian trail hue (PRD F4, pinned). */ + +export const TRAIL_COLOR = '#00d4ff'; + +/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +// --------------------------------------------------------------------------- +// Render-behind smoothing (PRD WS-C C2 — approved product decision): +// the fleet renders at now - RENDER_DELAY_SEC so positions interpolate +// BETWEEN two known fixes instead of extrapolating ahead and snapping back +// when the next poll lands. All consumers (labels, HUD, detection, +// frame_overhead) share this delayed clock; removing the delay reintroduces +// the back/forward oscillation and is a regression. +// --------------------------------------------------------------------------- + +/** @constant {number} Display latency in seconds (= one poll interval). */ + +export const RENDER_DELAY_SEC = 30; + +/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */ + +export const MISSING_POLL_LIMIT = 3; + +// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes +// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS +// the actual landing, so a landed plane's last airborne-classified fixes show +// it low + slow on the runway; when such a plane then drops out of the poll, +// it has landed (the feed reclassified it to ground traffic we filter out), +// not hit a transient gap — evict after ONE missed poll instead of the full +// grace. Thresholds: below ~150 m baro (MSL, so this only fires near +// sea-level-ish fields — deliberately conservative; a high-elevation airport +// ghost just falls back to the normal grace) AND below ~45 kts ground speed +// (≈23 m/s — rollout/taxi; nothing in normal FLIGHT is this slow, so cruise +// planes always keep the full grace that absorbs real feed gaps). +/** @constant {number} Max baro altitude (m, MSL) for the landed fast cull. */ + +export const LANDED_ALT_MAX_M = 150; + +/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ + +export const LANDED_SPEED_MAX_MPS = 23; + +/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ + +export const LANDED_MISSING_POLL_LIMIT = 1; + +// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only +// contacts rendering below the alt ceiling are ever clamped/warmed (terrain +// tops out well under it outside the extreme Himalaya; cruise traffic can't +// be below ground and costs zero lookups). The radius bounds the FLEET clamp +// to viewer-visible traffic — clamping thousands of global contacts would +// need unbounded terrain resolution (the tracked contact clamps regardless). +/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ + +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */ + +export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150; + +// --------------------------------------------------------------------------- +// Icon orientation (2026-06-10 playtest fix): rotation is computed by +// projecting each aircraft's course vector into WINDOW coordinates +// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera +// pitch/heading, including tracked-entity orbit mode. Rotation passes run on +// the fleet tick only when the camera pose changed (or 1s drift catch-up). +// The same tick horizon-culls billboards: with the Cesium globe hidden there +// is no far-side depth, so planes otherwise show through the planet. +// --------------------------------------------------------------------------- + +/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ + +export const FLEET_DR_INTERVAL_MS = 80; + +/** @constant {number} Max ms between rotation passes while the camera is idle. */ + +export const ROTATION_REFRESH_MS = 1000; + +/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary + * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ + +export const COURSE_MAX_DPS = 60; + +/** Never spend a long render stall's full elapsed time in one visible course step. */ + +export const COURSE_SLEW_DT_MAX_SEC = 0.25; + +// --------------------------------------------------------------------------- +// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 +// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart +// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally +// caches per-key on disk forever, negative results included, so repeat +// sessions never re-hit adsbdb). Each key is requested at most once per +// session. Priority jobs (tracked plane, model-eligible planes) jump the +// queue; the ambient fleet sweep (below) fills the back at poll cadence. +// --------------------------------------------------------------------------- + +export const ENRICH_MAX_INFLIGHT = 4; + +/** Min ms between request dispatches — the drip that bounds the fan-out to ≤5/s. */ + +export const ENRICH_DISPATCH_GAP_MS = 200; + +// --------------------------------------------------------------------------- +// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live +// category field is 0/"no info" for ~94% of planes, so ambient classification +// defaulted nearly the whole fleet to the airliner silhouette). Each poll, +// ON-SCREEN planes — same horizon-occluder + frustum tests the fleet tick's +// model-eligibility pass uses; no new per-plane raycast — that haven't been +// requested this session are enqueued NEAREST-TO-CAMERA FIRST, bounded by: +// - the shared queue's 4-concurrent / 200 ms-drip dispatch (above), +// - ≤ ENRICH_AMBIENT_PER_SWEEP new enqueues per poll (= one poll interval +// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and +// - a ROLLING token-bucket budget (below) bounding the sustained ambient +// request rate (repeat sessions resolve instantly from the proxy's +// permanent disk cache). +// Fail-silent by contract: the sweep never throws into the poll loop, never +// blocks rendering, and never touches tracking state. When a type answer +// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale +// in place (bb.scale composes multiplicatively with scaleByDistance). +// --------------------------------------------------------------------------- +// Rolling ambient budget (2026-07-03 field fix). The old ONE-SHOT session cap +// (300, refilled only in init) burned out in the first two polls of a busy +// region and never recovered — an hours-long session showed airliner +// monoculture in every NEW region until planes were clicked (the tracked path +// is uncapped). Token bucket instead: starts full at the ceiling, refills +// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped +// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains +// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with +// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL +// politeness bound on adsbdb; the 300 ceiling preserves the old first-look +// burst so a fresh region still classifies quickly. +/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */ + +export const ENRICH_AMBIENT_BUDGET_CEIL = 300; + +/** Tokens added back per refill window. */ + +export const ENRICH_AMBIENT_REFILL_TOKENS = 150; + +/** Refill window length (ms). */ + +export const ENRICH_AMBIENT_REFILL_WINDOW_MS = 5 * 60 * 1000; + +/** Max new ambient enqueues per poll sweep (≈ rate × poll interval). */ + +export const ENRICH_AMBIENT_PER_SWEEP = 150; + +// --------------------------------------------------------------------------- +// Tracked-display reconciliation. _deadReckon gives the RAW position from real +// fixes; at the warm-up→interpolation handoff (and on feed glitches / backfill +// splices) that raw value can step discontinuously. We absorb a step into a +// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked +// icon, camera, and trail head never visibly jump — with ZERO steady-state lag +// (the correction stays ~0 whenever motion is already continuous). +// --------------------------------------------------------------------------- + +export const DR_CORRECTION_MS = 900; + +/** Bounded on-demand loading for the tracked model. The tracked regime is + * DEFAULT-ON and its driver runs every `scene.preUpdate`, so a missing or + * corrupt GLB — or a dead network — would otherwise spin load→reject at frame + * rate for as long as the contact stays selected. Failures are counted PER + * SELECTION: a short backoff absorbs a transient blip, then the layer stops + * asking until the operator selects something else. The billboard is the + * visual throughout (the handoff only fades it once a model actually renders), + * so a latched failure degrades to exactly the pre-3D presentation. */ + +export const TRACKED_MODEL_MAX_LOAD_FAILS = 3; + +export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; + +/** @constant {number} Cap on NEW cells the display corridors may add to one + * poll's warm/sample batch — a view full of ground traffic must not balloon + * it. Cells the poll already collected are free (deduped before budgeting). */ + +export const DISPLAY_CORRIDOR_CELL_BUDGET = 64; + +/** @constant {number} Cells any single contact may claim in the first pass, so + * one long corridor cannot spend the whole budget while other contacts get + * nothing. Leftovers are handed out in a second pass. */ + +export const DISPLAY_CORRIDOR_FAIR_SHARE = 4; + +/** @constant {number} How far ahead a COASTING contact's corridor reaches: + * two poll intervals, so the ground it covers before the next batch lands is + * already warm. */ + +export const DISPLAY_CORRIDOR_LOOKAHEAD_SEC = RENDER_DELAY_SEC * 2; + +/** @constant {number} Corridors are only collected this close to the viewer: + * the mesh sampler ignores anything past 15 km, and a far contact's exact + * datum is subpixel. */ + +export const DISPLAY_CORRIDOR_RADIUS_KM = 25; + +/** @constant {number} How far a contact may travel from the cell that supplied + * its held floor before that floor stops describing the ground under it. + * + * Sized to the same worst-case ground segment `CORRIDOR_MAX_CELLS` is sized + * for — a 27 m/s rollout covers ~810 m in one poll interval — with headroom + * for a couple of polls of coasting. That is ~9 cells: aprons, taxiways and + * runways really are flat at that scale. An early draft used 5 km, which is + * ~45 cells and can leave the airfield entirely — the KAUS note in this file + * records a 21 m spread across the field alone — so the bound is the distance + * the contact can actually have travelled since the measurement rather than a + * comfortable-looking number. */ + +export const HELD_FLOOR_MAX_DRIFT_KM = 1; + +/** @constant {number} Minimum gap between adjacent-cell probes for one contact + * whose held floor is missing or came from a borrowed tier. A contact standing + * on its own resolved floor never probes at all. + * + * This per-contact throttle is the ONLY rationing on the probe path, and that + * is deliberate. A probe is eight synchronous `Map` reads against the shared + * floor cache — no fetch, no `sampleHeight`, nothing async — so it cannot + * queue work anywhere; every DEM request is driven by `warmGroundFloor` from + * the poll loop, already bounded by DISPLAY_CORRIDOR_CELL_BUDGET and the + * resolver's single-flight chain. Measured worst case (`scripts/qa-floorhold- + * probe-cost.mjs`): 200 synchronized all-cold contacts probing on the SAME + * tick cost 2.1 ms, 2.6% of one 80 ms fleet tick, and 2.1 ms per second of + * wall clock sustained under this throttle. An earlier draft added a global + * per-tick budget with a fairness queue on top of that; it protected ~2 ms and + * cost two starvation defects, so it was deleted. Nothing can starve here + * because there is no shared resource to be starved of. */ + +export const NEIGHBOR_FLOOR_PROBE_MS = 500; + +/** @constant {number} Time constant of the downward floor ease. A floor that + * drops UNDER a contact standing on a borrowed one is approached + * exponentially: each tick closes `1 - e^(-dt/TAU)` of the remaining gap, so + * at the 80 ms fleet cadence a tick moves ~20% of what is left and the value + * is within a few centimetres inside ~1.6 s. FLOOR_EASE_MAX_STEP caps that + * fraction so a delayed tick cannot close more. + * + * Exponential rather than a fixed-duration interpolation because the target + * MOVES: a second, lower neighbour can warm mid-ease. A from/duration ease + * re-evaluated against a new target jumps by the eased fraction of the change + * (measured: a 100 m single-tick drop late in an ease). Approaching from the + * CURRENTLY DISPLAYED value has no such seam — retargeting is just a different + * destination for the same continuous follow, and the per-tick bound holds + * however often the target moves. + * + * Rises are never eased: up is the safe direction, and easing up would park + * the contact under the mesh for the duration — the exact failure this whole + * path exists to prevent. + * + * Reachability note (2026-08-21, after neighborFloorM moved to a low lean): + * the ordinary arrival flows no longer produce a downward move at all — a + * borrowed floor is now the apron rather than a roof, so the contact rises + * once and stays (`scripts/qa-floorhold-staircase.mjs`: one step, zero float, + * in every scenario that settles). This machinery still guards the re-latch + * paths — a lower neighbour warming later, or an own-cell resolve below a + * hold — which the pins exercise directly. Whether those paths are worth the + * code is a follow-up judgement, deliberately not made in this change. */ + +export const FLOOR_EASE_TAU_MS = 360; + +/** @constant {number} Hard ceiling on the fraction of the remaining gap ONE + * tick may close, whatever its dt. The exponential alone is timing-dependent: + * it closes 19.9% at the 80 ms fleet cadence but 28.4% at 120 ms and 75% after + * a 500 ms stall (a hidden tab, a long frame, a GC pause), which would turn a + * delayed tick back into the snap this approach exists to prevent. Clamping + * the fraction rather than dt keeps the never-snap property a property of the + * code instead of a property of the schedule; a stalled tick simply resumes + * the approach at full rate rather than jumping most of the way. */ + +export const FLOOR_EASE_MAX_STEP = 0.22; + +/** @constant {number} Distance from the target at which the ease finishes + * exactly, so a parked contact stops rebuilding its position. Two orders of + * magnitude under GROUND_FLOOR_LIFT_M — invisible. */ + +export const FLOOR_EASE_EPSILON_M = 0.02; + +/** @constant {number} How long a retired hold stays usable as a rehydration + * seed — three poll intervals. + * + * Deleting the state outright was the first cut, and an owner sighting found + * what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good + * ground, then suddenly popped below the ground, then popped back up". + * OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and + * the fix's own height source switches at the same moment, from the resolved + * surface to baro + geoid N, which at a sea-level field IS the geoid and sits + * below the runway. A single airborne poll therefore wiped the only thing that + * was hiding that: the contact came back grounded with no prior, outrunning + * its own floor cells at 23 m/s, and rendered at the geoid until something + * ahead of it warmed (`scripts/qa-floorhold-staircase.mjs` §F1: 12 of 22 + * grounded ticks below the runway, and not recovering). + * + * So a retired hold is PARKED, not destroyed, and a contact that re-grounds + * soon after picks it back up. What makes that safe is the bound already in + * `_heldDisplayFloorM`: a seed only answers within HELD_FLOOR_MAX_DRIFT_KM of + * where it was measured AND only while no fresh neighbour contradicts it, so a + * genuine departure-and-landing-elsewhere still starts clean — the grace window + * is belt to those braces, retiring the seed outright once a contact has been + * airborne long enough to have gone anywhere. */ + +export const FLOOR_SEED_GRACE_MS = 90_000; + +/** Flip the whole 3D fleet's boost state by dropping models so the eligibility + * pass reloads them with creation-time boost options (both directions — a + * boosted model must not stay flat white back in Normal). Destroying 350 + * GPU-backed models synchronously inside the style handler stalls the render + * thread (review P1; same failure the cockpit path documents), so the release + * is BATCHED through the fleet tick: each tick drops a bounded slice, showing + * each plane's billboard first (gap-proof per plane, no double-image window). + * Models are tagged with the boost state they loaded under, so queue entries + * whose model already matches the current state (rapid style cycling, or a + * reload that already happened) are skipped. In-flight loads are invalidated + * immediately (cheap gen bumps); the tracked model is a single primitive and + * reloads synchronously. */ + +export const IR_RELOAD_BATCH = 40; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js new file mode 100644 index 000000000..83657ee3d --- /dev/null +++ b/src/layers/flights/queries.js @@ -0,0 +1,954 @@ +import { displayedKinematics } from '../../data/motionModel.js'; +import * as Cesium from 'cesium'; +import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; +import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; +import { + rankContactMatch, + contactMatchWins, + CONTACT_MATCH_TIER, +} from '../../data/contactMatch.js'; +import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; +import { + LANDED_ALT_MAX_M, + LANDED_SPEED_MAX_MPS, + FOCUS_EVIDENCE_DEV, + FLEET_DR_INTERVAL_MS, +} from './policy.js'; + +export function createQueries({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation; + const { formatFlightLevel } = services.labels; + const { isMilitaryIcao } = services.militaryRegistry; + const { applyTrackedCameraFrame } = services.camera; + const { + setFocusDeemphasisParams, + getFocusDeemphasisParams, + setFocusEvidenceNowMs, + advanceFocusEvidenceNowMs, + } = services.focus; + const { setAircraftRecessionParams, getAircraftRecessionParams } = + services.recession; + + /** + * Cheap equirectangular distance (km) — plenty accurate for the ~150 km + * ground-floor clamp gate; runs once per contact per poll, so no trig-heavy + * haversine needed. + * @param {number} lat1 @param {number} lon1 @param {number} lat2 @param {number} lon2 + * @returns {number} Approximate great-circle distance in km. + */ + + function _approxDistanceKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111.32; + const dLon = + (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLon); + } + + /** + * True when the aircraft's latest metadata reads "on or about the runway" + * (low + slow) — see the landed fast-cull rationale above. Both gates must + * hold, so a plane missing either datum keeps the normal grace. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {boolean} + */ + + function _likelyLanded(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return false; + // Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes + // never heal"): the fast cull only applies to contacts that were AIRBORNE + // this session — its original target, the post-LANDING ghost. OpenSky's + // ground coverage flaps constantly, so fast-culling every grounded contact + // put parked planes in an evict/re-enter churn: each re-entry was a + // brand-new contact with cold floor state (geoid-height first poll), so + // the apron looked half-empty AND perpetually sunken. First-seen-grounded + // contacts now ride the normal MISSING_POLL_LIMIT grace and keep their + // identity (and warmed floors) across feed flaps. + if (info.wasAirborne !== true) return false; + // Was airborne, then grounded, then VANISHED from the poll — landed ghost. + if (info.onGround) return true; + return ( + Number.isFinite(info.altitude) && + info.altitude < LANDED_ALT_MAX_M && + Number.isFinite(info.velocity) && + info.velocity < LANDED_SPEED_MAX_MPS + ); + } + + /** + * Normalize a value to a trimmed string. A whitespace-only field (" ") is + * truthy, so every label chain must trim FIRST and then fall through. + * @param {*} value - Any value (typically a metadata string or null). + * @returns {string} Trimmed string, or '' if falsy. + */ + + function _toCleanText(value) { + return String(value || '').trim(); + } + + /** + * The layer's ONE label convention: spoken callsign → tail registration → raw + * ICAO hex. Mirrors militaryFlights.js so the same aircraft reads identically + * in both layers. + * + * Registration is aircraft IDENTITY, not route, so unlike the origin/destination + * line it is NOT plausibility-gated — an adsbdb tail number describes the + * airframe itself and cannot go stale the way a leg can. + * + * This is a DISPLAY string only. Identity everywhere in this layer is `icao24` + * (the `_billboards`/`_flightData` key, the `sourceId` detection declutter hashes, + * the `id` that trackById/Context cohorts resolve) — never the label. + * @param {string} icao24 - ICAO 24-bit transponder address (the identity key). + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @returns {string} Display label; never empty. + */ + + function _contactLabel(icao24, info) { + return ( + _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24 + ); + } + + /** + * Build a public descriptor for one aircraft using its best current position + * (dead-reckoned when history exists, billboard position otherwise). + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Descriptor with a cloned position, or null if the aircraft is unknown. + */ + + function _describeFlight(icao24) { + // DATA, NOT PIXELS — deliberately UNFLOORED (2026-08-19). The display floor + // lifts grounded contacts onto the visible mesh so the sprite you see is not + // buried; that is a rendering correction, not a measurement. This descriptor + // feeds query/analyst/subject APIs — `findByQuery` (voice track-by-name), + // `getTrackedInfo` (cockpit + readout altitude), `getTrackedSubject` + // (proximity counts and distances) — where the honest answer is what the + // aircraft REPORTED, not where its icon was nudged to avoid clipping tiles. + // `altitudeM` is therefore the barometric/aviation value and `renderAltitudeM` + // the fix-time datum, neither of them the floored display height. No + // user-visible surface renders `position` as the plane's on-screen location — + // every visual consumer reads the floored per-frame cache instead (see + // `_trackedDisplayPosition`). If that ever changes, floor this path too. + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + const basePos = + parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + if (!basePos) return null; + const displayed = displayedKinematics({ + derivedSpeedMps: flightState._drSpeedMps, + derivedTrackDeg: flightState._drCourseDeg, + reportedSpeedMps: info?.velocity, + reportedTrackDeg: info?.true_track, + }); + const carto = Cesium.Cartographic.fromCartesian( + basePos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return null; + return { + icao24, + callsign: String(info?.callsign || '').trim() || null, + position: Cesium.Cartesian3.clone(basePos), + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + // The cockpit instrument reports aviation altitude, not the Cesium + // ellipsoid height of the camera/ground-clamped render position. The + // latter can be slightly negative over terrain near the surface. + altitudeM: Number.isFinite(info?.altitude) ? info.altitude : carto.height, + renderAltitudeM: Number.isFinite(info?.renderAltitudeM) + ? info.renderAltitudeM + : carto.height, + onGround: info?.onGround === true, + velocityMps: displayed.speedMps, + track: displayed.trackDeg, + stale: Boolean( + flightState._missingPolls.get(icao24) || flightState._backoff, + ), + airline: info?.airline ?? null, + // CLASS label follows the TR-3B conversion so every downstream card + // (cockpit, Contacts, analyst) agrees with the triangle on screen. + typeName: tr3bTypeLabel(icao24, info?.typeName ?? null), + typeCode: tr3bTypeLabel(icao24, info?.typeCode ?? null), + // IDENTITY, deliberately NOT converted: registration is the airframe's tail + // number and feeds `_contactLabel`'s callsign → registration → hex chain, so + // a converted contact keeps the label convention every other contact uses. + // Trimmed like `callsign` above so every consumer (cockpit readout, voice + // narration, getTrackedSubject) can use it as a label link without + // re-guarding a whitespace-only enrichment value. + registration: _toCleanText(info?.registration) || null, + origin: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.origin.code + : null, + destination: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.destination.code + : null, + route: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? { + origin: { ...info.route.origin }, + destination: { ...info.route.destination }, + } + : null, + }; + } + + function _normalizeTrackedIcao(candidate) { + const normalized = String(candidate ?? '') + .trim() + .toLowerCase(); + return normalized || null; + } + + /** + * Map one aircraft's internal poll record to a plain JSON-safe analyst + * record (analyst query engine seam). Pure — no Cesium types, no fetches; + * enrichment fields read the CACHED adsbdb values only. Missing/unknown + * fields are null, never NaN/undefined. The route-plausibility verdict is + * computed by the CALLER (it needs the billboard position) and passed in, + * so an implausible cached route is never surfaced as fact. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @param {{military?: boolean, routeOk?: boolean}} [flags] - Shared-registry + * military flag + route-plausibility verdict. + * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, + * lon: number|null, altitudeM: number|null, speedMps: number|null, + * heading: number|null, verticalRateMps: number|null, onGround: boolean, + * military: boolean, aircraftClass: string|null, originCountry: string|null, + * operator: string|null, routeOrigin: string|null, routeDestination: string|null}} + */ + + function mapAnalystRecord( + icao24, + info, + { military = false, routeOk = false } = {}, + ) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const callsign = text(info?.callsign); + return { + // Display identity for the narration layer. `id` is NOT a queryable field + // (see ANALYST_LAYERS) and follow-ups carry whole records, so this is a + // label, not a key — the engine keys on `icao24` below. + id: callsign || text(info?.registration) || icao24, + icao24, + callsign, + lat: num(info?.rawLat), + lon: num(info?.rawLon), + altitudeM: num(info?.altitude), // barometric/MSL — the aviation field, not the render height + speedMps: num(info?.velocity), + heading: num(info?.true_track), + verticalRateMps: num(info?.verticalRate), + onGround: info?.onGround === true, + military, + // A converted contact reports the class it RENDERS as, so an analyst + // filter/superlative agrees with the triangle on screen. + aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), + originCountry: text(info?.originCountry), + operator: text(info?.airline), + routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, + routeDestination: routeOk ? text(info?.route?.destination?.code) : null, + }; + } + const methods = { + mapAnalystRecord, + + id: 'flights', + + name: 'Live Flights', + + icon: '✈️', + + source: flightState._lastSource, + + // Browser-harness seam: isolates synthetic display-floor scenarios without + // changing any production lifecycle or cache policy. + _clearDisplayFloorStateForTest: + parts.testing._clearDisplayFloorStateForTest, + + /** @type {number} Polling interval (ms) between update() calls */ + updateInterval: 30000, + + /** + * Live layer params. + * `models3d` toggles 3D glTF model rendering for the FLEET (altitude-gated): when on, + * surrounding aircraft become 3D models once the camera is zoomed in past MODEL_ALT_CEIL_M. + * The TRACKED contact is NOT gated by this — it takes its 3D model by camera distance + * regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). + * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). + * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedFlightsTrackingId?: string|null}} params + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedFlightsTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + if ( + typeof params.models3d === 'boolean' && + params.models3d !== flightState._models3dEnabled + ) { + flightState._models3dEnabled = params.models3d; + if (!flightState._models3dEnabled) { + parts.rendering._releaseModels(); + parts.tracking._syncTracked2dRotation(); + // Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the + // tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it + // here would double-image the tracked plane when 3D is turned off mid-track. + if (flightState._billboardCollection) + for (const [icao, bb] of flightState._billboards) { + if (icao !== flightState._trackedIcao) bb.show = true; + } + } + } + if ( + (params.models3dMode === 'proximity' || + params.models3dMode === 'all') && + params.models3dMode !== flightState._models3dMode + ) { + // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. + flightState._models3dMode = params.models3dMode; + if (flightState._cockpitContactMode) { + parts.tracking._refreshCockpitNearContacts(); + flightState._lastFleetTickMs = 0; + } + } + if ( + typeof params.irBoost === 'boolean' && + params.irBoost !== flightState._irBoost + ) { + flightState._irBoost = params.irBoost; + parts.rendering._reloadModelsForIrBoost(); + // Sprites don't reload with the models — swap the TR-3B glyph between its + // cold and thermal-reactive variants directly. Bounded by the operator's + // own conversions, so this never touches the ordinary fleet. + parts.tracking._refreshTr3bForStyle(); + } + if (Object.hasOwn(params, 'selectedFlightsTrackingId')) { + const requested = _normalizeTrackedIcao( + params.selectedFlightsTrackingId, + ); + if (requested === flightState._trackedIcao) { + flightState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++flightState._trackingIntentGeneration; + flightState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + getParams() { + return { + models3d: flightState._models3dEnabled, + models3dMode: flightState._models3dMode, + irBoost: flightState._irBoost, + selectedFlightsTrackingId: flightState._trackedIcao, + }; + }, + + /** + * Re-render a contact whose TR-3B conversion just flipped (Easter egg). + * Callers own the registry write; this only re-derives what renders. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when this layer owns the contact. + */ + refreshTr3b(icao24) { + return parts.tracking._refreshTr3bContact(icao24); + }, + + /** + * Return a subsample of currently visible aircraft for detection overlay + * rendering (e.g. bounding boxes drawn on-screen by the CCTV detection layer). + * + * Uses a deterministic stride + seed to select a spatially distributed + * subset without sorting or shuffling. + * + * @param {object} [options] + * @param {number} [options.maxCount] - Maximum number of objects to return. + * @param {number} [options.seed] - Deterministic offset into the stride pattern. + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + // Compute a stride that evenly samples the billboard map. + // seed shifts the starting offset so successive calls can sample + // different aircraft without shuffling the underlying Map order. + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : flightState._billboards.size; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max( + 1, + Math.ceil(flightState._billboards.size / maxCount), + ); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [icao24, bb] of flightState._billboards) { + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if ( + flightState._cockpitContactMode && + icao24.toLowerCase() === flightState._cockpitSubjectId + ) + continue; + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so the detection box + // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + const model = flightState._models.get(icao24); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!isTracked && !bb.show && !modelOwnsVisual) continue; + const info = flightState._flightData.get(icao24); + let object = flightState._detectionObjects.get(icao24); + if (!object) { + object = { + sourceId: icao24, + type: 'AIR', + _weldPos: new Cesium.Cartesian3(), + }; + flightState._detectionObjects.set(icao24, object); + } + // WELD: anchor to whatever actually owns the visual. A model-owned contact is + // read straight off the translation the fleet tick already wrote into its + // modelMatrix, so bracket and label sit on the aircraft you can see instead of + // on the buried billboard position, which for a grounded plane is ~100 m below + // and rises only as the coarse ground-floor cell warms. Zero extra sampling and + // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep + // `bb.position` — sprite and bracket are co-located there, so association holds. + const spec = modelOwnsVisual + ? parts.rendering._modelSpec(info?.klass) + : null; + const pos = isTracked + ? parts.motion._trackedVisualCached() || bb.position + : modelOwnsVisual + ? modelVisualAnchor( + model.modelMatrix, + spec.visualCenterNative, + Number.isFinite(model.computedScale) + ? model.computedScale + : spec.scale, + object._weldPos || (object._weldPos = new Cesium.Cartesian3()), + ) + : bb.position; + if (!pos) continue; + object.position = pos; + object.skipLabel = isTracked; + // Card text only — declutter/cohort identity is `object.sourceId` (icao24). + const id = _contactLabel(icao24, info); + if (object.id !== id) object.id = id; + const altitude = info?.altitude; + if (object._altitude !== altitude) { + object._altitude = altitude; + object.metric = formatFlightLevel(altitude); // altitude is metres + } + result.push(object); + if (result.length >= maxCount) break; + } + if ( + flightState._detectionObjects.size > + flightState._billboards.size + 512 + ) { + for (const icao24 of flightState._detectionObjects.keys()) { + if (!flightState._billboards.has(icao24)) + flightState._detectionObjects.delete(icao24); + } + } + return result; + }, + + /** + * Find a single aircraft by free-text query. + * Match priority: exact icao24 hex, exact callsign, callsign prefix, + * then callsign substring (all case-insensitive, trimmed). + * @param {string} query - ICAO24 hex or full/partial callsign. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Best match with a cloned, dead-reckoned position, or null if none. + */ + findByQuery(query) { + if (!flightState._flightData || flightState._flightData.size === 0) + return null; + const q = String(query || '') + .trim() + .toLowerCase(); + if (!q) return null; + + // Registration is searched alongside callsign because it is what the + // operator SEES: a callsign-less contact reads as its tail number on the + // card, in the analyst's answer, and in the Contacts list. Matching only + // callsigns meant "follow 6606" — and the analyst → track_entity handoff + // the tool instructions prescribe — answered "nothing matched" for the + // very identity the app had just shown. Ranking is shared with the + // military layer (contactMatch.js) so the two cannot disagree, and it is + // strictly tiered so a registration can never out-rank a real callsign on + // feed order alone. + let best = null; + for (const [icao24, info] of flightState._flightData) { + const candidate = { + tier: rankContactMatch({ + query: q, + hex: icao24, + callsign: info?.callsign, + registration: info?.registration, + }), + id: icao24, + }; + if (!contactMatchWins(candidate, best)) continue; + best = candidate; + if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; + } + return best ? _describeFlight(best.id) : null; + }, + + /** + * Find aircraft near a given ECEF position, sorted ascending by distance. + * Return shape mirrors militaryFlightsLayer.getNearby (id/icao24/position/distance). + * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates. + * @param {number} range - Maximum distance in meters (Infinity if not finite). + * @param {number} [maxCount=50] - Maximum number of results to return. + * @param {object} [options] Query membership options. + * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. + * @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>} + */ + getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { + if ( + !center || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 50; + const maxRange = + Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; + + const now = Cesium.JulianDate.now(); + const nearby = []; + + for (const [icao24, bb] of flightState._billboards) { + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + if ( + !aircraftIncludedInNearby({ + isTracked, + billboardShown: bb.show, + modelRendering: parts.rendering._modelOwnsVisual(icao24), + includeHidden, + }) + ) + continue; + + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const pos = trackedPos || bb.position; + if (!pos) continue; + + const distance = Cesium.Cartesian3.distance(center, pos); + if (distance > maxRange) continue; + + const info = flightState._flightData.get(icao24); + const callsign = info?.callsign?.trim() || null; + nearby.push({ + // Label. Callers that need identity read `icao24` (Context cohorts do). + id: _contactLabel(icao24, info), + icao24, + callsign, + position: pos, + distance, + // Filter surface: the cockpit next/previous path matches on THIS field + // (militaryAwareness.aircraftClassMatchesFilter), so a converted contact + // has to report the class it renders as or a `tr3b` filter skips it. + aircraftClass: tr3bAircraftClass( + icao24, + String(info?.klass || '') + .trim() + .toLowerCase() || null, + ), + altitudeM: info?.altitude ?? null, + velocityMps: info?.velocity ?? null, + track: info?.true_track ?? null, + }); + } + + nearby.sort((a, b) => a.distance - b.distance); + return nearby.slice(0, limit); + }, + + /** + * Return id/label/position for up to maxCount currently rendered aircraft. + * Cheap snapshot for voice-tool framing — billboard positions only, no + * dead reckoning and no cloning. + * @param {number} [maxCount=500] - Maximum number of entries to return. + * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} + */ + /** + * Whether this layer still carries a contact, in O(1). + * + * Presence consumers must not infer absence from `getAllPositions`: it stops + * at its cap, and this layer routinely carries ~11k contacts against a + * 1,000-row cap, so "not in the returned rows" is not "gone". Id matching + * mirrors trackById: exact key first, then lowercase. + * A disabled layer keeps its records but hides the collection, so it must + * decline rather than answer from data the user can no longer see — + * otherwise a preserved subject reads as fresh off stale hidden state. + * @param {string} icao24 Contact identifier. + * @returns {boolean|null} Presence, or null when the layer is disabled or + * holds no data and therefore cannot answer. + */ + hasContact(icao24) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._billboards.size === 0 + ) + return null; + if (!icao24) return false; + const id = String(icao24).trim(); + return ( + flightState._billboards.has(id) || + flightState._billboards.has(id.toLowerCase()) + ); + }, + + getAllPositions(maxCount = 500) { + if ( + !flightState._billboardCollection || + flightState._billboards.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + + const result = []; + for (const [icao24, bb] of flightState._billboards) { + const pos = bb.position; + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) continue; + const info = flightState._flightData.get(icao24); + result.push({ + id: icao24, // identity (trackById resolves this) + label: _contactLabel(icao24, info), + position: pos, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + airline: info?.airline ?? null, + typeName: info?.typeName ?? null, + typeCode: info?.typeCode ?? null, + registration: info?.registration ?? null, + origin: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.origin.code + : null, + destination: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.destination.code + : null, + }); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory 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, no + * enrichment fetches (cached adsbdb values only). 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 ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._flightData.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const [icao24, info] of flightState._flightData) { + const routeOk = + !!info?.route && parts.tracking._routeIsPlausible(icao24, info.route); + result.push( + mapAnalystRecord(icao24, info, { + military: isMilitaryIcao(icao24), + routeOk, + }), + ); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Start camera-tracking an aircraft by ICAO24 address. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {boolean} True if the aircraft exists and tracking started. + */ + trackById(icao24, { origin = 'programmatic' } = {}) { + if (!icao24) return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._billboards.has(id)) return false; + if (parts.tracking._isExplicitTrackingOrigin(origin)) + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao === id) + return parts.tracking._publishTrackedSelection(id, origin); + parts.tracking._trackFlight(id, { origin }); + return true; + }, + + /** Resolve a shared Follow target only against the latest accepted refresh. */ + async resolveTrackingRestoreTarget( + icao24, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = _normalizeTrackedIcao(icao24); + if (!id) return { status: 'missing', reason: 'invalid-target' }; + const outcome = flightState._lastTrackingRefreshOutcome; + if (outcome.status !== 'accepted') { + return { + status: 'source-unavailable', + reason: 'OpenSky snapshot unavailable', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + }; + } + if (!outcome.ids.has(id)) { + return { + status: 'missing', + reason: 'target-absent-from-snapshot', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + }; + } + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const followed = this.trackById(id, { origin }); + return followed + ? { + status: 'found', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }, + + /** Reapply the canonical follow frame without recreating the selected flight. */ + refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { + if ( + !icao24 || + flightState._cockpitContactMode || + !flightState._viewer || + !flightState._trackedEntity + ) + return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if ( + id !== flightState._trackedIcao || + !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) + return false; + flightState._trackedCameraFrameStop?.(); + flightState._viewer.camera.cancelFlight(); + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + parts.tracking._publishTrackedSelection(id, origin); + return true; + }, + + /** + * Stop tracking the currently followed aircraft (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Describe the currently tracked aircraft at its dead-reckoned position. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Tracked aircraft info, or null when nothing is tracked. + */ + getTrackedInfo() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described) return null; + const { position, ...rest } = described; + return rest; + }, + + /** + * Return the current aircraft as a Context subject without changing + * tracking or camera ownership. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached subject descriptor, or null when no aircraft is tracked. + */ + getTrackedSubject() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described?.position) return null; + return { + layerId: 'flights', + id: described.icao24, + // Same label chain as getNearby/getDetectableObjects: a callsign-less + // contact reads as its registration, never as the raw ICAO hex. + label: + described.callsign || + _toCleanText(described.registration) || + described.icao24, + position: Cesium.Cartesian3.clone(described.position), + }; + }, + + ...(FOCUS_EVIDENCE_DEV + ? { + __focusEvidence: Object.freeze({ + setAircraft: parts.evidence._setFocusEvidenceAircraft, + moveAircraft: parts.evidence._moveFocusEvidenceAircraft, + snapshot: parts.evidence._focusEvidenceSnapshot, + setTuning({ focus = {}, horizon = {} } = {}) { + return { + focus: setFocusDeemphasisParams(focus), + horizon: setAircraftRecessionParams(horizon), + }; + }, + getTuning() { + return { + focus: { ...getFocusDeemphasisParams() }, + horizon: { ...getAircraftRecessionParams() }, + }; + }, + takeFrameClock(startMs = 1_000_000_000) { + if (!flightState._viewer || !Number.isFinite(startMs)) + return { ok: false, nowMs: null }; + flightState._viewer.useDefaultRenderLoop = false; + setFocusEvidenceNowMs(startMs); + flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; + // Cross a browser task boundary, then close Cesium's pending-loop + // latch. Any already-queued callback still observes the false gate, + // while manual evidence renders can begin without waiting on VSYNC. + return new Promise((resolve) => + setTimeout(() => { + if (flightState._viewer?._cesiumWidget) + flightState._viewer._cesiumWidget._renderLoopRunning = false; + resolve({ ok: true, nowMs: startMs }); + }, 0), + ); + }, + advanceFrameClock(deltaMs = FLEET_DR_INTERVAL_MS) { + return advanceFocusEvidenceNowMs(deltaMs); + }, + releaseFrameClock() { + setFocusEvidenceNowMs(null); + if (flightState._viewer) + flightState._viewer.useDefaultRenderLoop = true; + }, + }), + } + : {}), + + /** + * Return layer health/status for the HUD stats chip. + * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} + */ + getStats() { + const retryInSec = flightState._retryAt + ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) + : 0; + return { + count: flightState._count, + lastUpdate: flightState._lastUpdate, + stale: flightState._backoff, + error: flightState._lastError, + status: flightState._lastStatus, + retryInSec, + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + }, + }; + + return { + _approxDistanceKm, + _likelyLanded, + _toCleanText, + _contactLabel, + _describeFlight, + _normalizeTrackedIcao, + mapAnalystRecord, + methods, + }; +} diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js new file mode 100644 index 000000000..761d47536 --- /dev/null +++ b/src/layers/flights/rendering.js @@ -0,0 +1,1216 @@ +import * as Cesium from 'cesium'; +import { + CLASS_SCALE_2D, + CLASS_MODEL_REAL, + CLASS_SCALE_3D, + CLASS_MODEL_URL, +} from '../../data/aircraftClass.js'; +import { + visualCenterForModel, + trailAnchorForModel, +} from '../../data/modelVisualAnchor.js'; +import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { + cameraPoseSignature, + horizonOccluder, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { + MIL_TINT, + GROUND_SCALE, + MODEL_COLOR_BLEND_AMOUNT, + MODEL_SCALE, + PLANE_MODEL_URL, + MODEL_NATIVE_RADIUS_M, + MODEL_BELLY_OFFSET_NATIVE, + COCKPIT_CONTACT_SIZE_PX, + COCKPIT_CIVILIAN_COLOR, + MODEL_ALT_CEIL_M, + MODEL_MAX_ALL, + MODEL_MAX, + COCKPIT_MODEL_MAX, + MODEL_ALL_ADD_M, + MODEL_PROX_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_PROX_KEEP_M, + MODEL_HEADING_OFFSET_DEG, + IR_RELOAD_BATCH, + MODEL_MIN_PX, + TRACKED_MODEL_MIN_PX, + TRACKED_MODEL_MAX_PX, + FLEET_DR_INTERVAL_MS, + COURSE_SLEW_DT_MAX_SEC, + ROTATION_REFRESH_MS, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createRendering({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { isMilitaryIcao } = services.militaryRegistry; + const { tr3bIconKind, isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { + focusNowMs, + getFocusTarget, + nearFarScalarValueAtDistance, + advanceProjectedSpriteFocus, + } = services.focus; + const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = + services.recession; + + /** Fleet (untracked) billboard tint: amber for known-military, white otherwise. + * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ + + function _fleetBillboardColor(icao24) { + return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + } + + /** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ + + function _fleetBillboardScale(icao24, klass) { + return ( + (CLASS_SCALE_2D[klass] || 1) * + (flightState._flightData.get(icao24)?.onGround ? GROUND_SCALE : 1) + ); + } + + /** Depth-test policy for aircraft billboards. Round 5 (owner directive + * 2026-07-06: "I just want the planes and their lines to ALWAYS be + * visible... evenly applied"): EVERY contact renders depth-test-free at + * every distance — grounded, low, and airborne alike. The photoreal mesh + * writes depth and residual baro/floor error will always leave some sprite + * geometry at or below it; a uniform rule beats the grounded-only / + * low-AGL-only conditions that kept leaving classes of contacts buried + * (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field + * case). Far-side planes are still removed by the fleet tick's horizon + * occluder, which never depended on depth. Kept as a function so the + * callers' restyle sites stay diff-stable. */ + + function _groundDepthDistance() { + return Number.POSITIVE_INFINITY; + } + + function _modelSpec(klass) { + const cached = flightState._specCache.get(klass); + if (cached) return cached; + const real = CLASS_MODEL_REAL[klass]; + let spec; + if (real) { + spec = { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } else { + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; + spec = { + url, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(url), + trailAnchorNative: trailAnchorForModel(url), + }; + } + flightState._specCache.set(klass, spec); + return spec; + } + + function _normalBillboardScaleByDistance() { + // Preserve the established close-range 3× scale. Any smaller owner-visible + // default belongs in a separate evidence-backed proposal. + return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); + } + + function _cockpitBillboardScaleByDistance() { + return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); + } + + /** Sprite kind for one contact's billboard. Identity for every aircraft except + * the ones the operator converted into a TR-3B (Easter egg), which draw the + * black-triangle glyph — its thermal-reactive variant while an IR style owns + * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a + * conversion survive the poll reconciler and the two-tier raster swap. */ + + const _iconKind = (icao24, klass) => + tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); + + /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ + + function _applyFleetBillboardPresentation(icao24, bb) { + if (!bb) return; + const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; + const isCockpitContact = + flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; + const isCockpitNear = + isCockpitContact && flightState._cockpitNearContacts.has(icao24); + if (isCockpitContact && !isCockpitNear) { + const freshnessAlpha = bb.color?.alpha ?? 1; + bb.image = cockpitContactDotImage(); + bb.width = COCKPIT_CONTACT_SIZE_PX; + bb.height = COCKPIT_CONTACT_SIZE_PX; + bb.scale = limbScale; + bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + bb.color = ( + isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR + ).withAlpha(freshnessAlpha); + bb.rotation = 0; + return; + } + + const meta = flightState._flightData.get(icao24); + bb.image = aircraftIcon( + _iconKind(icao24, meta?.klass), + bb._gevIconLarge ? TRACKED_ICON_PX : undefined, + ); + bb.width = icao24 === flightState._trackedIcao ? 24 : 20; + bb.height = icao24 === flightState._trackedIcao ? 24 : 20; + bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale; + bb.scaleByDistance = _normalBillboardScaleByDistance(); + bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1); + } + + /** + * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, + * horizon-culls billboards beyond the limb (no far-side depth with the globe + * hidden), and refreshes screen-projected icon rotations whenever the camera + * pose changed (plus a 1s drift catch-up while idle). Driven by + * scene.preRender — NOT camera.changed, whose granularity is globally + * degraded by other layers mutating camera.percentageChanged. + * @returns {void} + */ + + /** Model tint, mirroring the billboard color rules. */ + + function _modelColor(icao24) { + if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; + return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + } + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). */ + + function _modelRegimeActive() { + if (!flightState._models3dEnabled) return false; + const h = + flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses + * at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is + // already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal + // retraversal, the cloud pass) and every model is its own draw call. + return flightState._cockpitContactMode + ? Math.min(COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */ + + function _modelAddDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_ADD_M + : MODEL_PROX_ADD_M; + } + + /** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */ + + function _modelKeepDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_KEEP_M + : MODEL_PROX_KEEP_M; + } + + /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into + * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one + * mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium + * clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at + * the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the + * scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */ + + function _modelMatrix( + pos, + headingDeg, + result = flightState._scratchModelMtx, + ) { + flightState._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + MODEL_HEADING_OFFSET_DEG, + ); + flightState._scratchModelHpr.pitch = 0; + flightState._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + flightState._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the + * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked + * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion + * matches picked-object IDs, and every billboard AND model in this layer carries its + * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the + * object itself. Built lazily — only when a sample actually fires (one-shot). */ + + function _groundSampleExclusions() { + const out = [...flightState._billboards.keys()]; + if (flightState._trackedEntity) out.push(flightState._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position + * verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near + * the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports, + * hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested + * model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides + * a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js, + * CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a + * bounded last-known and resamples), plus + * the belly offset so it sits on its gear rather than sinking to the model-origin + * fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss) + * there is no safe depth-tested placement at all: this returns null, the caller + * keeps the 2D billboard visible and the model hidden, and it retries later. Once + * a contact has resolved once, a later outage holds that measurement inside + * groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D + * because a resample is mid-backoff. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = flightState._flightData.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = flightState._groundSnap.heightFor( + flightState._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its billboard to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the billboard owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + if (!model) { + bb.show = true; + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + bb.show = true; + return false; + } + // The matrix is written BEFORE the readiness test on purpose: a model can flip + // `ready` during scene update after this tick, and a first rendered frame on a + // stale load-start matrix is the one-frame jump this ordering prevents. + _modelMatrix(displayPos, course, model.modelMatrix); + if (!model.ready) { + model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + bb.show = true; + return false; + } + beforeShow?.(); + if (!model.show) model.show = true; + if (bb.show) bb.show = false; // hand off ONLY once the model renders + return true; + } + + /** + * Whether a 3D model — not the billboard — is what the user actually SEES for + * this contact, and therefore whether the display floor should stand aside. + * + * Model EXISTENCE is not ownership. A fleet model that is still loading, or has + * no resolved ground to stand on, stays hidden while `bb.show` stays true (the + * gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model + * is retained but hidden whenever the model regime is off — 3D disabled, camera + * zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore + * suppressed the clamp in exactly the states where the BILLBOARD is the visual, + * putting the burial straight back. + * + * Cesium's own default `show === true` is the reason admission clears it + * explicitly: a fleet model registered in `_models` the instant its glTF resolves + * would otherwise claim ownership — at the identity matrix — for the frames + * between admission and the next fleet tick, while the BILLBOARD was still the + * visual. Ownership means actually rendering, and every site that sets `show` + * true does so only after committing a matrix. + * + * The two halves: + * - Fleet: the rendering test alone. Safe for the ground snap either way, + * because a fleet model is positioned from the RAW dead-reckon, not from + * the billboard. + * - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked + * model is fed from `_trackedDisplayPosition`, so once it is live the clamp + * must stand aside: two different chains would otherwise be deciding one + * contact's ground, and the billboard's is the one the operator is not + * looking at (T7). The regime check is what makes a regime + * flip take effect on the same frame rather than waiting for + * `_updateTrackedModel` to clear `show`; the rendering test is what keeps a + * null or still-loading tracked model from claiming a visual it is not + * drawing yet. + * @param {string} icao24 + * @returns {boolean} + */ + + function _modelOwnsVisual(icao24) { + if (icao24 === flightState._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(flightState._trackedModel) + ); + } + return _modelIsRendering(flightState._models.get(icao24)); + } + + /** A model is the visual only once it actually draws — loaded AND shown. + * + * `show` is sufficient evidence of a safe placement because only ONE site ever + * sets it true for a fleet model (`_driveFleetModelHandoff`, after the matrix is + * committed) and one for the tracked model (`_updateTrackedModel`, likewise); + * everything else — admission, an unresolved ground, a not-yet-ready glTF, the + * limb cull, a regime exit — only ever clears it. */ + + function _modelIsRendering(model) { + return !!model && model.ready === true && model.show === true; + } + + /** Spec identity for a LOADED model: URL and scale together (same-URL classes + * differ by scale — airliner vs quadjet both ship airplane.glb). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync (enrichment AND poll-path klass updates): when the + * aircraft's live model or in-flight load no longer matches its class's spec, + * drop it so the eligibility pass reloads the right asset at the right scale. + * Gap-proof: the fleet billboard is re-shown BEFORE the release so the + * contact never goes invisible for the tick gap; _releaseModel's generation + * bump also invalidates any pending load. The tracked standalone model gets + * the same rule (its billboard entity is always the fallback visual). */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const current = flightState._models.get(icao24); + if ( + (current && current._gevSpecKey !== key) || + (!current && flightState._modelPending.has(icao24)) + ) { + const bb = flightState._billboards.get(icao24); + if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao24); + } + if ( + icao24 === flightState._trackedIcao && + flightState._trackedModel && + flightState._trackedModel._gevSpecKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + flightState._irReloadQueue = [...flightState._models.keys()]; + for (const icao of flightState._modelPending) { + if (!flightState._models.has(icao)) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + if (!flightState._irReloadQueue) return; + const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); + for (const icao of batch) { + const model = flightState._models.get(icao); + if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state + const bb = flightState._billboards.get(icao); + if (bb && icao !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao); + } + if (flightState._irReloadQueue.length === 0) + flightState._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === flightState._trackedIcao) return; + parts.enrichment._requestTypeEnrichment(icao24, true); // model-eligible: about to render in 3D — jump the ambient backlog + if ( + flightState._models.has(icao24) || + flightState._modelPending.has(icao24) + ) + return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL. + if ( + flightState._models.size + flightState._modelPending.size >= + _modelCap() + ) + return; + const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + flightState._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START — if enrichment reclassifies the + // aircraft mid-load, the post-await admission below rejects the stale asset. + // Boost state likewise: the creation options bake it in, so a mid-load + // toggle must reject too (the reload queue only covers ADMITTED models). + const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const loadIrBoost = flightState._irBoost; + try { + const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // Launch presentation keeps the code-side tint dominant for every approved + // model; IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === flightState._modelEpoch) { + flightState._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== flightState._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + flightState._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (flightState._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() || + !flightState._flightData.has(icao24) || + icao24 === flightState._trackedIcao || + flightState._models.has(icao24) || + flightState._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + flightState._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // Keep the pick identity explicit on the resolved primitive. This also + // protects injected/custom loaders that do not copy the creation option. + model.id = icao24; + model._gevSpecKey = specKey; // class-change sync compares against this + model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this + // Admitted, not yet the visual. Cesium's default is show=true, which would let + // an unplaced primitive claim ownership from the billboard for the frames + // between admission and the next fleet tick (and draw at the identity matrix, + // i.e. the Earth's centre). The handoff turns it on once it has a matrix. + model.show = false; + flightState._modelCollection.add(model); + flightState._models.set(icao24, model); + flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). + * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ + + function _releaseModel(icao24) { + const m = flightState._models.get(icao24); + const pending = flightState._modelPending.has(icao24); + // Only bump the generation when there's something to invalidate (an in-flight load or a + // live model) — so removing never-modeled aircraft doesn't grow _modelGen. + if (m || pending) { + flightState._modelGen.set( + icao24, + (flightState._modelGen.get(icao24) || 0) + 1, + ); + } + if (m) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + flightState._models.delete(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if ( + !flightState._modelPending.has(icao24) && + !flightState._models.has(icao24) + ) + flightState._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + for (const icao of flightState._modelPending) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + for (const m of flightState._models.values()) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + } + flightState._models.clear(); + } + + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ + + function _releaseTrackedModel() { + flightState._trackedModelGen++; + flightState._trackedModelLoading = false; + if (flightState._trackedModel) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(flightState._trackedModel); + } catch { + /* gone */ + } + } + flightState._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet + * cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the + * follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed + * in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */ + + function _updateTrackedModel() { + const active = + flightState._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + flightState._modelCollection && + !flightState._modelCollection.isDestroyed(); + if (!active) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(flightState._trackedIcao) || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + if ( + !flightState._trackedModel && + !flightState._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + flightState._trackedModelLoading = true; + const gen = flightState._trackedModelGen; + const trackedSpec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedKey = _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedIrBoost = flightState._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, + colorBlendMode: Cesium.ColorBlendMode.MIX, + // The tracked aircraft uses the same dominant light tint as the fleet; + // IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) + .then((m) => { + // Untracked / re-tracked / torn down during the load → drop it. + if ( + gen !== flightState._trackedModelGen || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ) !== trackedKey || + flightState._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + flightState._trackedModelLoading = false; + return; + } + // Assign after resolution as well as in the creation options so the + // standalone primitive always exposes the tracked aircraft pick id. + m.id = flightState._trackedIcao; + m._gevSpecKey = trackedKey; // class-change sync compares against this + m.show = false; // admitted, not yet the visual — the driver shows it once placed + // Seed the world transform before the primitive enters the scene. A model + // can become ready+shown between render phases; leaving Cesium's identity + // default here produces a one-frame jump to the Earth's center. + const currentPos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (currentPos) { + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + currentPos, + flightState._scratchGroundPos, + ); + if (displayPos) + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + m.modelMatrix, + ); + } + flightState._trackedModel = m; + flightState._trackedModelLoading = false; + // A good load retires this selection's failure budget — a contact that + // recovers after a transient blip is not one attempt from giving up. + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + flightState._modelCollection.add(m); + flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up + }) + .catch((err) => { + if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure + flightState._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; // billboard carries the visual until the model is ready + } + if (flightState._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + return; + } + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + flightState._trackedModel.modelMatrix, + ); + if (!flightState._trackedModel.ready) return; + const spec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + flightState._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, + fovyRad: flightState._viewer.camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + flightState._trackedModel.show = true; + } + } + + function _fleetTick() { + if ( + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + const scene = flightState._viewer.scene; + const camera = flightState._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz + // primitive rebuild needed here anymore.) + + if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; + const tickDtSec = flightState._lastFleetTickMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._lastFleetTickMs) / 1000, + ) + : 0.08; + flightState._lastFleetTickMs = nowMs; + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (flightState._cockpitContactMode) + parts.tracking._refreshCockpitNearContacts(); + const poseSig = cameraPoseSignature(camera); + // Only the nearby Cockpit silhouettes need projected course; far dots are + // rotation-free. The per-contact gate below keeps the pip path cheap. + const doRotations = + poseSig !== flightState._lastCamPoseSig || + nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; + if (doRotations) { + flightState._lastCamPoseSig = poseSig; + flightState._lastRotPassMs = nowMs; + } + + const occluder = horizonOccluder(camera); + const focusTarget = getFocusTarget(); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if ( + !useModels && + (flightState._models.size || flightState._modelPending.size) + ) + _releaseModels(); + + // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the + // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen + // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, + // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots + // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). + // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an + // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap + // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum + // filtering dropped near off-screen planes entirely. This does both right. + let modelEligible = null; + if (useModels) { + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. + const cand = []; + for (const [icao, bb] of flightState._billboards) { + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as a billboard and can never take a model, so + // it must not occupy a CAP SLOT either — excluded here at selection time, + // not just at the handoff below, or accumulated conversions would starve + // ordinary contacts of 3D models. (The handoff guard stays as defence.) + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03: "3D mode is respected regardless of whether a plane is on the + // ground or in the air — no distinction"). The cap + nearest-first ordering + // below already bound airport clusters; grounded placement is handled by the + // one-shot ground snap in _modelDisplayPosition. + const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); // nearest first + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, + ); + flightState._lastModelCapWarnMs = nowMs; + } + modelEligible = new Set(); + // 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see). + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && flightState._models.has(icao)) modelEligible.add(icao); + } + // 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap). + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } + // 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a + // retained off-screen model can never starve an on-screen plane that wants one; dropping it is + // invisible and it re-adds the moment it's back in view). + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && flightState._models.has(icao)) modelEligible.add(icao); + } + // 4. ADD off-screen NEW inside the add radius with any leftover slots. + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + } + + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + + const info = flightState._flightData.get(icao24); + + const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + // The dead-reckoned point drifts away from the fix whose cell supplied the + // height, so a grounded contact's sprite ends up under the mesh it taxied + // (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and + // never while a 3D model owns the visual (T7). + const display = parts.motion._floorGroundedDisplayPosition( + icao24, + info, + dr, + _modelOwnsVisual(icao24), + nowMs, + ); + // Gate the write — assigning Billboard.position dirties the whole + // collection's vertex buffer, so skip sub-meter moves. + if ( + display && + Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0 + ) { + bb.position = display; + } + + // Round 6: occlusion-test a LIFTED point for contacts rendering below + // (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a + // sub-ellipsoid point near the limb "beyond the horizon" and the fleet + // pass would hide a plane that is really just low over high-N terrain + // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). + const beyondHorizon = !occluder.isPointVisible( + info?.cullPosition || bb.position, + ); + // A billboard flipping INTO view (horizon reveal while the camera idles) + // gets its rotation refreshed THIS tick even without a pose change — + // otherwise it reappears wearing its stale (often creation-north) nose for + // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false + // here; harmless — the model branch below `continue`s past the rotation.) + const revealed = !beyondHorizon && !bb.show; + if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + if (beyondHorizon) { + // Also hide any 3D model — otherwise a model that crossed the limb would keep + // rendering through the hidden globe at its last matrix. + const m = flightState._models.get(icao24); + if (m && m.show) m.show = false; + continue; + } + + // One order-independent write site composes freshness × focus × limb haze + // for alpha, and base class/ground scale × limb taper for scale. Cesium's + // locked NearFarScalar remains a separate multiplicative stage. This + // narrowly amends always-visible rendering without count culling or zero + // alpha; nearer focus behavior remains tunable rather than universal. + const cameraDistanceM = Cesium.Cartesian3.distance( + camera.positionWC, + bb.position, + ); + const distanceScale = nearFarScalarValueAtDistance( + bb.scaleByDistance, + cameraDistanceM, + ); + const focus = advanceProjectedSpriteFocus( + bb, + bb.position, + scene, + camera, + nowMs, + focusTarget, + undefined, + (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, + (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + ); + const isCockpitNear = + flightState._cockpitContactMode && + flightState._cockpitNearContacts.has(icao24); + const baseColor = + flightState._cockpitContactMode && !isCockpitNear + ? isMilitaryIcao(icao24) + ? MIL_TINT + : COCKPIT_CIVILIAN_COLOR + : _fleetBillboardColor(icao24); + const treatment = applyAircraftBillboardTreatment({ + billboard: bb, + baseScale: + flightState._cockpitContactMode && !isCockpitNear + ? 1 + : _fleetBillboardScale(icao24, info?.klass), + baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseColor, + focusFactor: focus.factor, + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }); + flightState._billboardLimbScale.set(bb, treatment.factors.scale); + // Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas + // has no mipmaps, so no single texture stays crisp across the ~25–150 + // device-px range scaleByDistance produces. Swap between the 64 px fleet + // raster and the 192 px close raster on the billboard's ACTUAL on-screen + // size — post-treatment bb.scale, so focus/limb recession counts — with + // hysteresis so zoom oscillation never thrashes the atlas. + if (!flightState._cockpitContactMode || isCockpitNear) { + const glyphDevPx = + (bb.width || 20) * + (bb.scale || 1) * + distanceScale * + (globalThis.devicePixelRatio || 1); + const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; + if (wantLarge !== !!bb._gevIconLarge) { + bb._gevIconLarge = wantLarge; + bb.image = aircraftIcon( + _iconKind(icao24, info?.klass), + wantLarge ? TRACKED_ICON_PX : undefined, + ); + } + } + + // Smoothed display course: the path direction _deadReckon just reported + // for THIS aircraft (nothing else calls _deadReckon in between), rate- + // limited so segment-boundary course steps glide instead of snapping. + // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering + // aircraft (hold flag) keeps its previous nose direction outright. + const rawCourse = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : (info && info.true_track) || 0; + const prevCourse = flightState._displayCourse.get(icao24); + const course = + flightState._drCourseHold && prevCourse != null + ? prevCourse + : limitCourseStep( + prevCourse, + rawCourse, + courseSlewCapDps( + flightState._drSpeedMps != null + ? flightState._drSpeedMps + : ((info && info.velocity) ?? NaN), + COURSE_MAX_DPS, + ), + tickDtSec, + ); + flightState._displayCourse.set(icao24, course); + + // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the + // billboard stays shown until the model is actually READY to render, so a plane is never both + // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every + // tick regardless so it's framed the instant it becomes ready. + // Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and + // there is no GLB for it, so the model handoff is suppressed rather than + // fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying + // the getNearby/getDetectableObjects `bb.show` visibility guards), so a + // converted contact still works in Contacts and Cockpit. + if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const ownsVisual = _driveFleetModelHandoff( + icao24, + model, + bb, + dr, + course, + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write — otherwise + // any alpha change would repaint the ordinary tint over the hot + // white. Boosted models also skip the recession fade: hot targets + // stay full-strength at any range (billboards keep their normal + // fade — full-opacity glyph walls read as overwhelming). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost ? 1 : treatment.alpha, + }); + }, + ); + if (ownsVisual) continue; // skip billboard rotation + } + + if ( + (!flightState._cockpitContactMode || isCockpitNear) && + (doRotations || revealed) + ) { + const rot = screenProjectedRotation( + scene, + bb.position, + course, + bb.rotation, + ); + if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { + bb.rotation = rot; + } + } + } + } + return { + _fleetBillboardColor, + _fleetBillboardScale, + _groundDepthDistance, + _modelSpec, + _normalBillboardScaleByDistance, + _cockpitBillboardScaleByDistance, + _iconKind, + _applyFleetBillboardPresentation, + _modelColor, + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; +} diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js new file mode 100644 index 000000000..cf825d648 --- /dev/null +++ b/src/layers/flights/state.js @@ -0,0 +1,501 @@ +import * as Cesium from 'cesium'; +import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; + +export function createFlightState({ source, services }) { + const { createGroundSnap } = services.groundSnap; + const flightState = { lifetime: new AbortController() }; + + flightState._source = source; + + /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs + * vertex-baked to real-world METERS in the airplane.glb axis convention, so + * they render at scale 1 with their own measured belly lift and bounding + * radius. Every other class keeps the shared-airplane.glb formula + * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap + * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in + * both branches. The code-side MIX tint dominates every existing asset so + * class silhouettes stay light without modifying third-party GLBs/textures. */ + /* Specs are static per class, and the detection weld now asks for one per + * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet + * pass — so this is memoized, as militaryFlights.js already does. */ + + flightState._specCache = new Map(); + + /** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ + + flightState._groundSnap = createGroundSnap(); + + flightState._scratchGroundCarto = new Cesium.Cartographic(); + + flightState._scratchGroundPos = new Cesium.Cartesian3(); + + /** @type {Cesium.PrimitiveCollection|null} */ + + flightState._modelCollection = null; + + /** @type {Map} icao24 → model */ + + flightState._models = new Map(); + + /** @type {Set} icao24 currently loading (async) */ + + flightState._modelPending = new Set(); + + /** @type {Map} icao24 → load generation; bumped on release to invalidate + * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ + + flightState._modelGen = new Map(); + + /** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle + * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ + + flightState._modelEpoch = 0; + + /** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs + * layer-state restoration, so this initializer — not the codec — is what the app + * actually starts with; it must stay in lockstep with the `models3d` default in + * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would + * light a button the layer has not armed. */ + + flightState._models3dEnabled = true; + + flightState._models3dMode = 'proximity'; + // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) + + flightState._lastModelCapWarnMs = 0; + // throttle the "more planes in view than the cap" console notice + // The tracked entity's billboard goes transparent (rather than hidden) once the model takes + // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its + // alpha after the GLB is preloaded so the model is ready to render the instant the billboard + // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not + // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + + flightState._planeModelLoaded = false; + + /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + + flightState._preloadModel = null; + + flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + + flightState._scratchModelMtx = new Cesium.Matrix4(); + + flightState._scratchModelBS = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1.0, + ); + // frustum-visibility test + /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ + + flightState._billboardLimbScale = new WeakMap(); + // keep last N positions per aircraft + + // --------------------------------------------------------------------------- + // Module-level state: billboard collection and per-aircraft lookup maps + // --------------------------------------------------------------------------- + + /** @type {Cesium.BillboardCollection|null} */ + + flightState._billboardCollection = null; + + /** @type {Map} icao24 -> billboard primitive */ + + flightState._billboards = new Map(); + + /** Stable lightweight records reused by the detection overlay between polls. */ + + flightState._detectionObjects = new Map(); + + /** @type {Map} */ + + flightState._flightData = new Map(); + + /** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */ + + flightState._focusEvidenceIds = new Set(); + + /** @type {Map>} */ + + flightState._positionHistory = new Map(); + + /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()) */ + + flightState._geoidReady = false; + + /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ + + flightState._geoidNCache = new Map(); + + /** @type {number} Current number of visible aircraft */ + + flightState._count = 0; + + /** @type {number|null} Epoch ms of last successful API update */ + + flightState._lastUpdate = null; + + /** @type {boolean} True while in a backoff/cooldown window */ + + flightState._backoff = false; + + /** @type {number} Epoch ms — earliest time the next fetch is allowed */ + + flightState._retryAt = 0; + + /** @type {string|null} Human-readable error string shown in stats chip */ + + flightState._lastError = null; + + flightState._activeUpdateControllers = new Set(); + + /** @type {number|null} HTTP status of the most recent API response */ + + flightState._lastStatus = null; + + /** @type {string} Source used by the latest successful snapshot. */ + + flightState._lastSource = source?.label || 'Aircraft'; + + /** @type {string} Completeness boundary for the latest successful snapshot. */ + + flightState._lastCoverage = 'worldwide upstream snapshot'; + + // --------------------------------------------------------------------------- + // Click-to-track state + // --------------------------------------------------------------------------- + + /** @type {string|null} ICAO24 of the currently tracked aircraft */ + + flightState._trackedIcao = null; + + flightState._pendingTrackingRestore = null; + + flightState._trackingIntentGeneration = 0; + + flightState._trackingRefreshEpoch = 0; + + flightState._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: flightState._lastSource, + coverage: null, + }; + + /** @type {Cesium.Entity|null} Entity used for camera tracking */ + + flightState._trackedEntity = null; + + /** Disposes the single active tracked-camera framing owner. */ + + flightState._trackedCameraFrameStop = null; + + /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a + * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's + * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on + * 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the + * model rides in _modelCollection and is driven per-frame, fully decoupled from the camera. */ + + flightState._trackedModel = null; + + /** Bumped on untrack/teardown so an in-flight tracked-model load resolves into a no-op. */ + + flightState._trackedModelGen = 0; + + flightState._trackedModelLoading = false; + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */ + + flightState._clickHandler = null; + + /** @type {Cesium.Viewer|null} Cached viewer reference */ + + flightState._viewer = null; + + /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ + + flightState._cockpitContactMode = false; + + /** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ + + flightState._cockpitNearContacts = new Set(); + + /** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ + + flightState._cockpitSubjectId = null; + + /** @type {((event: CustomEvent) => void)|null} */ + + flightState._cockpitModeListener = null; + + /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ + + flightState._trail = null; + + /** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE + * dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays + * glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */ + + flightState._trailHeadEntity = null; + + /** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ + + flightState._trailHeadSeq = 0; + + /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + + flightState._trailPositions = []; + + /** @type {number} Monotonic token — invalidates in-flight backfill responses */ + + flightState._trailBackfillToken = 0; + + /** @type {Map} icao24 -> consecutive missed polls */ + + flightState._missingPolls = new Map(); + + /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ + + flightState._lastFleetTickMs = 0; + + /** @type {string} Camera pose signature at the last rotation pass */ + + flightState._lastCamPoseSig = ''; + + /** @type {number} Epoch ms of the last full rotation pass */ + + flightState._lastRotPassMs = 0; + + /** @type {number} Last computed rotation for the tracked entity (radians) */ + + flightState._lastTrackedRotation = 0; + + /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + + flightState._preRenderRemove = null; + + flightState._trackedModelPreUpdateRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + + flightState._moveEndRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + flightState._trackedEntityChangedRemove = null; + + /** @type {(() => void)|null} militaryRegistry active-transition unsubscribe (M2 handoff sweep) */ + + flightState._milActiveChangeUnsub = null; + + // --------------------------------------------------------------------------- + // Scratch (reusable) variables — avoid per-frame heap allocation + // --------------------------------------------------------------------------- + + flightState._scratchOffset = new Cesium.Cartesian3(); + + flightState._scratchCarto = new Cesium.Cartographic(); + + flightState._scratchEnu = new Cesium.Matrix4(); + + flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; + + flightState._scratchRenderTime = new Cesium.JulianDate(); + + flightState._scratchFleetPos = new Cesium.Cartesian3(); + + flightState._scratchDrRaw = new Cesium.Cartesian3(); + + flightState._scratchWarmupTime = new Cesium.JulianDate(); + + flightState._trackedPosHolder = new Cesium.Cartesian3(); + + // --------------------------------------------------------------------------- + // Per-frame cache for the tracked entity's dead-reckoned position. + // The position, alignedAxis, and rotation CallbackProperties all fire each + // render frame; caching avoids running _deadReckon three times. + // --------------------------------------------------------------------------- + + /** @type {Cesium.Cartesian3|null} */ + + flightState._cachedDRPosition = null; + + /** @type {number} Frame number for which _cachedDRPosition is valid */ + + flightState._cachedDRFrame = -1; + + /** Course (deg) of the position `_deadReckon` most recently returned — set on + * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same + * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ + + flightState._drCourseDeg = null; + + /** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion + * `_deadReckon` just returned, and whether that motion is too slow for ANY + * course source to be trusted (hover/GPS drift — consumers HOLD their + * previous display course instead of chasing noise). */ + + flightState._drSpeedMps = null; + + flightState._drCourseHold = false; + + /** Sibling scratch: whether the position `_deadReckon` just returned came from + * EXTRAPOLATION (coasting past the newest fix, or the pre-history warm-up) + * rather than interpolation between two known fixes. The display-floor + * corridor needs it — an interpolating contact is walking TOWARD its newest + * fix, a coasting one is walking AWAY from it along its course, and warming + * the wrong end leaves a coaster permanently ahead of its floor data. */ + + flightState._drExtrapolating = false; + + /** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ + + flightState._cachedDRCourse = null; + + /** Frame-cached siblings of _cachedDRCourse (same discipline). */ + + flightState._cachedDRSpeedMps = null; + + flightState._cachedDRHold = false; + + /** Wall-clock of the tracked course limiter's last advance (dt source only — + * the course VALUE lives in the shared per-icao _displayCourse map below). */ + + flightState._trackedCourseMs = 0; + + /** Per-aircraft smoothed display course — the SINGLE source of truth for the + * nose direction an aircraft displays. The fleet pass reads/writes it at + * tick cadence for untracked planes; _trackedDisplayCourse reads/writes the + * SAME entry per frame for the tracked plane (the fleet pass skips the + * tracked icao, so exactly one writer owns an entry at a time). Sharing the + * entry — including its slew state — is what makes the tracked↔fleet + * handoff seamless (2026-07-03 field fix: separate states froze the fleet + * entry while tracked, so clicking / un-clicking a 65 kt helicopter FLIPPED + * its nose — "looks like it's going in reverse"). */ + + flightState._displayCourse = new Map(); + + flightState._enrichActive = 0; + + flightState._enrichLastDispatchMs = 0; + + /** @type {ReturnType|null} pending drip wake-up */ + + flightState._enrichDripTimer = null; + + flightState._enrichQueue = []; + + flightState._enrichSeen = new Set(); + + flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL; + + /** Epoch ms the bucket last accounted a refill window from (0 = unset). */ + + flightState._enrichAmbientRefillAnchorMs = 0; + + flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); + + flightState._drCorrectionStartMs = 0; + + flightState._drPrevRaw = new Cesium.Cartesian3(); + + flightState._drPrevDisplay = new Cesium.Cartesian3(); + + flightState._drPrevMs = 0; + + flightState._drReconcileValid = false; + + /** @type {string|null} icao the reconciliation state currently belongs to */ + + flightState._drReconcileIcao = null; + + /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ + + flightState._trackedVisualPos = new Cesium.Cartesian3(); + + flightState._trackedTrailPos = new Cesium.Cartesian3(); + + /** Scratch for the tracked model's world-space origin (the envelope centre). */ + + flightState._scratchTrailClip = new Cesium.Cartesian3(); + + /** Scratch for the shortened trail-head start. Owned by the head-segment + * callback alone, so nothing else can overwrite it mid-frame. */ + + flightState._scratchTrailHead = new Cesium.Cartesian3(); + + /** Hysteresis latch for the tracked contact's zoom regime, plus the selection it + * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER + * ceiling instead of inheriting the previous target's looser EXIT band. */ + + flightState._trackedZoomLatched = false; + + flightState._trackedZoomLatchIcao = null; + + flightState._trackedModelFailIcao = null; + + flightState._trackedModelFailCount = 0; + + flightState._trackedModelRetryAtMs = 0; + + /** @type {Cesium.Cartographic} Scratch for the grounded display-floor read. */ + + flightState._scratchDisplayCarto = new Cesium.Cartographic(); + + /** @type {Map} Per-grounded-contact + * display-floor state: the cell it is currently reading (boundary hysteresis), + * the last input position and the effective floor that produced the cached + * output (rebuild skip), the last floor that actually RESOLVED for it plus the + * tier it came from (the hold, below), whether that floor is a REHYDRATED SEED + * rather than something measured while the contact stood here (`seeded` — it + * ranks below live evidence), and the value the downward ease is currently + * displaying while it approaches a lower floor. + * Dropped with the contact, and whenever it stops being a grounded billboard. */ + + flightState._displayFloorState = new Map(); + + /** @type {number} Poll counter handed to the corridor allocator: it rotates + * runs of EQUALLY needy contacts so a tie larger than the budget cycles across + * polls instead of the same prefix winning forever. */ + + flightState._corridorEpoch = 0; + + /** @type {Cesium.Cartographic} Scratch for the corridor's display-end read. */ + + flightState._scratchCorridorCarto = new Cesium.Cartographic(); + + /** @type {Cesium.Cartesian3} Scratch for the corridor's dead-reckon probe. */ + + flightState._scratchCorridorPos = new Cesium.Cartesian3(); + + /** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles + * map LUMINANCE, so mid-gray textured models read cold and vanish into + * terrain. While a boost style is active every model renders flat white + * (hottest); per-spec color/tint restores on style exit. Driven by ui.js + * setStyle via the `irBoost` layer param. */ + + flightState._irBoost = false; + + /** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16): + * the white tint alone is applied to the MATERIAL, so Cesium still + * sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high + * sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed. + * LightingModel.UNLIT emits the flat white directly, orientation be damned. + * CRITICAL (field-verified via scene.pick): assigning customShader to an + * already-READY model is a silent no-op — the property sets but the shader + * program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD + * (see setParams), so every boosted model gets the shader AT CREATION. + * One shared shader instance — stateless, safe across models. */ + + flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }); + + flightState._irReloadQueue = null; + return flightState; +} diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js new file mode 100644 index 000000000..0c37d1152 --- /dev/null +++ b/src/layers/flights/testing.js @@ -0,0 +1,215 @@ +import * as Cesium from 'cesium'; +import { CYAN_TRANSPARENT } from './policy.js'; + +export function createTesting({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** Test seam for the display-floor clamp (unit-tested against real Cesium math + * with seeded mesh cells — the drift mechanism is otherwise only reachable + * through a live poll + render loop). */ + + function _floorGroundedDisplayPositionForTest( + info, + pos, + modelOwnsVisual, + icao24 = '__test__', + nowMs = Date.now(), + ) { + return parts.motion._floorGroundedDisplayPosition( + icao24, + info, + pos, + modelOwnsVisual, + nowMs, + ); + } + + /** Test hook: drops the per-contact display-floor state (hysteresis + rebuild + * cache) so each case starts clean. There is no cross-contact state to reset — + * the clamp is per-contact all the way down. */ + + function _clearDisplayFloorStateForTest() { + flightState._displayFloorState.clear(); + } + + /** + * Seed only the mutable state needed to exercise tracked-card refreshes through + * the production poll reconciler. Tests still call `flightsLayer.update()`; + * this seam avoids constructing the browser-only Cesium layer lifecycle. + * @param {object} state + * @param {string} state.icao24 + * @param {object} state.entity + * @param {object} state.meta + * @param {object} state.billboard + * @param {object} state.billboardCollection + * @param {object} state.viewer + * @param {Array} [state.history=[]] + * @param {boolean} [state.tracked=true] + * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed + * by icao24, for the billboard-hidden/model-shown handoff state. + */ + + function _setTrackedFlightRefreshStateForTest({ + icao24, + entity, + meta, + billboard, + billboardCollection, + viewer, + history = [], + tracked = true, + models = [], + modelCollection = null, + }) { + flightState._viewer = viewer; + flightState._modelCollection = modelCollection; + flightState._billboardCollection = billboardCollection; + flightState._billboards = new Map([[icao24, billboard]]); + flightState._models.clear(); + for (const [key, model] of models) flightState._models.set(key, model); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map([[icao24, meta]]); + flightState._positionHistory = new Map([[icao24, history]]); + flightState._missingPolls = new Map(); + flightState._displayCourse.clear(); + flightState._geoidNCache.clear(); + flightState._trackedIcao = tracked ? icao24 : null; + flightState._trackedEntity = tracked ? entity : null; + flightState._trackedModel = null; + parts.tracking._cancelPendingTrackingRestore(); + flightState._trackedModelLoading = false; + // NOTE: deliberately does NOT reset the per-selection latches. They are + // production state owned by the tracking lifecycle (_resetTrackedSelectionState), + // and clearing them here would mask exactly the deselect→re-track hole this + // seam is used to test. + flightState._backoff = false; + flightState._retryAt = 0; + } + + /** Seed the authoritative snapshot outcome used by share-Follow tests. */ + + function _setFlightTrackingRefreshOutcomeForTest({ + status = 'accepted', + ids = [], + source = 'OpenSky Network', + coverage = 'test', + } = {}) { + const epoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch, + status, + ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), + source, + coverage, + }; + } + + /** Add a cached contact so tests can model a target arriving on a later feed. */ + + function _addFlightTrackingCandidateForTest({ + icao24, + meta, + billboard, + history = [], + }) { + flightState._billboards.set(icao24, billboard); + flightState._flightData.set(icao24, meta); + flightState._positionHistory.set(icao24, history); + } + + /** Expose the military-suppression decision for the civil duplicate. */ + + function _militaryLayerSuppressesForTest(icao24) { + return parts.tracking._militaryLayerSuppresses(icao24); + } + + /** Arm the deferred restore latch directly, without a full setParams turn. */ + + function _armFlightTrackingRestoreForTest(id, origin = 'share-restore') { + flightState._pendingTrackingRestore = + id === null + ? null + : { id, generation: flightState._trackingIntentGeneration, origin }; + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingFlightTrackingRestoreForTest() { + return flightState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated feed refresh. */ + + function _applyPendingFlightTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Set the exact Cockpit subject through the production state transition for focused tests. */ + + function _setCockpitDetectionSubjectForTest(active, subjectId = null) { + parts.tracking._applyCockpitState({ active, subjectId }); + } + + /** Evaluate the TRACKED contact's zoom regime through the production predicate. + * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) + * and otherwise only observable through a live scene, so tests drive it here. */ + + function _trackedModelRegimeActiveForTest() { + return parts.tracking._trackedModelRegimeActive(); + } + + /** Run one frame of the production tracked-model driver (normally a + * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ + + function _updateTrackedModelForTest() { + return parts.rendering._updateTrackedModel(); + } + + /** Evaluate the production tracked-billboard handoff colour for focused tests. */ + + function _trackedBillboardColorForTest() { + return parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? CYAN_TRANSPARENT + : Cesium.Color.CYAN; + } + + /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + + function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { + return parts.rendering._driveFleetModelHandoff( + icao24, + flightState._models.get(icao24), + flightState._billboards.get(icao24), + position, + course, + ); + } + + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + + async function _ensureFleetModelForTest(icao24) { + await parts.rendering._ensureModel(icao24); + return flightState._models.get(icao24) || null; + } + return { + _floorGroundedDisplayPositionForTest, + _clearDisplayFloorStateForTest, + _setTrackedFlightRefreshStateForTest, + _setFlightTrackingRefreshOutcomeForTest, + _addFlightTrackingCandidateForTest, + _militaryLayerSuppressesForTest, + _armFlightTrackingRestoreForTest, + _pendingFlightTrackingRestoreForTest, + _applyPendingFlightTrackingRestoreForTest, + _setCockpitDetectionSubjectForTest, + _trackedModelRegimeActiveForTest, + _updateTrackedModelForTest, + _trackedBillboardColorForTest, + _driveFleetModelHandoffForTest, + _ensureFleetModelForTest, + }; +} diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js new file mode 100644 index 000000000..3db2ce862 --- /dev/null +++ b/src/layers/flights/tracking.js @@ -0,0 +1,1262 @@ +import * as Cesium from 'cesium'; +import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; +import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { trailHeadStart } from '../../data/modelVisualAnchor.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { routePlausible } from '../../data/routePlausible.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + bindTrackingClickGesture, + isTrackingSelectionGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import { + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + TRAIL_MAX_POINTS, + RENDER_DELAY_SEC, + TRAIL_COLOR, + CYAN_TRANSPARENT, +} from './policy.js'; + +export function createTracking({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + selectTrackedSubjectContext, + clearTrackedSubjectContext, + refreshTrackedSubjectContext, + } = services.context; + const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } = + services.aircraftPresentation; + const { createTrail } = services.trails; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = + services.groundFloor; + const { clearFocusTarget } = services.focus; + const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { applyTrackedCameraFrame } = services.camera; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const flightsLayer = layer; + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'flights', + id: icao24, + // Canonical display chain (callsign → registration → hex). Publishing a + // bare `callsign || icao24` here resurrected the pre-enrichment behavior: + // a callsign-less contact reached Context as its raw hex even once adsbdb + // had supplied a registration. Identity below stays `icao24`. + label: parts.queries._contactLabel(icao24, info), + position: Cesium.Cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; + } + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const altFt = Math.round((described.altitudeM || 0) * 3.28084); + const route = + described.route && _routeIsPlausible(icao24, described.route) + ? `${described.route.origin.code} → ${described.route.destination.code}` + : null; + return { + id: icao24, + layerId: 'flights', + layerName: 'Live Flights', + source: flightState._lastSource, + label: parts.queries._contactLabel( + icao24, + flightState._flightData.get(icao24), + ), + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: parts.queries._contactLabel( + icao24, + flightState._flightData.get(icao24), + ), + operator: described.airline || '', + callsign: described.callsign || '', + registration: described.registration || '', + type: described.typeName || described.typeCode || '', + altitude: described.onGround + ? 'on ground' + : `${altFt.toLocaleString('en-US')} ft`, + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${Math.round(described.track)}°` + : '', + route: route || '', + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; + } + + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } + + /** + * Refresh the Cockpit AIR near/far band without consulting model state. + * Near contacts keep their 2D aircraft silhouette when 3D is off, loading, or + * capped; only a ready admitted model may take that silhouette over later. + */ + + function _refreshCockpitNearContacts() { + if ( + !flightState._cockpitContactMode || + !flightState._viewer?.camera?.positionWC + ) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; + } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + Cesium.Cartesian3.distanceSquared( + flightState._viewer.camera.positionWC, + bb.position, + ), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; + } + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never destroy on entry: tearing down hundreds of live glTF instances + // synchronously blocked Chrome's renderer into Page Unresponsive. + if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + } + + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); + } + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to the original + * layer) never makes `_trackedIcao` *observably* change, so the guard never + * fires. Without the reset, a contact dropped inside the hysteresis band comes + * back as a MODEL above the ENTER ceiling, and a contact whose GLB had already + * failed out would never get its retries back. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `[Data:Flights] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the + * DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is + * what the camera is pointed at, and zooming in on a target should resolve it + * into an aircraft without the operator arming anything. The toggle keeps + * owning the FLEET (`_modelRegimeActive`), which is the draw-call budget. + * + * Thresholds + hysteresis live in trackedModelRegime.js: enter at + * TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance, + * deliberately NEARER than the fleet's 800 km ceiling this used to inherit), + * hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary + * cannot flap billboard↔model. See that module's header for why the tracked + * contact now goes 3D closer in than the fleet does. + * + * In cockpit you are sitting 7 m behind and 2.6 m above your own aircraft's + * origin, so its ~26 m airframe would fill the visor. First-person means your + * own airframe is not drawn. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._viewer?.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._viewer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push(position); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail with its head clamped to the render-behind display + * position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the + * displayed aircraft (PRD C2 delayed clock) — drawing them verbatim makes + * the trail extend in FRONT of the icon. The head is refreshed ~1Hz from + * the fleet tick so it stays glued to the moving aircraft. + */ + + function _refreshTrailDisplay() { + // The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest + // fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + // now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane. + // The cheap per-frame _trailHeadEntity segment bridges the last body point to the + // delayed dead-reckoned head, so the body primitive only rebuilds on a real fix + // (poll cadence), never at motion cadence. + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget an OpenSky track backfill. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join the trail via _appendTrailFix + // as they age past the delay. + const seedRenderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + for (const fix of history) { + if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position)); + } + } + if (!flightState._trail && flightState._viewer) { + flightState._trail = createTrail(flightState._viewer, { + color: TRAIL_COLOR, + width: 2.5, + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last fix → current dead-reckoned icon, updated every frame via + // a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the + // trail primitive which fully rebuilds on setPositions). Keeps the head glued to the + // 12 Hz icon instead of lagging ~1 s behind it. + if (!flightState._trailHeadEntity && flightState._viewer) { + flightState._trailHeadEntity = flightState._viewer.entities.add({ + // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + polyline: { + positions: new Cesium.CallbackProperty(() => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — which is ~now, AHEAD of the delayed icon — + // so the segment would draw IN FRONT of the plane. Likewise during warm-up the + // icon predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[ + flightState._trailPositions.length - 2 + ]; + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. + // See trailHeadStart. Read `head` in place and clone only on the draw + // path — a suppressed parked contact runs this every frame. + const from = trailHeadStart( + start, + head, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, Cesium.Cartesian3.clone(head)]; + }, false), + width: 2.5, + material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads). + depthFailMaterial: + Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }, + }); + } + _refreshTrailDisplay(); + + const oldestFixEpochSec = history.length + ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + /** + * Fire-and-forget OpenSky /tracks backfill (PRD F1). On success, splices + * waypoints strictly older than the oldest seeded fix AHEAD of the locally + * accumulated fine segment, capped at TRAIL_MAX_POINTS (newest kept). Any + * failure (404/429/timeout/malformed) silently keeps the local-only trail. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let path = null; + try { + const track = await flightState._source.getTrack?.( + flightState._flightData.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + path = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if ( + !path || + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // OpenSky track waypoints: [time, latitude, longitude, baro_altitude, true_track, on_ground] + // Height-datum fix (Task 6): /tracks only ever reports barometric/MSL altitude + // (no per-waypoint geo_altitude in this endpoint), so waypoint render height is + // the documented visual FALLBACK baroM + geoidHeight(waypointLat, waypointLon) + // — geometrically approximate, not exact, same honesty caveat as the live + // baro-fallback branch of pickRenderAltitudeM. + await ensureGeoidReady(); + const parsed = []; + for (const waypoint of path) { + const { + observedAtMs, + latitude: lat, + longitude: lon, + baroAltitudeM: baroAlt, + } = waypoint; + const time = observedAtMs / 1000; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAlt }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06 — mirror of + // militaryFlights.js): resolve the coarse ellipsoidal ground along the track + // and floor every waypoint at it so low baro segments never dive below the + // mesh; a no-baro waypoint (predominantly taxi/ground segments in /tracks) + // sits ON the surface when the floor is known. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + const older = []; + let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet + for (const { lat, lon, baroAlt } of parsed) { + const baroM = Number.isFinite(baroAlt) + ? baroAlt + geoidHeight(lat, lon) + : null; + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // No baro + unresolved floor: hold the previous waypoint's altitude + // (continuity — never a dive/spike to a made-up height). Leading points + // with nothing to carry keep the old 10 km airborne default. + if (altM == null) altM = lastAltM != null ? lastAltM : 10000; + lastAltM = altM; + older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if ( + flightState._trailHeadEntity && + flightState._viewer && + !flightState._viewer.isDestroyed() + ) { + try { + flightState._viewer.entities.remove(flightState._trailHeadEntity); + } catch { + /* already gone */ + } + } + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the currently followed aircraft. + * + * Restores the hidden billboard, removes the tracked Entity, resets lerp + * state, and RELEASES the camera IN PLACE — no flyTo. Deselect used to fly + * an ~80 km pulled-back overview; the owner field-ruled that wrong + * (2026-07-02: "it randomly zooms way up and loses my context"). The camera + * now simply stays at its current position/orientation, immediately free to + * orbit/zoom. Applies to every deselect path: click-empty-space, Escape, + * aged-out plane, layer disable, and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget('flights'); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget('flights', clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground/military-aware restore (a plane untracked while taxiing must come + // back muted-gray at ground scale, not white at full scale). + bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); + bb.scale = parts.rendering._fleetBillboardScale( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the + // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner + // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity + // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where + // the follow left it and the user can immediately orbit/zoom. + if (flightState._viewer && !skipViewerUntrack) { + flightState._viewer.trackedEntity = undefined; + } + if (flightState._trackedEntity) { + flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext('flights'); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'flights', + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache + reconciliation state so a same-frame re-track + // cannot read the previous aircraft's cached/smoothed position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + /** + * Whether the Military layer suppresses this civil duplicate right now. + * + * The dedicated Military layer owns icon/track/click for known-military + * contacts, so the OpenSky duplicate is dropped while that layer is on. Two + * contacts are exempt, both for the same reason — this layer still owns them: + * + * - the CURRENTLY tracked one, which hands off on untrack; and + * - a target this layer is holding on its deferred-restore latch. + * + * The second exemption is what makes a shared/local Follow of a mil-registry + * hex restorable at all. The accepted-snapshot id set is built BEFORE this + * suppression runs, so without it the target is provably present in a healthy + * feed yet has no billboard, `trackById` fails, and the restore reports + * "feed unavailable" about a feed that was perfectly fine. + * + * @param {string} icao24 - Normalized ICAO 24-bit address. + * @returns {boolean} True when the civil duplicate must be dropped. + */ + + function _militaryLayerSuppresses(icao24) { + if (!isMilitaryLayerActive()) return false; + if (icao24 === flightState._trackedIcao) return false; + if (icao24 === flightState._pendingTrackingRestore?.id) return false; + return true; + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._billboardCollection?.show || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" + + * "ORIG → DEST". The route line is gated by + * routePlausible so a wrong-leg adsbdb route is hidden, not displayed. + * While the plane is in its missed-poll grace (coasting on dead reckoning + * with sticky metadata), the first line carries a "· STALE" cue — the fleet's + * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity + * owns the visual), so without this the readout would present last-known + * velocity/altitude as live. */ + + function _trackedLabelText(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return icao24; + // A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)` + // kept it, then .trim() emptied it → the callsign slot dropped out of the + // readout. `_contactLabel` trims FIRST, then falls through registration to + // the ICAO hex, so a callsign-less enriched contact heads its readout with + // the tail number rather than raw hex. + const cs = parts.queries._contactLabel(icao24, info); + const altFt = Math.round((info.altitude || 0) * 3.28084); + const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`; + const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : ''; + const stale = + flightState._missingPolls.get(icao24) || flightState._backoff + ? 'STALE' + : ''; + const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')]; + // Converted contacts report their class as TR-3B and nothing else — the + // operator/type identity is exactly what the Easter egg is replacing. + const ident = isTr3b(icao24) + ? tr3bTypeLabel(icao24) + : [info.airline, info.typeName || info.typeCode] + .filter(Boolean) + .join(' · '); + if (ident) lines.push(ident); + if (info.route && _routeIsPlausible(icao24, info.route)) { + lines.push(`${info.route.origin.code} → ${info.route.destination.code}`); + } + return lines.join('\n'); + } + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _trackedLabelText(icao24), + '#39d0ff', + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) + return; + flightState._trackedEntity.billboard.image = aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ); + } + + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Converting drops any 3D model so the triangle owns the visual; the + * billboard image, tracked entity, and tracked card are all re-derived here. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ + + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + // Drop the 3D handoff for this contact — the fleet tick now skips it, so + // an already-loaded model would otherwise linger with the billboard hidden. + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const bb = flightState._billboards.get(id); + if (bb && id !== flightState._trackedIcao) bb.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._viewer?.scene?.requestRender?.(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; + } + + /** Re-image every converted contact this layer owns (IR style flip). */ + + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); + } + } + + /** Plausibility check anchored to the plane's billboard position (coarse is + * fine here — this gates a LABEL, and it must not touch the tracked frame + * cache). Missing data → true (never hide what we can't judge). */ + + function _routeIsPlausible(icao24, route) { + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + if (!info || !bb || !bb.position) return true; + const carto = Cesium.Cartographic.fromCartesian( + bb.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return true; + return routePlausible({ + latDeg: Cesium.Math.toDegrees(carto.latitude), + lonDeg: Cesium.Math.toDegrees(carto.longitude), + altitudeM: info.altitude ?? null, + verticalRateMps: info.verticalRate ?? null, + origin: route.origin, + destination: route.destination, + }); + } + + /** + * Begin tracking a specific aircraft by ICAO24 address. + * + * Clears any existing tracked flight, hides its billboard, and creates a + * new Entity with: + * - A CallbackProperty position driven by dead-reckoning (_deadReckon). + * - A CallbackProperty alignedAxis set to the surface normal at the + * dead-reckoned position (keeps the icon tangent to the earth). + * - A CallbackProperty rotation from the aircraft's true_track heading. + * - An explicit host presentation model with callsign, flight level, speed, + * identity, and plausible route text. + * + * The viewer's trackedEntity is set to this entity so the camera follows it. + * + * @param {string} icao24 - ICAO 24-bit transponder address to track. + */ + + function _trackFlight(icao24, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard — the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, + // with discontinuity reconciliation). Falls back to the last billboard position when + // the aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Dead-reckoning position property — smooth continuous motion between API updates. + const positionProperty = new Cesium.CallbackProperty(() => { + return getTrackedPosition(); + }, false); + + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the + // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the + // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). + // Keep Cesium's generated entity ID: re-init without destroy can temporarily + // overlap collections, and an explicit ICAO ID would throw on that duplicate. + flightState._trackedEntity = flightState._viewer.entities.add({ + position: positionProperty, + // Force Cesium's built-in EntityView and our close-range camera guard to + // use the same local frame. AUTO can select a velocity frame while the + // model matrix uses aircraft orientation; alternating between those + // frames makes the target oscillate forward/back on screen. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + CLASS_SCALE_2D[ + flightState._flightData.get(flightState._trackedIcao)?.klass + ] || 1, + // Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: new Cesium.CallbackProperty( + () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? CYAN_TRANSPARENT + : Cesium.Color.CYAN, + false, + ), + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the cyan icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: new Cesium.CallbackProperty(() => { + const tracked = flightState._flightData.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._viewer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, false), + }, + }); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `flights:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _trackedLabelText(icao24), + '#39d0ff', + ); + + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const followRange = Math.min( + Math.max((info.altitude || 1500) * 1.1 + 2500, 3000), + 30000, + ); + flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + 0, + -followRange * 0.8, + followRange * 0.55, + ); + + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the + // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which + // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it + // the way a click (idle camera) does. + // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target + // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in + // postRender (which would jitter the label against the now-stable plane). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera + // anti-jitter contract and must keep returning the cached DR position. Presentation + // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + flightState._viewer.camera.cancelFlight(); + // Camera follows the tracked entity + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + + // Track-history trail (PRD F1): seed from local history + async backfill. + // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the + // plane takes off while tracked, the on_ground→false transition in update() + // starts one. + parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue + parts.enrichment._requestRouteEnrichment(icao24); + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select. Grounded flights positions + // are already surface-clamped (the surfaceM chain), so seeds/appends drape. + _startTrail(icao24); + + _publishTrackedSelection(icao24, origin); + + console.log( + `[Data:Flights] Tracking ${parts.queries._contactLabel(icao24, info)} (${icao24})`, + ); + } + + /** + * Immediate military-suppression handoff sweep (pre-ship audit M2). + * + * The poll-time suppression branch in update() only reconciles every 30 s, so + * toggling the Military layer showed duplicate icons (military ON: both layers + * render the same aircraft ~3-4 km apart) or holes (military OFF: suppressed + * aircraft absent) for up to a full poll. Fired synchronously by the registry + * on the active-state TRANSITION: + * + * - activated → suppress known-military billboards NOW (mirror of the + * poll-time branch, tracked aircraft excluded — it hands off on untrack); + * - deactivated → the suppressed aircraft's state was deleted, so bring the + * next OpenSky poll forward instead of waiting out the interval (update() + * itself still honors _retryAt backoff). + * + * @param {boolean} active - New military-layer active state. + * @returns {void} + */ + + function _onMilitaryActiveChange(active) { + if (!flightState._viewer || !flightState._billboardCollection) return; + if (active) { + for (const [icao24, bb] of flightState._billboards) { + if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao) + continue; + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._missingPolls.delete(icao24); + } + flightState._count = flightState._billboards.size; + } else if (flightState._billboardCollection.show) { + // Fire-and-forget refresh; only while the layer is actually enabled. + void flightsLayer.update(flightState._viewer); + } + } + + /** + * Global keydown handler — Escape deselects the tracked flight. + * @param {KeyboardEvent} e + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Install a LEFT_CLICK handler on the scene canvas for flight selection. + * + * Picking logic checks both `picked.primitive` and `picked.id` because + * different CesiumJS versions surface BillboardCollection hits differently. + * Also registers a global keydown listener for the Escape key. + * + * Idempotent — returns immediately if a handler is already installed. + * + * @param {Cesium.Viewer} viewer + */ + + function _installClickHandler(viewer) { + if (flightState._clickHandler) return; // already installed + + // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our + // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity + // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch + // (viewer.trackedEntity briefly undefined) doesn't self-clear. + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if ( + flightState._trackedIcao && + flightState._viewer && + flightState._viewer.trackedEntity && + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: + flightState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + // Camera drags never select or deselect, even if they finish over a plane. + // Duration alone is allowed through so a stationary long press can still + // select/switch contacts; the destructive empty-space branch below applies + // the full travel + duration click classifier. + if (!isTrackingSelectionGesture(gesture)) return; + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference. A globe click must not fall through to the + // normal empty-space deselection path; cockpit has explicit exit controls. + if (document.body.classList.contains('cockpit-mode')) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking the tracked entity itself — ignore (don't deselect) + if (picked.id === flightState._trackedEntity) return; + + // Clicking the plane we're ALREADY tracking (its standalone 3D model or + // any pick carrying its icao) — same no-op as the 2D tracked-entity click + // above. H1: the model used to have no pick id, so this fell through to + // "empty space" and deselected the very plane being tracked. + if (flightState._trackedIcao) { + const rawPick = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if ( + picked.primitive === flightState._trackedModel || + rawPick === flightState._trackedIcao + ) + return; + } + + // For BillboardCollection picks, the billboard may be at picked.primitive or picked.id + const billboard = picked.primitive; + if ( + billboard && + billboard.id && + flightState._billboards.has(billboard.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(billboard.id, { origin: 'user' }); + return; + } + // Some CesiumJS versions surface the id as a string on picked.id instead + if ( + picked.id && + typeof picked.id === 'string' && + flightState._billboards.has(picked.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(picked.id, { origin: 'user' }); + return; + } + } + + // A pick that belongs to a sibling layer (military aircraft, satellite, + // vessel, station, CCTV camera…) is not "empty space" — leave tracking + // alone and let that layer handle it. resolvePickId String()-coerces the + // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the + // registry predicates can recognize them (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('flights', pickedId)) return; + } + + // Clicked empty space — deselect only for a clean, short click. A slow + // stationary press may select above, but cannot release existing tracking. + if (!isTrackingClickGesture(gesture)) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + }); + + document.addEventListener('keydown', _onKeyDown); + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _militaryLayerSuppresses, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackedLabelText, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _routeIsPlausible, + _trackFlight, + _onMilitaryActiveChange, + _onKeyDown, + _installClickHandler, + }; +} diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js new file mode 100644 index 000000000..1c894f5f7 --- /dev/null +++ b/src/layers/installations/controls.js @@ -0,0 +1,126 @@ +import * as Cesium from 'cesium'; +import { installationFeedback } from '../../data/installationFeedback.js'; +import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: LAYER_ID, + + name: 'Mapped Installations', + + icon: '⌖', + + source: 'OpenStreetMap + optional Google Maps Places', + + updateInterval: 0, + + statsRefreshInterval: 1000, + + /** Request a one-shot Google Maps Places search around the current map view. */ + searchNearby() { + layerState.googleSearchRequested = true; + return parts.ingestion.loadInstallations(); + }, + + getNearby(center, rangeM, maxCount = 50) { + if (!center) return []; + const range = Number.isFinite(rangeM) ? rangeM : Infinity; + const centerCartographic = Cesium.Cartographic.fromCartesian(center); + if (!centerCartographic) return []; + const nearby = []; + const approximateLimit = Number.isFinite(range) + ? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M + : Infinity; + for (const record of layerState.records) { + if (record.kind !== 'installation') continue; + if ( + parts.model.approximateSurfaceDistanceM( + centerCartographic.latitude, + centerCartographic.longitude, + record.latitude, + record.longitude, + ) > approximateLimit + ) + continue; + // The awareness disk is projected onto the ground. Confirm candidates + // with an exact ellipsoidal surface distance and reusable scratch state. + layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians( + record.longitude, + ); + layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians( + record.latitude, + ); + layerState.distanceEndpointScratch.height = 0; + layerState.distanceGeodesicScratch.setEndPoints( + centerCartographic, + layerState.distanceEndpointScratch, + ); + const distanceM = layerState.distanceGeodesicScratch.surfaceDistance; + if (!Number.isFinite(distanceM) || distanceM > range) continue; + nearby.push({ + ...record, + position: Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + parts.rendering.installationSurfaceHeightM(record), + ), + distanceM, + }); + } + nearby.sort((a, b) => a.distanceM - b.distanceM); + return nearby.slice( + 0, + Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50, + ); + }, + + /** + * Select and frame a mapped installation from another contextual UI. + * @param {string} id Source-backed installation id. + * @returns {boolean} True when an available installation was focused. + */ + focusById(id) { + const record = layerState.recordById.get(String(id)); + if (!record || !layerState.viewer) return false; + // No camera flight without a real selection: a flight plus a stale subject + // reads as success to Context navigation and strands NEXT on this item. + if (!parts.selection.selectRecord(record.id)) return false; + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + parts.rendering.installationSurfaceHeightM(record), + ), + 18000, + ), + { duration: 1.4 }, + ); + return true; + }, + + getStats() { + return { + count: layerState.records.length, + lastUpdate: layerState.lastUpdate, + stale: layerState.stale, + saturated: layerState.saturated, + error: layerState.error, + status: layerState.status, + loading: layerState.loading, + retryAt: layerState.retryAt, + retrying: layerState.loading && Boolean(layerState.failureReason), + failureReason: layerState.failureReason, + statusMessage: installationFeedback({ + ...layerState, + retrying: layerState.loading && Boolean(layerState.failureReason), + }), + loadingLabel: layerState.loading + ? 'loading mapped installation context' + : '', + }; + }, + }; + + return { methods }; +} diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js new file mode 100644 index 000000000..d8433fe12 --- /dev/null +++ b/src/layers/installations/index.js @@ -0,0 +1,43 @@ +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createIngestion } from './ingestion.js'; +import { createViewport } from './viewport.js'; +import { createSelection } from './selection.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createInstallationsLayer({ services, source }) { + if ( + typeof source?.getMappedSites !== 'function' || + typeof source?.searchNearby !== 'function' + ) + throw new TypeError('An installation source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.ingestion = createIngestion(context); + parts.viewport = createViewport(context); + parts.selection = createSelection(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + approximateSurfaceDistanceM: parts.model.approximateSurfaceDistanceM, + classifyGoogleMilitaryPlace: parts.model.classifyGoogleMilitaryPlace, + installationSourceLabel: parts.model.installationSourceLabel, + installationSurfaceHeightM: parts.rendering.installationSurfaceHeightM, + installationWithinViewport: parts.model.installationWithinViewport, + installationResponseSaturated: parts.model.installationResponseSaturated, + installationRetryDelayMs: parts.viewport.installationRetryDelayMs, + }, + ); +} +export { createInstallationSource } from './source.js'; diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js new file mode 100644 index 000000000..7564b6025 --- /dev/null +++ b/src/layers/installations/ingestion.js @@ -0,0 +1,214 @@ +import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const { governorRequestRender } = services.render; + const { resolveGroundFloorCellsBounded } = services.ground; + + /** + * Commit a status/error transition and buy the one frame it needs. + * + * With the render governor idle — Contacts has released its hold and nothing + * else animates — no frame would otherwise arrive to re-read this, so a load + * that fails after the scene went quiet would leave the last healthy readout on + * screen indefinitely. + * @param {string} status @param {?string} error + */ + + function setInstallationStatus(status, error = null) { + if (layerState.status === status && layerState.error === error) return; + layerState.status = status; + layerState.error = error; + governorRequestRender('installations-status'); + } + + async function loadInstallations() { + if (!layerState.enabled || !layerState.viewer) return; + const box = parts.viewport.viewportBox(layerState.viewer); + // Guidance, not a fault: the layer chose not to query because the view is + // unbounded (a global view, or Cockpit looking at the horizon). Keep it out + // of `error` — the manager derives refresh failures and the global status + // chip derives LOAD FAILED from that field, and a "zoom in" prompt turned + // every Cockpit refresh into a reported failure. The row and toast read the + // prompt from `statusMessage` (installationFeedback) instead. + if (!box) { + layerState.abort?.abort(); + layerState.abort = null; + layerState.loading = false; + parts.viewport.clearUnavailableRetry(); + setInstallationStatus('zoom-in'); + return; + } + layerState.abort?.abort(); + const requestAbort = new AbortController(); + layerState.abort = requestAbort; + layerState.loading = true; + parts.viewport.clearUnavailableRetry({ resetBackoff: false }); + // The previous attempt's failure is not the outcome of this new attempt. + setInstallationStatus('loading'); + try { + const fetchInstallations = (exact) => + source.getMappedSites(box, { exact, signal: requestAbort.signal }); + + let payload = await fetchInstallations(false); + // A SATURATED snapped tile was truncated upstream, so features from the + // snap's extra ring may have crowded out sites actually on screen. Re-ask + // for the exact viewport (separately keyed and cached) before rendering. + let saturated = parts.model.installationResponseSaturated(payload); + if (saturated) { + payload = await fetchInstallations(true); + saturated = parts.model.installationResponseSaturated(payload); + } + const normalized = normalizeMilitaryInstallations( + payload, + payload.retrievedAt || new Date().toISOString(), + ); + // The proxy answers a bbox at least as large as the viewport; keep only what + // was actually asked for so nothing off-screen reaches the map or the + // "current viewport only" context claim. + const records = normalized.records.filter((record) => + parts.model.installationWithinViewport(record, box), + ); + let placesError = null; + if (layerState.googleSearchRequested) { + layerState.googleSearchRequested = false; + const latitude = (box.south + box.north) / 2; + const longitude = (box.west + box.east) / 2; + const radiusM = Math.min( + 50000, + Math.max( + 1000, + Math.round( + Math.max(box.north - box.south, box.east - box.west) * 55_000, + ), + ), + ); + try { + const placesPayload = await source.searchNearby( + { latitude, longitude, radiusM }, + { signal: requestAbort.signal }, + ); + const seen = new Set( + records.map( + (record) => + `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`, + ), + ); + for (const place of Array.isArray(placesPayload?.places) + ? placesPayload.places + : []) { + if ( + !place?.id || + !place?.name || + !Number.isFinite(place.latitude) || + !Number.isFinite(place.longitude) + ) + continue; + const placeClass = parts.model.classifyGoogleMilitaryPlace(place); + const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`; + if (seen.has(signature)) continue; + seen.add(signature); + const retrievedAt = new Date().toISOString(); + records.push({ + id: `google:${place.id}`, + kind: + placeClass === 'military_land' + ? 'installation' + : 'place_candidate', + class: placeClass, + name: String(place.name).trim(), + latitude: place.latitude, + longitude: place.longitude, + footprint: null, + primaryType: place.primaryType || null, + placeTypes: Array.isArray(place.types) ? place.types : [], + sources: [ + { name: 'Google Maps Places', id: place.id, retrievedAt }, + ], + validation: 'unreviewed', + retrievedAt, + }); + } + } catch (error) { + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled || + error?.name === 'AbortError' + ) + return; + placesError = + 'Google Places search unavailable; showing mapped sites'; + } + } + await resolveGroundFloorCellsBounded( + records.map((record) => ({ + lat: record.latitude, + lon: record.longitude, + })), + ); + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled + ) + return; + layerState.records = records; + layerState.recordById = new Map( + layerState.records.map((record) => [record.id, record]), + ); + layerState.lastUpdate = Date.now(); + layerState.stale = payload.status === 'stale'; + // Even the exact-viewport retry can saturate in a dense area. Say so rather + // than implying the view is completely surveyed. + layerState.saturated = saturated; + layerState.failureReason = null; + parts.viewport.clearUnavailableRetry(); + setInstallationStatus( + layerState.records.length + ? layerState.stale + ? 'stale' + : 'ready' + : 'empty', + payload.status === 'stale' + ? 'Serving cached mapped context' + : saturated + ? 'Too many mapped sites in view to list them all' + : placesError, + ); + parts.rendering.renderRecords(); + parts.rendering.warmInstallationFloors(layerState.records); + } catch (error) { + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled || + error?.name === 'AbortError' + ) + return; + layerState.failureReason = error?.failureReason || 'unavailable'; + setInstallationStatus( + 'unavailable', + error?.message || 'Installation context unavailable', + ); + parts.viewport.scheduleUnavailableRetry(); + } finally { + // An older aborted request must not clear a newer request's busy state. + if (layerState.abort === requestAbort) { + layerState.abort = null; + layerState.loading = false; + } + } + } + const methods = { + update() { + return loadInstallations(); + }, + }; + + return { setInstallationStatus, loadInstallations, methods }; +} diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/lifecycle.js new file mode 100644 index 000000000..8df912244 --- /dev/null +++ b/src/layers/installations/lifecycle.js @@ -0,0 +1,62 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { clearSelectedEntityContextForLayer } = services.context; + + const methods = { + init(viewer) { + layerState.viewer = viewer; + layerState.dataSource = new Cesium.CustomDataSource( + 'military-installations', + ); + viewer.dataSources.add(layerState.dataSource); + layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener( + parts.viewport.scheduleLoad, + ); + parts.selection.installInteraction(viewer); + }, + + enable() { + layerState.enabled = true; + registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id)); + layerState.dataSource.show = true; + // DataLayerManager invokes update() immediately after enable(), which owns + // the first fetch. Avoid racing it with a second aborting request here. + }, + + disable() { + layerState.enabled = false; + unregisterPickOwner(LAYER_ID); + parts.viewport.clearUnavailableRetry(); + clearTimeout(layerState.timer); + layerState.abort?.abort(); + layerState.abort = null; + layerState.loading = false; + if (layerState.dataSource) layerState.dataSource.show = false; + clearSelectedEntityContextForLayer(LAYER_ID); + layerState.selectedId = null; + layerState.failureReason = null; + parts.ingestion.setInstallationStatus('idle'); + }, + + destroy(viewer) { + this.disable(); + layerState.moveEndRemove?.(); + layerState.clickHandler?.destroy(); + layerState.clickHandler = null; + parts.rendering.clearRendered(); + if (layerState.dataSource && viewer) + viewer.dataSources.remove(layerState.dataSource, true); + layerState.dataSource = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/installations/model.js b/src/layers/installations/model.js new file mode 100644 index 000000000..dc72898ed --- /dev/null +++ b/src/layers/installations/model.js @@ -0,0 +1,165 @@ +import * as Cesium from 'cesium'; +import { + EARTH_MEAN_RADIUS_M, + COLOR_BY_CLASS, + GOOGLE_MILITARY_PLACE_TYPES, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + /** + * Allocation-free spherical distance used only as a conservative rejection + * pass before the exact ellipsoidal geodesic calculation. + */ + + function approximateSurfaceDistanceM( + latitudeARad, + longitudeARad, + latitudeBDeg, + longitudeBDeg, + ) { + const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg); + const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg); + const latitudeDelta = latitudeBRad - latitudeARad; + const longitudeDelta = Math.atan2( + Math.sin(longitudeBRad - longitudeARad), + Math.cos(longitudeBRad - longitudeARad), + ); + const sinLatitude = Math.sin(latitudeDelta / 2); + const sinLongitude = Math.sin(longitudeDelta / 2); + const haversine = + sinLatitude * sinLatitude + + Math.cos(latitudeARad) * + Math.cos(latitudeBRad) * + sinLongitude * + sinLongitude; + return ( + 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine))) + ); + } + + function colorFor(record) { + return Cesium.Color.fromCssColorString( + COLOR_BY_CLASS[record.class] || '#9ca6b0', + ); + } + + /** + * Classify a Places text-search result without turning a name match into a + * mapped military-land claim. Google currently has no documented military + * Places type, so ordinary results remain visually distinct candidates; the + * explicit branch is retained for any source response that does carry one. + * @param {object} place Google Places result. + * @returns {string|null} Installation class, or null when not authoritative. + */ + + function classifyGoogleMilitaryPlace(place) { + const types = new Set( + [place?.primaryType, ...(Array.isArray(place?.types) ? place.types : [])] + .map((value) => + String(value || '') + .trim() + .toLowerCase(), + ) + .filter(Boolean), + ); + return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type)) + ? 'military_land' + : 'places_candidate'; + } + + /** @param {object} record @returns {string} Human-readable source attribution. */ + + function installationSourceLabel(record) { + const names = [ + ...new Set( + (Array.isArray(record?.sources) ? record.sources : []) + .map((source) => String(source?.name || '').trim()) + .filter(Boolean), + ), + ]; + return names.join(' + ') || 'Unknown mapped source'; + } + + /** + * Whether a mapped record belongs to the REQUESTED viewport. + * + * The proxy snaps the request bbox outward onto a shared cache grid, so a + * response is a SUPERSET of what was asked for, and rendering that superset + * would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY" + * context claim. What may be tested depends on how much of a feature's geometry + * we actually hold: + * + * - A NODE is a point: its centre IS its whole geometry, so an exact + * containment test is correct and loses nothing. + * - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox + * queries return features that merely INTERSECT the box, so centre-testing + * these would drop large bases whose centre sits just outside. + * - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on + * their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without + * one, so their true extent is unknown here — and Overpass already proved + * they intersect the queried bbox. Centre-testing them would erase exactly + * the biggest installations. The honest cost is slight over-inclusion, + * bounded by one snap cell (~5.5 km) around the viewport. + * + * @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record + * @param {{south:number, west:number, north:number, east:number}} box Requested viewport. + * @returns {boolean} + */ + + function installationWithinViewport(record, box) { + if (!record || !box) return false; + const { latitude, longitude, footprint } = record; + const centreInside = + latitude >= box.south && + latitude <= box.north && + longitude >= box.west && + longitude <= box.east; + if (centreInside) return true; + if (Array.isArray(footprint) && footprint.length) { + let minLat = Infinity; + let maxLat = -Infinity; + let minLon = Infinity; + let maxLon = -Infinity; + for (const [lon, lat] of footprint) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + } + return ( + maxLat >= box.south && + minLat <= box.north && + maxLon >= box.west && + minLon <= box.east + ); + } + // Unknown extent: inclusive. Only a point feature may be excluded on centre. + return record.osmType !== 'node'; + } + + /** + * Whether a response was truncated at the upstream element cap. + * + * The proxy states this outright, but a `saturated`-less payload is NOT + * evidence of a complete answer: entries cached before the saturation guard + * shipped predate the field and live for 30 days. Fall back to deriving it from + * the element count against the cap the payload itself reports. + * @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload + * @returns {boolean} + */ + + function installationResponseSaturated(payload) { + if (typeof payload?.saturated === 'boolean') return payload.saturated; + const cap = Number(payload?.elementCap); + if (!Number.isFinite(cap) || cap <= 0) return false; + return Array.isArray(payload?.elements) && payload.elements.length >= cap; + } + return { + approximateSurfaceDistanceM, + colorFor, + classifyGoogleMilitaryPlace, + installationSourceLabel, + installationWithinViewport, + installationResponseSaturated, + }; +} diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js new file mode 100644 index 000000000..b64a3ed16 --- /dev/null +++ b/src/layers/installations/policy.js @@ -0,0 +1,21 @@ +export const LAYER_ID = 'military-installations'; + +export const REQUEST_DEBOUNCE_MS = 500; + +export const MAX_VIEWPORT_DEGREES = 10; + +export const MAX_RENDERED = 700; + +export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); + +export const COLOR_BY_CLASS = { + airfield: '#5aa9ff', + naval_base: '#48c7d5', + range: '#d9a85d', + military_land: '#9ca6b0', + places_candidate: '#c58cff', +}; + +export const EARTH_MEAN_RADIUS_M = 6371008.8; + +export const DISTANCE_PREFILTER_MARGIN_M = 5000; diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js new file mode 100644 index 000000000..3ff922f49 --- /dev/null +++ b/src/layers/installations/rendering.js @@ -0,0 +1,195 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { floorAltitudeM, cachedGroundFloor } = services.ground; + const { + removeEntityContextsForLayer, + getSelectedEntityContext, + registerEntityContext, + selectEntityContext, + } = services.context; + const { governorRequestRender } = services.render; + const { warmFireAnchorFloors } = services.anchors; + + /** + * Shared rendered-surface height for an installation anchor or footprint. + * @param {{latitude:number, longitude:number}} record Installation record. + * @returns {number} Ellipsoidal render height in metres. + */ + + function installationSurfaceHeightM(record) { + return ( + floorAltitudeM( + null, + cachedGroundFloor(record?.latitude, record?.longitude), + ) ?? 0 + ); + } + + function clearRendered() { + if (layerState.dataSource?.entities) + layerState.dataSource.entities.removeAll(); + removeEntityContextsForLayer(LAYER_ID); + } + + /** + * The records that get entities this paint: the nearest `MAX_RENDERED`, plus + * the selected one when it falls outside that window. + * + * Context navigation walks the FULL nearby cohort, which is not bounded by the + * render cap, so selecting item 701+ used to produce no entity at all — the + * camera flew, `getById` returned null, and the selection was silently dropped + * on the floor, leaving the Context subject stale so NEXT offered the same + * installation forever. One extra entity keeps every cohort item selectable + * and the cohort count honest. + * @returns {Array} Records to render this paint. + */ + + function renderableRecords() { + const rendered = layerState.records.slice(0, MAX_RENDERED); + if (!layerState.selectedId) return rendered; + if (rendered.some((record) => record.id === layerState.selectedId)) + return rendered; + const selected = layerState.recordById.get(layerState.selectedId); + return selected ? [...rendered, selected] : rendered; + } + + function renderRecords({ claimSelection = false } = {}) { + // Context navigation can select another layer without a canvas click. + // A delayed floor/data repaint must not steal that newer selection back. + const selectedContext = getSelectedEntityContext(); + if ( + !claimSelection && + layerState.selectedId && + selectedContext && + selectedContext.id !== layerState.selectedId + ) { + layerState.selectedId = null; + } + // Post-moveEnd debounced fetches commit after the camera settles; the + // rebuilt entities need one frame in idle mode. (perf wave 2 fix) + governorRequestRender('installations-render'); + clearRendered(); + for (const record of renderableRecords()) { + const color = parts.model.colorFor(record); + const surfaceHeightM = installationSurfaceHeightM(record); + const displayPosition = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + surfaceHeightM, + ); + const entity = layerState.dataSource.entities.add({ + id: record.id, + position: displayPosition, + point: { + pixelSize: record.id === layerState.selectedId ? 13 : 9, + color: + record.id === layerState.selectedId ? Cesium.Color.WHITE : color, + outlineColor: Cesium.Color.BLACK.withAlpha(0.8), + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + polygon: record.footprint + ? { + hierarchy: new Cesium.PolygonHierarchy( + record.footprint.map(([longitude, latitude]) => + Cesium.Cartesian3.fromDegrees(longitude, latitude), + ), + ), + material: color.withAlpha(0.12), + outline: true, + outlineColor: color.withAlpha(0.65), + height: surfaceHeightM, + } + : undefined, + }); + entity.gevTrackedId = `installations:${record.id}`; + entity.gevDisplayPosition = () => displayPosition; + entity.gevLabelModel = { + title: record.name || 'MAPPED INSTALLATION', + details: [ + String(record.class || 'installation') + .replaceAll('_', ' ') + .toUpperCase(), + ], + accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', + }; + registerEntityContext(entity, { + id: record.id, + layerId: LAYER_ID, + layerName: + record.kind === 'place_candidate' + ? 'Military Site Search Candidates' + : 'Mapped Military Installations', + source: parts.model.installationSourceLabel(record), + label: record.name, + latitude: record.latitude, + longitude: record.longitude, + properties: { + class: record.class, + primaryType: record.primaryType || null, + placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], + validation: record.validation, + retrievedAt: record.retrievedAt, + }, + }); + } + const selectedEntity = layerState.selectedId + ? layerState.dataSource.entities.getById(layerState.selectedId) + : null; + if (selectedEntity) selectEntityContext(selectedEntity); + else layerState.selectedId = null; + } + + /** + * Second paint for floors that missed the bounded pre-render deadline. + * + * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so + * a cold DEM can never hold the dots hostage — but the resolve keeps running + * and lands seconds later, and without this the records it covers stay pinned + * at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest + * 2026-08-18: "orange dots at the bottom"). + * + * This is the render -> warm -> re-render chain FIRMS already uses, with one + * difference the installations path forces: the trigger is whether a cell that + * was COLD AT PAINT TIME is warm now, not whether this particular batch warmed + * it. The bounded resolve above is still running against the same cells, so + * asking "did MY batch warm anything" would answer false exactly when the other + * resolve won the race — the common case. Still terminating: a set that is + * wholly cold afterwards re-renders zero times and the next camera-driven load + * retries. + * @param {Array} records Records just rendered. + * @returns {void} + */ + + function warmInstallationFloors(records) { + const cold = records + .filter( + (record) => + cachedGroundFloor(record.latitude, record.longitude) == null, + ) + .map((record) => ({ lat: record.latitude, lon: record.longitude })); + if (!cold.length) return; + warmFireAnchorFloors(cold).then(() => { + if (!layerState.enabled || !layerState.dataSource) return; + if ( + !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) + ) + return; + renderRecords(); + }); + } + return { + installationSurfaceHeightM, + clearRendered, + renderableRecords, + renderRecords, + warmInstallationFloors, + }; +} diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js new file mode 100644 index 000000000..ace9124cb --- /dev/null +++ b/src/layers/installations/selection.js @@ -0,0 +1,43 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { clearSelectedEntityContextForLayer } = services.context; + + function selectRecord(id) { + const record = layerState.recordById.get(id); + if (!record || !layerState.dataSource) return false; + layerState.selectedId = id; + parts.rendering.renderRecords({ claimSelection: true }); + // renderRecords drops selectedId when the record produced no entity. + return layerState.selectedId === id; + } + + function installInteraction(viewer) { + if (layerState.clickHandler) return; + layerState.clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState.clickHandler.setInputAction((click) => { + if (!layerState.enabled) return; + const picked = viewer.scene.pick(click.position); + const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; + if (id && layerState.recordById.has(id) && id !== layerState.selectedId) { + selectRecord(id); + } else if (layerState.selectedId) { + // Clicking the selected site again, empty map, or another contact + // releases this layer's selection. Clear only our shared context so a + // sibling click handler's newly selected aircraft/site stays intact. + layerState.selectedId = null; + clearSelectedEntityContextForLayer(LAYER_ID); + parts.rendering.renderRecords(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + return { selectRecord, installInteraction }; +} diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js new file mode 100644 index 000000000..ccbb799d4 --- /dev/null +++ b/src/layers/installations/source.js @@ -0,0 +1,78 @@ +/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */ +export function createInstallationSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getMappedSites(box, { exact = false, signal } = {}) { + const { south, west, north, east } = box || {}; + if ( + ![south, west, north, east].every(Number.isFinite) || + south < -90 || + north > 90 || + west < -180 || + east > 180 || + north <= south || + east <= west || + north - south > 10 || + east - west > 10 + ) + throw new TypeError('A bounded installation viewport is required'); + signal?.throwIfAborted(); + const query = new URLSearchParams( + Object.entries({ south, west, north, east }).map(([key, value]) => [ + key, + value.toFixed(5), + ]), + ); + if (exact) query.set('exact', '1'); + const response = await fetchImpl(`/api/military-installations?${query}`, { + signal, + }); + const body = await response.json(); + signal?.throwIfAborted(); + if (!response.ok) + throw Object.assign( + new Error(body?.error || `Installation feed HTTP ${response.status}`), + { + failureReason: ['rate_limited', 'timeout', 'query_failed'].includes( + body?.reason, + ) + ? body.reason + : 'unavailable', + }, + ); + if (!Array.isArray(body?.elements)) + throw new Error('Malformed installation snapshot'); + return body; + }, + async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) { + if ( + ![latitude, longitude, radiusM].every(Number.isFinite) || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 || + radiusM < 1000 || + radiusM > 50000 + ) + throw new TypeError('Invalid nearby installation search'); + signal?.throwIfAborted(); + const response = await fetchImpl( + `/api/google/text-search?${new URLSearchParams({ + q: 'military installation', + lat: latitude.toFixed(5), + lon: longitude.toFixed(5), + radiusM: String(radiusM), + })}`, + { signal }, + ); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!response.ok) + throw new Error( + payload?.error || `Google Places HTTP ${response.status}`, + ); + if (!Array.isArray(payload?.places)) + throw new Error('Malformed nearby-place snapshot'); + return payload; + }, + }; +} diff --git a/src/layers/installations/source.test.mjs b/src/layers/installations/source.test.mjs new file mode 100644 index 000000000..3aa54bbef --- /dev/null +++ b/src/layers/installations/source.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createInstallationSource } from './source.js'; + +const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 }; +test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => { + const calls = []; + const source = createInstallationSource({ + fetchImpl: async (url) => { + calls.push(new URL(url, 'https://example.test')); + return new Response(JSON.stringify({ elements: [], status: 'stale' })); + }, + }); + for (const invalid of [ + null, + { ...box, east: Infinity }, + { ...box, north: 90 }, + { ...box, south: 31 }, + ]) + await assert.rejects( + source.getMappedSites(invalid), + /bounded installation viewport/, + ); + assert.equal(calls.length, 0); + const payload = await source.getMappedSites(box, { exact: true }); + assert.equal(payload.status, 'stale'); + assert.equal(calls[0].pathname, '/api/military-installations'); + assert.equal(calls[0].searchParams.get('exact'), '1'); + assert.equal(calls[0].searchParams.get('south'), '30.10000'); +}); +test('malformed installation and place snapshots are never accepted as empty success', async () => { + const source = createInstallationSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects( + source.getMappedSites(box), + /Malformed installation snapshot/, + ); + await assert.rejects( + source.searchNearby({ latitude: 30.2, longitude: -97.7, radiusM: 1000 }), + /Malformed nearby-place snapshot/, + ); +}); +test('installation response parsing respects cancellation before any follow-on search', async () => { + const controller = new AbortController(); + const source = createInstallationSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { elements: [] }; + }, + }), + }); + await assert.rejects( + source.getMappedSites(box, { signal: controller.signal }), + { name: 'AbortError' }, + ); +}); diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js new file mode 100644 index 000000000..217d54c14 --- /dev/null +++ b/src/layers/installations/state.js @@ -0,0 +1,37 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const state = {}; + + state.distanceEndpointScratch = new Cesium.Cartographic(); + + state.distanceGeodesicScratch = new Cesium.EllipsoidGeodesic(); + + Object.assign(state, { + viewer: null, + dataSource: null, + enabled: false, + records: [], + recordById: new Map(), + selectedId: null, + lastUpdate: null, + error: null, + status: 'idle', + stale: false, + /** Whether the upstream truncated at its element cap for the current view. */ + saturated: false, + loading: false, + abort: null, + /** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */ + retryTimer: null, + /** Current backoff step for that retry; 0 = next failure starts at the minimum. */ + retryDelayMs: 0, + retryAt: 0, + failureReason: null, + moveEndRemove: null, + clickHandler: null, + timer: null, + googleSearchRequested: false, + }); + return state; +} diff --git a/src/layers/installations/viewport.js b/src/layers/installations/viewport.js new file mode 100644 index 000000000..70269257e --- /dev/null +++ b/src/layers/installations/viewport.js @@ -0,0 +1,84 @@ +import * as Cesium from 'cesium'; +import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + function viewportBox(viewer) { + const rectangle = viewer?.camera?.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + if (!rectangle) return null; + const south = Cesium.Math.toDegrees(rectangle.south); + const north = Cesium.Math.toDegrees(rectangle.north); + const west = Cesium.Math.toDegrees(rectangle.west); + const east = Cesium.Math.toDegrees(rectangle.east); + // Cross-dateline/global views require a zoom before a bounded request. + if ( + !Number.isFinite(south + north + west + east) || + east <= west || + north - south > MAX_VIEWPORT_DEGREES || + east - west > MAX_VIEWPORT_DEGREES + ) + return null; + return { south, west, north, east }; + } + + /** + * Backoff progression for the unavailable-state retry: 30 s, doubling to a + * 240 s ceiling. Pure so the progression is pinnable without booting the layer. + */ + + function installationRetryDelayMs(prevDelayMs) { + const RETRY_MIN_MS = 30000; + const RETRY_CEIL_MS = 240000; + if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS; + return Math.min(prevDelayMs * 2, RETRY_CEIL_MS); + } + + /** + * 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only + * on enable and on camera moveEnd, so a parked camera whose first request died + * (one flaky Overpass mirror is enough) stayed unavailable forever while the + * proxy sat healthy while the layer refused to show its features. While the + * layer is enabled and + * unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load, + * zoom-out, or disable cancels it. + */ + + function scheduleUnavailableRetry() { + if (!layerState.enabled) return; + clearTimeout(layerState.retryTimer); + layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs); + layerState.retryAt = Date.now() + layerState.retryDelayMs; + layerState.retryTimer = setTimeout(() => { + layerState.retryTimer = null; + layerState.retryAt = 0; + if (layerState.enabled && !layerState.loading) + parts.ingestion.loadInstallations(); + }, layerState.retryDelayMs); + } + + function clearUnavailableRetry({ resetBackoff = true } = {}) { + clearTimeout(layerState.retryTimer); + layerState.retryTimer = null; + layerState.retryAt = 0; + if (resetBackoff) layerState.retryDelayMs = 0; + } + + function scheduleLoad() { + if (!layerState.enabled) return; + // A user-driven load supersedes any pending retry; the load reschedules on + // failure, so the backoff step is kept rather than reset. + clearUnavailableRetry({ resetBackoff: false }); + clearTimeout(layerState.timer); + layerState.timer = setTimeout(() => { + parts.ingestion.loadInstallations(); + }, REQUEST_DEBOUNCE_MS); + } + return { + viewportBox, + installationRetryDelayMs, + scheduleUnavailableRetry, + clearUnavailableRetry, + scheduleLoad, + }; +} diff --git a/src/layers/launches/camera.js b/src/layers/launches/camera.js new file mode 100644 index 000000000..e6a1ef8c9 --- /dev/null +++ b/src/layers/launches/camera.js @@ -0,0 +1,375 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + REPLAY_CONTEXT_ALTITUDE_END_M, + REPLAY_CONTEXT_MAX_RANGE_M, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_GLOBE_RANGE_M, + REPLAY_ORBIT_FRAME_CENTER_BLEND, +} from './policy.js'; + +export function createCamera({ state: layerState, services, parts, source }) { + function cameraHeadingForPath(path, progress, fallback = Math.PI) { + if (!path?.length) return fallback; + const current = parts.paths.samplePath(path, progress); + const currentCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); + if (!currentCartographic) return fallback; + for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { + const next = parts.paths.samplePath(path, Math.min(1, progress + step)); + const nextCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); + if (!nextCartographic) continue; + const geodesic = new Cesium.EllipsoidGeodesic( + currentCartographic, + nextCartographic, + ); + if ( + geodesic.surfaceDistance > 10 && + Number.isFinite(geodesic.startHeading) + ) { + // HeadingPitchRange positions the camera opposite its heading vector. + // Passing the forward path heading therefore keeps the camera behind + // the vehicle, with the remaining ascent receding into the scene. + return Cesium.Math.zeroToTwoPi(geodesic.startHeading); + } + } + return fallback; + } + + /** + * Resolve the initial replay heading as a profile view of the path. + * @param {Cesium.Cartesian3[]} path Replay path. + * @returns {number} Heading perpendicular to the initial path direction. + */ + + function replayInitialCameraHeading(path) { + return Cesium.Math.zeroToTwoPi( + cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, + ); + } + + /** + * Keep ascent framing behind and slightly to one side of the vehicle, then + * widen that rear-quarter angle as the orbit becomes visible. + * @param {number} pathHeading Forward path bearing in radians. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {number} Cesium HeadingPitchRange heading. + */ + + function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + return Cesium.Math.zeroToTwoPi( + pathHeading + + Cesium.Math.lerp( + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + blend, + ), + ); + } + + /** + * Limit replay-camera yaw changes so a path heading wrap or insertion turn + * cannot swing the chase view through the front of the vehicle. + * @param {number} previous Previous Cesium heading in radians. + * @param {number} desired Desired Cesium heading in radians. + * @param {number} [maxStepRad] Maximum angular change for one rendered frame. + * @returns {number} + */ + + function smoothReplayCameraHeading( + previous, + desired, + maxStepRad = Cesium.Math.toRadians(2), + ) { + if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); + if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); + const delta = Cesium.Math.negativePiToPi(desired - previous); + const step = Cesium.Math.clamp( + delta, + -Math.abs(maxStepRad), + Math.abs(maxStepRad), + ); + return Cesium.Math.zeroToTwoPi(previous + step); + } + + /** + * Blend from a global nadir view into an oblique local 3D view as the user + * approaches a selected launch site. + * @param {number} rangeM Camera distance from the launch-site anchor. + * @returns {number} Cesium camera pitch in radians. + */ + + function missionZoomPitch(rangeM) { + const range = Math.max(0, Number(rangeM) || 0); + const blend = Cesium.Math.clamp( + (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)) / + (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)), + 0, + 1, + ); + return Cesium.Math.lerp( + Cesium.Math.toRadians(-42), + -Cesium.Math.PI_OVER_TWO, + blend, + ); + } + + /** + * Resolve the replay camera offset for either close ascent tracking or the + * orbital globe pullback. + * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. + * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. + * @returns {{range: number, pitch: number}} + */ + + function replayCameraView(state, altitudeM) { + const altitude = Math.max(0, Number(altitudeM) || 0); + const localRange = Cesium.Math.clamp( + REPLAY_INITIAL_RANGE_M + altitude * 0.7, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + ); + const rawContextBlend = Cesium.Math.clamp( + (altitude - 20000) / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), + 0, + 1, + ); + const contextBlend = + rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); + const contextRange = Cesium.Math.clamp( + 180000 + altitude * 3.8, + MISSION_CLOSE_VIEW_RANGE_M, + REPLAY_CONTEXT_MAX_RANGE_M, + ); + const ascentRange = Cesium.Math.lerp( + localRange, + contextRange, + contextBlend, + ); + const ascentPitch = Cesium.Math.lerp( + Cesium.Math.toRadians(-20), + Cesium.Math.toRadians(-34), + contextBlend, + ); + if (state?.ascending) { + return { range: ascentRange, pitch: ascentPitch }; + } + const rawBlend = Cesium.Math.clamp( + (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ); + const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); + return { + range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), + // Keep an oblique tactical view of the complete orbit rather than ending + // in a nadir view. This also prevents the camera from appearing to + // return toward the launch site after insertion. + pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), + }; + } + + /** + * Move the orbit-follow target from the vehicle toward its sub-satellite + * globe anchor, keeping Earth centered while the vehicle remains in frame. + * @param {Cesium.Cartesian3} position Animated orbital position. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Camera look-at target. + */ + + function replayOrbitGlobeAnchor(position, orbitBlend = 0) { + if (!position) return position; + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + if (blend <= 0) return Cesium.Cartesian3.clone(position); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + if (!cartographic) return Cesium.Cartesian3.clone(position); + const altitude = Math.max(0, cartographic.height || 0); + return Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + Cesium.Math.lerp(altitude, altitude * 0.1, blend), + ); + } + + /** + * Keep the orbital camera's look-at frame biased toward the moving vehicle. + * Blending completely to a whole-orbit bounding-sphere center can place the + * target at (or numerically close to) Earth's center. That frame is singular + * for a heading/pitch camera and lets compact-orbit vehicles leave the view as + * the camera rotates. Retaining a radial vehicle bias keeps the local frame + * stable while the wider camera range still contains Earth and the full orbit. + * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. + * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Stable camera look-at target. + */ + + function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { + if (!vehicleAnchor) return vehicleAnchor; + if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); + const blend = + Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) * + REPLAY_ORBIT_FRAME_CENTER_BLEND; + return Cesium.Cartesian3.lerp( + vehicleAnchor, + frameCenter, + blend, + new Cesium.Cartesian3(), + ); + } + + /** + * Build a stable orbit-relative camera pose. The camera remains on one side + * of the orbital plane and uses the vehicle radial as its visual up axis. + * Consequently the forward orbit tangent always projects toward screen-left + * instead of changing direction when local compass headings wrap near a pole. + * @param {Cesium.Cartesian3} position Current vehicle position. + * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. + * @param {Cesium.Cartesian3} target Camera look-at target. + * @param {number} range Camera distance from the target. + * @param {number} pitch Camera elevation below the local horizon. + * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} + */ + + function replayOrbitCameraPose( + position, + tangentPosition, + target, + range, + pitch, + ) { + if (!position || !tangentPosition || !target) return null; + const radial = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.clone(position), + new Cesium.Cartesian3(), + ); + const tangent = Cesium.Cartesian3.subtract( + tangentPosition, + position, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; + Cesium.Cartesian3.normalize(tangent, tangent); + const orbitNormal = Cesium.Cartesian3.cross( + radial, + tangent, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; + Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); + + const distance = Math.max(1, Number(range) || 1); + const elevation = Cesium.Math.clamp( + Math.abs(Number(pitch) || 0), + Cesium.Math.toRadians(5), + Cesium.Math.toRadians(80), + ); + const planeOffset = Cesium.Cartesian3.multiplyByScalar( + orbitNormal, + Math.cos(elevation) * distance, + new Cesium.Cartesian3(), + ); + const radialOffset = Cesium.Cartesian3.multiplyByScalar( + radial, + Math.sin(elevation) * distance, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + target, + planeOffset, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.add(destination, radialOffset, destination); + const direction = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const radialAlongView = Cesium.Cartesian3.multiplyByScalar( + direction, + Cesium.Cartesian3.dot(radial, direction), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + radial, + radialAlongView, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return { destination, direction, up }; + } + + /** + * Build one conservative frame that contains both Earth and the complete + * selected orbit. High-apogee missions cannot be composed from the vehicle's + * instantaneous altitude alone because the opposite side of the orbit may + * extend much farther from the globe. + * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. + * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. + */ + + function replayOrbitFrameSphere(orbitPath = []) { + const earth = new Cesium.BoundingSphere( + Cesium.Cartesian3.ZERO, + Cesium.Ellipsoid.WGS84.maximumRadius, + ); + if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; + const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); + return Cesium.BoundingSphere.union( + earth, + orbit, + new Cesium.BoundingSphere(), + ); + } + + /** + * Ensure high-altitude missions frame both the globe and selected vehicle. + * @param {number} baseRange Range from the normal replay camera transition. + * @param {number} altitudeM Vehicle altitude above the ellipsoid. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. + * @returns {number} Camera range in metres. + */ + + function replayOrbitGlobeRange( + baseRange, + altitudeM, + orbitBlend = 0, + frameRadiusM = 0, + ) { + const range = Math.max(0, Number(baseRange) || 0); + const altitude = Math.max(0, Number(altitudeM) || 0); + const frameRadius = Math.max(0, Number(frameRadiusM) || 0); + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + const globeAndVehicleRange = Math.max( + range, + altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, + frameRadius * 3, + ); + return Cesium.Math.lerp(range, globeAndVehicleRange, blend); + } + return { + cameraHeadingForPath, + replayInitialCameraHeading, + replayChaseCameraHeading, + smoothReplayCameraHeading, + missionZoomPitch, + replayCameraView, + replayOrbitGlobeAnchor, + replayOrbitCameraTarget, + replayOrbitCameraPose, + replayOrbitFrameSphere, + replayOrbitGlobeRange, + }; +} diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js new file mode 100644 index 000000000..1d119ebdb --- /dev/null +++ b/src/layers/launches/controls.js @@ -0,0 +1,35 @@ +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'rocket-launches', + + name: 'Space Missions (30d)', + + icon: '🚀', + + source: 'Launch Library 2', + + updateInterval: 300000, + + /** Release only Space Mission camera ownership, preserving layer and selection state. */ + releaseCameraOwnership() { + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + }, + + getStats() { + return { + count: layerState._count, + orbitMatches: layerState._orbitMatches, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + }; + }, + + attachDataManager(dataManager) { + layerState._dataManager = dataManager; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js new file mode 100644 index 000000000..8df45903e --- /dev/null +++ b/src/layers/launches/index.js @@ -0,0 +1,118 @@ +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createOrbitRendering } from './orbitRendering.js'; +import { createOverlays } from './overlays.js'; +import { createSelection } from './selection.js'; +import { createReplay } from './replay.js'; +import { createPanel } from './panel.js'; +import { createPaths } from './paths.js'; +import { createLaunchPad } from './launchPad.js'; +import { createCamera } from './camera.js'; +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createTesting } from './testing.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createRocketLaunchesLayer({ services, source }) { + if (typeof source?.getLaunches !== 'function') + throw new TypeError('A launches source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.ingestion = createIngestion(context); + parts.orbitRendering = createOrbitRendering(context); + parts.overlays = createOverlays(context); + parts.selection = createSelection(context); + parts.replay = createReplay(context); + parts.panel = createPanel(context); + parts.paths = createPaths(context); + parts.launchPad = createLaunchPad(context); + parts.camera = createCamera(context); + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.testing = createTesting(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + { + satelliteParamsForSpaceMissions: + parts.policyHelpers.satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions: + parts.policyHelpers.satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit: parts.policyHelpers.launchStatusAllowsOrbit, + missionPathPresentation: parts.policyHelpers.missionPathPresentation, + shouldRetryAfterActiveTle: parts.ingestion.shouldRetryAfterActiveTle, + releaseAircraftTracking: parts.policyHelpers.releaseAircraftTracking, + launchPadZoneVisible: parts.policyHelpers.launchPadZoneVisible, + missionAnchorHorizonVisible: parts.overlays.missionAnchorHorizonVisible, + missionAnchorVisible: parts.overlays.missionAnchorVisible, + compactLaunchSiteName: parts.overlays.compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry: + parts.overlays.createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry: + parts.overlays.createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort: + parts.overlays.selectRocketMissionMarkerOverlayCohort, + replayOverlayMode: parts.overlays.replayOverlayMode, + replayVehicleScreenRotation: parts.replay.replayVehicleScreenRotation, + smoothReplayWindowPosition: parts.replay.smoothReplayWindowPosition, + missionDataCompleteness: parts.policyHelpers.missionDataCompleteness, + missionRosterEntries: parts.policyHelpers.missionRosterEntries, + missionHoverPreviewRange: parts.panel.missionHoverPreviewRange, + createMissionRosterPreviewOwnership: + parts.panel.createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview: + parts.panel.bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus: parts.panel.captureMissionRosterFocus, + restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch: + parts.panel.resolveMissionRosterPreviewLaunch, + formatMissionEventTime: parts.policyHelpers.formatMissionEventTime, + parseMissionDurationSeconds: + parts.policyHelpers.parseMissionDurationSeconds, + replayAscentDurationSeconds: parts.replay.replayAscentDurationSeconds, + normalizeReplaySpeed: parts.replay.normalizeReplaySpeed, + replayStartAfterPause: parts.replay.replayStartAfterPause, + replayState: parts.replay.replayState, + approximateOrbitPath: parts.paths.approximateOrbitPath, + samplePath: parts.paths.samplePath, + orbitProgressAtTime: parts.paths.orbitProgressAtTime, + reconstructedAscentPath: parts.paths.reconstructedAscentPath, + buildMissionPaths: parts.paths.buildMissionPaths, + cameraHeadingForPath: parts.camera.cameraHeadingForPath, + replayInitialCameraHeading: parts.camera.replayInitialCameraHeading, + replayChaseCameraHeading: parts.camera.replayChaseCameraHeading, + smoothReplayCameraHeading: parts.camera.smoothReplayCameraHeading, + missionZoomPitch: parts.camera.missionZoomPitch, + replayCameraView: parts.camera.replayCameraView, + replayOrbitGlobeAnchor: parts.camera.replayOrbitGlobeAnchor, + replayOrbitCameraTarget: parts.camera.replayOrbitCameraTarget, + replayOrbitCameraPose: parts.camera.replayOrbitCameraPose, + replayOrbitFrameSphere: parts.camera.replayOrbitFrameSphere, + replayOrbitGlobeRange: parts.camera.replayOrbitGlobeRange, + missionMarkerColor: parts.model.missionMarkerColor, + normalizeRocketLaunches: parts.model.normalizeRocketLaunches, + _setRocketMissionOverlayHostForTest: + parts.testing._setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest: + parts.testing._setSelectedRocketMissionForTest, + }, + ); +} +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from './policy.js'; +export { createLaunchSource } from './source.js'; diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js new file mode 100644 index 000000000..e7eab0565 --- /dev/null +++ b/src/layers/launches/ingestion.js @@ -0,0 +1,268 @@ +import { MAX_POST_TLE_RETRIES, POST_TLE_RETRY_DELAY_MS } from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + /** + * Decide whether one bounded rebuild is needed after the active TLE lookup. + * @param {object} input Retry state. + * @param {boolean} input.enabled Whether Space Missions is active. + * @param {number} input.retryCount Number of post-TLE retries already used. + * @param {string|null} input.activeTleText Resolved active TLE catalog. + * @param {string|null} input.renderedTleText TLE catalog used for the last build. + * @returns {boolean} Whether to schedule a refresh. + */ + + function shouldRetryAfterActiveTle({ + enabled, + retryCount, + activeTleText, + renderedTleText, + }) { + return Boolean( + enabled && + activeTleText && + activeTleText !== renderedTleText && + retryCount < MAX_POST_TLE_RETRIES, + ); + } + + function clearPostTleRetry() { + if (layerState._retryTimer) clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + } + + function schedulePostTleRetry(token) { + if ( + layerState._retryTimer || + token !== layerState._lifecycleToken || + !shouldRetryAfterActiveTle({ + enabled: layerState._enabled, + retryCount: layerState._postTleRetryCount, + activeTleText: layerState._activeTleText, + renderedTleText: layerState._renderedTleText, + }) + ) + return; + layerState._postTleRetryCount++; + layerState._retryTimer = setTimeout(() => { + layerState._retryTimer = null; + if (layerState._enabled && token === layerState._lifecycleToken) + requestMissionUpdate(); + }, POST_TLE_RETRY_DELAY_MS); + } + + function ensureActiveTleLookup(token) { + if (layerState._activeTleText) + return Promise.resolve(layerState._activeTleText); + if ( + layerState._activeTlePromise && + layerState._activeTlePromiseToken === token + ) + return layerState._activeTlePromise; + const request = source + .getActiveTle({ signal: layerState._sourceController.signal }) + .then((text) => { + if (!layerState._enabled || token !== layerState._lifecycleToken) + return null; + layerState._activeTleText = text; + layerState._focusAfterActiveLookup = Boolean( + layerState._selectedLaunchId, + ); + schedulePostTleRetry(token); + return text; + }) + .catch((error) => { + if (layerState._enabled && token === layerState._lifecycleToken) { + console.warn( + '[Data:RocketLaunches] Active satellite lookup unavailable:', + error.message, + ); + } + return null; + }) + .finally(() => { + if (layerState._activeTlePromise === request) { + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + } + }); + layerState._activeTlePromise = request; + layerState._activeTlePromiseToken = token; + return request; + } + + async function captureSatelliteDependency() { + if (!layerState._dataManager || layerState._satelliteStateBeforeMission) + return; + layerState._satelliteStateBeforeMission = { + // Effective visibility: a user enable still mid-activation is intent ON — + // capturing settled false would restore the user's enable away on exit. + enabled: + layerState._dataManager.isEffectivelyEnabled?.('satellites') ?? + layerState._dataManager.isEnabled('satellites'), + params: parts.policyHelpers.satelliteParamsAfterSpaceMissions( + layerState._dataManager.getLayerParams('satellites'), + ), + }; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsForSpaceMissions( + layerState._satelliteStateBeforeMission.params, + ), + ); + const token = layerState._lifecycleToken; + const activation = Promise.resolve( + layerState._dataManager.setEnabled('satellites', true), + ); + layerState._satelliteActivationPromise = activation; + try { + const activated = await activation; + // Space Missions without its satellite dependency is a broken replay + // surface; fail the mission enable so the manager's fail-closed path and + // the Context rollback see an honest failure instead of a silent success. + if ( + token === layerState._lifecycleToken && + layerState._enabled && + (activated === false || + !layerState._dataManager.isEnabled('satellites')) + ) { + throw new Error( + 'Space Missions requires the satellites layer, which failed to start', + ); + } + } finally { + if ( + token === layerState._lifecycleToken && + layerState._satelliteActivationPromise === activation + ) { + layerState._satelliteActivationPromise = null; + } + } + } + + async function restoreSatelliteDependency() { + const snapshot = layerState._satelliteStateBeforeMission; + if (!snapshot || !layerState._dataManager) return; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsAfterSpaceMissions(snapshot.params), + ); + const restored = await layerState._dataManager.setEnabled( + 'satellites', + snapshot.enabled, + ); + if ( + restored === false || + layerState._dataManager.isEnabled('satellites') !== snapshot.enabled + ) { + throw new Error('Space Missions could not restore the satellites layer'); + } + } + + async function performMissionUpdate(token) { + try { + ensureActiveTleLookup(token); + const payload = await source.getLaunches({ + signal: layerState._sourceController.signal, + }); + const launches = parts.model.normalizeRocketLaunches(payload); + if ( + !layerState._enabled || + token !== layerState._lifecycleToken || + !layerState._dataSource + ) + return; + const activeTleText = layerState._activeTleText; + if (layerState._replayCameraLaunchId) parts.replay.stopMissionReplay(); + layerState._launches = launches; + parts.orbitRendering.removeMissionOrbitPrimitives(); + layerState._dataSource.entities.removeAll(); + layerState._missionOverlayRecords.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._orbitMatches = 0; + launches.forEach((launch) => + parts.rendering.addLaunchEntity(launch, activeTleText), + ); + layerState._renderedTleText = activeTleText; + if (layerState._renderedTleText === layerState._activeTleText) + clearPostTleRetry(); + layerState._count = launches.length; + parts.panel.renderMissionRoster(); + if ( + !layerState._selectedLaunchId || + !launches.some((launch) => launch.id === layerState._selectedLaunchId) + ) { + parts.selection.setSelectedMission(null, false); + } else { + parts.selection.setSelectedMission(layerState._selectedLaunchId, true); + if (layerState._focusAfterActiveLookup) { + parts.selection.focusMission( + launches.find( + (launch) => launch.id === layerState._selectedLaunchId, + ), + ); + } + } + layerState._focusAfterActiveLookup = false; + parts.panel.renderMissionPanel(); + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + } catch (error) { + if (layerState._enabled && token === layerState._lifecycleToken) { + layerState._lastError = error.message; + console.warn('[Data:RocketLaunches] Fetch error:', error); + } + } + } + + function requestMissionUpdate() { + if (!layerState._enabled) return Promise.resolve(); + const token = layerState._lifecycleToken; + layerState._updateDirty = true; + if (layerState._updatePromise && layerState._updatePromiseToken === token) + return layerState._updatePromise; + let request; + request = (async () => { + while ( + layerState._enabled && + token === layerState._lifecycleToken && + layerState._updateDirty + ) { + layerState._updateDirty = false; + await performMissionUpdate(token); + } + })().finally(() => { + if (layerState._updatePromise === request) { + layerState._updatePromise = null; + layerState._updatePromiseToken = 0; + } + }); + layerState._updatePromise = request; + layerState._updatePromiseToken = token; + return request; + } + const methods = { + update() { + return requestMissionUpdate(); + }, + }; + + return { + shouldRetryAfterActiveTle, + clearPostTleRetry, + schedulePostTleRetry, + ensureActiveTleLookup, + captureSatelliteDependency, + restoreSatelliteDependency, + performMissionUpdate, + requestMissionUpdate, + methods, + }; +} diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js new file mode 100644 index 000000000..f0a87ce6a --- /dev/null +++ b/src/layers/launches/launchPad.js @@ -0,0 +1,158 @@ +import * as Cesium from 'cesium'; +import { LAUNCH_PAD_ZONE_RADIUS_M } from './policy.js'; + +export function createLaunchPad({ + state: layerState, + services, + parts, + source, +}) { + function hideLaunchPadZone() { + if (layerState._launchPadZonePrimitive) + layerState._launchPadZonePrimitive.show = false; + } + + function removeLaunchPadZonePrimitive() { + if ( + layerState._launchPadZonePrimitive && + layerState._viewer?.scene?.primitives + ) { + layerState._viewer.scene.primitives.remove( + layerState._launchPadZonePrimitive, + ); + } + layerState._launchPadZonePrimitive = null; + layerState._launchPadZoneLaunchId = null; + } + + function createLaunchPadZonePrimitive(launch) { + removeLaunchPadZonePrimitive(); + const material = new Cesium.Material({ + fabric: { + type: 'GevLaunchPadZone', + uniforms: { + color: Cesium.Color.fromCssColorString('#22e6e6'), + fillAlpha: 0.105, + rimAlpha: 0.72, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; + float radius = length(centered); + float inside = 1.0 - smoothstep(0.985, 1.0, radius); + float rim = smoothstep(0.952, 0.985, radius) * inside; + material.diffuse = color.rgb; + material.emission = color.rgb * rim * 0.35; + material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); + return material; + }`, + }, + }); + const geometry = new Cesium.EllipseGeometry({ + center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), + semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + granularity: Cesium.Math.toRadians(0.08), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }); + layerState._launchPadZonePrimitive = + layerState._viewer.scene.primitives.add( + new Cesium.GroundPrimitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry, + id: `rocket-pad-zone:${launch.id}`, + }), + appearance: new Cesium.MaterialAppearance({ + material, + translucent: true, + closed: false, + faceForward: true, + flat: true, + // Keep the zone classified onto the photoreal surface, but bias only its + // rasterized depth toward the camera. This avoids coplanar fragments + // being intermittently buried by the launch-pad mesh at oblique angles + // without adding a world-space height that would make the ring float. + renderState: { + depthTest: { + enabled: true, + }, + depthMask: false, + polygonOffset: { + enabled: true, + factor: -1, + units: -4, + }, + blending: Cesium.BlendingState.ALPHA_BLEND, + }, + }), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + show: true, + }), + ); + layerState._launchPadZoneLaunchId = launch.id; + } + + function initLaunchPadZonePrimitive() { + if (!layerState._viewer || layerState._launchPadZoneRemover) return; + layerState._launchPadZoneRemover = + layerState._viewer.scene.preRender.addEventListener(() => { + if (!layerState._enabled || !layerState._dataSource?.show) { + hideLaunchPadZone(); + return; + } + const nowMs = Date.now(); + if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { + layerState._missionRingDate.setTime(nowMs); + parts.orbitRendering.updateMissionOrbitPrimitiveFrames( + layerState._missionRingDate, + ); + layerState._lastMissionRingRotationMs = nowMs; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const camera = layerState._viewer?.camera; + if (!launch || !camera) { + hideLaunchPadZone(); + return; + } + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + const visible = parts.policyHelpers.launchPadZoneVisible({ + layerActive: Boolean(layerState._dataSource?.show), + selectedLaunchId: layerState._selectedLaunchId, + launchId: launch.id, + cameraHeightM: camera.positionCartographic?.height, + cameraDistanceM: Cesium.Cartesian3.distance( + camera.positionWC, + launchPosition, + ), + }); + if (!visible) { + hideLaunchPadZone(); + return; + } + if (layerState._launchPadZoneLaunchId !== launch.id) + createLaunchPadZonePrimitive(launch); + layerState._launchPadZonePrimitive.show = true; + }); + } + + function destroyLaunchPadZonePrimitive() { + if (layerState._launchPadZoneRemover) layerState._launchPadZoneRemover(); + layerState._launchPadZoneRemover = null; + removeLaunchPadZonePrimitive(); + } + return { + hideLaunchPadZone, + removeLaunchPadZonePrimitive, + createLaunchPadZonePrimitive, + initLaunchPadZonePrimitive, + destroyLaunchPadZonePrimitive, + }; +} diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js new file mode 100644 index 000000000..2eea788d0 --- /dev/null +++ b/src/layers/launches/lifecycle.js @@ -0,0 +1,172 @@ +import * as Cesium from 'cesium'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + const methods = { + init(viewer) { + layerState._viewer = viewer; + layerState._enabled = false; + parts.panel.createMissionPanel(); + parts.overlays.createReplayVehicleOverlay(); + layerState._dataSource = new Cesium.CustomDataSource('rocket-launches'); + layerState._dataSource.show = false; + viewer.dataSources.add(layerState._dataSource); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastError = null; + layerState._orbitMatches = 0; + parts.overlays.clearMissionOverlaySources(); + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const entity = viewer.scene + .drillPick(movement.position, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + const launchId = parts.selection.entityLaunchId(entity); + if (!launchId) return; + parts.selection.setSelectedMission(launchId); + parts.selection.focusMission( + layerState._launches.find((launch) => launch.id === launchId), + ); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._moveHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const missionEntity = viewer.scene + .drillPick(movement.endPosition, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + // Apply horizon visibility before Cesium traverses and picks the scene. + // A postRender write is one frame late and can remain visibly stale when + // request-on-demand rendering stops after a globe camera move. + layerState._declutterHandler = viewer.scene.preRender.addEventListener( + parts.rendering.updateMissionFrame, + ); + parts.launchPad.initLaunchPadZonePrimitive(); + }, + + async enable() { + layerState._sourceController.abort(); + layerState._sourceController = new AbortController(); + layerState._enabled = true; + holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) + layerState._lifecycleToken++; + layerState._postTleRetryCount = 0; + try { + await this._enableBody(); + } catch (error) { + // Enable is a transaction: a failed dependency must not leave mission + // UI/datasource visible or the satellite snapshot retained (a retained + // snapshot makes the next enable skip dependency capture entirely). + try { + await this.disable(); + } catch (cleanupError) { + console.warn('[Missions] enable rollback failed:', cleanupError); + } + throw error; + } + }, + + async _enableBody() { + layerState._updateDirty = false; + if (layerState._dataSource) layerState._dataSource.show = true; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.selection.focusFullGlobe(layerState._viewer); + document.getElementById('cockpit-context')?.setAttribute('hidden', ''); + if (layerState._selectedLaunchId) + parts.selection.setSelectedMission( + layerState._selectedLaunchId, + layerState._explicitSelection, + ); + else parts.selection.setSelectedMission(null, false); + await parts.ingestion.captureSatelliteDependency(); + }, + + async disable() { + layerState._sourceController.abort(); + layerState._enabled = false; + releaseContinuousRender('rocket-launches'); + layerState._lifecycleToken++; + layerState._updateDirty = false; + parts.ingestion.clearPostTleRetry(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + if (layerState._dataSource) layerState._dataSource.show = false; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + if (layerState._viewer?.scene?.canvas) + layerState._viewer.scene.canvas.style.cursor = ''; + parts.launchPad.hideLaunchPadZone(); + parts.overlays.hideReplayVehicleOverlay(); + layerState._selectedLaunchId = null; + parts.overlays.clearMissionOverlaySources(); + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.renderMissionPanel(); + }, + + async destroy(viewer) { + layerState._sourceController.abort(); + releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) + layerState._enabled = false; + layerState._lifecycleToken++; + layerState._updateDirty = false; + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + parts.ingestion.clearPostTleRetry(); + if (layerState._clickHandler) layerState._clickHandler.destroy(); + layerState._clickHandler = null; + if (layerState._moveHandler) layerState._moveHandler.destroy(); + layerState._moveHandler = null; + if (layerState._declutterHandler) layerState._declutterHandler(); + layerState._declutterHandler = null; + parts.launchPad.destroyLaunchPadZonePrimitive(); + parts.orbitRendering.removeMissionOrbitPrimitives(); + parts.overlays.clearMissionOverlaySources(); + parts.overlays.destroyReplayVehicleOverlay(); + layerState._launches = []; + layerState._missionOverlayRecords.clear(); + layerState._animationStarts.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._missionPanel?.remove(); + layerState._missionPanel = null; + if (layerState._missionRoster) { + layerState._missionRoster.onclick = null; + } + layerState._missionRosterPreviewOwnership = null; + layerState._missionRoster = null; + layerState._viewer = null; + if (layerState._dataSource) + viewer.dataSources.remove(layerState._dataSource, true); + layerState._dataSource = null; + layerState._count = 0; + layerState._orbitMatches = 0; + layerState._lastUpdate = null; + layerState._activeTleText = null; + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + layerState._renderedTleText = null; + layerState._postTleRetryCount = 0; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js new file mode 100644 index 000000000..641b8d157 --- /dev/null +++ b/src/layers/launches/model.js @@ -0,0 +1,241 @@ +import * as Cesium from 'cesium'; +import { WINDOW_DAYS } from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + function finiteCoordinate(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + function normalizePayloadFlights(launch) { + const flights = + launch.rocket?.payloads || + launch.payloads || + launch.mission?.payloads || + []; + if (!Array.isArray(flights)) return []; + return flights.map((flight, index) => { + const payload = flight.payload || flight; + return { + id: String(flight.id || payload.id || `payload-${index}`), + name: payload.name || flight.name || 'Unnamed payload', + type: payload.type?.name || flight.type?.name || null, + manufacturer: payload.manufacturer?.name || null, + operator: payload.operator?.name || null, + destination: flight.destination || payload.destination || null, + amount: Number.isFinite(Number(flight.amount)) + ? Number(flight.amount) + : 1, + massKg: + (typeof payload.mass === 'number' || + (typeof payload.mass === 'string' && payload.mass.trim() !== '')) && + Number.isFinite(Number(payload.mass)) && + Number(payload.mass) >= 0 + ? Number(payload.mass) + : null, + }; + }); + } + + function normalizeLanding(stage, fallbackName, category, index) { + const landing = stage?.landing; + const location = landing?.landing_location || {}; + const launcher = stage?.launcher || stage?.spacecraft || {}; + const serial = launcher.serial_number || stage?.serial_number || null; + const stageType = stage?.type?.name || stage?.type || category; + const attempted = landing?.attempt === true; + const success = landing?.success; + const recoveryType = landing?.type?.name || null; + const launcherStatus = launcher.status?.name || null; + const status = + success === true + ? 'RECOVERED' + : success === false + ? 'LOST' + : attempted + ? 'RECOVERY ATTEMPT' + : recoveryType + ? recoveryType.toUpperCase() + : launcherStatus + ? launcherStatus.toUpperCase() + : 'NO RECOVERY DATA'; + return { + id: String(stage?.id || landing?.id || `${category}-${index}`), + category, + name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, + serial, + reused: stage?.reused === true, + flightNumber: finiteCoordinate(stage?.launcher_flight_number), + status, + attempted, + success: success === true ? true : success === false ? false : null, + recoveryType, + destination: + location.name || landing?.destination || landing?.type?.name || null, + description: landing?.description || null, + downrangeKm: finiteCoordinate(landing?.downrange_distance), + lat: finiteCoordinate(location.latitude ?? landing?.latitude), + lon: finiteCoordinate(location.longitude ?? landing?.longitude), + }; + } + + function normalizeRecoveryStages(launch, payloads) { + const rocket = launch.rocket || {}; + const launcherStages = Array.isArray(rocket.launcher_stage) + ? rocket.launcher_stage + : []; + const spacecraftStages = Array.isArray(rocket.spacecraft_stage) + ? rocket.spacecraft_stage + : []; + const stages = [ + ...launcherStages.map((stage, index) => + normalizeLanding( + stage, + `Launcher stage ${index + 1}`, + 'LAUNCHER', + index, + ), + ), + ...spacecraftStages.map((stage, index) => + normalizeLanding( + stage, + `Spacecraft stage ${index + 1}`, + 'SPACECRAFT', + index, + ), + ), + ]; + const payloadFlights = rocket.payloads || launch.payloads || []; + if (Array.isArray(payloadFlights)) { + payloadFlights.forEach((flight, index) => { + if (!flight?.landing) return; + stages.push( + normalizeLanding( + { + ...flight, + type: payloads[index]?.type || 'Payload', + serial_number: payloads[index]?.name, + }, + payloads[index]?.name || `Payload ${index + 1}`, + 'PAYLOAD', + index, + ), + ); + }); + } + return stages; + } + + /** + * Select a stable marker color from the mission operator and payload name. + * Labels stay cyan so the layer remains visually coherent. + * @param {object} launch Normalized launch record. + * @returns {Cesium.Color} + */ + + function missionMarkerColor(launch) { + const identity = + `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); + if (/nasa|national aeronautics/.test(identity)) + return Cesium.Color.fromCssColorString('#ff9f43'); + if (/starlink|spacex|space exploration/.test(identity)) + return Cesium.Color.fromCssColorString('#4cc9f0'); + if (/rocket lab/.test(identity)) + return Cesium.Color.fromCssColorString('#7bed9f'); + if (/isro|indian space/.test(identity)) + return Cesium.Color.fromCssColorString('#ff66c4'); + if (/cnsa|china national|long march/.test(identity)) + return Cesium.Color.fromCssColorString('#ffd166'); + if (/blue origin/.test(identity)) + return Cesium.Color.fromCssColorString('#a78bfa'); + if (/ula|united launch alliance/.test(identity)) + return Cesium.Color.fromCssColorString('#f97316'); + if (/arianespace|esa|european space/.test(identity)) + return Cesium.Color.fromCssColorString('#60a5fa'); + if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); + return Cesium.Color.fromCssColorString('#22e6e6'); + } + + /** + * Normalize a Launch Library 2 response into records suitable for the layer. + * Trajectory points are retained only when the upstream explicitly supplies + * them; orbital tracks must not be reconstructed from launch metadata. + * @param {object} payload Launch Library 2-compatible response. + * @param {Date} [now] Reference time used for the rolling window. + * @returns {Array} + */ + + function normalizeRocketLaunches(payload, now = new Date()) { + const launches = Array.isArray(payload) ? payload : payload?.results; + if (!Array.isArray(launches)) return []; + const cutoff = now.getTime() - WINDOW_DAYS * 86400000; + return launches + .map((launch) => { + const launchTime = + launch.net || launch.window_start || launch.pad?.location?.name; + const date = Date.parse(launchTime); + const pad = launch.pad || {}; + const location = pad.location || {}; + const coordinates = location.coordinates || ''; + const [coordinateLon, coordinateLat] = String(coordinates) + .split(',') + .map(Number); + const lat = Number.isFinite(Number(pad.latitude)) + ? Number(pad.latitude) + : coordinateLat; + const lon = Number.isFinite(Number(pad.longitude)) + ? Number(pad.longitude) + : coordinateLon; + const payloads = normalizePayloadFlights(launch); + return { + id: String( + launch.id || launch.slug || launch.name || `launch-${date}`, + ), + name: launch.name || 'Unnamed launch', + status: launch.status?.name || 'Unknown', + launchTime: Number.isFinite(date) + ? new Date(date).toISOString() + : null, + launchSite: pad.name || location.name || 'Unknown launch site', + lat: Number.isFinite(lat) ? lat : null, + lon: Number.isFinite(lon) ? lon : null, + provider: launch.launch_service_provider?.name || null, + mission: launch.mission?.description || null, + missionName: launch.mission?.name || null, + satelliteQuery: launch.mission?.name || launch.name || null, + payloads, + recoveryStages: normalizeRecoveryStages(launch, payloads), + trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], + timeline: Array.isArray(launch.timeline) + ? launch.timeline.map((event) => ({ + name: + event.type?.abbrev || + event.type?.name || + event.name || + 'Mission event', + relativeTime: event.relative_time || event.relativeTime || null, + offsetSeconds: parts.policyHelpers.parseMissionDurationSeconds( + event.relative_time || event.relativeTime, + ), + })) + : [], + orbit: launch.mission?.orbit || launch.orbit || null, + source: 'Launch Library 2', + inWindow: + Number.isFinite(date) && date >= cutoff && date <= now.getTime(), + }; + }) + .filter( + (launch) => + launch.inWindow && launch.lat !== null && launch.lon !== null, + ); + } + return { + finiteCoordinate, + normalizePayloadFlights, + normalizeLanding, + normalizeRecoveryStages, + missionMarkerColor, + normalizeRocketLaunches, + }; +} diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js new file mode 100644 index 000000000..d907904eb --- /dev/null +++ b/src/layers/launches/orbitRendering.js @@ -0,0 +1,183 @@ +import * as Cesium from 'cesium'; +import { + MISSION_ORBIT_PATTERN_GROUPS, + MISSION_ORBIT_DASHES_PER_GROUP, +} from './policy.js'; + +export function createOrbitRendering({ + state: layerState, + services, + parts, + source, +}) { + const { orbitFrameModelMatrix } = services.satellites; + + /** + * Register the selected-orbit tactical material once. Each group begins with + * one compact round dot followed by one hundred short dashes. + */ + + function ensureMissionOrbitPatternRegistered() { + if (layerState._missionOrbitPatternRegistered) return; + new Cesium.Material({ + fabric: { + type: 'GevMissionOrbitTactical', + uniforms: { + color: Cesium.Color.CYAN, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float groupPosition = fract(materialInput.st.s * groupCount); + float markPosition = groupPosition * (dashCount + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); + float dash = dashAlong * dashAcross; + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (materialInput.st.t - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + float visible = mix(dash, dot, isDot); + material.diffuse = color.rgb; + material.emission = color.rgb * mix(0.07, 0.65, isDot); + material.alpha = color.a * visible * mix(0.58, 1.0, isDot); + return material; + }`, + }, + }); + layerState._missionOrbitPatternRegistered = true; + } + + function createMissionOrbitPatternMaterial(color) { + ensureMissionOrbitPatternRegistered(); + return Cesium.Material.fromType('GevMissionOrbitTactical', { + color, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }); + } + + function missionOrbitPrimitiveVisible(launchId) { + return Boolean( + layerState._enabled && + layerState._dataSource?.show && + (!layerState._selectedLaunchId || + layerState._selectedLaunchId === launchId), + ); + } + + function syncMissionOrbitPrimitiveVisibility() { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (path.primitive) + path.primitive.show = missionOrbitPrimitiveVisible(launchId); + } + } + + function removeMissionOrbitPrimitives() { + for (const path of layerState._missionOrbitPrimitives.values()) { + if (path.primitive && layerState._viewer?.scene?.primitives) { + layerState._viewer.scene.primitives.remove(path.primitive); + } + } + layerState._missionOrbitPrimitives.clear(); + } + + function updateMissionOrbitPrimitiveFrames(nowDate) { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; + orbitFrameModelMatrix( + path.gmstAtBake, + nowDate, + path.primitive.modelMatrix, + ); + if (path.labelBakePosition && path.labelPosition) { + Cesium.Matrix4.multiplyByPoint( + path.primitive.modelMatrix, + path.labelBakePosition, + path.labelPosition, + ); + } + } + } + + function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { + if ( + !layerState._viewer || + !satelliteTrack || + !Number.isFinite(satelliteTrack.gmstAtBake) + ) + return false; + const collection = new Cesium.PolylineCollection(); + collection.add({ + positions: orbitPath, + width: 3, + material: createMissionOrbitPatternMaterial( + Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), + ), + }); + collection.show = missionOrbitPrimitiveVisible(launch.id); + orbitFrameModelMatrix( + satelliteTrack.gmstAtBake, + new Date(), + collection.modelMatrix, + ); + layerState._viewer.scene.primitives.add(collection); + layerState._missionOrbitPrimitives.set(launch.id, { + primitive: collection, + gmstAtBake: satelliteTrack.gmstAtBake, + }); + return true; + } + + function MissionOrbitPatternMaterialProperty(color) { + ensureMissionOrbitPatternRegistered(); + this._color = color; + this._definitionChanged = new Cesium.Event(); + } + Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { + isConstant: { + get() { + return true; + }, + }, + definitionChanged: { + get() { + return this._definitionChanged; + }, + }, + }); + MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { + return 'GevMissionOrbitTactical'; + }; + MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue( + time, + result, + ) { + if (!Cesium.defined(result)) result = {}; + result.color = this._color; + result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; + result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; + return result; + }; + MissionOrbitPatternMaterialProperty.prototype.equals = function equals( + other, + ) { + return this === other; + }; + return { + ensureMissionOrbitPatternRegistered, + createMissionOrbitPatternMaterial, + missionOrbitPrimitiveVisible, + syncMissionOrbitPrimitiveVisibility, + removeMissionOrbitPrimitives, + updateMissionOrbitPrimitiveFrames, + addMissionOrbitPrimitive, + MissionOrbitPatternMaterialProperty, + }; +} diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js new file mode 100644 index 000000000..600b5c781 --- /dev/null +++ b/src/layers/launches/overlays.js @@ -0,0 +1,616 @@ +import * as Cesium from 'cesium'; +import { + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createOverlays({ state: layerState, services, parts, source }) { + /** + * Resolve whether a surface mission anchor is safely on the camera-facing + * side of Earth. The small positive limb margin prevents labels anchored just + * beyond the horizon from leaking through the hidden Cesium globe. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {number} [limbMargin] Additional normalized horizon clearance. + * @returns {boolean} Whether the marker belongs on the visible hemisphere. + */ + + function missionAnchorHorizonVisible( + cameraPosition, + markerPosition, + limbMargin = 0.012, + ) { + if (!cameraPosition || !markerPosition) return false; + const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); + if ( + !Number.isFinite(cameraMagnitude) || + cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius + ) { + return false; + } + const cameraDirection = Cesium.Cartesian3.normalize( + cameraPosition, + new Cesium.Cartesian3(), + ); + const markerDirection = Cesium.Cartesian3.normalize( + markerPosition, + new Cesium.Cartesian3(), + ); + const limbThreshold = + Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; + return ( + Cesium.Cartesian3.dot(cameraDirection, markerDirection) > + limbThreshold + limbMargin + ); + } + + /** + * Resolve launch-anchor visibility for overview and selected-mission views. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {string} markerId Mission represented by this anchor. + * @param {string|null} selectedLaunchId Explicitly selected mission. + * @returns {boolean} Whether the launch anchor should render. + */ + + function missionAnchorVisible( + cameraPosition, + markerPosition, + markerId, + selectedLaunchId = null, + ) { + if (selectedLaunchId && markerId !== selectedLaunchId) return false; + return missionAnchorHorizonVisible(cameraPosition, markerPosition); + } + + function shortMissionLabel(name, maxLength = 24) { + const text = String(name || 'Unnamed mission') + .replace(/\s+/g, ' ') + .trim() + .split(' | ')[0]; + const compact = text.split(' — ')[0].trim(); + return compact.length > maxLength + ? `${compact.slice(0, maxLength - 1).trimEnd()}…` + : compact; + } + + /** + * Reduce generic launch-complex names to their identifying pad or area suffix. + * @param {string|null} launchSite Launch Library pad name. + * @returns {string|null} Compact launch-site identifier. + */ + + function compactLaunchSiteName(launchSite) { + const text = String(launchSite || '') + .replace(/\s+/g, ' ') + .trim(); + if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; + const genericPrefix = + /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; + const compact = text.replace(genericPrefix, '').trim(); + return compact || null; + } + + /** + * Build the source-owned launch-site marker presentation. Overview markers + * compete in the bounded ambient-label domain; the selected mission moves to + * the protected selected lane and gains the former launch-site detail line. + * @param {object} launch Normalized Launch Library mission. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. + * @param {boolean} [selected=false] Whether the mission owns the selected view. + * @returns {object} + */ + + function createRocketMissionMarkerOverlayEntry( + launch, + position, + selected = false, + ) { + const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); + const siteName = compactLaunchSiteName(launch?.launchSite); + const launchTimeMs = Date.parse(launch?.launchTime); + const details = selected + ? [ + siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE', + ] + : []; + return { + id: `launch:${launch?.id}`, + position, + variant: 'label', + title: mission, + details, + accent: '#22e6e6', + priority: selected + ? Number.MAX_SAFE_INTEGER + : Number.isFinite(launchTimeMs) + ? Math.floor(launchTimeMs / 1000) + : 0, + selected, + protected: selected, + paintLane: selected ? 'selected' : 'ambient-label', + collisionGroup: 'ambient-label', + interactive: false, + distanceScale: { + near: 1000, + nearValue: 1.08, + far: 20_000_000, + farValue: 0.78, + }, + gapPx: 12, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** + * Build one protected label belonging to the selected mission's trajectory, + * live payload position, or orbit. Copy is already source-formatted by the + * caller; newline semantics become explicit host detail rows. + * @param {object} input + * @param {string} input.id Stable mission-element identity. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. + * @param {string} input.text Former native-label text, including newlines. + * @param {string} input.accent Source-owned label color. + * @param {number} [input.priority=0] Protected placement order. + * @param {number} [input.gapPx=8] Anchor-to-label gap. + * @returns {object} + */ + + function createRocketMissionElementOverlayEntry({ + id, + position, + text, + accent, + priority = 0, + gapPx = 8, + }) { + const [title, ...details] = String(text || '').split('\n'); + return { + id: String(id), + position, + variant: 'label', + title, + details, + accent, + priority, + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-label', + interactive: false, + gapPx, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** Keep the newest ambient mission markers with stable identity tie-breaking. */ + + function selectRocketMissionMarkerOverlayCohort( + entries, + limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ) { + const cap = Math.max( + 0, + Math.min( + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + Math.floor(Number(limit) || 0), + ), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); + } + + function missionHoverReticleImage() { + if (layerState._missionHoverReticleImage) + return layerState._missionHoverReticleImage; + const canvas = document.createElement('canvas'); + const size = 48; + const inset = 6; + const arm = 12; + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + context.strokeStyle = '#22e6e6'; + context.lineWidth = 2; + context.lineCap = 'square'; + context.shadowColor = 'rgba(34, 230, 230, .72)'; + context.shadowBlur = 5; + context.beginPath(); + context.moveTo(inset, inset + arm); + context.lineTo(inset, inset); + context.lineTo(inset + arm, inset); + context.moveTo(size - inset - arm, inset); + context.lineTo(size - inset, inset); + context.lineTo(size - inset, inset + arm); + context.moveTo(inset, size - inset - arm); + context.lineTo(inset, size - inset); + context.lineTo(inset + arm, size - inset); + context.moveTo(size - inset - arm, size - inset); + context.lineTo(size - inset, size - inset); + context.lineTo(size - inset, size - inset - arm); + context.stroke(); + layerState._missionHoverReticleImage = canvas; + return canvas; + } + + /** + * Resolve the one permitted screen-space replay overlay state. + * @param {object} input Overlay state. + * @param {boolean} input.replayActive Whether replay owns the camera. + * @param {boolean} input.ascending Whether the replay marker is on ascent. + * @param {boolean} input.countdownActive Whether the T-minus hold is active. + * @returns {'countdown'|'ascent'|'orbit'|null} + */ + + function replayOverlayMode({ + replayActive, + ascending, + countdownActive, + preCountdownActive = false, + }) { + if (!replayActive) return null; + if (preCountdownActive) return null; + if (countdownActive) return 'countdown'; + return ascending ? 'ascent' : 'orbit'; + } + + function createReplayVehicleOverlay() { + if (layerState._replayVehicleOverlay || typeof document === 'undefined') + return; + const host = document.getElementById('cesiumContainer') || document.body; + layerState._replayVehicleOverlay = document.createElement('div'); + layerState._replayVehicleOverlay.className = + 'mission-replay-vehicle-overlay'; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true'); + layerState._replayVehicleOverlay.innerHTML = ` + + + + + + + + + + + + + + + + + + + `; + host.appendChild(layerState._replayVehicleOverlay); + } + + function hideReplayVehicleOverlay() { + if (!layerState._replayVehicleOverlay) return; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.classList.remove( + 'is-thrusting', + 'is-paused', + ); + } + + function destroyReplayVehicleOverlay() { + layerState._replayVehicleOverlay?.remove(); + layerState._replayVehicleOverlay = null; + layerState._replayVehicleOverlayText = ''; + } + + function clearMissionOverlaySources() { + layerState._selectedMissionOverlayTimeText = null; + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + } + + function syncMissionOverlayEntries() { + if (!layerState._enabled || !layerState._dataSource?.show) { + clearMissionOverlaySources(); + return; + } + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (selectedRecord) { + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + const entries = selectedRecord.elementEntryFactories.map((createEntry) => + createEntry(), + ); + if (layerState._replayCameraLaunchId !== selectedRecord.launch.id) { + entries.unshift( + createRocketMissionMarkerOverlayEntry( + selectedRecord.launch, + selectedRecord.anchorPosition, + true, + ), + ); + } + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + true, + ); + layerState._selectedMissionOverlayTimeText = selectedRecord.liveEventTime + ? parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ) + : null; + return; + } + + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + layerState._selectedMissionOverlayTimeText = null; + const entries = selectRocketMissionMarkerOverlayCohort( + Array.from(layerState._missionOverlayRecords.values(), (record) => { + const entry = createRocketMissionMarkerOverlayEntry( + record.launch, + record.anchorPosition, + ); + if (record.launch.id === layerState._hoveredRosterLaunchId) { + entry.pinned = true; + entry.priority = Number.MAX_SAFE_INTEGER - 1; + } + return entry; + }), + ); + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + true, + ); + } + + function refreshSelectedMissionOverlayText() { + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (!selectedRecord?.liveEventTime) return; + const nextText = parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ); + if (nextText !== layerState._selectedMissionOverlayTimeText) + syncMissionOverlayEntries(); + } + + function updateReplayVehicleOverlay(occluder) { + if ( + !layerState._viewer || + !layerState._replayVehicleOverlay || + !layerState._selectedLaunchId + ) { + hideReplayVehicleOverlay(); + return null; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const track = layerState._replayTracks.get(layerState._selectedLaunchId); + if (!launch || !track) { + hideReplayVehicleOverlay(); + return null; + } + const replayActive = layerState._replayCameraLaunchId === launch.id; + const state = track.getReplayState(); + const mode = replayOverlayMode({ + replayActive, + ascending: state.ascending, + countdownActive: state.countdownActive, + preCountdownActive: state.preCountdownActive, + }); + if (!mode) { + track.lastOverlayWindowPosition = null; + track.lastOverlayMode = null; + hideReplayVehicleOverlay(); + return null; + } + const path = state.ascending ? track.ascentPath : track.animatedOrbitPath; + // Do not call scene.sampleHeight() from this post-render path. A selected + // mission can remain visible from globe range, and a periodic remote height + // probe forces Google Photorealistic 3D Tiles to reconsider its refinement + // set even though the camera is stationary. That presented as a one-second + // shrink/expand pulse across the globe. Replay follows its already-built, + // surface-safe Cartesian path and therefore needs no runtime pad sampling. + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position || !occluder.isPointVisible(position)) { + hideReplayVehicleOverlay(); + return null; + } + const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + position, + ); + if (!windowPosition) { + hideReplayVehicleOverlay(); + return null; + } + const canvas = layerState._viewer.scene.canvas; + if ( + windowPosition.x < -80 || + windowPosition.y < -100 || + windowPosition.x > canvas.clientWidth + 80 || + windowPosition.y > canvas.clientHeight + 100 + ) { + hideReplayVehicleOverlay(); + return null; + } + // The replay camera already targets this same frame-cached world position. + // Applying a second temporal filter here made the DOM vehicle trail the + // camera until the snap threshold was crossed, producing a repeating + // forward/back jump. Keep smoothing only for the non-tracked close-up pad + // marker, where the user can move the camera independently. + const renderedWindowPosition = + !replayActive && track.lastOverlayMode === mode + ? parts.replay.smoothReplayWindowPosition( + track.lastOverlayWindowPosition, + windowPosition, + ) + : windowPosition; + track.lastOverlayWindowPosition = renderedWindowPosition; + track.lastOverlayMode = mode; + layerState._replayVehicleOverlay.hidden = false; + layerState._replayVehicleOverlay.dataset.mode = mode; + layerState._replayVehicleOverlay.classList.toggle( + 'is-thrusting', + replayActive && state.ascending && !state.countdownActive, + ); + layerState._replayVehicleOverlay.classList.toggle( + 'is-paused', + replayActive && layerState._replayPaused, + ); + layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; + let vehicleRotation = 0; + if (replayActive && !state.countdownActive) { + const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); + const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); + const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); + const useForward = forwardProgress > state.phaseProgress; + const tangentPosition = parts.paths.samplePath( + path, + useForward ? forwardProgress : backwardProgress, + ); + const tangentWindowPosition = tangentPosition + ? Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + tangentPosition, + ) + : null; + if (tangentWindowPosition) { + const screenDelta = Math.hypot( + tangentWindowPosition.x - windowPosition.x, + tangentWindowPosition.y - windowPosition.y, + ); + // When the path points almost directly into/out of the camera, its + // screen projection has no trustworthy direction. Keep the last valid + // path-facing pose instead of snapping the rocket to a camera-facing + // upright orientation. + if (screenDelta >= 2) { + vehicleRotation = useForward + ? parts.replay.replayVehicleScreenRotation( + windowPosition, + tangentWindowPosition, + ) + : parts.replay.replayVehicleScreenRotation( + tangentWindowPosition, + windowPosition, + ); + track.lastVehicleRotation = vehicleRotation; + } else if (Number.isFinite(track.lastVehicleRotation)) { + vehicleRotation = track.lastVehicleRotation; + } + } + } + layerState._replayVehicleOverlay.style.setProperty( + '--mission-replay-rotation', + `${Cesium.Math.toDegrees(vehicleRotation)}deg`, + ); + + const mission = shortMissionLabel(launch.name, 22).toUpperCase(); + const siteName = compactLaunchSiteName(launch.launchSite); + const siteCallout = siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE'; + let title = mission; + let detail = siteCallout; + if (mode === 'countdown') { + title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; + detail = `LAUNCH STANDBY\n${siteCallout}`; + } else if (mode === 'ascent') { + title = + state.elapsedSinceStart < 1 + ? `LIFTOFF · ${mission}` + : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'recovery') { + title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'orbit') { + title = `ORBIT REPLAY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } + if (layerState._replayPaused) title = `PAUSED · ${title}`; + const nextText = `${title}\n${detail}`; + if (nextText !== layerState._replayVehicleOverlayText) { + layerState._replayVehicleOverlayText = nextText; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-title]', + ).textContent = title; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-detail]', + ).textContent = detail; + } + return mode; + } + return { + missionAnchorHorizonVisible, + missionAnchorVisible, + shortMissionLabel, + compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort, + missionHoverReticleImage, + replayOverlayMode, + createReplayVehicleOverlay, + hideReplayVehicleOverlay, + destroyReplayVehicleOverlay, + clearMissionOverlaySources, + syncMissionOverlayEntries, + refreshSelectedMissionOverlayText, + updateReplayVehicleOverlay, + }; +} diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js new file mode 100644 index 000000000..1555b5a66 --- /dev/null +++ b/src/layers/launches/panel.js @@ -0,0 +1,543 @@ +import * as Cesium from 'cesium'; +import { + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, +} from './policy.js'; + +export function createPanel({ state: layerState, services, parts, source }) { + /** + * Preserve the user's globe scale for roster previews while avoiding an + * accidental surface-level fly-to when the list is opened from a close view. + * @param {number} cameraHeight Current camera height above the ellipsoid. + * @returns {number} Preview range in metres. + */ + + function missionHoverPreviewRange(cameraHeight) { + const height = Number(cameraHeight); + return Math.max( + MISSION_CLOSE_VIEW_RANGE_M, + Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, + ); + } + + /** + * Coordinate pointer and keyboard ownership of the temporary roster preview. + * The most recent input owns the preview until it leaves, then any remaining + * input resumes ownership. This lets keyboard focus take over from a pointer + * resting on another row without losing that pointer preview on blur. + * @param {{preview: (index: number) => void, clear: () => void}} handlers + * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, + * reset: () => void}} + */ + + function createMissionRosterPreviewOwnership({ preview, clear }) { + let pointerIndex = null; + let focusIndex = null; + let latestOwner = null; + let activeIndex = null; + + const sync = () => { + const nextIndex = + latestOwner === 'pointer' + ? (pointerIndex ?? focusIndex) + : (focusIndex ?? pointerIndex); + if (nextIndex === activeIndex) return; + activeIndex = nextIndex; + if (Number.isInteger(nextIndex)) preview(nextIndex); + else clear(); + }; + + return { + pointerEnter(index) { + pointerIndex = index; + latestOwner = 'pointer'; + sync(); + }, + pointerLeave() { + pointerIndex = null; + if (latestOwner === 'pointer') + latestOwner = focusIndex === null ? null : 'focus'; + sync(); + }, + focus(index) { + focusIndex = index; + latestOwner = 'focus'; + sync(); + }, + blur(nextIndex = null) { + focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; + if (latestOwner === 'focus') + latestOwner = + focusIndex === null + ? pointerIndex === null + ? null + : 'pointer' + : 'focus'; + sync(); + }, + reset() { + pointerIndex = null; + focusIndex = null; + latestOwner = null; + activeIndex = null; + clear(); + }, + }; + } + + /** + * Bind native keyboard focus events for one rendered roster row. + * @param {Element} button Mission roster button. + * @param {number} index Source-array mission index. + * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership + * @param {Element} roster Roster root used to reject focus targets outside the list. + */ + + function bindMissionRosterItemKeyboardPreview( + button, + index, + ownership, + roster, + ) { + button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); + button.addEventListener('mouseleave', () => ownership.pointerLeave()); + button.addEventListener('focus', () => ownership.focus(index)); + button.addEventListener('blur', (event) => { + const nextButton = event.relatedTarget?.closest?.( + '[data-mission-roster-index]', + ); + const nextIndex = + nextButton && roster.contains(nextButton) + ? Number(nextButton.dataset.missionRosterIndex) + : null; + ownership.blur(nextIndex); + }); + } + + /** Capture the exact mission identity owned by keyboard focus before a refresh. */ + + function captureMissionRosterFocus( + list, + activeElement = globalThis.document?.activeElement, + ) { + const button = activeElement?.closest?.('[data-mission-roster-id]'); + if (!button || !list?.contains(button)) return null; + const launchId = button.dataset.missionRosterId; + return launchId ? { launchId } : null; + } + + /** Restore focus by mission identity, or continue after the list if it departed. */ + + function restoreMissionRosterFocus(list, snapshot, continuation) { + if (!snapshot?.launchId) return 'none'; + const button = Array.from( + list?.querySelectorAll?.('[data-mission-roster-id]') || [], + ).find( + (candidate) => candidate.dataset.missionRosterId === snapshot.launchId, + ); + if (button) { + button.focus({ preventScroll: true }); + return 'restored'; + } + if (continuation?.focus) { + continuation.focus({ preventScroll: true }); + return 'continued'; + } + return 'none'; + } + + /** Resolve a pending preview against the current refresh, never a stale object. */ + + function resolveMissionRosterPreviewLaunch(launches, launchId) { + return ( + (launches || []).find((candidate) => candidate.id === launchId) || null + ); + } + + function missionTableRows(items, columns, emptyText) { + if (!items.length) + return `${emptyText}`; + return items.map((item) => item).join(''); + } + + function setMissionPanelField(selector, value, title = '') { + const output = layerState._missionPanel?.querySelector(selector); + if (!output) return; + const row = output.closest('[data-mission-field]'); + const available = + value !== null && value !== undefined && String(value).trim() !== ''; + if (row) row.hidden = !available; + if (!available) { + output.textContent = ''; + output.removeAttribute('title'); + return; + } + output.textContent = value; + if (title) output.title = title; + else output.removeAttribute('title'); + } + + function renderMissionPanel() { + if (!layerState._missionPanel) return; + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const index = launch ? layerState._launches.indexOf(launch) : -1; + layerState._missionPanel.hidden = !launch; + if (!launch) return; + layerState._missionPanel.querySelector('[data-mission-title]').textContent = + parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(); + setMissionPanelField('[data-mission-provider]', launch.provider); + setMissionPanelField('[data-mission-status]', launch.status); + setMissionPanelField( + '[data-mission-site]', + launch.launchSite && launch.launchSite !== 'Unknown launch site' + ? launch.launchSite + : null, + ); + setMissionPanelField('[data-mission-time]', launch.launchTime); + const pathPresentation = parts.policyHelpers.missionPathPresentation( + launch, + layerState._replayTracks.has(launch.id), + ); + setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); + layerState._missionPanel.querySelector( + '[data-mission-ascent-source]', + ).textContent = pathPresentation.ascent; + const payloadRows = launch.payloads.length + ? launch.payloads.slice(0, 5).map((payload) => { + const detail = [ + payload.manufacturer, + payload.operator && payload.operator !== payload.manufacturer + ? payload.operator + : null, + Number.isFinite(payload.massKg) + ? `${payload.massKg.toLocaleString()} KG` + : null, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; + }) + : []; + if (launch.payloads.length > 5) { + payloadRows.push( + `+${launch.payloads.length - 5} additional payload records`, + ); + } + layerState._missionPanel.querySelector( + '[data-mission-payloads]', + ).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE'); + const stageRows = launch.recoveryStages.map((stage) => { + const endpoint = stage.endpoint; + const destination = + stage.destination || + (endpoint?.accuracy === 'PAD / RTLS' + ? launch.launchSite + : 'UNAVAILABLE'); + const position = endpoint + ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` + : stage.downrangeKm > 0 + ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` + : 'POSITION UNAVAILABLE'; + const stageDetail = [ + Number.isFinite(stage.flightNumber) + ? `FLIGHT ${stage.flightNumber}` + : null, + stage.reused ? 'REUSED' : null, + stage.recoveryType, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; + }); + layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML = + missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA'); + const stageSection = layerState._missionPanel.querySelector( + '[data-mission-stages-section]', + ); + if (stageSection) stageSection.hidden = stageRows.length === 0; + updateMissionTelemetry(true); + layerState._missionPanel.querySelector('[data-mission-index]').textContent = + `${index + 1} / ${layerState._launches.length}`; + layerState._missionPanel.querySelector('[data-mission-prev]').disabled = + index <= 0; + layerState._missionPanel.querySelector('[data-mission-next]').disabled = + index < 0 || index >= layerState._launches.length - 1; + parts.replay.syncReplayButton(); + const panelScroller = layerState._missionPanel.closest( + '.global-context-panel-inner', + ); + if (panelScroller) panelScroller.scrollTop = 0; + } + + function renderMissionRoster() { + if (!layerState._missionRoster) return; + const list = layerState._missionRoster.querySelector( + '[data-mission-roster-list]', + ); + const count = layerState._missionRoster.querySelector( + '[data-mission-roster-count]', + ); + if (count) count.textContent = `${layerState._launches.length} / 30D`; + if (!list) return; + const focusSnapshot = captureMissionRosterFocus(list); + layerState._missionRosterPreviewOwnership?.reset(); + const entries = parts.policyHelpers.missionRosterEntries( + layerState._launches, + ); + if (!entries.length) { + list.innerHTML = + 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'; + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + return; + } + list.innerHTML = entries + .map(({ launch, index }) => { + const color = parts.model.missionMarkerColor(launch).toCssColorString(); + const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; + const provider = launch.provider || 'UNSPECIFIED OPERATOR'; + const label = parts.overlays + .shortMissionLabel(launch.name, 27) + .toUpperCase(); + return `${escapeMissionText(label)}${escapeMissionText(provider)} · ${escapeMissionText(date)}›`; + }) + .join(''); + list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { + const index = Number(button.dataset.missionRosterIndex); + bindMissionRosterItemKeyboardPreview( + button, + index, + layerState._missionRosterPreviewOwnership, + layerState._missionRoster, + ); + }); + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + } + + function escapeMissionText(value) { + return String(value ?? '').replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); + } + + function updateMissionTelemetry(force = false) { + if ( + !layerState._missionPanel || + !layerState._selectedLaunchId || + !layerState._dataSource + ) + return; + const now = performance.now(); + if (!force && now - layerState._lastPanelTelemetryMs < 250) return; + layerState._lastPanelTelemetryMs = now; + const satellite = layerState._dataSource.entities.getById( + `rocket-satellite:${layerState._selectedLaunchId}`, + ); + const position = satellite?.position?.getValue( + Cesium.JulianDate.now(layerState._declutterTime), + ); + const altitudeM = position + ? Cesium.Cartographic.fromCartesian(position)?.height + : null; + setMissionPanelField( + '[data-mission-distance]', + Number.isFinite(altitudeM) + ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` + : null, + ); + const speedMps = layerState._satelliteTelemetry.get( + layerState._selectedLaunchId, + )?.speedMps; + setMissionPanelField( + '[data-mission-speed]', + Number.isFinite(speedMps) + ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` + : null, + Number.isFinite(speedMps) + ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` + : '', + ); + } + + function selectMissionAt(index) { + const launch = layerState._launches[index]; + if (!launch) return; + parts.selection.setSelectedMission(launch.id, true); + parts.selection.focusMission(launch); + } + + function clearMissionRosterPreviewState() { + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._missionRosterHoverTimer = null; + const changed = layerState._hoveredRosterLaunchId !== null; + layerState._hoveredRosterLaunchId = null; + if (changed) parts.overlays.syncMissionOverlayEntries(); + } + + function clearMissionRosterHover() { + if (layerState._missionRosterPreviewOwnership) + layerState._missionRosterPreviewOwnership.reset(); + else clearMissionRosterPreviewState(); + } + + function scheduleMissionRosterPreview(index) { + const launch = layerState._launches[index]; + if (!launch || layerState._selectedLaunchId) return; + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._hoveredRosterLaunchId = launch.id; + parts.overlays.syncMissionOverlayEntries(); + layerState._missionRosterHoverTimer = setTimeout(() => { + layerState._missionRosterHoverTimer = null; + if (layerState._hoveredRosterLaunchId !== launch.id) return; + const currentLaunch = resolveMissionRosterPreviewLaunch( + layerState._launches, + launch.id, + ); + if (currentLaunch) + parts.policyHelpers.previewMissionFromRoster(currentLaunch); + }, 140); + } + + function createMissionPanel() { + if (layerState._missionPanel || typeof document === 'undefined') return; + const host = + document.getElementById('space-mission-panel-host') || + document.getElementById('right-context-rail'); + if (!host) return; + layerState._missionRoster = document.getElementById('space-mission-roster'); + if (layerState._missionRoster) { + layerState._missionRosterPreviewOwnership = + createMissionRosterPreviewOwnership({ + preview: scheduleMissionRosterPreview, + clear: clearMissionRosterPreviewState, + }); + layerState._missionRoster.onclick = (event) => { + const button = + event.target instanceof Element + ? event.target.closest('[data-mission-roster-index]') + : null; + if (!button) return; + selectMissionAt(Number(button.dataset.missionRosterIndex)); + }; + } + layerState._missionPanel = document.createElement('aside'); + layerState._missionPanel.id = 'space-mission-panel'; + layerState._missionPanel.className = 'context-space-mission-detail'; + layerState._missionPanel.setAttribute( + 'aria-label', + 'Selected Space Mission', + ); + layerState._missionPanel.innerHTML = `SELECTED SPACE MISSION×MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED · PAYLOADNAMETYPEDESTINATIONSTAGE / RE-ENTRY / RECOVERYSTAGESTATUSFINAL POSITIONREPLAY SPEED1×0.25×1×4×FOCUSREPLAY ASCENT‹ PREV—NEXT ›SHOW ALL / DESELECT`; + layerState._missionPanel + .querySelector('.mission-action-row') + .insertAdjacentHTML( + 'beforeend', + ` + Ⅱ + × + `, + ); + host.appendChild(layerState._missionPanel); + layerState._missionPanel + .querySelector('[data-mission-prev]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) - 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-next]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) + 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-close]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-show-all]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-focus]') + .addEventListener('click', () => { + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + if (launch) parts.selection.focusLaunchSite(launch); + }); + layerState._missionPanel + .querySelector('[data-mission-replay]') + .addEventListener('click', () => { + if (layerState._selectedLaunchId) + parts.replay.startMissionReplay(layerState._selectedLaunchId); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-toggle]') + .addEventListener('click', () => { + if (layerState._replayPaused) parts.replay.resumeMissionReplay(); + else parts.replay.pauseMissionReplay(); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-cancel]') + .addEventListener('click', parts.replay.stopMissionReplay); + layerState._missionPanel + .querySelector('[data-mission-replay-speed]') + .addEventListener('input', (event) => { + parts.replay.setReplaySpeed(event.currentTarget.value); + }); + parts.replay.syncReplaySpeedControl(); + } + return { + missionHoverPreviewRange, + createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus, + restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch, + missionTableRows, + setMissionPanelField, + renderMissionPanel, + renderMissionRoster, + escapeMissionText, + updateMissionTelemetry, + selectMissionAt, + clearMissionRosterPreviewState, + clearMissionRosterHover, + scheduleMissionRosterPreview, + createMissionPanel, + }; +} diff --git a/src/layers/launches/paths.js b/src/layers/launches/paths.js new file mode 100644 index 000000000..72819ffb8 --- /dev/null +++ b/src/layers/launches/paths.js @@ -0,0 +1,588 @@ +import * as Cesium from 'cesium'; +import { + EARTH_ROTATION_RAD_PER_SEC, + PROJECTED_ASCENT_ROTATION_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createPaths({ state: layerState, services, parts, source }) { + function orbitInsertionOffsetSeconds(launch) { + const events = (launch.timeline || []).filter( + (event) => + Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0, + ); + if (!events.length) return null; + const deployment = events.filter((event) => + /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test( + event.name, + ), + ); + if (deployment.length) + return Math.max(...deployment.map((event) => event.offsetSeconds)); + const engineCutoff = events.filter((event) => + /seco|second engine cutoff/i.test(event.name), + ); + if (engineCutoff.length) + return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); + return Math.max(...events.map((event) => event.offsetSeconds)); + } + + function estimatedOrbitPeriodSeconds(orbitPath) { + if (!orbitPath?.length) return 5400; + const meanRadius = + orbitPath.reduce( + (total, point) => total + Cesium.Cartesian3.magnitude(point), + 0, + ) / orbitPath.length; + return 2 * Math.PI * Math.sqrt(meanRadius ** 3 / 3.986004418e14); + } + + function approximateOrbitPath(launch) { + if ( + !launch.orbit?.name || + !Number.isFinite(launch.lat) || + !Number.isFinite(launch.lon) + ) + return null; + const orbitName = launch.orbit.name.toLowerCase(); + const altitude = + orbitName.includes('geostationary') || orbitName.includes('transfer') + ? 35786000 + : orbitName.includes('medium') + ? 20200000 + : 550000; + const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; + const longitude = Cesium.Math.toRadians(launch.lon); + const latitude = Cesium.Math.toRadians(launch.lat); + const up = new Cesium.Cartesian3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude), + ); + const east = new Cesium.Cartesian3( + -Math.sin(longitude), + Math.cos(longitude), + 0, + ); + const north = new Cesium.Cartesian3( + -Math.sin(latitude) * Math.cos(longitude), + -Math.sin(latitude) * Math.sin(longitude), + Math.cos(latitude), + ); + const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); + const isWesternNorthAmerica = + launch.lat > 20 && + launch.lat < 60 && + launch.lon > -140 && + launch.lon < -105; + const launchAzimuthDeg = isPolar + ? launch.lat >= 0 + ? 180 + : 0 + : isWesternNorthAmerica + ? 190 + : 90; + const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); + const forward = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + north, + Math.cos(launchAzimuth), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + east, + Math.sin(launchAzimuth), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + // A reconstructed orbit is an estimate, not a claim that the vehicle was + // inserted directly above the pad. Offset the orbital plane downrange by a + // small launch-to-insertion arc so a top-down view does not draw the entire + // orbit on top of the launch site. The ascent still joins this ring at its + // propagated insertion reference. + const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); + const orbitAnchor = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + up, + Math.cos(insertionArc), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + forward, + Math.sin(insertionArc), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const planeNormal = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const crossTrack = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + planeNormal, + orbitAnchor, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Array.from({ length: 97 }, (_, index) => { + const angle = (index / 96) * Math.PI * 2; + return new Cesium.Cartesian3( + radius * + (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), + radius * + (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), + radius * + (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), + ); + }); + } + + function samplePath(path, progress, result) { + if (!path?.length) return undefined; + // Degenerate returns clone into `result` when provided — handing back a + // path vertex would let an in-place caller mutate the path geometry. + if (path.length === 1) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + let distances = layerState._pathDistanceCache.get(path); + if (!distances) { + distances = new Float64Array(path.length); + for (let index = 1; index < path.length; index++) { + distances[index] = + distances[index - 1] + + Cesium.Cartesian3.distance(path[index - 1], path[index]); + } + layerState._pathDistanceCache.set(path, distances); + } + const totalDistance = distances.at(-1); + if (!(totalDistance > 0)) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; + let low = 1; + let high = distances.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (distances[middle] < targetDistance) low = middle + 1; + else high = middle; + } + const index = Math.max(0, low - 1); + const segmentDistance = distances[index + 1] - distances[index]; + const segmentProgress = + segmentDistance > 0 + ? (targetDistance - distances[index]) / segmentDistance + : 0; + return Cesium.Cartesian3.lerp( + path[index], + path[index + 1], + segmentProgress, + result || new Cesium.Cartesian3(), + ); + } + + /** + * Resolve a continuously advancing orbit fraction from a wall-clock epoch. + * Fractional seconds prevent the selected satellite marker from jumping at + * each second boundary and forcing a one-frame photoreal globe LOD pulse. + * @param {number} nowMs Wall-clock epoch in milliseconds. + * @param {number} periodSec Orbital period in seconds. + * @returns {number} Normalized progress in the range [0, 1). + */ + + function orbitProgressAtTime(nowMs, periodSec) { + const period = Math.max(1, Number(periodSec) || 1); + return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; + } + + /** + * Construct one continuous estimated climb from the pad to orbit insertion. + * Horizontal movement begins much more slowly than altitude gain, preserving + * the near-vertical launch appearance without a hard corner at 120 km. + * @param {Cesium.Cartesian3} launchPosition Launch-pad position. + * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. + * @param {number} [samples] Number of curve intervals. + * @returns {Cesium.Cartesian3[]} + */ + + function reconstructedAscentPath( + launchPosition, + insertionPosition, + samples = 512, + ) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const origin = ellipsoid.cartesianToCartographic(launchPosition); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!origin || !insertion) return [launchPosition, insertionPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(origin.longitude, origin.latitude), + new Cesium.Cartographic(insertion.longitude, insertion.latitude), + ellipsoid, + ); + return Array.from({ length: samples + 1 }, (_, index) => { + const progress = index / samples; + if (index === 0) return launchPosition; + if (index === samples) return insertionPosition; + const horizontalProgress = progress ** 4; + const cartographic = geodesic.interpolateUsingFraction( + horizontalProgress, + new Cesium.Cartographic(), + ); + // Approximate the inertial eastward lead accumulated during a ten-minute + // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the + // upper climb, and returns to the fixed insertion endpoint. + const rotationalLead = + EARTH_ROTATION_RAD_PER_SEC * + PROJECTED_ASCENT_ROTATION_SEC * + Math.sin(Math.PI * progress ** 3); + cartographic.longitude = Cesium.Math.negativePiToPi( + cartographic.longitude + rotationalLead, + ); + cartographic.height = Cesium.Math.lerp( + Math.max(0, origin.height), + Math.max(0, insertion.height), + Math.sin(progress * Cesium.Math.PI_OVER_TWO), + ); + return ellipsoid.cartographicToCartesian(cartographic); + }); + } + + function nearestOrbitIndex(orbitPath, referencePosition) { + if (!orbitPath?.length || !referencePosition) return 0; + const referenceDirection = Cesium.Cartesian3.normalize( + referencePosition, + new Cesium.Cartesian3(), + ); + let bestIndex = 0; + let bestDot = -Number.MAX_VALUE; + orbitPath.forEach((candidate, index) => { + const direction = Cesium.Cartesian3.normalize( + candidate, + new Cesium.Cartesian3(), + ); + const dot = Cesium.Cartesian3.dot(referenceDirection, direction); + if (dot > bestDot) { + bestDot = dot; + bestIndex = index; + } + }); + return bestIndex; + } + + function orbitPathFromInsertion(orbitPath, insertionIndex) { + if (!orbitPath?.length) return []; + const first = orbitPath[0]; + const last = orbitPath.at(-1); + const isClosed = + orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; + const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); + if (!core.length) return orbitPath.slice(); + const index = Cesium.Math.mod(insertionIndex, core.length); + const rotated = [...core.slice(index), ...core.slice(0, index)]; + rotated.push(rotated[0]); + return rotated; + } + + function surfaceSafeSegment(startPosition, endPosition) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = ellipsoid.cartesianToCartographic(startPosition); + const end = ellipsoid.cartesianToCartographic(endPosition); + if (!start || !end) return [startPosition, endPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(start.longitude, start.latitude), + new Cesium.Cartographic(end.longitude, end.latitude), + ellipsoid, + ); + // Replay advances in real time across this path. Keep the geometry + // surface-safe, but give the animated marker and chase camera enough + // samples that they do not visibly pause at long segment boundaries. + const steps = Cesium.Math.clamp( + Math.ceil(geodesic.surfaceDistance / 75000), + 2, + 256, + ); + const positions = []; + for (let index = 0; index <= steps; index++) { + const fraction = index / steps; + const eased = fraction * fraction * (3 - 2 * fraction); + const cartographic = geodesic.interpolateUsingFraction( + fraction, + new Cesium.Cartographic(), + ); + cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); + positions.push(ellipsoid.cartographicToCartesian(cartographic)); + } + positions[0] = startPosition; + positions[positions.length - 1] = endPosition; + return positions; + } + + function surfaceSafePath(controlPositions) { + if (!controlPositions?.length) return []; + if (controlPositions.length === 1) return controlPositions.slice(); + const path = []; + for (let index = 0; index < controlPositions.length - 1; index++) { + const segment = surfaceSafeSegment( + controlPositions[index], + controlPositions[index + 1], + ); + path.push(...(index === 0 ? segment : segment.slice(1))); + } + return path; + } + + function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { + if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) + return ascentPath; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const transferEnd = orbitPath[insertionIndex]; + const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; + const orbitTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + nextOrbit, + transferEnd, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if ( + Cesium.Cartesian3.equalsEpsilon( + orbitTangent, + Cesium.Cartesian3.ZERO, + 1e-8, + ) + ) + return ascentPath; + + // Replace a substantial final section with one cubic Bézier transition. + // Matching both endpoint tangents avoids the short corrective hook produced + // by locally pulling only the last few ascent samples toward the orbit. + const blendCount = Math.min(52, ascentPath.length - 2); + const blendStart = ascentPath.length - 1 - blendCount; + const start = ascentPath[blendStart]; + const previous = ascentPath[Math.max(0, blendStart - 1)]; + const altitudeEnvelope = ascentPath + .slice(blendStart) + .map((position) => + Math.max(0, ellipsoid.cartesianToCartographic(position)?.height || 0), + ); + const ascentTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const chordLength = Math.max( + Cesium.Cartesian3.distance(start, transferEnd), + 1000, + ); + const handleLength = chordLength * 0.28; + const controlA = Cesium.Cartesian3.add( + start, + Cesium.Cartesian3.multiplyByScalar( + ascentTangent, + handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const controlB = Cesium.Cartesian3.add( + transferEnd, + Cesium.Cartesian3.multiplyByScalar( + orbitTangent, + -handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + for (let index = 0; index <= blendCount; index++) { + const progress = index / blendCount; + const inverse = 1 - progress; + const point = new Cesium.Cartesian3(); + Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlA, + 3 * inverse ** 2 * progress, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlB, + 3 * inverse * progress ** 2, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + transferEnd, + progress ** 3, + new Cesium.Cartesian3(), + ), + point, + ); + // A Cartesian Bézier is a chord in world space and can pass through the + // ellipsoid when its orbit-tangent handle is long. Preserve the original + // climb's smooth altitude envelope while retaining the Bézier's horizontal + // curvature and tangent-aligned insertion. + const cartographic = ellipsoid.cartesianToCartographic(point); + const minimumHeight = altitudeEnvelope[index] ?? 0; + if (cartographic && cartographic.height < minimumHeight) { + cartographic.height = minimumHeight; + ascentPath[blendStart + index] = + ellipsoid.cartographicToCartesian(cartographic); + } else { + ascentPath[blendStart + index] = point; + } + } + ascentPath[ascentPath.length - 1] = transferEnd; + return ascentPath; + } + + /** + * Build a globe-safe ascent ending at the nearest point on the selected orbit. + * The returned orbit is rotated to begin at that same insertion point so the + * animated marker cannot jump between ascent and orbital phases. + * @param {Cesium.Cartesian3} launchPosition Launch-site position. + * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. + * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. + * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. + * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} + */ + + function buildMissionPaths( + launchPosition, + trajectoryPositions, + orbitPath, + insertionReference = null, + ) { + const suppliedTrajectory = trajectoryPositions || []; + const controls = [launchPosition, ...suppliedTrajectory]; + const insertionIndex = nearestOrbitIndex( + orbitPath, + insertionReference || controls.at(-1), + ); + const transferEnd = orbitPath[insertionIndex]; + const ascentPath = suppliedTrajectory.length + ? surfaceSafePath([...controls, transferEnd]) + : reconstructedAscentPath(launchPosition, transferEnd); + blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); + return { + ascentPath, + animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), + insertionIndex, + }; + } + + function landingEndpoint(stage, launch, insertionPosition) { + if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { + return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; + } + const recoveryIdentity = + `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); + if ( + /return to launch site|rtls|launch site|landing zone/.test( + recoveryIdentity, + ) + ) { + return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; + } + if (!(stage.downrangeKm > 0) || !insertionPosition) return null; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!insertion) return null; + const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); + const bearing = geodesic.startHeading; + const angularDistance = + (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; + const lat1 = start.latitude; + const lon1 = start.longitude; + const lat = Math.asin( + Math.sin(lat1) * Math.cos(angularDistance) + + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const lon = + lon1 + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), + Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), + ); + return { + lat: Cesium.Math.toDegrees(lat), + lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), + accuracy: 'EST. DOWNRANGE', + }; + } + + function stageReentryRecoveryPath( + ascentPath, + endpoint, + stageIndex, + stageCount, + ) { + if (!ascentPath?.length || !endpoint) return []; + const progress = Cesium.Math.clamp( + 0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, + 0.28, + 0.68, + ); + const separation = samplePath(ascentPath, progress); + const destination = Cesium.Cartesian3.fromDegrees( + endpoint.lon, + endpoint.lat, + 12, + ); + return surfaceSafeSegment(separation, destination); + } + + function atmosphericReentryIndex(path) { + if (!path?.length) return 0; + const ellipsoid = Cesium.Ellipsoid.WGS84; + for (let index = 1; index < path.length; index++) { + const previousHeight = ellipsoid.cartesianToCartographic( + path[index - 1], + )?.height; + const height = ellipsoid.cartesianToCartographic(path[index])?.height; + if ( + Number.isFinite(previousHeight) && + Number.isFinite(height) && + previousHeight > STAGE_REENTRY_ALTITUDE_M && + height <= STAGE_REENTRY_ALTITUDE_M + ) { + return index; + } + } + return Math.min( + Math.max(Math.round(path.length * 0.55), 0), + path.length - 1, + ); + } + return { + orbitInsertionOffsetSeconds, + estimatedOrbitPeriodSeconds, + approximateOrbitPath, + samplePath, + orbitProgressAtTime, + reconstructedAscentPath, + nearestOrbitIndex, + orbitPathFromInsertion, + surfaceSafeSegment, + surfaceSafePath, + blendAscentIntoOrbitTangent, + buildMissionPaths, + landingEndpoint, + stageReentryRecoveryPath, + atmosphericReentryIndex, + }; +} diff --git a/src/layers/launches/policy.js b/src/layers/launches/policy.js new file mode 100644 index 000000000..786aed79c --- /dev/null +++ b/src/layers/launches/policy.js @@ -0,0 +1,102 @@ +import * as Cesium from 'cesium'; + +export const WINDOW_DAYS = 30; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = + 'rocket-mission-selected'; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 12, + collisionCapacity: 0, + moving: true, + solveIntervalMs: 0, +}); + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + moving: false, +}); + +export const REPLAY_ASCENT_FALLBACK_SEC = 12; + +export const REPLAY_ASCENT_MIN_SEC = 8; + +export const REPLAY_ASCENT_MAX_SEC = 36; + +export const REPLAY_ORBIT_DURATION_SEC = 28; + +export const REPLAY_COUNTDOWN_DURATION_SEC = 10; + +export const REPLAY_TILE_SETTLE_DELAY_SEC = 5; + +export const REPLAY_INITIAL_RANGE_M = 3500; + +export const REPLAY_LOCAL_MAX_RANGE_M = 900000; + +export const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; + +export const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; + +export const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; + +export const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; + +export const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); + +export const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); + +export const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; + +export const REPLAY_SPEED_MIN = 0.25; + +export const REPLAY_SPEED_MAX = 4; + +export const REPLAY_SPEED_STEP = 0.25; + +export const MAX_POST_TLE_RETRIES = 1; + +export const POST_TLE_RETRY_DELAY_MS = 1500; + +export const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; + +export const PROJECTED_ASCENT_ROTATION_SEC = 600; + +export const STAGE_REENTRY_ALTITUDE_M = 100000; + +export const MISSION_CLOSE_VIEW_RANGE_M = 180000; + +export const MISSION_GLOBE_VIEW_RANGE_M = 5000000; + +export const MISSION_FOCUS_RANGE_M = 12000; + +export const SATELLITE_STANDALONE_DEFAULTS = { + catalog: 'core', + showPoints: true, + showOrbits: true, +}; + +export const MISSION_ORBIT_PATTERN_GROUPS = 4; + +export const MISSION_ORBIT_DASHES_PER_GROUP = 100; + +export const LAUNCH_PAD_ZONE_RADIUS_M = 500; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; + +export const TRAJECTORY_STAGE_COLORS = [ + '#ff9f43', + '#ff66c4', + '#a78bfa', + '#7bed9f', + '#ffd166', + '#60a5fa', +]; diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js new file mode 100644 index 000000000..c3cf77c07 --- /dev/null +++ b/src/layers/launches/policyHelpers.js @@ -0,0 +1,256 @@ +import * as Cesium from 'cesium'; +import { + SATELLITE_STANDALONE_DEFAULTS, + LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, + LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, +} from './policy.js'; + +export function createPolicyHelpers({ + state: layerState, + services, + parts, + source, +}) { + /** + * Derive the temporary Satellite display mode required by Space Missions. + * @param {object|null} currentParams Complete pre-mission Satellite parameters. + * @returns {object} Temporary mission-specific Satellite parameters. + */ + + function satelliteParamsForSpaceMissions(currentParams) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(currentParams || {}), + catalog: 'dense', + showPoints: false, + showOrbits: false, + }; + } + + /** + * Resolve the complete Satellite parameter set restored after mission mode. + * @param {object|null} snapshot Complete pre-mission Satellite parameters. + * @returns {object} Standalone Satellite parameters. + */ + + function satelliteParamsAfterSpaceMissions(snapshot) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(snapshot || {}), + }; + } + + /** + * Failed launch records may retain a planned orbit in Launch Library, but + * must not be represented as a live or estimated payload in orbit. + * @param {string|null} status Normalized Launch Library status name. + * @returns {boolean} Whether orbital visualization is allowed. + */ + + function launchStatusAllowsOrbit(status) { + return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test( + String(status || ''), + ); + } + + /** + * Describe only the mission paths that the selected record can actually show. + * Launch Library may retain a target orbit after a failure, but that target is + * not evidence of orbital insertion and cannot support a reconstructed replay. + * @param {object|null} launch Normalized launch record. + * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. + * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} + */ + + function missionPathPresentation(launch, replayAvailable = false) { + const orbitName = + launch?.orbit?.name || + (typeof launch?.orbit === 'string' ? launch.orbit : null); + const orbitAllowed = launchStatusAllowsOrbit(launch?.status); + const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) + ? launch.trajectory.filter( + (point) => + Number.isFinite(Number(point?.latitude)) && + Number.isFinite(Number(point?.longitude)), + ).length + : 0; + return { + orbit: orbitName + ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` + : null, + ascent: + suppliedTrajectoryPoints > 1 + ? 'SUPPLIED TRAJECTORY POINTS' + : replayAvailable + ? 'RECONSTRUCTED ESTIMATE' + : 'UNAVAILABLE', + replayAvailable: Boolean(replayAvailable), + }; + } + + /** + * Release aircraft follow state through each owning flight layer before a + * mission replay takes over the camera. + * @param {object|null} dataManager DataLayerManager instance. + * @returns {number} Number of owner APIs invoked. + */ + + function releaseAircraftTracking(dataManager) { + let released = 0; + for (const layerId of ['flights', 'military']) { + const module = dataManager?.layers?.get(layerId)?.module; + if (typeof module?.stopTracking !== 'function') continue; + module.stopTracking(); + released++; + } + return released; + } + + /** + * Decide whether the selected launch-pad zone belongs in the current view. + * Both altitude and direct camera range are bounded so an oblique close-up can + * show the effect without leaking it into regional or globe views. + * @param {object} input Visibility inputs. + * @param {boolean} input.layerActive Whether Space Missions is active. + * @param {string|null} input.selectedLaunchId Selected mission identifier. + * @param {string} input.launchId Candidate mission identifier. + * @param {number} input.cameraHeightM Camera height above the ellipsoid. + * @param {number} input.cameraDistanceM Direct camera range to the launch pad. + * @returns {boolean} + */ + + function launchPadZoneVisible({ + layerActive, + selectedLaunchId, + launchId, + cameraHeightM, + cameraDistanceM, + }) { + return Boolean( + layerActive && + selectedLaunchId && + selectedLaunchId === launchId && + Number.isFinite(cameraHeightM) && + cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M && + Number.isFinite(cameraDistanceM) && + cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, + ); + } + + /** + * Score the amount of useful mission context available for roster triage. + * @param {object} launch Normalized launch record. + * @returns {number} Completeness score. + */ + + function missionDataCompleteness(launch = {}) { + let score = 0; + const present = (value) => + value !== null && value !== undefined && value !== ''; + score += present(launch.provider) ? 1 : 0; + score += present(launch.mission) ? 2 : 0; + score += present(launch.missionName) ? 1 : 0; + score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; + score += Array.isArray(launch.payloads) + ? Math.min(launch.payloads.length, 5) * 2 + : 0; + score += Array.isArray(launch.recoveryStages) + ? Math.min(launch.recoveryStages.length, 4) * 2 + : 0; + score += Array.isArray(launch.trajectory) + ? Math.min(launch.trajectory.length, 12) + : 0; + score += Array.isArray(launch.timeline) + ? Math.min(launch.timeline.length, 6) + : 0; + return score; + } + + /** + * Build a data-rich roster while preserving the source-array index + * used by mission selection and Previous/Next navigation. + * @param {Array} launches Normalized launch records. + * @returns {Array<{launch: object, index: number}>} + */ + + function missionRosterEntries(launches) { + return (launches || []) + .map((launch, index) => ({ launch, index })) + .sort((a, b) => { + const completeness = + missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); + if (completeness !== 0) return completeness; + const aTime = Date.parse(a.launch?.launchTime); + const bTime = Date.parse(b.launch?.launchTime); + if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) + return bTime - aTime; + return b.index - a.index; + }); + } + + /** + * Format a mission epoch for compact on-globe replay labels. + * @param {string|Date|null} launchTime ISO-8601 mission time or Date. + * @returns {string} UTC timestamp or a clear unavailable state. + */ + + function formatMissionEventTime(launchTime) { + const date = new Date(launchTime); + if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; + return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; + } + + /** + * Parse the ISO-8601 durations supplied by Launch Library timeline events. + * @param {string|null} value ISO duration such as PT8M40S or -PT35M. + * @returns {number|null} Signed duration in seconds. + */ + + function parseMissionDurationSeconds(value) { + const match = String(value || '') + .trim() + .match( + /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, + ); + if (!match) return null; + const seconds = + Number(match[2] || 0) * 86400 + + Number(match[3] || 0) * 3600 + + Number(match[4] || 0) * 60 + + Number(match[5] || 0); + return match[1] ? -seconds : seconds; + } + + function previewMissionFromRoster(launch) { + if (!layerState._viewer || !launch || layerState._selectedLaunchId) return; + const range = parts.panel.missionHoverPreviewRange( + layerState._viewer.camera.positionCartographic?.height, + ); + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(position, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + -Cesium.Math.PI_OVER_TWO, + range, + ), + duration: 0.8, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + ); + } + return { + satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit, + missionPathPresentation, + releaseAircraftTracking, + launchPadZoneVisible, + missionDataCompleteness, + missionRosterEntries, + formatMissionEventTime, + parseMissionDurationSeconds, + previewMissionFromRoster, + }; +} diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js new file mode 100644 index 000000000..60e6e0070 --- /dev/null +++ b/src/layers/launches/rendering.js @@ -0,0 +1,573 @@ +import * as Cesium from 'cesium'; +import { + TRAJECTORY_STAGE_COLORS, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_TILE_SETTLE_DELAY_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } = + services.satellites; + + function setGraphicVisibility(graphic, visible, time) { + if (!graphic) return; + const next = Boolean(visible); + const current = graphic.show?.getValue?.(time); + if (current !== next) graphic.show = next; + } + + function updateMissionFrame() { + if ( + !layerState._enabled || + !layerState._viewer || + !layerState._dataSource?.show + ) { + parts.overlays.hideReplayVehicleOverlay(); + return; + } + parts.panel.updateMissionTelemetry(); + const time = Cesium.JulianDate.now(layerState._declutterTime); + layerState._declutterOccluder.cameraPosition = + layerState._viewer.scene.camera.positionWC; + parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder); + parts.overlays.refreshSelectedMissionOverlayText(); + for (const entity of layerState._dataSource.entities.values) { + if (!entity.show) continue; + if (!entity.position) continue; + const id = parts.selection.entityLaunchId(entity); + if (!id) continue; + const position = entity.position.getValue(time); + let horizonVisible = Boolean( + position && layerState._declutterOccluder.isPointVisible(position), + ); + const launchAnchor = entity.id.startsWith('rocket-launch:'); + if (launchAnchor) { + horizonVisible = parts.overlays.missionAnchorVisible( + layerState._viewer.scene.camera.positionWC, + position, + id, + layerState._selectedLaunchId, + ); + // Keep entity.show reserved for mission-selection isolation. Horizon + // state is applied to the individual graphics below so a rear-side + // anchor remains eligible for reevaluation as the camera moves. + } + if (entity.point) { + setGraphicVisibility(entity.point, horizonVisible, time); + } + if (launchAnchor && entity.billboard) { + setGraphicVisibility( + entity.billboard, + horizonVisible && id === layerState._hoveredRosterLaunchId, + time, + ); + } + } + } + + function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const overlayRecord = { + launch, + anchorPosition: position, + elementEntryFactories: [], + liveEventTime: null, + }; + layerState._missionOverlayRecords.set(launch.id, overlayRecord); + const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit( + launch.status, + ); + const coreTrack = + orbitAllowed && launch.satelliteQuery + ? getSatelliteOrbitTrack(launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null; + const satelliteTrack = + coreTrack || + (orbitAllowed && activeTleText && launch.satelliteQuery + ? findSatelliteOrbitTrackInTle(activeTleText, launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null); + const orbitPath = !orbitAllowed + ? null + : satelliteTrack?.orbitPath?.length > 1 + ? satelliteTrack.orbitPath + : parts.paths.approximateOrbitPath(launch); + launch.recoveryStages.forEach((stage) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + orbitPath?.[0] || null, + ); + }); + if (satelliteTrack) layerState._orbitMatches++; + const entity = layerState._dataSource.entities.add({ + id: `rocket-launch:${launch.id}`, + position, + point: { + pixelSize: 5, + color: parts.model.missionMarkerColor(launch), + outlineColor: parts.model.missionMarkerColor(launch), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + billboard: { + image: parts.overlays.missionHoverReticleImage(), + width: 24, + height: 24, + show: false, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + launchName: launch.name, + launchTime: launch.launchTime, + launchSite: launch.launchSite, + provider: launch.provider, + mission: launch.mission, + status: launch.status, + source: launch.source, + orbit: JSON.stringify(launch.orbit || {}), + satelliteName: satelliteTrack?.name || '', + noradId: satelliteTrack?.noradId || '', + }, + }); + const points = launch.trajectory + .filter( + (point) => + Number.isFinite(Number(point.latitude)) && + Number.isFinite(Number(point.longitude)), + ) + .map((point) => ({ + stage: String( + point.stage || + point.stage_name || + point.phase || + point.stageName || + 'trajectory', + ), + position: Cesium.Cartesian3.fromDegrees( + Number(point.longitude), + Number(point.latitude), + Number(point.altitude || 0), + ), + })); + if (points.length > 1) { + const segments = []; + points.forEach((point) => { + const previous = segments.at(-1); + if (!previous || previous.stage !== point.stage) + segments.push({ stage: point.stage, positions: [] }); + segments.at(-1).positions.push(point.position); + }); + segments.forEach((segment, index) => { + if (segment.positions.length < 2) return; + layerState._dataSource.entities.add({ + id: `rocket-trajectory:${launch.id}:${index}`, + polyline: { + positions: parts.paths.surfaceSafePath(segment.positions), + width: 2, + material: Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length], + ).withAlpha(0.8), + clampToGround: false, + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stage: segment.stage, + source: launch.source, + }, + }); + }); + } + if (orbitPath?.length > 1) { + const orbitCurrent = satelliteTrack?.current || { + longitude: launch.lon, + latitude: launch.lat, + altitude: 0, + }; + const orbitPeriodSec = + satelliteTrack?.periodSec || + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const launchEpochMs = Date.parse(launch.launchTime); + const disclosedInsertionOffsetSec = + parts.paths.orbitInsertionOffsetSeconds(launch); + const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) + ? Math.max(0, disclosedInsertionOffsetSec) + : 600; + const insertionEpochMs = Number.isFinite(launchEpochMs) + ? launchEpochMs + insertionDurationSec * 1000 + : Number.NaN; + let insertionReference = points.at(-1)?.position || null; + if ( + !insertionReference && + satelliteTrack?.positionAt && + Number.isFinite(insertionEpochMs) + ) { + const propagatedInsertion = satelliteTrack.positionAt( + new Date(insertionEpochMs), + ); + if (propagatedInsertion) { + insertionReference = Cesium.Cartesian3.fromDegrees( + propagatedInsertion.longitude, + propagatedInsertion.latitude, + propagatedInsertion.altitude, + ); + } + } + if (!insertionReference && !satelliteTrack) { + // A projected orbit has no authoritative historical phase. Start its + // plane over the launch site, advance only by the estimated powered + // ascent duration, and join the forward orbit tangent. Using the UTC + // epoch as phase sent some ascents toward the far side of Earth and + // forced a visible 180-degree corrective hook before insertion. + const insertionProgress = Cesium.Math.clamp( + insertionDurationSec / orbitPeriodSec, + 1 / 96, + 0.16, + ); + insertionReference = parts.paths.samplePath( + orbitPath, + insertionProgress, + ); + } + const { ascentPath, animatedOrbitPath } = parts.paths.buildMissionPaths( + position, + points.map((point) => point.position), + orbitPath, + insertionReference, + ); + const ascentDurationSec = parts.replay.replayAscentDurationSeconds( + launch, + ascentPath, + ); + // Cesium can evaluate CallbackProperty values multiple times while it + // traverses one frame. Camera tracking and the HTML replay overlay must + // use the exact same replay epoch as those callbacks; sampling Date.now() + // independently made the vehicle advance relative to the camera and then + // snap back on the next frame. + let replayStateForFrame = null; + const sampleReplayState = () => + parts.replay.replayState( + launch, + layerState._animationStarts.get(launch.id) || Date.now(), + ascentDurationSec, + REPLAY_ORBIT_DURATION_SEC, + orbitPeriodSec, + layerState._replaySpeed, + parts.replay.replayClockNow(launch.id), + REPLAY_TILE_SETTLE_DELAY_SEC, + layerState._replayCameraLaunchId !== launch.id, + ); + // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed + // one state there and retain it through postRender so every consumer in + // that traversal sees the same timestamp. scene.frameState.frameNumber is + // advanced between some of those phases, so it is not a reliable cache key. + const beginReplayFrame = () => { + replayStateForFrame = sampleReplayState(); + return replayStateForFrame; + }; + const getReplayState = () => { + if (!replayStateForFrame) { + replayStateForFrame = sampleReplayState(); + } + return replayStateForFrame; + }; + launch.recoveryStages.forEach((stage, stageIndex) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + ascentPath.at(-1), + ); + const path = parts.paths.stageReentryRecoveryPath( + ascentPath, + stage.endpoint, + stageIndex, + launch.recoveryStages.length, + ); + if (path.length < 2) return; + const reentryIndex = parts.paths.atmosphericReentryIndex(path); + const reentryPath = path.slice(reentryIndex); + const color = Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[ + (stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length + ], + ); + layerState._dataSource.entities.add({ + id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, + polyline: { + positions: path, + width: 2, + material: new Cesium.PolylineDashMaterialProperty({ + color: color.withAlpha(0.82), + dashLength: 12, + dashPattern: 0xaaaa, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'STAGE_REENTRY_RECOVERY', + accuracy: stage.endpoint.accuracy, + source: launch.source, + }, + }); + if (reentryPath.length > 1) { + layerState._dataSource.entities.add({ + id: `rocket-reentry:${launch.id}:${stageIndex}`, + polyline: { + positions: reentryPath, + width: 2.5, + material: new Cesium.PolylineDashMaterialProperty({ + color: + Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), + dashLength: 8, + dashPattern: 0xf0f0, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'ATMOSPHERIC_REENTRY', + altitudeM: STAGE_REENTRY_ALTITUDE_M, + source: 'Estimated 100 km atmospheric interface', + }, + }); + const reentryPosition = path[reentryIndex]; + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `reentry:${launch.id}:${stageIndex}`, + position: reentryPosition, + text: 'STAGE RE-ENTRY', + accent: '#ffd166', + priority: 700_000 - stageIndex, + gapPx: 8, + }), + ); + } + layerState._dataSource.entities.add({ + id: `rocket-recovery-end:${launch.id}:${stageIndex}`, + position: path.at(-1), + point: { + pixelSize: 4, + color, + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'RECOVERY_ENDPOINT', + source: launch.source, + }, + }); + }); + layerState._replayTracks.set(launch.id, { + ascentPath, + animatedOrbitPath, + orbitFrameSphere: + parts.camera.replayOrbitFrameSphere(animatedOrbitPath), + ascentDurationSec, + beginReplayFrame, + getReplayState, + lastCameraHeading: Math.PI, + orbitCameraWorldFrame: false, + lastVehicleRotation: 0, + lastOverlayWindowPosition: null, + lastOverlayMode: null, + }); + let livePosition = Cesium.Cartesian3.fromDegrees( + orbitCurrent.longitude, + orbitCurrent.latitude, + orbitCurrent.altitude, + ); + const liveTime = new Date(); + const liveTelemetry = { speedMps: null }; + layerState._satelliteTelemetry.set(launch.id, liveTelemetry); + let liveFrameNumber = -1; + const fallbackPeriodSec = + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const updateLiveState = () => { + const frameNumber = + layerState._viewer?.scene?.frameState?.frameNumber ?? -1; + if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; + liveFrameNumber = frameNumber; + const nowMs = Date.now(); + liveTime.setTime(nowMs); + if (satelliteTrack) { + const propagated = + satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; + if (propagated) { + Cesium.Cartesian3.fromDegrees( + propagated.longitude, + propagated.latitude, + propagated.altitude, + undefined, + livePosition, + ); + liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) + ? propagated.speedMps + : null; + } + } else { + // In-place write keeps one Cartesian3 for the mission's lifetime — + // the estimated branch previously reallocated per propagation. + livePosition = + parts.paths.samplePath( + orbitPath, + parts.paths.orbitProgressAtTime(nowMs, fallbackPeriodSec), + livePosition, + ) || livePosition; + } + }; + layerState._dataSource.entities.add({ + id: `rocket-satellite:${launch.id}`, + position: new Cesium.CallbackProperty(() => { + updateLiveState(); + return livePosition; + }, false), + point: { + pixelSize: 6, + color: Cesium.Color.fromCssColorString( + satelliteTrack ? '#7bed9f' : '#ffd166', + ), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: satelliteTrack + ? 'CURRENT_SATELLITE_POSITION' + : 'ESTIMATED_ORBIT_POSITION', + source: satelliteTrack + ? 'Satellites layer' + : 'Approximate mission orbit', + }, + }); + overlayRecord.liveEventTime = () => liveTime; + overlayRecord.elementEntryFactories.push(() => { + const name = satelliteTrack + ? parts.overlays + .shortMissionLabel(satelliteTrack.name, 22) + .toUpperCase() + : 'EST. ORBIT POSITION'; + return parts.overlays.createRocketMissionElementOverlayEntry({ + id: `payload-position:${launch.id}`, + position: () => livePosition, + text: `${name}\n${parts.policyHelpers.formatMissionEventTime(liveTime)}`, + accent: satelliteTrack ? '#7bed9f' : '#ffd166', + priority: 900_000, + gapPx: 12, + }); + }); + // Live satellite rings use a primitive collection so the core Satellite + // GMST transform can rotate their baked ECEF geometry without rebuilding + // it. Estimated launch-site-relative rings remain ordinary entities. + const primitiveOrbitAdded = parts.orbitRendering.addMissionOrbitPrimitive( + launch, + orbitPath, + satelliteTrack, + ); + if (!primitiveOrbitAdded) { + layerState._dataSource.entities.add({ + id: `rocket-orbit:${launch.id}`, + polyline: { + positions: orbitPath, + // The 3 px canvas gives the periodic dot room to read as round; the + // shader masks the ten intervening dashes down to a thin center stroke. + width: 3, + material: + new parts.orbitRendering.MissionOrbitPatternMaterialProperty( + Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), + ), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + satelliteName: '', + noradId: '', + source: 'Approximate mission orbit', + }, + }); + } + const orbitLabelBakePosition = ascentPath.at(-1); + let orbitLabelPosition = orbitLabelBakePosition; + const primitivePath = layerState._missionOrbitPrimitives.get(launch.id); + if (primitivePath) { + primitivePath.labelBakePosition = orbitLabelBakePosition; + primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( + primitivePath.primitive.modelMatrix, + orbitLabelBakePosition, + new Cesium.Cartesian3(), + ); + orbitLabelPosition = () => primitivePath.labelPosition; + } + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `orbit:${launch.id}`, + position: orbitLabelPosition, + text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', + accent: satelliteTrack ? '#22e6e6' : '#c084fc', + priority: 800_000, + gapPx: 8, + }), + ); + const replayPosition = new Cesium.CallbackProperty(() => { + const state = getReplayState(); + return state.ascending + ? parts.paths.samplePath(ascentPath, state.phaseProgress) + : parts.paths.samplePath(animatedOrbitPath, state.phaseProgress); + }, false); + layerState._dataSource.entities.add({ + id: `rocket-vehicle:${launch.id}`, + position: replayPosition, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: 'ASCENT_THEN_ORBIT', + source: satelliteTrack + ? 'Satellite trajectory animation' + : 'Approximate trajectory animation', + }, + }); + layerState._dataSource.entities.add({ + id: `rocket-transfer:${launch.id}`, + polyline: { + positions: ascentPath, + width: 1.5, + // The animated vehicle uses Cartesian interpolation between these + // exact samples. Prevent Cesium from replacing each segment with a + // geodesic arc, which would visually separate the dot from the line. + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), + dashLength: 24, + dashPattern: 0xf0f0, + }), + }, + properties: { + launchId: launch.id, + phase: 'APPROXIMATE_TRANSFER', + source: satelliteTrack + ? 'Launch site to propagated insertion' + : 'Launch site to forward projected insertion', + }, + }); + } + } + return { setGraphicVisibility, updateMissionFrame, addLaunchEntity }; +} diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js new file mode 100644 index 000000000..e6cbff98b --- /dev/null +++ b/src/layers/launches/replay.js @@ -0,0 +1,557 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_FALLBACK_SEC, + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + REPLAY_SPEED_STEP, + REPLAY_TILE_SETTLE_DELAY_SEC, + REPLAY_COUNTDOWN_DURATION_SEC, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_INITIAL_RANGE_M, +} from './policy.js'; + +export function createReplay({ state: layerState, services, parts, source }) { + /** + * Rotate an upright screen-space rocket so its nose follows a projected path. + * @param {{x: number, y: number}} from Current screen point. + * @param {{x: number, y: number}} to Forward screen point. + * @returns {number} Clockwise CSS rotation in radians. + */ + + function replayVehicleScreenRotation(from, to) { + const dx = Number(to?.x) - Number(from?.x); + const dy = Number(to?.y) - Number(from?.y); + if ( + !Number.isFinite(dx) || + !Number.isFinite(dy) || + Math.hypot(dx, dy) < 0.01 + ) + return 0; + return Math.atan2(dx, -dy); + } + + /** + * Reduce small screen-space reprojection jitter without allowing the marker + * to lag behind a camera jump or a phase transition. + * @param {{x:number,y:number}|null} previous Previous rendered position. + * @param {{x:number,y:number}} next Current path projection. + * @param {number} [alpha] Interpolation amount. + * @param {number} [snapDistance] Maximum distance to smooth. + * @returns {{x:number,y:number}} + */ + + function smoothReplayWindowPosition( + previous, + next, + alpha = 0.55, + snapDistance = 24, + ) { + if (!previous || !next) return next; + const distance = Math.hypot(next.x - previous.x, next.y - previous.y); + if (!Number.isFinite(distance) || distance > snapDistance) return next; + const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); + return { + x: Cesium.Math.lerp(previous.x, next.x, amount), + y: Cesium.Math.lerp(previous.y, next.y, amount), + }; + } + + /** + * Derive a compressed but mission-specific ascent replay duration. + * Launch Library timelines are authoritative when they expose insertion, + * SECO, or separation timing. Sparse records fall back to the reconstructed + * path length and a conservative ascent velocity estimate. + * @param {object} launch Normalized launch record. + * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. + * @returns {number} Replay duration in seconds. + */ + + function replayAscentDurationSeconds(launch, ascentPath = []) { + const disclosedSeconds = parts.paths.orbitInsertionOffsetSeconds(launch); + let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; + if (!realAscentSeconds && ascentPath.length > 1) { + const pathLength = ascentPath + .slice(1) + .reduce( + (total, point, index) => + total + Cesium.Cartesian3.distance(ascentPath[index], point), + 0, + ); + realAscentSeconds = Math.max(180, pathLength / 9000); + } + if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; + return Cesium.Math.clamp( + REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + ); + } + + /** + * Clamp and snap a replay speed multiplier to the supported slider range. + * @param {number|string} value Requested playback multiplier. + * @returns {number} Supported multiplier between 0.25x and 4x. + */ + + function normalizeReplaySpeed(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 1; + const clamped = Cesium.Math.clamp( + numeric, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + ); + return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; + } + + /** + * Preserve replay elapsed time when resuming after a pause. + * @param {number} startedAt Original replay start epoch in milliseconds. + * @param {number} pausedAt Pause epoch in milliseconds. + * @param {number} resumedAt Resume epoch in milliseconds. + * @returns {number} Shifted start epoch. + */ + + function replayStartAfterPause(startedAt, pausedAt, resumedAt) { + if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) + return startedAt; + return startedAt + Math.max(0, resumedAt - pausedAt); + } + + function replayState( + launch, + startedAt, + ascentDurationSec, + orbitDurationSec, + orbitPeriodSec, + speed = 1, + nowMs = Date.now(), + preCountdownDurationSec = 0, + loop = true, + ) { + const animationDurationSec = ascentDurationSec + orbitDurationSec; + const realSecondsSinceStart = (nowMs - startedAt) / 1000; + const preCountdownDuration = Math.max( + 0, + Number(preCountdownDurationSec) || 0, + ); + const preCountdownActive = + preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration; + const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; + const countdownSeconds = countdownActive + ? Math.ceil(-realSecondsSinceStart) + : 0; + const elapsedSinceStart = Math.max( + 0, + realSecondsSinceStart * normalizeReplaySpeed(speed), + ); + const elapsed = loop + ? elapsedSinceStart % animationDurationSec + : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); + const insertionOffsetSec = parts.paths.orbitInsertionOffsetSeconds(launch); + const ascending = elapsed < ascentDurationSec; + const phaseProgress = ascending + ? elapsed / ascentDurationSec + : (elapsed - ascentDurationSec) / orbitDurationSec; + const missionOffsetSec = + insertionOffsetSec === null + ? null + : ascending + ? phaseProgress * insertionOffsetSec + : insertionOffsetSec + phaseProgress * orbitPeriodSec; + const launchEpoch = Date.parse(launch.launchTime); + const eventTime = + Number.isFinite(launchEpoch) && missionOffsetSec !== null + ? new Date(launchEpoch + missionOffsetSec * 1000) + : null; + return { + ascending, + phaseProgress, + eventTime, + elapsedSinceStart, + countdownActive, + preCountdownActive, + countdownSeconds, + }; + } + + function syncReplayButton() { + const button = layerState._missionPanel?.querySelector( + '[data-mission-replay]', + ); + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + const speedControl = layerState._missionPanel?.querySelector( + '.mission-replay-speed-control', + ); + if (!button) return; + const active = Boolean( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId === layerState._selectedLaunchId, + ); + const replayAvailable = Boolean( + layerState._selectedLaunchId && + layerState._replayTracks.has(layerState._selectedLaunchId), + ); + if (speedControl) speedControl.hidden = !replayAvailable; + button.hidden = active || !replayAvailable; + button.disabled = !replayAvailable; + button.textContent = 'REPLAY ASCENT'; + button.classList.remove('active'); + button.setAttribute('aria-pressed', String(active)); + button.title = 'Replay the estimated ascent with a following camera'; + if (transport) { + transport.hidden = !active; + transport.classList.toggle( + 'is-paused', + active && layerState._replayPaused, + ); + const toggleButton = transport.querySelector( + '[data-mission-replay-toggle]', + ); + if (toggleButton) { + toggleButton.disabled = !active; + toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ'; + toggleButton.setAttribute( + 'aria-label', + layerState._replayPaused ? 'Resume replay' : 'Pause replay', + ); + toggleButton.title = layerState._replayPaused + ? 'Resume replay' + : 'Pause replay'; + } + } + } + + function syncReplayCountdownButton(state) { + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + if (!transport || !layerState._replayCameraLaunchId) return; + const phase = state.countdownActive + ? `T minus ${state.countdownSeconds}` + : state.preCountdownActive + ? 'Preparing launch site' + : state.elapsedSinceStart < 1 + ? 'Liftoff' + : state.ascending + ? 'Ascent replay' + : 'Orbit replay'; + transport.setAttribute( + 'aria-label', + `${phase}${layerState._replayPaused ? ', paused' : ''}`, + ); + } + + function syncReplaySpeedControl() { + const input = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed]', + ); + const output = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed-output]', + ); + if (input) { + input.value = String(layerState._replaySpeed); + const progress = + ((layerState._replaySpeed - REPLAY_SPEED_MIN) / + (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * + 100; + input.style.setProperty('--replay-speed-progress', `${progress}%`); + } + if (output) + output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`; + } + + function setReplaySpeed(value) { + const nextSpeed = normalizeReplaySpeed(value); + const previousSpeed = layerState._replaySpeed; + if (nextSpeed === previousSpeed) { + syncReplaySpeedControl(); + return; + } + const now = + layerState._replayPaused && Number.isFinite(layerState._replayPausedAtMs) + ? layerState._replayPausedAtMs + : Date.now(); + for (const [launchId, startedAt] of layerState._animationStarts) { + if (!Number.isFinite(startedAt)) continue; + const elapsedMissionMs = (now - startedAt) * previousSpeed; + layerState._animationStarts.set( + launchId, + now - elapsedMissionMs / nextSpeed, + ); + } + layerState._replaySpeed = nextSpeed; + syncReplaySpeedControl(); + } + + function replayClockNow(launchId) { + if ( + layerState._replayPaused && + layerState._replayCameraLaunchId === launchId && + Number.isFinite(layerState._replayPausedAtMs) + ) { + return layerState._replayPausedAtMs; + } + return Date.now(); + } + + function pauseMissionReplay() { + if (!layerState._replayCameraLaunchId || layerState._replayPaused) + return false; + layerState._replayPausedAtMs = Date.now(); + layerState._replayPaused = true; + syncReplayButton(); + return true; + } + + function resumeMissionReplay() { + if ( + !layerState._replayCameraLaunchId || + !layerState._replayPaused || + !Number.isFinite(layerState._replayPausedAtMs) + ) { + return false; + } + const resumedAt = Date.now(); + const startedAt = layerState._animationStarts.get( + layerState._replayCameraLaunchId, + ); + if (Number.isFinite(startedAt)) { + layerState._animationStarts.set( + layerState._replayCameraLaunchId, + replayStartAfterPause( + startedAt, + layerState._replayPausedAtMs, + resumedAt, + ), + ); + } + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + syncReplayButton(); + return true; + } + + function stopMissionReplay() { + const stoppedLaunchId = layerState._replayCameraLaunchId; + layerState._replayCameraToken++; + if (layerState._replayCameraRemover) layerState._replayCameraRemover(); + layerState._replayCameraRemover = null; + layerState._viewer?.camera?.cancelFlight(); + if (layerState._viewer?.camera) + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + layerState._replayCameraLaunchId = null; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + if (stoppedLaunchId) + layerState._animationStarts.set(stoppedLaunchId, Date.now()); + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + } + + function startMissionReplay(launchId) { + const launch = layerState._launches.find((item) => item.id === launchId); + const track = layerState._replayTracks.get(launchId); + if (!layerState._viewer || !launch || !track) return false; + if (layerState._replayCameraLaunchId === launchId) { + stopMissionReplay(); + return false; + } + + parts.selection.stopMissionZoomAnchor(); + stopMissionReplay(); + layerState._replayCameraLaunchId = launchId; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + const token = ++layerState._replayCameraToken; + const ascentDurationSec = track.ascentDurationSec; + // Start broadside to the ascent/orbit direction so the launch profile is + // visible. The chase heading then eases toward the path tangent after the + // camera is established. + const initialHeading = parts.camera.replayInitialCameraHeading( + track.ascentPath, + ); + track.lastCameraHeading = initialHeading; + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); + layerState._viewer.trackedEntity = undefined; + layerState._viewer.camera.cancelFlight(); + layerState._animationStarts.set( + launchId, + Date.now() + + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, + ); + let cameraReady = false; + let lastCameraUpdateMs = null; + + // Move the chase camera before scene traversal. Mutating it in preRender + // makes Cesium discover a new view after 3D-tile refinement, which can cause + // a self-sustaining refinement loop and visible stutter during slow replay. + layerState._replayCameraRemover = + layerState._viewer.scene.preUpdate.addEventListener(() => { + if ( + token !== layerState._replayCameraToken || + layerState._replayCameraLaunchId !== launchId + ) + return; + const state = track.beginReplayFrame(); + syncReplayCountdownButton(state); + if (!cameraReady) return; + const path = state.ascending + ? track.ascentPath + : track.animatedOrbitPath; + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position) return; + const pathHeading = parts.camera.cameraHeadingForPath( + path, + state.phaseProgress, + track.lastCameraHeading, + ); + const orbitBlend = !state.ascending + ? Cesium.Math.clamp( + (Number(state.phaseProgress) || 0) / + REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ) + : 0; + const desiredHeading = parts.camera.replayChaseCameraHeading( + pathHeading, + orbitBlend, + ); + if (state.ascending) track.orbitCameraWorldFrame = false; + const nowMs = performance.now(); + const frameDurationMs = Number.isFinite(lastCameraUpdateMs) + ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) + : 1000 / 60; + lastCameraUpdateMs = nowMs; + track.lastCameraHeading = parts.camera.smoothReplayCameraHeading( + track.lastCameraHeading, + desiredHeading, + (Cesium.Math.toRadians(2) * frameDurationMs) / (1000 / 60), + ); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + const altitude = Math.max(0, cartographic?.height || 0); + const cameraView = parts.camera.replayCameraView(state, altitude); + const defaultOrbitTarget = !state.ascending + ? parts.camera.replayOrbitGlobeAnchor(position, orbitBlend) + : position; + const cameraTarget = !state.ascending + ? parts.camera.replayOrbitCameraTarget( + defaultOrbitTarget, + track.orbitFrameSphere?.center, + orbitBlend, + ) + : defaultOrbitTarget; + const cameraRange = !state.ascending + ? parts.camera.replayOrbitGlobeRange( + cameraView.range, + altitude, + orbitBlend, + track.orbitFrameSphere?.radius, + ) + : cameraView.range; + if (state.ascending) { + layerState._viewer.camera.lookAt( + cameraTarget, + new Cesium.HeadingPitchRange( + track.lastCameraHeading, + cameraView.pitch, + cameraRange, + ), + ); + } else { + const tangentStep = Math.max( + 0.002, + 0.8 / Math.max(2, path.length - 1), + ); + const tangentProgress = Math.min( + 1, + state.phaseProgress + tangentStep, + ); + const tangentPosition = parts.paths.samplePath( + path, + tangentProgress > state.phaseProgress + ? tangentProgress + : Math.max(0, state.phaseProgress - tangentStep), + ); + const orbitPose = parts.camera.replayOrbitCameraPose( + position, + tangentPosition, + cameraTarget, + cameraRange, + cameraView.pitch, + ); + if (orbitPose) { + if (!track.orbitCameraWorldFrame) { + layerState._viewer.camera.lookAtTransform( + Cesium.Matrix4.IDENTITY, + ); + track.orbitCameraWorldFrame = true; + } + layerState._viewer.camera.setView({ + destination: orbitPose.destination, + orientation: { + direction: orbitPose.direction, + up: orbitPose.up, + }, + }); + } + } + if ( + state.elapsedSinceStart >= + ascentDurationSec + REPLAY_ORBIT_DURATION_SEC + ) { + stopMissionReplay(); + } + }); + + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(track.ascentPath[0], 0), + { + offset: new Cesium.HeadingPitchRange( + initialHeading, + Cesium.Math.toRadians(-20), + REPLAY_INITIAL_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + cameraReady = true; + }, + cancel: () => { + if ( + token === layerState._replayCameraToken && + layerState._replayCameraLaunchId === launchId + ) + stopMissionReplay(); + }, + }, + ); + return true; + } + return { + replayVehicleScreenRotation, + smoothReplayWindowPosition, + replayAscentDurationSeconds, + normalizeReplaySpeed, + replayStartAfterPause, + replayState, + syncReplayButton, + syncReplayCountdownButton, + syncReplaySpeedControl, + setReplaySpeed, + replayClockNow, + pauseMissionReplay, + resumeMissionReplay, + stopMissionReplay, + startMissionReplay, + }; +} diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js new file mode 100644 index 000000000..813b7875f --- /dev/null +++ b/src/layers/launches/selection.js @@ -0,0 +1,239 @@ +import * as Cesium from 'cesium'; +import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { getKeyholeGeometry } = services.geometry; + + function focusFullGlobe(viewer, duration = 2.4) { + const canvas = viewer?.scene?.canvas; + const height = canvas?.clientHeight || canvas?.height; + const width = canvas?.clientWidth || canvas?.width; + const cartographic = viewer?.camera?.positionCartographic; + const fovy = viewer?.camera?.frustum?.fovy; + if ( + !(width > 0) || + !(height > 0) || + !cartographic || + !Number.isFinite(fovy) || + fovy <= 0 || + fovy >= Math.PI + ) + return; + const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; + const keyholeRadius = getKeyholeGeometry(width, height).radius; + const targetScreenRadius = keyholeRadius * 0.61; + const angularRadius = Math.atan( + (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5), + ); + const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); + const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + altitude, + ), + orientation: { + heading: viewer.camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + duration, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }); + } + + function entityLaunchId(entity) { + if (!entity?.id || typeof entity.id !== 'string') return null; + const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); + return match?.[1] || null; + } + + function setSelectedMission(launchId, isolate = true) { + if (launchId) parts.panel.clearMissionRosterHover(); + if ( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId !== launchId + ) + parts.replay.stopMissionReplay(); + if ( + layerState._missionZoomAnchorId && + layerState._missionZoomAnchorId !== launchId + ) + stopMissionZoomAnchor(); + layerState._selectedLaunchId = launchId; + layerState._explicitSelection = Boolean(launchId && isolate); + if (launchId) layerState._animationStarts.set(launchId, Date.now()); + if (!launchId && layerState._viewer) { + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = undefined; + } + if (!layerState._dataSource) return; + for (const entity of layerState._dataSource.entities.values) { + const relatedId = entityLaunchId(entity); + entity.show = launchId + ? relatedId === launchId + : entity.id.startsWith('rocket-launch:'); + } + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.overlays.syncMissionOverlayEntries(); + parts.panel.renderMissionPanel(); + } + + function stopMissionZoomAnchor() { + if (layerState._missionZoomAnchorRemover) + layerState._missionZoomAnchorRemover(); + layerState._missionZoomAnchorRemover = null; + layerState._missionZoomAnchorId = null; + if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) { + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } + } + + function startMissionZoomAnchor(launch, initialRange) { + if (!layerState._viewer || !launch || layerState._replayCameraLaunchId) + return; + stopMissionZoomAnchor(); + const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const range = Math.max( + 2000, + Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M, + ); + layerState._missionZoomAnchorId = launch.id; + layerState._viewer.camera.lookAt( + target, + new Cesium.HeadingPitchRange( + 0, + parts.camera.missionZoomPitch(range), + range, + ), + ); + } + + function focusMission(launch) { + if (!layerState._viewer || !launch) return; + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + const orbitEntity = layerState._dataSource.entities.getById( + `rocket-orbit:${launch.id}`, + ); + const orbitPositions = orbitEntity?.polyline?.positions?.getValue( + Cesium.JulianDate.now(), + ); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + let range = 18000000; + if (orbitPositions?.length > 1) { + const canvas = layerState._viewer.scene.canvas; + const fovy = + layerState._viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); + const aspect = Math.max( + 0.1, + (canvas.clientWidth || canvas.width) / + Math.max(1, canvas.clientHeight || canvas.height), + ); + const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); + const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; + const orbitExtent = orbitPositions.reduce( + (largest, point) => + Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), + 0, + ); + range = Math.max( + orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), + 18000000, + ); + } + const targetNormal = Cesium.Cartesian3.normalize( + launchPosition, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + launchPosition, + Cesium.Cartesian3.multiplyByScalar( + targetNormal, + range, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const direction = Cesium.Cartesian3.negate( + targetNormal, + new Cesium.Cartesian3(), + ); + let right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Z, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { + right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Y, + right, + ); + } + Cesium.Cartesian3.normalize(right, right); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + layerState._viewer.camera.flyTo({ + destination, + orientation: { direction, up }, + duration: 1.1, + complete: () => { + if (layerState._selectedLaunchId === launch.id) + startMissionZoomAnchor(launch, range); + }, + }); + } + + function focusLaunchSite(launch) { + if (!layerState._viewer || !launch) return; + parts.replay.stopMissionReplay(); + stopMissionZoomAnchor(); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(launchPosition, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + Cesium.Math.toRadians(-42), + MISSION_FOCUS_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + if (layerState._selectedLaunchId === launch.id) { + startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); + } + }, + }, + ); + } + return { + focusFullGlobe, + entityLaunchId, + setSelectedMission, + stopMissionZoomAnchor, + startMissionZoomAnchor, + focusMission, + focusLaunchSite, + }; +} diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js new file mode 100644 index 000000000..39a49e36c --- /dev/null +++ b/src/layers/launches/source.js @@ -0,0 +1,25 @@ +/** Read launch records and their optional active-orbit catalog with explicit cancellation. */ +export function createLaunchSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getLaunches({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/launches', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload) && !Array.isArray(payload?.results)) + throw new Error('Malformed launch snapshot'); + return payload; + }, + async getActiveTle({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/celestrak/active', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + signal?.throwIfAborted(); + return text; + }, + }; +} diff --git a/src/layers/launches/source.test.mjs b/src/layers/launches/source.test.mjs new file mode 100644 index 000000000..916ad291e --- /dev/null +++ b/src/layers/launches/source.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLaunchSource } from './source.js'; +import { createRocketLaunchesLayer } from './index.js'; + +test('launch sources preserve last-good eligibility by rejecting malformed snapshots', async () => { + for (const payload of [{}, { results: null }]) { + const source = createLaunchSource({ + fetchImpl: async () => new Response(JSON.stringify(payload)), + }); + await assert.rejects(source.getLaunches(), /Malformed launch snapshot/); + } +}); + +test('launch and active-orbit responses reject cancellation during parsing', async () => { + for (const method of ['getLaunches', 'getActiveTle']) { + const controller = new AbortController(); + const source = createLaunchSource({ + fetchImpl: async () => ({ + ok: true, + async json() { + controller.abort(); + return { results: [] }; + }, + async text() { + controller.abort(); + return 'late orbit'; + }, + }), + }); + await assert.rejects(source[method]({ signal: controller.signal }), { + name: 'AbortError', + }); + } +}); + +test('launch factories construct independently without starting a scene or source request', () => { + const source = { + getLaunches() { + assert.fail('construction fetched launches'); + }, + getActiveTle() { + assert.fail('construction fetched orbits'); + }, + }; + const services = { satellites: {}, geometry: {}, overlays: {}, render: {} }; + const first = createRocketLaunchesLayer({ source, services }); + const second = createRocketLaunchesLayer({ source, services }); + first._setSelectedRocketMissionForTest('first'); + assert.notEqual(first, second); + assert.equal(first.getStats().count, 0); + assert.equal(second.getStats().count, 0); +}); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js new file mode 100644 index 000000000..0f0194221 --- /dev/null +++ b/src/layers/launches/state.js @@ -0,0 +1,138 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const state = {}; + + state.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + state._dataSource = null; + + state._count = 0; + + state._lastUpdate = null; + + state._lastError = null; + + state._orbitMatches = 0; + + state._clickHandler = null; + + state._moveHandler = null; + + state._viewer = null; + + state._declutterHandler = null; + + state._dataManager = null; + + state._retryTimer = null; + + state._postTleRetryCount = 0; + + state._updatePromise = null; + + state._updatePromiseToken = 0; + + state._updateDirty = false; + + state._lifecycleToken = 0; + + state._enabled = false; + + state._selectedLaunchId = null; + + state._explicitSelection = false; + + state._launches = []; + + state._missionPanel = null; + + state._missionRoster = null; + + state._missionRosterHoverTimer = null; + + state._hoveredRosterLaunchId = null; + + state._missionRosterPreviewOwnership = null; + + state._missionHoverReticleImage = null; + + state._replayVehicleOverlay = null; + + state._replayVehicleOverlayText = ''; + + state._animationStarts = new Map(); + + state._satelliteTelemetry = new Map(); + + state._lastPanelTelemetryMs = 0; + + state._lastMissionRingRotationMs = 0; + + state._activeTleText = null; + + state._activeTlePromise = null; + + state._activeTlePromiseToken = 0; + + state._renderedTleText = null; + + state._focusAfterActiveLookup = false; + + state._satelliteStateBeforeMission = null; + + state._satelliteActivationPromise = null; + + state._replayCameraRemover = null; + + state._replayCameraLaunchId = null; + + state._replayCameraToken = 0; + + state._replayPaused = false; + + state._replayPausedAtMs = null; + + state._replaySpeed = 1; + + state._replayTracks = new Map(); + + state._missionOverlayRecords = new Map(); + + state._missionOverlayHost = state.DEFAULT_OVERLAY_HOST; + + state._selectedMissionOverlayTimeText = null; + + state._missionZoomAnchorRemover = null; + + state._missionZoomAnchorId = null; + + state._launchPadZonePrimitive = null; + + state._launchPadZoneRemover = null; + + state._launchPadZoneLaunchId = null; + + state._missionOrbitPrimitives = new Map(); + + state._declutterTime = new Cesium.JulianDate(); + + state._declutterOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + new Cesium.Cartesian3(), + ); + + state._missionRingDate = new Date(0); + + state._missionOrbitPatternRegistered = false; + + state._pathDistanceCache = new WeakMap(); + state._sourceController = new AbortController(); + return state; +} diff --git a/src/layers/launches/testing.js b/src/layers/launches/testing.js new file mode 100644 index 000000000..ad14633ac --- /dev/null +++ b/src/layers/launches/testing.js @@ -0,0 +1,19 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** Test seam for real layer lifecycle coverage with a recording host. */ + + function _setRocketMissionOverlayHostForTest(host = null) { + layerState._missionOverlayHost = host + ? { ...layerState.DEFAULT_OVERLAY_HOST, ...host } + : layerState.DEFAULT_OVERLAY_HOST; + } + + /** Test seam that exercises the real selection/deselection path. */ + + function _setSelectedRocketMissionForTest(launchId = null) { + parts.selection.setSelectedMission(launchId, Boolean(launchId)); + } + return { + _setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest, + }; +} diff --git a/src/layers/military/controller.js b/src/layers/military/controller.js new file mode 100644 index 000000000..d1f172819 --- /dev/null +++ b/src/layers/military/controller.js @@ -0,0 +1,14 @@ +export function createController({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _abortActiveUpdates() { + for (const controller of flightState._activeUpdateControllers) + controller.abort(); + flightState._activeUpdateControllers.clear(); + } + return { _abortActiveUpdates }; +} diff --git a/src/layers/military/index.js b/src/layers/military/index.js new file mode 100644 index 000000000..c972bebb1 --- /dev/null +++ b/src/layers/military/index.js @@ -0,0 +1,37 @@ +import { createFlightState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createMotion } from './motion.js'; +import { createTracking } from './tracking.js'; +import { createController } from './controller.js'; +import { createIngestion } from './ingestion.js'; +import { createLifecycle } from './lifecycle.js'; +import { createTesting } from './testing.js'; +import { createQueries } from './queries.js'; +/** Compose one military-flight layer with application-owned scene services. */ +export function createMilitaryFlightLayer({ + source, + services, + resolveAsset = (url) => url, +} = {}) { + const flightState = createFlightState({ source, services }); + const parts = {}; + const layer = {}; + const context = { flightState, services, parts, layer, resolveAsset }; + parts.rendering = createRendering(context); + parts.motion = createMotion(context); + parts.tracking = createTracking(context); + parts.controller = createController(context); + parts.ingestion = createIngestion(context); + parts.lifecycle = createLifecycle(context); + parts.testing = createTesting(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { TRACKED_MODEL_MAX_PX } from './policy.js'; diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js new file mode 100644 index 000000000..1dbc8e0ff --- /dev/null +++ b/src/layers/military/ingestion.js @@ -0,0 +1,547 @@ +import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; +import * as Cesium from 'cesium'; +import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; +import { classifyAircraft, CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + BILLBOARD_SCALE, + GROUND_SCALE, + TRACKED_ICON_COLOR, + MIL_ICON_COLOR, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './policy.js'; + +export function createIngestion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { geoidHeight } = services.geoid; + const { + floorAltitudeM, + cachedGroundFloor, + warmGroundFloor, + GROUND_FLOOR_LIFT_M, + } = services.groundFloor; + const { sampleMeshFloorCells } = services.meshFloor; + const { registerMilitaryIcaos } = services.militaryRegistry; + const militaryFlightsLayer = layer; + + const methods = { + /** + * Fetch the latest military aircraft positions from adsb.lol and reconcile + * with the billboard collection. Handles error backoff, feed-time-stamped + * position history updates, and grace-period removal of absent aircraft. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * @returns {Promise} + */ + async update(viewer, { signal = null } = {}) { + const nowMs = Date.now(); + const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + ids: new Set(), + source: flightState._lastSource, + }; + if (flightState._retryAt && nowMs < flightState._retryAt) { + flightState._backoff = true; + return; + } + + const resourceController = new AbortController(); + flightState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + const snapshot = await flightState._source.getSnapshot( + {}, + { signal: updateSignal }, + ); + updateSignal.throwIfAborted(); + flightState._lastStatus = snapshot.status ?? 200; + flightState._lastSource = snapshot.source; + militaryFlightsLayer.source = flightState._lastSource; + const usableAircraft = snapshot.records; + flightState._backoff = + snapshot.stale || snapshot.freshness === 'unknown'; + flightState._retryAt = 0; + flightState._lastError = + snapshot.reason || + (snapshot.freshness === 'unknown' + ? 'Source snapshot time unavailable' + : null); + const currentIcaos = new Set(); + const receiptNowMs = snapshot.observedAtMs; + // Field-test fix (RS46): coarse floor cells to warm for the below-ground + // clamp — collected during the loop (low airborne contacts only), + // batch-resolved once after it. Never a fetch inside the loop. + const _floorWarmPoints = []; + + for (const aircraft of usableAircraft) { + const { id: icao24, longitude: lon, latitude: lat } = aircraft; + + currentIcaos.add(icao24); + flightState._missingPolls.delete(icao24); + + const prevMeta = flightState._flightData.get(icao24); + // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no + // separate boolean). Grounded planes fall back to their last known + // altitude (field elevation is unknowable here), else 0 m — never the + // 3 km airborne default (a parked plane must not float). + const onGround = aircraft.onGround; + const altitudeFt = + aircraft.baroAltitudeM == null + ? null + : aircraft.baroAltitudeM / 0.3048; + const altitudeM = + aircraft.baroAltitudeM ?? + (onGround + ? Number.isFinite(prevMeta?.altitudeFt) + ? prevMeta.altitudeFt * 0.3048 + : 0 + : 3048); + const track = aircraft.courseDeg || 0; + const speedMps = aircraft.speedMps ?? 0; + const verticalRateMps = aircraft.verticalRateMps; + const callsign = aircraft.callsign; + const type = aircraft.typeCode; + const registration = aircraft.registration; + const operator = aircraft.operator; + const geoAltitudeM = aircraft.ellipsoidAltitudeM; + const baroAltitudeM = aircraft.baroAltitudeM; + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = flightState._geoidNCache.get(icao24); + if (geoidN === undefined && flightState._geoidReady) { + geoidN = geoidHeight(lat, lon); + flightState._geoidNCache.set(icao24, geoidN); + } + + // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a + // grounded plane's MODEL already rides groundSnap.js's one-shot tileset + // sample (_modelDisplayPosition), which is the visual on the ground, and + // its billboard is depth-test-free (_groundDepthDistance) so its exact + // height is cosmetic. Deliberately pass surfaceM=null so + // pickRenderAltitudeM's on-ground surface branch never fires here: + // 1. It would be the SECOND correction of the same grounded plane + // (model tileset-snap is the first) — the exact double-correct the + // brief forbids. + // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the + // grounded billboard sits at 0 m until a surface value warms; letting + // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls + // drags the model's ground-snap input past groundSnap's 50 m + // move-invalidation threshold and forces a re-sample every time the + // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant + // the track regression locks (qa: sampleHeight count must stay flat). + // Grounded planes therefore keep the pre-existing `altitudeM` default + // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what + // matters for AIRBORNE military planes — the actual "renders at MSL" bug. + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: baroAltitudeM, + onGround, + surfaceM: null, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // alt_geom nor alt_baro was ever reported for this aircraft (not even + // stickily) — fall back to the SAME existing default policy `altitudeM` + // already uses (which also carries the on-ground 0 m / last-known-baro + // case), so the two never disagree on the "no data yet" case. + let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; + // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only + // AIRBORNE contact near steep terrain can compute a render height + // BELOW the local surface (no alt_geom; baro+N carries QNH error + // larger than the height above ground). Floor it at the coarse-grid + // ellipsoidal ground (warm-cache read only — the batch warm below + // fills cells for later polls). Grounded contacts are deliberately + // NOT touched: their model rides groundSnap's tileset sample and + // their billboard is depth-test-free (see the surfaceM:null block + // above — same one-shot-invariant reasoning). + if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + _floorWarmPoints.push({ lat, lon }); + } else if (onGround) { + // Grounded contacts: warm the floor cell, and — round 4 — when NO + // 3D model owns this contact's visual, lift the billboard itself + // onto the floor (mesh-first): the R20053 heli sat "straight up in + // the ground" because grounded rows render at the legacy ~0 m. + // With a model present the billboard stays put (it hides behind + // the tileset-snapped model, and moving it would drag groundSnap's + // input past its move-invalidation threshold — the T7 one-shot + // invariant the track regression locks). + _floorWarmPoints.push({ lat, lon }); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsVisual) { + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); + } + } + } + + const position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + renderAltitudeM, + ); + // Landing/takeoff transition: the ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Either flip direction retires the model's ground snap: a departing plane + // flies free of it, a landing plane earns a fresh sample where it rolls out. + if (groundFlipped) flightState._groundSnap.forget(icao24); + + // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold + // last-known-good (bounded by the layer's eviction, which deletes the entry). + const stickyType = stickyText(type, prevMeta?.type); + const meta = { + sourceReference: aircraft.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + type: stickyType, + // Type outranks category automatically inside classifyAircraft. + klass: classifyAircraft({ + typeCode: stickyType, + category: aircraft?.category, + }), + registration: stickyText(registration, prevMeta?.registration), + operator: stickyText(operator, prevMeta?.operator), + altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitudeFt` — never rename/replace it (labels, + // the FL readout, and the landed-fast-cull heuristic all still read + // altitudeFt/baro). + geoAltitudeM, + renderAltitudeM, + speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), + track: stickyNumber(track, prevMeta?.track, null), + // Analyst seam (additive): sticky like the other kinematics. + verticalRateMps: stickyNumber( + verticalRateMps, + prevMeta?.verticalRateMps, + null, + ), + lastContactEpochMs: stickyNumber( + aircraft.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + turnRateDps: prevMeta?.turnRateDps || 0, + onGround, + // Round 7: sticky airborne history (see _likelyLanded). + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Round 6: lifted occlusion-test point for at/below-ellipsoid + // renders (mirror of flights.js). + cullPosition: + renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) + : null, + // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded + // re-floor sweep keys floors off these (mirror of flights.js). + rawLat: lat, + rawLon: lon, + }; + flightState._flightData.set(icao24, meta); + + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch. + // adsb.lol's seen_pos is the AGE in seconds of the last position + // report, so the fix epoch is receipt time minus that age. Only + // append when the fix actually advances, so stale repeats don't + // create zero-dt segments. + const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs; + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.speedMps, + track: meta.track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F2) + // so the visible trail keeps growing while tracked. Round 2 + // (owner): ground traffic appends too — taxi history stays live + // after touchdown; _appendTrailFix floors grounded positions at + // the surface so the ground leg drapes instead of diving. + if (isTracked) parts.tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = + parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + const kinematicsChanged = + newest.velocity !== meta.speedMps || newest.track !== meta.track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.speedMps, + track: meta.track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) + parts.tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + // Ground flips (landing/takeoff) restyle this SAME billboard in + // place — the transition is never a removal. + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Per-class GLBs: a class change can mean a different asset OR scale — + // resync the live model, any in-flight load, and the tracked + // standalone (mirror of flights.js). + if (prevMeta?.klass !== meta.klass) + parts.rendering._syncModelToClass(icao24); + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon( + parts.rendering._iconKind(icao24, meta.klass), + ), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta.klass] || 1) * + (meta.onGround ? GROUND_SCALE : 1), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + sizeInMeters: false, + scaleByDistance: + parts.rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + + // Takeoff while TRACKED: ground traffic drew no trail, so start one + // from the fresh airborne history (touchdown needs no action — the + // append gate above simply freezes the existing trail). + if (isTracked && groundFlipped && !meta.onGround) + parts.tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + const info = flightState._flightData.get(icao24); + parts.tracking._updateTrackedLabelModel(icao24); + } + } + + // Field-test fix (RS46): one batch warm of the low-airborne floor cells + // collected above — fire-and-forget, single-flight, results read + // synchronously by NEXT poll's clamp (same contract as flights.js's + // grounded-surface warm). + warmGroundFloor(_floorWarmPoints); + // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — + // a parked contact whose feed went quiet froze at its pre-warm height. + // The model-ownership gate matches the live grounded-clamp path (a + // tileset-snapped model owns its visual; moving its billboard would + // disturb groundSnap's input — the T7 one-shot invariant). + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (parts.rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + info.cullPosition = null; + const liftedPos = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const hist = flightState._positionHistory.get(icao24); + const newest = hist?.[hist.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone( + liftedPos, + newest.position, + ); + const bbStale = flightState._billboards.get(icao24); + if (bbStale) bbStale.position = liftedPos; + } + // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, + // budget-capped, viewer-proximate, google-3d regime only), excluding + // this layer's own billboards/models from the probe. + { + const viewerCarto = + flightState._viewer?.camera?.positionCartographic || null; + sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // adsb.lol routinely drops aircraft for a single poll; immediate removal + // made icons blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if ( + !snapshot.complete && + Date.now() - + (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < + 300000 + ) + continue; + const misses = (flightState._missingPolls.get(icao24) || 0) + 1; + const limit = parts.queries._likelyLanded(icao24) + ? LANDED_MISSING_POLL_LIMIT + : MISSING_POLL_LIMIT; + if (misses < limit) { + flightState._missingPolls.set(icao24, misses); + if ( + icao24 === flightState._trackedIcao && + flightState._trackedEntity + ) { + // Honest readout: the tracked plane has no faded billboard (its + // entity owns the visual), so refresh the label — with icao24 now + // in _missingPolls, _buildTrackedLabel appends the STALE cue so + // last-known altitude/speed aren't presented as live. + parts.tracking._updateTrackedLabelModel(icao24); + } + continue; + } + flightState._missingPolls.delete(icao24); + + // If the tracked flight is truly gone, clear tracking BEFORE deleting + // its state (M3 ordering, keep it): teardown reads the maps this loop + // is about to delete (billboard restore, DR cache reset), and we must + // never leave the camera mid-follow with stale tracking state. The + // camera is then RELEASED IN PLACE — it stays where the follow left + // it, fully free (owner decision 2026-07-02: no overview flyTo). + if (icao24 === flightState._trackedIcao) { + parts.tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._geoidNCache.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Feed the shared registry so the flights layer can classify/suppress + registerMilitaryIcaos(currentIcaos); + + flightState._count = flightState._billboards.size; + flightState._lastUpdate = receiptNowMs; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'accepted', + ids: currentIcaos, + source: flightState._lastSource, + }; + console.log(`[Data:Military] Updated: ${flightState._count} aircraft`); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Military update aborted', 'AbortError'); + } + console.warn('[Data:Military] Fetch error:', e); + flightState._backoff = true; + flightState._retryAt = + Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); + flightState._lastStatus = e?.status ?? null; + if (e?.source) { + flightState._lastSource = e.source; + this.source = flightState._lastSource; + } + flightState._lastError = + e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; + } finally { + flightState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js new file mode 100644 index 000000000..31d8b558e --- /dev/null +++ b/src/layers/military/lifecycle.js @@ -0,0 +1,301 @@ +import * as Cesium from 'cesium'; +import { JET_MODEL_URL } from './policy.js'; + +export function createLifecycle({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { clearFocusTarget } = services.focus; + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { setMilitaryLayerActive } = services.militaryRegistry; + + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (flightState._viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + flightState._source = source; + flightState._lastSource = source.label || flightState._lastSource; + this.source = flightState._lastSource; + }, + + /** + * Initialize the layer: create the billboard collection, reset all state, + * and install the click-to-track handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + init(viewer) { + if (flightState._viewer) + throw new Error('Flight layer is already initialized'); + if (typeof flightState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + if (flightState.lifetime.signal.aborted) + flightState.lifetime = new AbortController(); + clearFocusTarget('militaryFlights'); + flightState._viewer = viewer; + flightState._billboardCollection = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(flightState._billboardCollection); + registerSpriteCollection('military', flightState._billboardCollection); + flightState._modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(flightState._modelCollection); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(JET_MODEL_URL), + asynchronous: false, + }) + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + try { + m.destroy(); + } catch { + /* gone */ + } + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + flightState._billboards = new Map(); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map(); + flightState._positionHistory = new Map(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._backoff = false; + flightState._retryAt = 0; + flightState._lastError = null; + flightState._lastStatus = null; + flightState._trackedIcao = null; + parts.tracking._resetTrackedSelectionState(); + flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; + flightState._cockpitContactMode = + document.body.classList.contains('cockpit-mode'); + flightState._cockpitNearContacts = new Set(); + if (!flightState._cockpitModeListener) { + flightState._cockpitModeListener = (event) => + parts.tracking._applyCockpitState(event?.detail); + window.addEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + } + + parts.tracking._installClickHandler(viewer); + + restoreSpriteOrder(viewer); + + console.log('[Data:Military] Initialized with billboard icons'); + }, + + /** + * Show the layer and re-install the click handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + enable(viewer) { + if (flightState._billboardCollection) + flightState._billboardCollection.show = true; + holdContinuousRender('military'); // per-frame animator (perf wave 2) + if (flightState._modelCollection) + flightState._modelCollection.show = true; + parts.tracking._setCockpitContactMode( + document.body.classList.contains('cockpit-mode'), + ); + // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop + // only reads geoidHeight() synchronously after this resolves (guarded by + // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. + if (!flightState._geoidReady) { + const lifetime = flightState.lifetime; + ensureGeoidReady() + .then(() => { + if (!lifetime.signal.aborted) flightState._geoidReady = true; + }) + .catch(() => { + /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ + }); + } + parts.tracking._installClickHandler(viewer); + registerPickOwner('military', (pickedId) => + flightState._billboards.has(pickedId), + ); + // The flights layer suppresses its military duplicates while we render them + setMilitaryLayerActive(true); + // Force a fresh rotation pass on the first tick after re-enable + flightState._lastCamPoseSig = ''; + if (!flightState._preRenderRemove && viewer?.scene) { + flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering._fleetTick, + ); + } + if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { + flightState._trackedModelPreUpdateRemove = + viewer.scene.preUpdate.addEventListener( + parts.rendering._updateTrackedModel, + ); + } + if (!flightState._moveEndRemove && viewer?.camera) { + // Arrival polish (mirror of flights.js — see the comment there): a settled + // camera move forces a full rotation pass on the very next frame, because + // the pose-signature gate can eat the settle (final easing frames land + // inside one quantization bucket) and leave stale noses for up to + // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. + flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( + () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }, + ); + } + restoreSpriteOrder(viewer); + }, + + /** + * Hide the layer, clear any active tracking, and remove input handlers + * so clicks do not get intercepted while the layer is off. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + disable(viewer) { + parts.controller._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._billboardCollection) + flightState._billboardCollection.show = false; + releaseContinuousRender('military'); + parts.rendering._releaseModels(); + if (flightState._modelCollection) + flightState._modelCollection.show = false; + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + // Remove click handler + keydown listener while disabled to avoid + // intercepting input when the layer is off + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + unregisterPickOwner('military'); + // Flights layer takes over rendering known-military aircraft (amber) + setMilitaryLayerActive(false); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + }, + + /** + * Tear down the layer: clear tracking, remove handlers, remove the billboard + * collection from the scene, and release all state. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + destroy(viewer) { + flightState.lifetime.abort(); + parts.controller._abortActiveUpdates(); + releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + if (flightState._cockpitModeListener) { + window.removeEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + flightState._cockpitModeListener = null; + } + unregisterPickOwner('military'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + parts.rendering._releaseModels(); + if (flightState._billboardCollection) { + viewer.scene.primitives.remove(flightState._billboardCollection); + flightState._billboardCollection = null; + } + if (flightState._modelCollection) { + viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models + flightState._modelCollection = null; + } + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + try { + flightState._preloadModel.destroy(); + } catch { + /* gone */ + } + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._detectionObjects.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._missingPolls.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._cockpitContactMode = false; + flightState._cockpitNearContacts = new Set(); + flightState._cockpitSubjectId = null; + flightState._trackingRefreshEpoch += 1; + flightState._lastTrackingRefreshOutcome = { + epoch: flightState._trackingRefreshEpoch, + status: 'destroyed', + ids: new Set(), + source: flightState._lastSource, + }; + parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling + flightState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js new file mode 100644 index 000000000..004558e2e --- /dev/null +++ b/src/layers/military/motion.js @@ -0,0 +1,528 @@ +import * as Cesium from 'cesium'; +import { + lerpAngleDeg, + speedRamp, + courseBetweenCartesians, + COURSE_HOLD_SPEED_MPS, + staleCoastLimitSeconds, + arcOffsetEnu, + courseSlewCapDps, + limitCourseStep, +} from '../../data/motionModel.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + modelVisualAnchor, + modelAnchorWorld, +} from '../../data/modelVisualAnchor.js'; +import { + RENDER_DELAY_SEC, + TRACKED_MODEL_MAX_PX, + TRACKED_MODEL_MIN_PX, + BILLBOARD_SCALE, + TRACKED_BILLBOARD_SCALE_BY_DISTANCE, + DR_CORRECTION_MS, + COURSE_SLEW_DT_MAX_SEC, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createMotion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { trackedModelScaleForPixelCap } = services.camera; + const { + nearFarScalarValueAtDistance, + clearFocusTarget, + publishFocusTargetFromCachedPosition, + } = services.focus; + + /** + * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. + * + * Renders one poll interval behind real time so positions interpolate between + * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the + * newest fix is older than the delayed render time (stale position), projects + * forward using the aircraft's ground speed and track through the freshest + * source contact plus a bounded grace window. + * + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. + * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history + */ + + function _deadReckon(icao24, result) { + const history = flightState._positionHistory.get(icao24); + const info = flightState._flightData.get(icao24); + if (!history || history.length === 0) { + flightState._drCourseDeg = null; + flightState._drSpeedMps = null; + flightState._drCourseHold = false; + return null; + } + + const out = result || new Cesium.Cartesian3(); + // Render one poll interval behind real time so we interpolate between + // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); + + // Bracketing pair: interpolate — no extrapolation error, no snap-back. + for (let i = history.length - 1; i >= 1; i--) { + const a = history[i - 1]; + const b = history[i]; + if ( + Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && + Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) { + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + // Course of the DISPLAYED motion. The chord is only trustworthy when the + // segment covers real ground (at hover its direction is GPS jitter; on a + // slow tight turn it STEPS the whole per-segment turn at each boundary), + // so it is blended against the reported per-fix track by displayed + // ground speed — and the track is TIME-INTERPOLATED between the fixes so + // a slow turner's nose advances continuously through the segment instead + // of snapping once per poll. Helicopters always use the reported track + // (rotorcraft chords are noise-dominated at their typical speeds). + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const segSpeed = + span > 0 ? chordLenM / span : (info && info.speedMps) || 0; + const fallbackTrack = (info && info.track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); + const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); + const chordCourse = + w > 0 ? courseBetweenCartesians(a.position, b.position) : null; + flightState._drCourseDeg = + chordCourse != null + ? lerpAngleDeg(trackCourse, chordCourse, w) + : trackCourse; + flightState._drSpeedMps = segSpeed; + flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + } + + const newest = history[history.length - 1]; + const elapsedSec = Cesium.JulianDate.secondsDifference( + renderTime, + newest.time, + ); + if (elapsedSec <= 0) { + // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking + // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing + // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time + // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history + // fills, renderTime advances toward the first fix and the icon glides FORWARD into + // the bracketing interpolation above with NO freeze and NO backward snap. (Holding + // the oldest fix froze the icon until enough history accrued, then jumped.) + const oldest = history[0]; + const lookbackSec = Cesium.JulianDate.secondsDifference( + oldest.time, + renderTime, + ); // ≥ 0 + return _extrapolateFix( + oldest, + info, + -Math.min(lookbackSec, 60), + out, + (info && info.turnRateDps) || 0, + ); + } + + // Continue through fresh adsb.lol contacts even when the position epoch has + // not advanced, but keep an absolute stale-feed drift ceiling. + const coastLimitSec = staleCoastLimitSeconds({ + // Captured at poll normalization so the fleet tick does not allocate a + // Date for every aircraft twelve times per second. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, + }); + return _extrapolateFix( + newest, + info, + Math.min(elapsedSec, coastLimitSec), + out, + (info && info.turnRateDps) || 0, + ); + } + + /** + * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a + * constant-rate-turn arc when `turnRateDps` is significant (straight line + * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` + * projects BACKWARD (before the fix) — used for warm-up, estimating where the + * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, + * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets + * `_drCourseDeg` to the arc's instantaneous end course on every path. + * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). + */ + + function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { + const speed = Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.speedMps) || 0; + const heading = Number.isFinite(fix.track) + ? fix.track + : (info && info.track) || 0; + flightState._drSpeedMps = speed; + flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; + if (speed === 0 || dt === 0) { + flightState._drCourseDeg = heading; + return Cesium.Cartesian3.clone(fix.position, out); + } + // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a + // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. + arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); + flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; + Cesium.Cartesian3.fromElements( + flightState._scratchArc.east, + flightState._scratchArc.north, + 0, + flightState._scratchOffset, + ); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchEnu, + ); + return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); + } + + /** + * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) + * predates its oldest real fix — _deadReckon is extrapolating backward with no real + * history yet behind the displayed icon, so the trail must draw nothing. + * @returns {boolean} + */ + + function _isTrackWarmingUp() { + if (!flightState._trackedIcao) return false; + const history = flightState._positionHistory.get(flightState._trackedIcao); + if (!history || history.length === 0) return true; + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + return Cesium.JulianDate.lessThan(renderTime, history[0].time); + } + + /** Resolve the selected aircraft's actual rendered square extent this frame. */ + + function _trackedFocusSizePx(icao24, position) { + const camera = flightState._viewer?.camera; + const scene = flightState._viewer?.scene; + if (!camera?.positionWC || !position || !scene) return 28; + const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); + if (parts.rendering._modelOwnsVisual(icao24)) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(icao24)?.klass, + ); + const scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM, + viewportHeightPx: scene.canvas.clientHeight, + fovyRad: camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + const focalLengthPx = + scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + const projectedDiameterPx = + (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; + return Math.max( + TRACKED_MODEL_MIN_PX, + Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx), + ); + } + + const billboard = flightState._trackedEntity?.billboard; + const time = flightState._viewer.clock.currentTime; + const width = billboard?.width?.getValue(time) ?? 28; + const height = billboard?.height?.getValue(time) ?? 28; + const scale = + billboard?.scale?.getValue(time) ?? + BILLBOARD_SCALE * + (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + const scaleByDistance = + billboard?.scaleByDistance?.getValue(time) ?? + TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); + return Math.max(width, height) * scale * distanceScale; + } + + /** + * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium + * frame number so the position / rotation / trail-head callbacks share ONE computation + * (and one reconciliation-state update) per frame. Returns a stable module holder, or + * null when the aircraft has no fix. + * @param {string} icao24 + * @returns {Cesium.Cartesian3|null} + */ + + function _trackedDisplayPosition(icao24) { + const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frame === flightState._cachedDRFrame && + icao24 === flightState._drReconcileIcao + ) + return flightState._cachedDRPosition; + + // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting + // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) + const sameTrack = + flightState._drReconcileValid && flightState._drReconcileIcao === icao24; + const raw = _deadReckon(icao24, flightState._scratchDrRaw); + flightState._cachedDRCourse = flightState._drCourseDeg; + flightState._cachedDRSpeedMps = flightState._drSpeedMps; + flightState._cachedDRHold = flightState._drCourseHold; + flightState._cachedDRFrame = frame; + flightState._drReconcileIcao = icao24; + if (!raw) { + flightState._cachedDRPosition = null; + flightState._drReconcileValid = false; + clearFocusTarget('militaryFlights', icao24); + return null; + } + + const nowMs = Date.now(); + const info = flightState._flightData.get(icao24); + if (sameTrack) { + const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); + const speed = (info && info.speedMps) || 0; + // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. + const plausible = speed * dtSec * 4 + 25; + if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { + // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. + Cesium.Cartesian3.subtract( + flightState._drPrevDisplay, + raw, + flightState._drCorrection, + ); + flightState._drCorrectionStartMs = nowMs; + } + } else { + Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); + flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed + } + + const elapsed = nowMs - flightState._drCorrectionStartMs; + const factor = + elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; + const display = Cesium.Cartesian3.multiplyByScalar( + flightState._drCorrection, + factor, + flightState._trackedPosHolder, + ); + Cesium.Cartesian3.add(raw, display, display); + + Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); + Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); + flightState._drPrevMs = nowMs; + flightState._drReconcileValid = true; + flightState._cachedDRPosition = display; + const focusSizePx = _trackedFocusSizePx( + icao24, + flightState._cachedDRPosition, + ); + // Publish the exact frame cache shared by entity + follow camera. A second + // DR sample here would advance reconciliation in a different frame phase + // and visibly jitter the focus rectangle against the tracked aircraft. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'militaryFlights', + id: icao24, + scene: flightState._viewer?.scene, + camera: flightState._viewer?.camera, + displayPosition: flightState._cachedDRPosition, + widthPx: focusSizePx, + heightPx: focusSizePx, + }); + return display; + } + + /** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already + * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at + * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the + * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ + + function _trackedDisplayCached() { + return flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao + ? flightState._cachedDRPosition + : null; + } + + /** + * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D + * model is actually rendering with when the model owns the visual, otherwise the cached + * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. + * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, + * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its + * follow-camera anti-jitter contract untouched. + */ + + function _trackedVisualCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + return modelVisualAnchor( + flightState._trackedModel.modelMatrix, + spec.visualCenterNative, + Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale, + flightState._trackedVisualPos, + ); + } + return _trackedDisplayCached(); + } + + /** The tracked model's rendered bounding radius (m), or 0 when no model of this + * contact is drawing. Carries Cesium's effective `computedScale`, which may be + * above `scale` to satisfy minimumPixelSize — the trail head has to be judged + * against the size the operator SEES, not the nominal one. */ + /** World-space origin of the tracked model, or null when no model of this + * contact is drawing. This is the centre the rendered bounding sphere is + * measured from, so the trail clip and the envelope agree on one frame. */ + + function _trackedModelCenterWorld() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return null; + return Cesium.Matrix4.getTranslation( + flightState._trackedModel.modelMatrix, + flightState._scratchTrailClip, + ); + } + + function _trackedModelEnvelopeM() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return 0; + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const scale = Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale; + return spec.nativeRadiusM * scale; + } + + /** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on + * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ + + function _trackedTrailCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + // Through the model's OWN render chain (modelVisualAnchor's hand-rolled + // half-correction put this offset on the lateral axis — see + // modelAnchorWorld). + return modelAnchorWorld( + flightState._trackedModel, + spec.trailAnchorNative, + flightState._trackedTrailPos, + ); + } + return _trackedDisplayCached(); + } + + /** Smoothed world course for the tracked aircraft this frame. Reads the + * frame-cached course (set by _trackedDisplayPosition — the follow-camera's + * own computation), NEVER re-runs _deadReckon. Safe to call more than once + * per frame: the second call sees dt≈0 and the limiter is a no-op. + * + * The smoothed value lives in the SHARED per-icao _displayCourse entry (see + * its declaration): on click the limiter continues from whatever nose the + * fleet pass was displaying, and on untrack the fleet pass continues from + * whatever nose this path last wrote — tracked and fleet consumers of the + * same aircraft can never disagree across the handoff (mirror of flights.js). */ + + function _trackedDisplayCourse() { + const info = flightState._flightData.get(flightState._trackedIcao); + const fallback = (info && info.track) || 0; + const cacheValid = + flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao && + flightState._cachedDRCourse != null; + const raw = cacheValid ? flightState._cachedDRCourse : fallback; + const nowMs = Date.now(); + const dt = flightState._trackedCourseMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._trackedCourseMs) / 1000, + ) + : 0; + flightState._trackedCourseMs = nowMs; + const prev = flightState._displayCourse.get(flightState._trackedIcao); + // Hover hold: at near-zero displayed speed both the chord and the reported + // track are noise — keep the last stable nose direction instead of chasing. + if (cacheValid && flightState._cachedDRHold && prev != null) return prev; + const cap = courseSlewCapDps( + cacheValid + ? flightState._cachedDRSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ); + const course = limitCourseStep(prev, raw, cap, dt); + flightState._displayCourse.set(flightState._trackedIcao, course); + return course; + } + + /** Reset reconciliation + per-frame cache when tracking stops or switches + * target. Deliberately does NOT touch _displayCourse: the smoothed course + * entry belongs to the AIRCRAFT (not to the tracked session) and must + * survive the handoff back to the fleet pass. */ + + function _resetTrackedDisplay() { + flightState._drReconcileValid = false; + flightState._drReconcileIcao = null; + flightState._cachedDRFrame = -1; + flightState._cachedDRPosition = null; + flightState._cachedDRCourse = null; + flightState._cachedDRSpeedMps = null; + flightState._cachedDRHold = false; + flightState._trackedCourseMs = 0; + } + return { + _deadReckon, + _extrapolateFix, + _isTrackWarmingUp, + _trackedFocusSizePx, + _trackedDisplayPosition, + _trackedDisplayCached, + _trackedVisualCached, + _trackedModelCenterWorld, + _trackedModelEnvelopeM, + _trackedTrailCached, + _trackedDisplayCourse, + _resetTrackedDisplay, + }; +} diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs new file mode 100644 index 000000000..75a559f3f --- /dev/null +++ b/src/layers/military/ownership.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMilitaryFlightLayer } from './index.js'; +import { createFlightState } from './state.js'; + +function services() { + const names = [ + 'picking', + 'sprites', + 'trails', + 'aircraftPresentation', + 'camera', + 'militaryRegistry', + 'labels', + 'groundFloor', + 'meshFloor', + 'geoid', + 'focus', + 'readout', + 'context', + 'render', + 'recession', + ]; + return { + ...Object.fromEntries(names.map((name) => [name, {}])), + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + }; +} + +test('military layer instances isolate policy and restoration state without requesting a source', async () => { + let requests = 0; + const source = { + label: 'Fixture aircraft', + getSnapshot() { + requests++; + }, + }; + const first = createMilitaryFlightLayer({ source, services: services() }); + const second = createMilitaryFlightLayer({ source, services: services() }); + first.setParams({ models3dMode: 'all' }); + assert.equal(first.getParams().models3dMode, 'all'); + assert.equal(second.getParams().models3dMode, 'proximity'); + first.testing._setMilitaryTrackingRefreshOutcomeForTest({ ids: [] }); + assert.equal( + (await first.resolveTrackingRestoreTarget('abc123')).status, + 'missing', + ); + assert.equal( + (await second.resolveTrackingRestoreTarget('abc123')).status, + 'source-unavailable', + ); + assert.equal(requests, 0); +}); + +test('military source omission fails before viewer initialization', () => { + const layer = createMilitaryFlightLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); +}); + +test('military state owns separate contact maps, motion scratch and ground sampling', () => { + const first = createFlightState({ services: services() }); + const second = createFlightState({ services: services() }); + for (const key of [ + '_flightData', + '_billboards', + '_positionHistory', + '_groundSnap', + '_scratchCarto', + '_models', + '_activeUpdateControllers', + 'lifetime', + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test('a normalized source can retain its stale reason without changing standalone cache policy', async () => { + let reason = 'Source is refreshing'; + const supplied = services(); + supplied.groundFloor.warmGroundFloor = async () => {}; + supplied.meshFloor.sampleMeshFloorCells = () => {}; + supplied.militaryRegistry.registerMilitaryIcaos = () => {}; + const layer = createMilitaryFlightLayer({ + services: supplied, + source: { + label: 'Fixture aircraft', + async getSnapshot() { + return { + source: 'Fixture aircraft', + records: [], + complete: true, + observedAtMs: 123000, + stale: true, + freshness: 'stale', + reason, + }; + }, + }, + }); + await layer.update({}); + assert.equal(layer.getStats().error, reason); + assert.equal(layer.getStats().lastUpdate, 123000); + assert.equal(layer.getStats().stale, true); + reason = null; + await layer.update({}); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().stale, true); +}); diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js new file mode 100644 index 000000000..c72c06088 --- /dev/null +++ b/src/layers/military/policy.js @@ -0,0 +1,277 @@ +import * as Cesium from 'cesium'; + +/** + * @module militaryFlights + * @description Real-time military flight tracking layer powered by the adsb.lol API. + * + * Renders aircraft as amber chevron billboards in a single BillboardCollection + * for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft + * spawns an Entity the camera follows, whose position is driven by a + * dead-reckoning CallbackProperty. The whole fleet renders at + * now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes + * instead of extrapolating ahead and snapping back when the next poll lands. + * + * Billboard orientation uses alignedAxis set to the WGS84 surface normal at each + * position so that the rotation angle (heading/track) operates in the local tangent + * plane where 0 deg = north; near nadir the icons switch to screen-aligned + * rotation (camera.heading - track) with hysteresis. + */ + +/** @constant {number} Milliseconds to wait before retrying after a transient error */ + +export const ERROR_BACKOFF_INTERVAL = 20000; + +/** @constant {number} Max position samples retained per aircraft for dead reckoning */ + +export const POSITION_HISTORY_LIMIT = 5; + +/** @constant {number} Base billboard display scale */ + +export const BILLBOARD_SCALE = 0.7; + +/** @constant {Cesium.Color} Default amber tint for untracked military billboards */ + +export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); + +/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */ + +export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); + +// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- +// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate +// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude +// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable, +// sticky metadata updating normally. The on-ground flip restyles the existing +// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes +// draw no trails. In 3D mode they take model slots like airborne planes (owner +// decision 2026-07-03 — no air/ground distinction), placed by the one-shot +// ground snap (see _modelDisplayPosition). +// +// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field +// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can +// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail; +// "feed-dropped, coasting" stays the 45%-alpha stale fade. +/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ + +export const GROUND_SCALE = 0.8; + +// --- 3D model rendering (mirrors flights.js) ----------------------------------------- +// When enabled, military aircraft render as 3D glTF jet models once the camera is below +// MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is +// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') +// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion +// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer +// apart from the commercial flights layer. + +export const JET_MODEL_URL = '/models/jet.glb'; + +export const MODEL_ALT_CEIL_M = 800000; +// m: below this camera altitude, draw 3D models (raised so it's easy to trigger) + +export const MODEL_MIN_PX = 24; +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) + +export const TRACKED_MODEL_MIN_PX = 40; +// keep the glTF silhouette comparable to the selected 2D glyph at handoff + +export const TRACKED_MODEL_MAX_PX = 200; +// owner-selected close-range tracked-target feel + +export const MODEL_NATIVE_RADIUS_M = 29.83; + +// jet.glb is transform-applied at real-world scale — native bounding radius +// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching +// flights.js world sizes. At the old copied ×24, models rendered +// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize +// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by +// modelScale.test.mjs. + +export const MODEL_SCALE = 1; + +// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity +// and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). + +export const MODEL_MAX = 150; +// 'proximity' cap + +export const MODEL_MAX_ALL = 350; +// 'all' cap + +export const MODEL_PROX_ADD_M = 150000; +// proximity: model NEW planes within 150 km + +export const MODEL_PROX_KEEP_M = 185000; +// proximity: KEEP modeled planes out to 185 km + +// Cockpit keeps the standard Display radii but lowers the GLB budget. Contacts +// inside the selected band remain AIR silhouettes when they cannot own a model; +// contacts outside the band use compact dots. + +export const COCKPIT_MODEL_MAX = 60; +// max concurrent GLBs in cockpit (never raises the map cap) + +export const MODEL_ALL_ADD_M = 400000; +// all: model NEW planes within 400 km (~to the horizon) + +export const MODEL_ALL_KEEP_M = 450000; +// all: KEEP modeled planes out to 450 km + +export const MODEL_HEADING_OFFSET_DEG = 180; +// every aircraft GLB is exported nose -X in the shared transform-applied convention +// Preserve the layer's amber identity while reducing approved texture/livery +// contribution to a weak diffuse hint, matching civilian launch presentation. + +export const MODEL_COLOR_BLEND_AMOUNT = 0.94; + +// airplane.glb (the shared 747) constants for this layer's heavy classes. The +// asset has its former 24× runtime calibration baked into transform-applied +// meter-scale geometry; these values mirror flights.js and are regression-pinned. + +export const PLANE_MODEL_URL = '/models/airplane.glb'; + +export const PLANE_MODEL_SCALE = 1; + +export const PLANE_NATIVE_RADIUS_M = 34.41; + +export const PLANE_BELLY_OFFSET_NATIVE = 6.719; + +// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this +// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node +// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). +// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model +// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to +// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. + +export const MODEL_BELLY_OFFSET_NATIVE = 5.631; + +/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ + +export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); + +export const COCKPIT_CONTACT_SIZE_PX = 6; + +export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 3.0, + 8000000, + 0.5, +); + +// --------------------------------------------------------------------------- +// Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the +// tracked aircraft. The accumulation array is intentionally SEPARATE from +// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) +// so the visible trail can grow to TRAIL_MAX_POINTS fixes. +// --------------------------------------------------------------------------- + +/** @constant {string} Military trail hue (PRD F4, pinned). */ + +export const TRAIL_COLOR = '#FFB800'; + +/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +// --------------------------------------------------------------------------- +// Render-behind smoothing (mirrors flights.js): the fleet renders at +// now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes +// instead of extrapolating ahead and snapping back when the next poll lands. +// Removing the delay reintroduces the back/forward oscillation and is a +// regression. +// --------------------------------------------------------------------------- + +/** @constant {number} Display latency in seconds (= one poll interval). */ + +export const RENDER_DELAY_SEC = 15; + +/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */ + +export const MISSING_POLL_LIMIT = 3; + +// --- Landed-plane fast cull (mirror of flights.js; owner field report +// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown). +// The feed's ground flag lags the actual landing, so a landed plane's last +// airborne fixes show it low + slow on the runway; when it then drops out of +// the poll it has landed, not hit a transient gap — evict after ONE missed +// poll. Thresholds: below ~500 ft baro (≈150 m MSL — near-sea-level fields +// only; a high-elevation airport ghost falls back to the normal grace) AND +// below ~45 kts ground speed (≈23 m/s — rollout/taxi; nothing in normal +// FLIGHT is this slow, so cruise planes always keep the full grace). +/** @constant {number} Max baro altitude (ft, MSL) for the landed fast cull (~150 m). */ + +export const LANDED_ALT_MAX_FT = 500; + +/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ + +export const LANDED_SPEED_MAX_MPS = 23; + +/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ + +export const LANDED_MISSING_POLL_LIMIT = 1; + +// Field-test fix (RS46, 2026-07-06): only contacts rendering below this +// ellipsoidal height get the below-ground floor clamp + a coarse floor-cell +// warm. Terrain outside the extreme Himalaya tops out well under this, so +// cruise traffic (which can never be below ground) costs zero terrain +// lookups; low pattern/heli work — the class that actually clips hillsides — +// gets the floor. +/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ + +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +// --------------------------------------------------------------------------- +// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment +// degenerates when the camera looks straight down (the normal is parallel to +// the view direction and the shader's screen-projected angle is 0/0). Near +// nadir we switch to screen-aligned billboards with +// rotation = camera.heading - track (screen-up points at azimuth +// camera.heading; a CW track t appears t-h from screen-up; billboard rotation +// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations +// are continuous at the boundary with this formula. +// --------------------------------------------------------------------------- + +/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ + +export const FLEET_DR_INTERVAL_MS = 80; + +/** @constant {number} Max ms between rotation passes while the camera is idle. */ + +export const ROTATION_REFRESH_MS = 1000; + +/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary + * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ + +export const COURSE_MAX_DPS = 60; + +/** Never spend a long render stall's full elapsed time in one visible course step. */ + +export const COURSE_SLEW_DT_MAX_SEC = 0.25; + +// -- Tracked-display reconciliation (see flights.js for the rationale): absorb a raw +// position step (warm-up→interpolation handoff, feed glitch, backfill splice) into a +// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked icon, +// camera, and trail head never visibly jump — with zero steady-state lag. -- + +export const DR_CORRECTION_MS = 900; + +/** Bounded on-demand loading for the tracked model (mirror of flights.js). The + * tracked regime is DEFAULT-ON and its driver runs every `scene.preUpdate`, so + * a missing or corrupt GLB — or a dead network — would otherwise spin + * load→reject at frame rate for as long as the contact stays selected. + * Failures are counted PER SELECTION: a short backoff absorbs a transient + * blip, then the layer stops asking until the operator selects something else. + * The billboard is the visual throughout (the handoff only fades it once a + * model actually renders), so a latched failure degrades to exactly the + * pre-3D presentation. */ + +export const TRACKED_MODEL_MAX_LOAD_FAILS = 3; + +export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; + +/** Boost flip = BATCHED release through the fleet tick (mirror of flights.js — + * destroying the whole fleet synchronously in the style handler stalls the + * render thread). Models tagged with their load-time boost state; stale queue + * entries skip; in-flight loads invalidated immediately; tracked reloads + * synchronously (single primitive). */ + +export const IR_RELOAD_BATCH = 40; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js new file mode 100644 index 000000000..a1e545d6d --- /dev/null +++ b/src/layers/military/queries.js @@ -0,0 +1,819 @@ +import { displayedKinematics } from '../../data/motionModel.js'; +import * as Cesium from 'cesium'; +import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; +import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; +import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; +import { + rankContactMatch, + contactMatchWins, + CONTACT_MATCH_TIER, +} from '../../data/contactMatch.js'; +import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; + +export function createQueries({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; + const { formatFlightLevel } = services.labels; + const { applyTrackedCameraFrame } = services.camera; + + /** + * Coerce a value to a finite number, returning null if not possible. + * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). + * @param {*} value - Raw value from the API response + * @returns {number|null} Finite number or null + */ + + function _toFiniteNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const n = Number.parseFloat(value.trim()); + return Number.isFinite(n) ? n : null; + } + return null; + } + + /** + * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. + * @param {*} value - Raw value to stringify + * @returns {string} Trimmed string + */ + + function _toCleanText(value) { + return String(value || '').trim(); + } + + /** + * Format an altitude value in feet for display, with fallback text. + * @param {number|null|undefined} altitudeFt - Barometric altitude in feet + * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") + */ + + function _formatAltitude(altitudeFt) { + if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; + return `${Math.round(altitudeFt)} ft`; + } + + /** + * True when the aircraft's latest metadata reads "on or about the runway" + * (low + slow) — see the landed fast-cull rationale above. Both gates must + * hold, so a plane missing either datum keeps the normal grace. + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @returns {boolean} + */ + + function _likelyLanded(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return false; + // Round 7 (mirror of flights.js): fast cull only for contacts seen + // AIRBORNE this session — parked contacts ride the normal grace so feed + // flaps don't churn their identity/floor state. + if (info.wasAirborne !== true) return false; + if (info.onGround) return true; + return ( + Number.isFinite(info.altitudeFt) && + info.altitudeFt < LANDED_ALT_MAX_FT && + Number.isFinite(info.speedMps) && + info.speedMps < LANDED_SPEED_MAX_MPS + ); + } + + /** + * Build a public descriptor for one aircraft using its best current position + * (dead-reckoned when history exists, billboard position otherwise). + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Descriptor with a cloned position, or null if the aircraft is unknown. + */ + + function _describeFlight(icao24) { + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + const basePos = + parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + if (!basePos) return null; + const displayed = displayedKinematics({ + derivedSpeedMps: flightState._drSpeedMps, + derivedTrackDeg: flightState._drCourseDeg, + reportedSpeedMps: info?.speedMps, + reportedTrackDeg: info?.track, + }); + const carto = Cesium.Cartographic.fromCartesian( + basePos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return null; + return { + icao24, + callsign: _toCleanText(info?.callsign) || null, + // Additive: the label chain's middle link, trimmed like `callsign`, so + // getTrackedSubject and the voice narration can read it straight off the + // descriptor instead of reaching back into `_flightData`. + registration: _toCleanText(info?.registration) || null, + position: Cesium.Cartesian3.clone(basePos), + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + // Keep the cockpit readout on the reported aviation altitude. Render + // terrain height is a separate visual datum and may be below zero. + altitudeM: Number.isFinite(info?.altitudeM) + ? info.altitudeM + : carto.height, + renderAltitudeM: Number.isFinite(info?.renderAltitudeM) + ? info.renderAltitudeM + : carto.height, + onGround: info?.onGround === true, + velocityMps: displayed.speedMps, + track: displayed.trackDeg, + stale: Boolean( + flightState._missingPolls.get(icao24) || flightState._backoff, + ), + }; + } + + function _normalizeTrackedIcao(candidate) { + const normalized = String(candidate ?? '') + .trim() + .toLowerCase(); + return normalized || null; + } + + /** + * Map one military aircraft's internal poll record to a plain JSON-safe + * analyst record (analyst query engine seam) — same shape as the flights + * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium + * types, no fetches. Missing/unknown fields are null, never NaN/undefined. + * adsb.lol carries no origin-country field and this layer has no route + * enrichment, so originCountry/routeOrigin/routeDestination are always null. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, + * lon: number|null, altitudeM: number|null, speedMps: number|null, + * heading: number|null, verticalRateMps: number|null, onGround: boolean, + * military: boolean, aircraftClass: string|null, originCountry: null, + * operator: string|null, routeOrigin: null, routeDestination: null}} + */ + + function mapAnalystRecord(icao24, info) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const callsign = text(info?.callsign); + return { + id: callsign || text(info?.registration) || icao24, + icao24, + callsign, + lat: num(info?.rawLat), + lon: num(info?.rawLon), + // altitudeFt is the sticky barometric/MSL aviation field — converted to + // meters here for shape parity with the flights layer. + altitudeM: Number.isFinite(info?.altitudeFt) + ? info.altitudeFt * 0.3048 + : null, + speedMps: num(info?.speedMps), + heading: num(info?.track), + verticalRateMps: num(info?.verticalRateMps), + onGround: info?.onGround === true, + military: true, + // A converted contact reports the class it RENDERS as (mirror of + // flights.js), so an analyst filter/superlative agrees with the triangle. + aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), + originCountry: null, + operator: text(info?.operator), + routeOrigin: null, + routeDestination: null, + }; + } + const methods = { + mapAnalystRecord, + + id: 'military', + + name: 'Military Flights', + + icon: '🎖️', + + source: flightState._lastSource, + + /** @type {number} Polling interval in ms between API fetches */ + updateInterval: 15000, + + /** @deprecated Compatibility alias for {@link enable}. */ + show(viewer) { + this.enable(viewer); + }, + + /** @deprecated Compatibility alias for {@link disable}. */ + hide(viewer) { + this.disable(viewer); + }, + + /** + * Live layer params. + * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, + * surrounding military aircraft become 3D models once the camera is zoomed in past + * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by + * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). + * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). + * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedMilitaryTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + if ( + typeof params.models3d === 'boolean' && + params.models3d !== flightState._models3dEnabled + ) { + flightState._models3dEnabled = params.models3d; + if (!flightState._models3dEnabled) { + parts.rendering._releaseModels(); + parts.tracking._syncTracked2dRotation(); + // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked + // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). + if (flightState._billboardCollection) + for (const [icao, bb] of flightState._billboards) { + if (icao !== flightState._trackedIcao) bb.show = true; + } + } + } + if ( + (params.models3dMode === 'proximity' || + params.models3dMode === 'all') && + params.models3dMode !== flightState._models3dMode + ) { + // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. + flightState._models3dMode = params.models3dMode; + if (flightState._cockpitContactMode) { + parts.tracking._refreshCockpitNearContacts(); + flightState._lastFleetTickMs = 0; + } + } + if ( + typeof params.irBoost === 'boolean' && + params.irBoost !== flightState._irBoost + ) { + flightState._irBoost = params.irBoost; + parts.rendering._reloadModelsForIrBoost(); + // Sprites don't reload with the models — swap the TR-3B glyph between its + // cold and thermal-reactive variants directly (mirror of flights.js). + parts.tracking._refreshTr3bForStyle(); + } + if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { + const requested = _normalizeTrackedIcao( + params.selectedMilitaryTrackingId, + ); + if (requested === flightState._trackedIcao) { + flightState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++flightState._trackingIntentGeneration; + flightState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + getParams() { + return { + models3d: flightState._models3dEnabled, + models3dMode: flightState._models3dMode, + irBoost: flightState._irBoost, + selectedMilitaryTrackingId: flightState._trackedIcao, + }; + }, + + /** + * Re-render a contact whose TR-3B conversion just flipped (Easter egg). + * Callers own the registry write; this only re-derives what renders. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when this layer owns the contact. + */ + refreshTr3b(icao24) { + return parts.tracking._refreshTr3bContact(icao24); + }, + + /** + * Find military aircraft near a given ECEF position, sorted by distance. + * Used by proximity-based features (e.g. detection overlay, spatial queries). + * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates + * @param {number} range - Maximum distance in meters (Infinity if not specified) + * @param {number} [maxCount=50] - Maximum number of results to return + * @param {object} [options] Query membership options. + * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. + * @returns {Array} Sorted array of nearby aircraft descriptors + */ + getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { + if ( + !center || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 50; + const maxRange = + Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; + + const now = Cesium.JulianDate.now(); + const nearby = []; + + for (const [icao24, bb] of flightState._billboards) { + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + if ( + !aircraftIncludedInNearby({ + isTracked, + billboardShown: bb.show, + modelRendering: parts.rendering._modelOwnsVisual(icao24), + includeHidden, + }) + ) + continue; + + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const pos = trackedPos || bb.position; + if (!pos) continue; + + const distance = Cesium.Cartesian3.distance(center, pos); + if (distance > maxRange) continue; + + const info = flightState._flightData.get(icao24); + nearby.push({ + id: info?.callsign?.trim() || info?.registration?.trim() || icao24, + icao24, + position: pos, + distance, + // Filter surface (mirror of flights.js): the cockpit next/previous path + // matches on this field, so it follows the conversion. + aircraftClass: tr3bAircraftClass( + icao24, + String(info?.klass || info?.type || '') + .trim() + .toLowerCase() || null, + ), + track: info?.track ?? null, + // Display type — converted too, so a Contacts row can't still name the + // airframe the triangle replaced (the filter matcher reads this as a + // fallback candidate as well). + type: tr3bTypeLabel(icao24, info?.type || null), + registration: info?.registration || null, + operator: info?.operator || null, + altitudeFt: info?.altitudeFt ?? null, + }); + } + + nearby.sort((a, b) => a.distance - b.distance); + return nearby.slice(0, limit); + }, + + /** + * Return a subset of aircraft suitable for the detection overlay system. + * Uses a deterministic stride-based sampling to keep the count manageable + * while distributing selections evenly across the collection. + * @param {Object} [options={}] - Options + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) + * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : flightState._billboards.size; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Deterministic stride: evenly space selections across the billboard map + const stride = Math.max( + 1, + Math.ceil(flightState._billboards.size / maxCount), + ); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [icao24, bb] of flightState._billboards) { + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if ( + flightState._cockpitContactMode && + icao24.toLowerCase() === flightState._cockpitSubjectId + ) + continue; + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so the detection box + // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + const model = flightState._models.get(icao24); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!isTracked && !bb.show && !modelOwnsVisual) continue; + const info = flightState._flightData.get(icao24); + const callsign = info?.callsign?.trim(); + const registration = info?.registration?.trim(); + let object = flightState._detectionObjects.get(icao24); + if (!object) { + object = { + sourceId: icao24, + type: 'AIR', + tier: 'military', + _weldPos: new Cesium.Cartesian3(), + }; + flightState._detectionObjects.set(icao24, object); + } + // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned + // contacts read the translation the fleet tick already wrote into their + // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket + // are co-located anyway. + const spec = modelOwnsVisual + ? parts.rendering._modelSpec(info?.klass) + : null; + const pos = isTracked + ? parts.motion._trackedVisualCached() || bb.position + : modelOwnsVisual + ? modelVisualAnchor( + model.modelMatrix, + spec.visualCenterNative, + Number.isFinite(model.computedScale) + ? model.computedScale + : spec.scale, + object._weldPos || (object._weldPos = new Cesium.Cartesian3()), + ) + : bb.position; + if (!pos) continue; + object.position = pos; + object.skipLabel = isTracked; + const id = callsign || registration || icao24; + if (object.id !== id) object.id = id; + // Card surface: detectionDraw composes its secondary line from + // `[src.klass, src.metric]`, so a converted contact's card must name the + // TR-3B, not the airframe underneath it. + const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); + if (object.klass !== klass) object.klass = klass; + const altitudeFt = info?.altitudeFt ?? 0; + if (object._altitudeFt !== altitudeFt) { + object._altitudeFt = altitudeFt; + // military metadata stores altitudeFt (feet); the helper takes metres + object.metric = formatFlightLevel(altitudeFt * 0.3048); + } + result.push(object); + if (result.length >= maxCount) break; + } + if ( + flightState._detectionObjects.size > + flightState._billboards.size + 512 + ) { + for (const icao24 of flightState._detectionObjects.keys()) { + if (!flightState._billboards.has(icao24)) + flightState._detectionObjects.delete(icao24); + } + } + return result; + }, + + /** + * Find a single aircraft by free-text query. + * Match priority: exact icao24 hex, exact callsign, callsign prefix, + * then callsign substring (all case-insensitive, trimmed). + * @param {string} query - ICAO hex or full/partial callsign. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Best match with a cloned, dead-reckoned position, or null if none. + */ + findByQuery(query) { + if (!flightState._flightData || flightState._flightData.size === 0) + return null; + const q = String(query || '') + .trim() + .toLowerCase(); + if (!q) return null; + + // Registration is searched alongside callsign because it is what the + // operator SEES: a callsign-less contact reads as its tail number on the + // card, in the analyst's answer, and in the Contacts list. Matching only + // callsigns meant "follow 6606" — and the analyst → track_entity handoff + // the tool instructions prescribe — answered "nothing matched" for the + // very identity the app had just shown (owner field session 2026-08-21, + // 23:48: three failed track_entity retries before a fallback stuck). + // Ranking is shared with the flights layer (contactMatch.js) so the two + // cannot disagree, and it is strictly tiered so a registration can never + // out-rank a real callsign on feed order alone. + let best = null; + for (const [icao24, info] of flightState._flightData) { + const candidate = { + tier: rankContactMatch({ + query: q, + hex: icao24, + callsign: info?.callsign, + registration: info?.registration, + }), + id: icao24, + }; + if (!contactMatchWins(candidate, best)) continue; + best = candidate; + if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; + } + return best ? _describeFlight(best.id) : null; + }, + + /** + * Return id/label/position for up to maxCount currently rendered aircraft. + * Cheap snapshot for voice-tool framing — billboard positions only, no + * dead reckoning and no cloning. + * @param {number} [maxCount=500] - Maximum number of entries to return. + * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} + */ + /** + * Whether this layer still carries a contact, in O(1). + * + * Mirror of `flights.hasContact`: presence consumers must not infer absence + * from the capped `getAllPositions` rows. Id matching follows trackById — + * exact key first, then lowercase. + * A disabled layer keeps its records but hides the collection, so it must + * decline rather than answer from data the user can no longer see. + * @param {string} icao24 Contact identifier. + * @returns {boolean|null} Presence, or null when the layer is disabled or + * holds no data and therefore cannot answer. + */ + hasContact(icao24) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._billboards.size === 0 + ) + return null; + if (!icao24) return false; + const id = String(icao24).trim(); + return ( + flightState._billboards.has(id) || + flightState._billboards.has(id.toLowerCase()) + ); + }, + + getAllPositions(maxCount = 500) { + if ( + !flightState._billboardCollection || + flightState._billboards.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + + const result = []; + for (const [icao24, bb] of flightState._billboards) { + const pos = bb.position; + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) continue; + const info = flightState._flightData.get(icao24); + result.push({ + id: icao24, // identity (trackById/Context resolve this) + // Last surface still reading as the raw hex for a callsign-less + // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. + label: + _toCleanText(info?.callsign) || + _toCleanText(info?.registration) || + icao24, + position: pos, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + }); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory 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 ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._flightData.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const [icao24, info] of flightState._flightData) { + result.push(mapAnalystRecord(icao24, info)); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Start camera-tracking an aircraft by ICAO hex identifier. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @returns {boolean} True if the aircraft exists and tracking started. + */ + trackById(icao24, { origin = 'programmatic' } = {}) { + if (!icao24) return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._billboards.has(id)) return false; + if (parts.tracking._isExplicitTrackingOrigin(origin)) + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao === id) + return parts.tracking._publishTrackedSelection(id, origin); + parts.tracking._trackFlight(id, { origin }); + return true; + }, + + /** Resolve a shared Follow target only against the latest accepted refresh. */ + async resolveTrackingRestoreTarget( + icao24, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = String(icao24 ?? '') + .trim() + .toLowerCase(); + if (!id) return { status: 'missing', reason: 'invalid-target' }; + const outcome = flightState._lastTrackingRefreshOutcome; + if (outcome.status !== 'accepted') { + return { + status: 'source-unavailable', + reason: `${flightState._lastSource} snapshot unavailable`, + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (!outcome.ids.has(id)) { + return { + status: 'missing', + reason: 'target-absent-from-snapshot', + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const followed = this.trackById(id, { origin }); + return followed + ? { + status: 'found', + refreshEpoch: outcome.epoch, + source: outcome.source, + } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }, + + /** Reapply the canonical follow frame without recreating the selected flight. */ + refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { + if ( + !icao24 || + flightState._cockpitContactMode || + !flightState._viewer || + !flightState._trackedEntity + ) + return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if ( + id !== flightState._trackedIcao || + !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) + return false; + flightState._trackedCameraFrameStop?.(); + flightState._viewer.camera.cancelFlight(); + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + parts.tracking._publishTrackedSelection(id, origin); + return true; + }, + + /** + * Stop tracking the currently followed aircraft (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Describe the currently tracked aircraft at its dead-reckoned position. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Tracked aircraft info, or null when nothing is tracked. + */ + getTrackedInfo() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described) return null; + const { position, ...rest } = described; + return rest; + }, + + /** + * Return the current aircraft as a Context subject without changing + * tracking or camera ownership. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached subject descriptor, or null when no aircraft is tracked. + */ + getTrackedSubject() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described?.position) return null; + return { + layerId: 'military', + id: described.icao24, + // Same label chain as getNearby/getDetectableObjects: a callsign-less + // contact reads as its registration, never as the raw ICAO hex. + label: described.callsign || described.registration || described.icao24, + position: Cesium.Cartesian3.clone(described.position), + }; + }, + + /** + * Return current layer health/status for the HUD status chip. + * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} + */ + getStats() { + const retryInSec = flightState._retryAt + ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) + : 0; + return { + count: flightState._count, + lastUpdate: flightState._lastUpdate, + stale: flightState._backoff, + error: flightState._lastError, + status: flightState._lastStatus, + retryInSec, + source: flightState._lastSource, + fallback: false, + }; + }, + }; + + return { + _toFiniteNumber, + _toCleanText, + _formatAltitude, + _likelyLanded, + _describeFlight, + _normalizeTrackedIcao, + mapAnalystRecord, + methods, + }; +} diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js new file mode 100644 index 000000000..1e5dbb573 --- /dev/null +++ b/src/layers/military/rendering.js @@ -0,0 +1,1118 @@ +import { + CLASS_MODEL_REAL, + CLASS_SCALE_3D, + CLASS_SCALE_2D, +} from '../../data/aircraftClass.js'; +import { + visualCenterForModel, + trailAnchorForModel, +} from '../../data/modelVisualAnchor.js'; +import * as Cesium from 'cesium'; +import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { + cameraPoseSignature, + horizonOccluder, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { + PLANE_MODEL_SCALE, + PLANE_MODEL_URL, + PLANE_NATIVE_RADIUS_M, + PLANE_BELLY_OFFSET_NATIVE, + MODEL_SCALE, + JET_MODEL_URL, + MODEL_NATIVE_RADIUS_M, + MODEL_BELLY_OFFSET_NATIVE, + MODEL_HEADING_OFFSET_DEG, + BILLBOARD_SCALE, + GROUND_SCALE, + COCKPIT_CONTACT_SIZE_PX, + MIL_ICON_COLOR, + TRACKED_ICON_COLOR, + MODEL_ALT_CEIL_M, + MODEL_MAX_ALL, + MODEL_MAX, + COCKPIT_MODEL_MAX, + MODEL_ALL_ADD_M, + MODEL_PROX_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_PROX_KEEP_M, + IR_RELOAD_BATCH, + MODEL_MIN_PX, + MODEL_COLOR_BLEND_AMOUNT, + TRACKED_MODEL_MIN_PX, + TRACKED_MODEL_MAX_PX, + FLEET_DR_INTERVAL_MS, + COURSE_SLEW_DT_MAX_SEC, + ROTATION_REFRESH_MS, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createRendering({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bIconKind, isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { + focusNowMs, + getFocusTarget, + nearFarScalarValueAtDistance, + advanceProjectedSpriteFocus, + } = services.focus; + const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = + services.recession; + + /** Depth-test policy for aircraft billboards (mirror of flights.js — see the + * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact + * renders depth-test-free at every distance — a uniform always-visible rule; + * the fleet tick's horizon occluder still removes far-side contacts. */ + + function _groundDepthDistance() { + return Number.POSITIVE_INFINITY; + } + + function _modelSpec(klass) { + let spec = flightState._specCache.get(klass); + if (spec) return spec; + const real = CLASS_MODEL_REAL[klass]; + if (real) { + spec = { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } else if ( + klass === 'airliner' || + klass === 'quadjet' || + klass === 'glider' + ) { + const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: PLANE_MODEL_URL, + scale, + nativeRadiusM: PLANE_NATIVE_RADIUS_M, + bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), + trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), + }; + } else { + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: JET_MODEL_URL, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + visualCenterNative: visualCenterForModel(JET_MODEL_URL), + trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), + }; + } + flightState._specCache.set(klass, spec); + return spec; + } + + function _normalBillboardScaleByDistance() { + // Match commercial flights and preserve the established close-range 3× + // default. Smaller owner-visible scaling must be proposed separately. + return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); + } + + function _cockpitBillboardScaleByDistance() { + return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); + } + + function _militaryBillboardScale(icao24) { + const meta = flightState._flightData.get(icao24); + return ( + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1) + ); + } + + /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ + + function _applyFleetBillboardPresentation(icao24, bb) { + if (!bb) return; + const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; + const isCockpitContact = + flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; + const isCockpitNear = + isCockpitContact && flightState._cockpitNearContacts.has(icao24); + if (isCockpitContact && !isCockpitNear) { + const freshnessAlpha = bb.color?.alpha ?? 1; + bb.image = cockpitContactDotImage(); + bb.width = COCKPIT_CONTACT_SIZE_PX; + bb.height = COCKPIT_CONTACT_SIZE_PX; + bb.scale = limbScale; + bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.rotation = 0; + return; + } + + const meta = flightState._flightData.get(icao24); + bb.image = aircraftIcon( + _iconKind(icao24, meta?.klass), + bb._gevIconLarge ? TRACKED_ICON_PX : undefined, + ); + bb.width = icao24 === flightState._trackedIcao ? 24 : 20; + bb.height = icao24 === flightState._trackedIcao ? 24 : 20; + bb.scale = _militaryBillboardScale(icao24) * limbScale; + bb.scaleByDistance = _normalBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); + } + + /** Sprite kind for one contact's billboard. Identity for every aircraft except + * the ones the operator converted into a TR-3B (Easter egg), which draw the + * black-triangle glyph — its thermal-reactive variant while an IR style owns + * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a + * conversion survive the poll reconciler and the two-tier raster swap. */ + + const _iconKind = (icao24, klass) => + tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); + + /** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ + + function _modelColor(icao24) { + if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; + return MIL_ICON_COLOR; + } + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). Mirror of flights.js. */ + + function _modelRegimeActive() { + if (!flightState._models3dEnabled) return false; + const h = + flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the + * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. + return flightState._cockpitContactMode + ? Math.min(COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ + + function _modelAddDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_ADD_M + : MODEL_PROX_ADD_M; + } + + function _modelKeepDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_KEEP_M + : MODEL_PROX_KEEP_M; + } + + /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into + * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable + * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on + * the last-written transform, and the per-frame tracked write made it flicker). */ + + function _modelMatrix( + pos, + headingDeg, + result = flightState._scratchModelMtx, + offsetDeg = MODEL_HEADING_OFFSET_DEG, + ) { + flightState._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + offsetDeg, + ); + flightState._scratchModelHpr.pitch = 0; + flightState._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + flightState._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of + * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its + * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches + * picked-object IDs — every billboard AND model here carries its icao as `id` — plus + * the tracked entity object itself. Built lazily, only when a sample actually fires. */ + + function _groundSampleExclusions() { + const out = [...flightState._billboards.keys()]; + if (flightState._trackedEntity) out.push(flightState._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). + * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin + * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly + * offset so they sit on their + * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the + * depth-test-free billboard visible and the model hidden while this returns null; a + * contact that has already resolved once holds that measurement through a later + * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = flightState._flightData.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = flightState._groundSnap.heightFor( + flightState._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its billboard to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the billboard owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + if (!model) { + bb.show = true; + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + bb.show = true; + return false; + } + // The matrix is written BEFORE the readiness test on purpose: a model can flip + // `ready` during scene update after this tick, and a first rendered frame on a + // stale load-start matrix is the one-frame jump this ordering prevents. + _modelMatrix( + displayPos, + course, + model.modelMatrix, + _modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg, + ); + if (!model.ready) { + model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + bb.show = true; + return false; + } + beforeShow?.(); + if (!model.show) model.show = true; + if (bb.show) bb.show = false; // hand off ONLY once the model renders + return true; + } + + /** A model owns the visual only while it is ready, shown, and (for the tracked + * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ + + function _modelOwnsVisual(icao24) { + if (icao24 === flightState._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(flightState._trackedModel) + ); + } + return _modelIsRendering(flightState._models.get(icao24)); + } + + /** `show` is sufficient evidence of a safe placement because only the handoff and + * the tracked driver ever set it true, and both do so after committing a matrix; + * everything else — admission, unresolved ground, an unready glTF, the limb cull, + * a regime exit — only ever clears it. Mirror of flights.js. */ + + function _modelIsRendering(model) { + return !!model && model.ready === true && model.show === true; + } + + /** Spec identity for a LOADED model — mirror of flights.js: URL and scale + * together (same-URL classes differ by scale). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the class, re-showing the + * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const current = flightState._models.get(icao24); + if ( + (current && current._gevSpecKey !== key) || + (!current && flightState._modelPending.has(icao24)) + ) { + const bb = flightState._billboards.get(icao24); + if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao24); + } + if ( + icao24 === flightState._trackedIcao && + flightState._trackedModel && + flightState._trackedModel._gevSpecKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + flightState._irReloadQueue = [...flightState._models.keys()]; + for (const icao of flightState._modelPending) { + if (!flightState._models.has(icao)) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + if (!flightState._irReloadQueue) return; + const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); + for (const icao of batch) { + const model = flightState._models.get(icao); + if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state + const bb = flightState._billboards.get(icao); + if (bb && icao !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao); + } + if (flightState._irReloadQueue.length === 0) + flightState._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === flightState._trackedIcao) return; + if ( + flightState._models.has(icao24) || + flightState._modelPending.has(icao24) + ) + return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). + if ( + flightState._models.size + flightState._modelPending.size >= + _modelCap() + ) + return; + const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + flightState._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START (mirror of flights.js): a mid-load + // reclassification makes the post-await admission reject the stale asset. + // Boost state likewise — creation options bake it in. + const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const loadIrBoost = flightState._irBoost; + try { + const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === flightState._modelEpoch) { + flightState._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== flightState._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + flightState._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (flightState._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() || + !flightState._flightData.has(icao24) || + icao24 === flightState._trackedIcao || + flightState._models.has(icao24) || + flightState._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + flightState._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // Keep the pick identity explicit on the resolved primitive. This also + // protects injected/custom loaders that do not copy the creation option. + model.id = icao24; + model._gevSpecKey = specKey; // class-change sync compares against this + model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this + // Admitted, not yet the visual. Cesium's default is show=true, which would let + // an unplaced primitive claim ownership from the billboard for the frames + // between admission and the next fleet tick (and draw at the identity matrix, + // i.e. the Earth's centre). The handoff turns it on once it has a matrix. + model.show = false; + flightState._modelCollection.add(model); + flightState._models.set(icao24, model); + flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / track handoff). + * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ + + function _releaseModel(icao24) { + const m = flightState._models.get(icao24); + const pending = flightState._modelPending.has(icao24); + // Only bump the generation when there's something to invalidate (an in-flight load or a + // live model) — so removing never-modeled aircraft doesn't grow _modelGen. + if (m || pending) { + flightState._modelGen.set( + icao24, + (flightState._modelGen.get(icao24) || 0) + 1, + ); + } + if (m) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + flightState._models.delete(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if ( + !flightState._modelPending.has(icao24) && + !flightState._models.has(icao24) + ) + flightState._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + for (const icao of flightState._modelPending) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + for (const m of flightState._models.values()) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + } + flightState._models.clear(); + } + + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ + + function _releaseTrackedModel() { + flightState._trackedModelGen++; + flightState._trackedModelLoading = false; + if (flightState._trackedModel) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(flightState._trackedModel); + } catch { + /* gone */ + } + } + flightState._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The + * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ + + function _updateTrackedModel() { + const active = + flightState._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + flightState._modelCollection && + !flightState._modelCollection.isDestroyed(); + if (!active) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(flightState._trackedIcao) || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + if ( + !flightState._trackedModel && + !flightState._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + flightState._trackedModelLoading = true; + const gen = flightState._trackedModelGen; + const trackedSpec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedKey = _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedIrBoost = flightState._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) + .then((m) => { + if ( + gen !== flightState._trackedModelGen || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ) !== trackedKey || + flightState._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + flightState._trackedModelLoading = false; + return; + } + // Assign after resolution as well as in the creation options so the + // standalone primitive always exposes the tracked aircraft pick id. + m.id = flightState._trackedIcao; + m._gevSpecKey = trackedKey; // class-change sync compares against this + m.show = false; // admitted, not yet the visual — the driver shows it once placed + // Seed the world transform before the primitive enters the scene. A model + // can become ready+shown between render phases; leaving Cesium's identity + // default here produces a one-frame jump to the Earth's center. + const currentPos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (currentPos) { + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + currentPos, + flightState._scratchGroundPos, + ); + if (displayPos) { + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + m.modelMatrix, + trackedSpec.headingOffsetDeg, + ); + } + } + flightState._trackedModel = m; + flightState._trackedModelLoading = false; + // A good load retires this selection's failure budget — a contact that + // recovers after a transient blip is not one attempt from giving up. + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + flightState._modelCollection.add(m); + flightState._planeModelLoaded = true; + }) + .catch((err) => { + if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure + flightState._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; + } + if (flightState._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + return; + } + const spec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + flightState._trackedModel.modelMatrix, + spec.headingOffsetDeg, + ); + if (!flightState._trackedModel.ready) return; + flightState._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, + fovyRad: flightState._viewer.camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + flightState._trackedModel.show = true; + } + } + + /** + * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, + * horizon-culls billboards beyond the limb (no far-side depth with the globe + * hidden), and refreshes screen-projected icon rotations whenever the camera + * pose changed (plus a 1s drift catch-up while idle). Driven by + * scene.preRender — NOT camera.changed, whose granularity is globally + * degraded by other layers mutating camera.percentageChanged. + * @returns {void} + */ + + function _fleetTick() { + if ( + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + const scene = flightState._viewer.scene; + const camera = flightState._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz + // primitive rebuild here. The body rebuilds only when a real fix arrives.) + + if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; + const tickDtSec = flightState._lastFleetTickMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._lastFleetTickMs) / 1000, + ) + : 0.08; + flightState._lastFleetTickMs = nowMs; + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (flightState._cockpitContactMode) + parts.tracking._refreshCockpitNearContacts(); + const poseSig = cameraPoseSignature(camera); + // Only nearby Cockpit silhouettes need projected course; far dots remain + // rotation-free through the per-contact gate below. + const doRotations = + poseSig !== flightState._lastCamPoseSig || + nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; + if (doRotations) { + flightState._lastCamPoseSig = poseSig; + flightState._lastRotPassMs = nowMs; + } + + const occluder = horizonOccluder(camera); + const focusTarget = getFocusTarget(); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if ( + !useModels && + (flightState._models.size || flightState._modelPending.size) + ) + _releaseModels(); + + // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — + // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen + // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP + // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). + let modelEligible = null; + if (useModels) { + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + const cand = []; + for (const [icao, bb] of flightState._billboards) { + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as a billboard and can never take a model, so + // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded + // at selection time, not just at the handoff below. + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded + // placement is handled by the one-shot ground snap in _modelDisplayPosition). + const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + if (d2 > keepDistSq) continue; // beyond keep radius → never eligible + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`, + ); + flightState._lastModelCapWarnMs = nowMs; + } + modelEligible = new Set(); + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 1. KEEP on-screen + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 2. ADD on-screen + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 3. KEEP off-screen (can't starve visible) + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 4. ADD off-screen leftover + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + } + + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + + const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + // Gate the write — assigning Billboard.position dirties the whole + // collection's vertex buffer, so skip sub-meter moves. + if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { + bb.position = dr; + } + + // Round 6: occlusion-test a LIFTED point for contacts at/below the + // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb + // read "beyond the horizon" and would hide low contacts awaiting floors). + const beyondHorizon = !occluder.isPointVisible( + flightState._flightData.get(icao24)?.cullPosition || bb.position, + ); + // A billboard flipping INTO view (horizon reveal while the camera idles) + // gets its rotation refreshed THIS tick even without a pose change — + // otherwise it reappears wearing its stale (often creation-north) nose for + // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false + // here; harmless — the model branch below `continue`s past the rotation.) + const revealed = !beyondHorizon && !bb.show; + if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + if (beyondHorizon) { + // Also hide any 3D model — otherwise a model that crossed the limb would keep + // rendering through the hidden globe at its last matrix. + const m = flightState._models.get(icao24); + if (m && m.show) m.show = false; + continue; + } + + const info = flightState._flightData.get(icao24); + + // One sprite-owned write site composes freshness × focus × limb haze and + // base class/ground scale × limb taper. The locked NearFarScalar remains + // untouched and multiplicative; there is no cull or zero-alpha path. + const cameraDistanceM = Cesium.Cartesian3.distance( + camera.positionWC, + bb.position, + ); + const distanceScale = nearFarScalarValueAtDistance( + bb.scaleByDistance, + cameraDistanceM, + ); + const focus = advanceProjectedSpriteFocus( + bb, + bb.position, + scene, + camera, + nowMs, + focusTarget, + undefined, + (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, + (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + ); + const isCockpitNear = + flightState._cockpitContactMode && + flightState._cockpitNearContacts.has(icao24); + const treatment = applyAircraftBillboardTreatment({ + billboard: bb, + baseScale: + flightState._cockpitContactMode && !isCockpitNear + ? 1 + : _militaryBillboardScale(icao24), + baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseColor: MIL_ICON_COLOR, + focusFactor: focus.factor, + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }); + flightState._billboardLimbScale.set(bb, treatment.factors.scale); + // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on + // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so + // focus/limb recession counts) with hysteresis (atlas has no mips). + if (!flightState._cockpitContactMode || isCockpitNear) { + const glyphDevPx = + (bb.width || 20) * + (bb.scale || 1) * + distanceScale * + (globalThis.devicePixelRatio || 1); + const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; + if (wantLarge !== !!bb._gevIconLarge) { + bb._gevIconLarge = wantLarge; + bb.image = aircraftIcon( + _iconKind(icao24, flightState._flightData.get(icao24)?.klass), + wantLarge ? TRACKED_ICON_PX : undefined, + ); + } + } + + // Smoothed display course: the path direction _deadReckon just reported + // for THIS aircraft (nothing else calls _deadReckon in between), rate- + // limited so segment-boundary course steps glide instead of snapping. + // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering + // aircraft (hold flag) keeps its previous nose direction outright. + const rawCourse = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : (info && info.track) || 0; + const prevCourse = flightState._displayCourse.get(icao24); + const course = + flightState._drCourseHold && prevCourse != null + ? prevCourse + : limitCourseStep( + prevCourse, + rawCourse, + courseSlewCapDps( + flightState._drSpeedMps != null + ? flightState._drSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ), + tickDtSec, + ); + flightState._displayCourse.set(icao24, course); + + // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the + // billboard stays shown until the model is actually READY to render, so a plane is never both + // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every + // tick regardless so it's framed the instant it becomes ready. + // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter + // egg IS the triangle and there is no GLB for it, so the model handoff is + // suppressed. The billboard keeps rendering, so the contact still satisfies + // the getNearby/getDetectableObjects visibility guards. + if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const ownsVisual = _driveFleetModelHandoff( + icao24, + model, + bb, + dr, + course, + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write; boosted + // models also skip the recession fade (mirror of flights.js — + // billboards keep their normal fade, hot MODELS stay full-strength). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost ? 1 : treatment.alpha, + }); + }, + ); + if (ownsVisual) continue; // skip billboard rotation + } + + if ( + (!flightState._cockpitContactMode || isCockpitNear) && + (doRotations || revealed) + ) { + const rot = screenProjectedRotation( + scene, + bb.position, + course, + bb.rotation, + ); + if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { + bb.rotation = rot; + } + } + } + } + return { + _groundDepthDistance, + _modelSpec, + _normalBillboardScaleByDistance, + _cockpitBillboardScaleByDistance, + _militaryBillboardScale, + _applyFleetBillboardPresentation, + _iconKind, + _modelColor, + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; +} diff --git a/src/layers/military/state.js b/src/layers/military/state.js new file mode 100644 index 000000000..8d5cc27cd --- /dev/null +++ b/src/layers/military/state.js @@ -0,0 +1,400 @@ +import * as Cesium from 'cesium'; + +export function createFlightState({ source, services }) { + const { createGroundSnap } = services.groundSnap; + const flightState = { lifetime: new AbortController() }; + + flightState._source = source; + + /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: + * military contacts should read as their WEIGHT CLASS, always in this layer's + * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → + * 180° offset); airliner/quadjet/glider get the shared 747 silhouette + * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); + * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays + * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, + * and the tint must dominate any livery (owner: "stays yellow"). Specs are + * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + + flightState._specCache = new Map(); + + /** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ + + flightState._groundSnap = createGroundSnap(); + + flightState._scratchGroundCarto = new Cesium.Cartographic(); + + flightState._scratchGroundPos = new Cesium.Cartesian3(); + + /** @type {Cesium.PrimitiveCollection|null} */ + + flightState._modelCollection = null; + + /** @type {Map} icao24 → model */ + + flightState._models = new Map(); + + /** @type {Set} icao24 currently loading (async) */ + + flightState._modelPending = new Set(); + + /** @type {Map} icao24 → load generation; bumped on release to invalidate + * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ + + flightState._modelGen = new Map(); + + /** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle + * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ + + flightState._modelEpoch = 0; + + /** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs + * layer-state restoration, so this initializer — not the codec — is what the app + * actually starts with; it must stay in lockstep with the `models3d` default in + * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would + * light a button the layer has not armed. */ + + flightState._models3dEnabled = true; + + flightState._models3dMode = 'proximity'; + // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) + + flightState._lastModelCapWarnMs = 0; + // throttle the "more planes in view than the cap" console notice + // The tracked entity's billboard goes transparent (rather than hidden) once the model takes + // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its + // alpha after the GLB is preloaded so the model is ready to render the instant the billboard + // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not + // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + + flightState._planeModelLoaded = false; + + /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + + flightState._preloadModel = null; + + flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + + flightState._scratchModelMtx = new Cesium.Matrix4(); + + flightState._scratchModelBS = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1.0, + ); + // frustum-visibility test + /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ + + flightState._billboardLimbScale = new WeakMap(); + + // --------------------------------------------------------------------------- + // Module-level state + // --------------------------------------------------------------------------- + + /** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ + + flightState._billboardCollection = null; + + /** @type {Map} ICAO hex -> billboard primitive */ + + flightState._billboards = new Map(); + + /** Stable lightweight records reused by the detection overlay between polls. */ + + flightState._detectionObjects = new Map(); + + /** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ + + flightState._flightData = new Map(); + + /** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ + + flightState._positionHistory = new Map(); + + /** @type {number} Current number of visible aircraft */ + + flightState._count = 0; + + /** @type {number|null} Epoch ms of last successful API update */ + + flightState._lastUpdate = null; + + /** @type {boolean} True when in error-backoff mode */ + + flightState._backoff = false; + + /** @type {number} Epoch ms after which the next retry is allowed */ + + flightState._retryAt = 0; + + /** @type {string|null} Human-readable description of the last error */ + + flightState._lastError = null; + + flightState._activeUpdateControllers = new Set(); + + /** @type {number|null} HTTP status code from the last API response */ + + flightState._lastStatus = null; + + flightState._lastSource = source?.label || 'Aircraft'; + + /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ + + flightState._geoidReady = false; + + /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ + + flightState._geoidNCache = new Map(); + + // -- Click-to-track state -- + /** @type {string|null} ICAO hex of the currently tracked aircraft */ + + flightState._trackedIcao = null; + + flightState._pendingTrackingRestore = null; + + flightState._trackingIntentGeneration = 0; + + flightState._trackingRefreshEpoch = 0; + + flightState._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: flightState._lastSource, + }; + + /** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ + + flightState._trackedEntity = null; + + /** Disposes the single active tracked-camera framing owner. */ + + flightState._trackedCameraFrameStop = null; + + /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on + * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere + * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ + + flightState._trackedModel = null; + + flightState._trackedModelGen = 0; + + flightState._trackedModelLoading = false; + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ + + flightState._clickHandler = null; + + /** @type {Cesium.Viewer|null} Cached viewer reference */ + + flightState._viewer = null; + + /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ + + flightState._cockpitContactMode = false; + + /** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ + + flightState._cockpitNearContacts = new Set(); + + /** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ + + flightState._cockpitSubjectId = null; + + /** @type {((event: CustomEvent) => void)|null} */ + + flightState._cockpitModeListener = null; + + /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ + + flightState._trail = null; + + /** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ + + flightState._trailHeadEntity = null; + + /** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ + + flightState._trailHeadSeq = 0; + + /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + + flightState._trailPositions = []; + + /** @type {number} Monotonic token — invalidates in-flight backfill responses */ + + flightState._trailBackfillToken = 0; + + /** @type {Map} icao24 -> consecutive missed polls */ + + flightState._missingPolls = new Map(); + + /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ + + flightState._lastFleetTickMs = 0; + + /** @type {string} Camera pose signature at the last rotation pass */ + + flightState._lastCamPoseSig = ''; + + /** @type {number} Epoch ms of the last full rotation pass */ + + flightState._lastRotPassMs = 0; + + /** @type {number} Last computed rotation for the tracked entity (radians) */ + + flightState._lastTrackedRotation = 0; + + /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + + flightState._preRenderRemove = null; + + flightState._trackedModelPreUpdateRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + + flightState._moveEndRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + flightState._trackedEntityChangedRemove = null; + + // -- Scratch variables (reused each frame to avoid GC pressure) -- + + flightState._scratchOffset = new Cesium.Cartesian3(); + + flightState._scratchCarto = new Cesium.Cartographic(); + + flightState._scratchEnu = new Cesium.Matrix4(); + + flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; + + flightState._scratchRenderTime = new Cesium.JulianDate(); + + flightState._scratchFleetPos = new Cesium.Cartesian3(); + + flightState._scratchDrRaw = new Cesium.Cartesian3(); + + flightState._scratchWarmupTime = new Cesium.JulianDate(); + + flightState._trackedPosHolder = new Cesium.Cartesian3(); + + // -- Per-frame dead-reckoning cache for the tracked entity -- + /** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ + + flightState._cachedDRPosition = null; + + /** @type {number} Frame number the cache was last populated for */ + + flightState._cachedDRFrame = -1; + + /** Course (deg) of the position `_deadReckon` most recently returned — set on + * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same + * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ + + flightState._drCourseDeg = null; + + /** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion + * `_deadReckon` just returned, and whether that motion is too slow for ANY + * course source to be trusted (hover/GPS drift — consumers HOLD their + * previous display course instead of chasing noise). */ + + flightState._drSpeedMps = null; + + flightState._drCourseHold = false; + + /** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ + + flightState._cachedDRCourse = null; + + /** Frame-cached siblings of _cachedDRCourse (same discipline). */ + + flightState._cachedDRSpeedMps = null; + + flightState._cachedDRHold = false; + + /** Wall-clock of the tracked course limiter's last advance (dt source only — + * the course VALUE lives in the shared per-icao _displayCourse map below). */ + + flightState._trackedCourseMs = 0; + + /** Per-aircraft smoothed display course — the SINGLE source of truth for the + * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 + * field fix). The fleet pass reads/writes it at tick cadence for untracked + * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for + * the tracked plane (the fleet pass skips the tracked icao, so exactly one + * writer owns an entry at a time). Sharing the entry — including its slew + * state — keeps the tracked↔fleet handoff seamless (separate states froze + * the fleet entry while tracked → nose flip on click / click-away). */ + + flightState._displayCourse = new Map(); + + flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); + + flightState._drCorrectionStartMs = 0; + + flightState._drPrevRaw = new Cesium.Cartesian3(); + + flightState._drPrevDisplay = new Cesium.Cartesian3(); + + flightState._drPrevMs = 0; + + flightState._drReconcileValid = false; + + /** @type {string|null} icao the reconciliation state currently belongs to */ + + flightState._drReconcileIcao = null; + + /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ + + flightState._trackedVisualPos = new Cesium.Cartesian3(); + + flightState._trackedTrailPos = new Cesium.Cartesian3(); + + /** Scratch for the tracked model's world-space origin (the envelope centre). */ + + flightState._scratchTrailClip = new Cesium.Cartesian3(); + + /** Scratch for the shortened trail-head start. Owned by the head-segment + * callback alone, so nothing else can overwrite it mid-frame. */ + + flightState._scratchTrailHead = new Cesium.Cartesian3(); + + /** Hysteresis latch for the tracked contact's zoom regime, plus the selection it + * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER + * ceiling instead of inheriting the previous target's looser EXIT band. */ + + flightState._trackedZoomLatched = false; + + flightState._trackedZoomLatchIcao = null; + + flightState._trackedModelFailIcao = null; + + flightState._trackedModelFailCount = 0; + + flightState._trackedModelRetryAtMs = 0; + + /** IR hot-target mode — mirror of flights.js: under the luminance-mapped + * NVG/FLIR post-styles every model renders flat white (hottest); the dominant + * amber/tracked tint restores on style exit. */ + + flightState._irBoost = false; + + /** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR + * field rounds): material-stage tint still sun-shades, so side-on planes + * read near-black under a high sun; UNLIT emits flat white regardless. + * customShader on a READY model is a silent no-op (field-verified), so the + * boost flips by release-and-reload — creation-time options carry it. */ + + flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }); + + flightState._irReloadQueue = null; + + /** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ + + flightState._scratchTrailCarto = new Cesium.Cartographic(); + return flightState; +} diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js new file mode 100644 index 000000000..670676f4f --- /dev/null +++ b/src/layers/military/testing.js @@ -0,0 +1,165 @@ +import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; + +export function createTesting({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** + * Seed only the mutable state needed to exercise tracked-card refreshes through + * the production military poll reconciler. Tests still call + * `militaryFlightsLayer.update()` rather than invoking the writer directly. + * @param {object} state + * @param {string} state.icao24 + * @param {object} state.entity + * @param {object} state.meta + * @param {object} state.billboard + * @param {object} state.billboardCollection + * @param {object} state.viewer + * @param {Array} [state.history=[]] + * @param {boolean} [state.tracked=true] + * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed + * by icao24, for the billboard-hidden/model-shown handoff state. + */ + + function _setTrackedMilitaryRefreshStateForTest({ + icao24, + entity, + meta, + billboard, + billboardCollection, + viewer, + history = [], + tracked = true, + models = [], + modelCollection = null, + }) { + flightState._viewer = viewer; + flightState._modelCollection = modelCollection; + flightState._billboardCollection = billboardCollection; + flightState._billboards = new Map([[icao24, billboard]]); + flightState._models.clear(); + for (const [key, model] of models) flightState._models.set(key, model); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map([[icao24, meta]]); + flightState._positionHistory = new Map([[icao24, history]]); + flightState._missingPolls = new Map(); + flightState._displayCourse.clear(); + flightState._geoidNCache.clear(); + flightState._trackedIcao = tracked ? icao24 : null; + flightState._trackedEntity = tracked ? entity : null; + flightState._trackedModel = null; + parts.tracking._cancelPendingTrackingRestore(); + flightState._trackedModelLoading = false; + // NOTE: deliberately does NOT reset the per-selection latches. They are + // production state owned by the tracking lifecycle (_resetTrackedSelectionState), + // and clearing them here would mask exactly the deselect→re-track hole this + // seam is used to test. + flightState._backoff = false; + flightState._retryAt = 0; + } + + /** Seed the authoritative snapshot outcome used by share-Follow tests. */ + + function _setMilitaryTrackingRefreshOutcomeForTest({ + status = 'accepted', + ids = [], + source = flightState._lastSource, + } = {}) { + const epoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch, + status, + ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), + source, + }; + } + + /** Add a cached contact so tests can model a target arriving on a later feed. */ + + function _addMilitaryTrackingCandidateForTest({ + icao24, + meta, + billboard, + history = [], + }) { + flightState._billboards.set(icao24, billboard); + flightState._flightData.set(icao24, meta); + flightState._positionHistory.set(icao24, history); + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingMilitaryTrackingRestoreForTest() { + return flightState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated feed refresh. */ + + function _applyPendingMilitaryTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Set the exact Cockpit subject through the production state transition for focused tests. */ + + function _setCockpitDetectionSubjectForTest(active, subjectId = null) { + parts.tracking._applyCockpitState({ active, subjectId }); + } + + /** Evaluate the TRACKED contact's zoom regime through the production predicate. + * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) + * and otherwise only observable through a live scene, so tests drive it here. */ + + function _trackedModelRegimeActiveForTest() { + return parts.tracking._trackedModelRegimeActive(); + } + + /** Run one frame of the production tracked-model driver (normally a + * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ + + function _updateTrackedModelForTest() { + return parts.rendering._updateTrackedModel(); + } + + /** Evaluate the production tracked-billboard handoff colour for focused tests. */ + + function _trackedBillboardColorForTest() { + return parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR; + } + + /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + + function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { + return parts.rendering._driveFleetModelHandoff( + icao24, + flightState._models.get(icao24), + flightState._billboards.get(icao24), + position, + course, + ); + } + + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + + async function _ensureFleetModelForTest(icao24) { + await parts.rendering._ensureModel(icao24); + return flightState._models.get(icao24) || null; + } + return { + _setTrackedMilitaryRefreshStateForTest, + _setMilitaryTrackingRefreshOutcomeForTest, + _addMilitaryTrackingCandidateForTest, + _pendingMilitaryTrackingRestoreForTest, + _applyPendingMilitaryTrackingRestoreForTest, + _setCockpitDetectionSubjectForTest, + _trackedModelRegimeActiveForTest, + _updateTrackedModelForTest, + _trackedBillboardColorForTest, + _driveFleetModelHandoffForTest, + _ensureFleetModelForTest, + }; +} diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js new file mode 100644 index 000000000..f4c6e2fc6 --- /dev/null +++ b/src/layers/military/tracking.js @@ -0,0 +1,1172 @@ +import * as Cesium from 'cesium'; +import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { trailHeadStart } from '../../data/modelVisualAnchor.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + bindTrackingClickGesture, + isTrackingSelectionGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import { + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + TRAIL_MAX_POINTS, + RENDER_DELAY_SEC, + TRAIL_COLOR, + BILLBOARD_SCALE, + GROUND_SCALE, + MIL_ICON_COLOR, + AMBER_TRANSPARENT, + TRACKED_ICON_COLOR, +} from './policy.js'; + +export function createTracking({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + selectTrackedSubjectContext, + refreshTrackedSubjectContext, + clearTrackedSubjectContext, + } = services.context; + const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = + services.aircraftPresentation; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = + services.groundFloor; + const { createTrail } = services.trails; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { clearFocusTarget } = services.focus; + const { applyTrackedCameraFrame } = services.camera; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'military', + id: icao24, + label: + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24, + position: Cesium.Cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; + } + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const info = flightState._flightData.get(icao24); + const label = described.callsign || described.registration || icao24; + return { + id: icao24, + layerId: 'military', + layerName: 'Military Flights', + source: flightState._lastSource, + label, + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: label, + operator: parts.queries._toCleanText(info?.operator) || '', + callsign: described.callsign || '', + registration: described.registration || '', + // Converted contacts report their class as TR-3B — the same override + // the readout and Contacts card show. + type: tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || '', + ), + altitude: described.onGround + ? 'on ground' + : parts.queries._formatAltitude(info?.altitudeFt), + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${Math.round(described.track)}°` + : '', + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; + } + + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } + + /** Refresh the AIR-only Cockpit near band independently from model admission. */ + + function _refreshCockpitNearContacts() { + if ( + !flightState._cockpitContactMode || + !flightState._viewer?.camera?.positionWC + ) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; + } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + Cesium.Cartesian3.distanceSquared( + flightState._viewer.camera.positionWC, + bb.position, + ), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; + } + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. + if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + } + + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); + } + + /** + * Build the multi-line presentation text for the protected tracked host card. + * Lines: callsign, type/registration, operator/altitude. + * While the plane is in its missed-poll grace (coasting on dead reckoning + * with sticky metadata) the first line carries a "· STALE" cue — the fleet's + * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity + * owns the visual), so without this the readout would present last-known + * altitude/speed as live. + * @param {Object|null} info - Flight metadata from _flightData + * @param {string} icao24 - ICAO hex identifier (fallback display name) + * @returns {string} Newline-separated label text + */ + + function _buildTrackedLabel(info, icao24) { + const stale = + flightState._missingPolls.get(icao24) || flightState._backoff + ? ' · STALE' + : ''; + const callsign = + (parts.queries._toCleanText(info?.callsign) || + parts.queries._toCleanText(info?.registration) || + icao24) + stale; + // Converted contacts report their class as TR-3B — that override is exactly + // what the Easter egg replaces the real type with. + const type = tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || 'Type unknown', + ); + const registration = + parts.queries._toCleanText(info?.registration) || 'Reg unknown'; + const operator = + parts.queries._toCleanText(info?.operator) || 'Operator unknown'; + const altitude = parts.queries._formatAltitude(info?.altitudeFt); + const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; + const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; + return [ + callsign, + `${type} · ${registration}`, + `${operator} · ${tail}`, + ].join('\n'); + } + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(flightState._flightData.get(icao24), icao24), + '#ffd166', + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) + return; + flightState._trackedEntity.billboard.image = aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ); + } + + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Mirror of flights.js: converting drops any 3D model so the triangle + * owns the visual, then the billboard, tracked entity, and card re-derive. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ + + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const modelled = flightState._billboards.get(id); + if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._viewer?.scene?.requestRender?.(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; + } + + /** Re-image every converted contact this layer owns (IR style flip). */ + + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); + } + } + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to this layer) never + * makes `_trackedIcao` *observably* change, so the guard never fires. Without + * the reset, a contact dropped inside the hysteresis band comes back as a + * MODEL above the ENTER ceiling, and a contact whose GLB had already failed + * out would never get its retries back. Mirror of flights.js. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the + * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET + * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live + * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the + * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km + * ceiling this used to inherit), hand back only above + * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. + * First-person means your own airframe is not drawn in cockpit — the eye sits + * metres from its origin. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. Mirror of flights.js. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._viewer?.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._viewer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm + * coarse ground cell — a pure LIFT (above-floor positions pass through + * untouched, unknown floors change nothing). Grounded military billboards + * deliberately render at the pre-datum default height (T7 groundSnap + * invariant), so every position entering the TRAIL subsystem (seed, append, + * per-frame head) goes through this instead — the visible taxi history sits + * on the surface without touching the billboard/model machinery. + * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). + * @returns {Cesium.Cartesian3} The same or a lifted position. + */ + + function _trailFloorPosition(position) { + const carto = Cesium.Cartographic.fromCartesian( + position, + Cesium.Ellipsoid.WGS84, + flightState._scratchTrailCarto, + ); + if (!carto) return position; + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const floored = floorAltitudeM( + carto.height, + cachedGroundFloor(latDeg, lonDeg), + ); + if (floored == null || floored === carto.height) return position; + return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push(_trailFloorPosition(position)); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That + * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. + * The cheap per-frame _trailHeadEntity segment bridges the last body point to the + * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll + * cadence), never at motion cadence. + */ + + function _refreshTrailDisplay() { + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget an adsb.lol trace backfill (~24 h of real history). + * @param {string} icao24 - ICAO hex identifier being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join via _appendTrailFix as they age. + const seedRenderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + for (const fix of history) { + if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + // Floor grounded-history seeds (round 2): a grounded contact's stored + // fixes carry the pre-datum default height — the trail copy sits on + // the surface instead (pure lift; airborne fixes pass through). + flightState._trailPositions.push( + _trailFloorPosition(Cesium.Cartesian3.clone(fix.position)), + ); + } + } + if (!flightState._trail && flightState._viewer) { + flightState._trail = createTrail(flightState._viewer, { + color: TRAIL_COLOR, + width: 2.5, + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated + // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, + // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued + // to the 12 Hz icon instead of lagging ~1 s behind it. + if (!flightState._trailHeadEntity && flightState._viewer) { + flightState._trailHeadEntity = flightState._viewer.entities.add({ + // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + polyline: { + positions: new Cesium.CallbackProperty(() => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the + // segment would draw IN FRONT of the plane. Likewise during warm-up the icon + // predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[ + flightState._trailPositions.length - 2 + ]; + // Floor the head too (round 2): a grounded tracked contact's DR + // display height is the pre-datum default — without this the last + // segment dives underground while taxiing. + const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. Measured against + // the FLOORED endpoint, which is the one actually drawn. + // See trailHeadStart. + const from = trailHeadStart( + start, + end, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, end]; + }, false), + width: 2.5, + material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads). + depthFailMaterial: + Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }, + }); + } + _refreshTrailDisplay(); + + const oldestFixEpochSec = history.length + ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + /** + * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * stride-thinned to the remaining vertex budget, then spliced strictly older + * than the oldest seeded fix AHEAD of the locally accumulated fine segment, + * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ + * malformed) silently keeps the local-only trail. + * @param {string} icao24 - ICAO hex identifier being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let trace = null; + try { + const track = await flightState._source.getTrack?.( + flightState._flightData.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + trace = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if (!trace) return; + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a + // readsb trace point's altitude is barometric feet (same datum as the live + // alt_baro, NOT geometric), so a trail waypoint's render height is the same + // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) + // — geometrically approximate, not exact. + await ensureGeoidReady(); + + // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] + const parsed = []; + for (const point of trace) { + const t = point.observedAtMs / 1000; + const lat = point.latitude, + lon = point.longitude; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse + // ellipsoidal ground along the trace and clamp every waypoint at it. A + // 'ground'/null point sits ON the local surface — the old fixed 50 m + // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m + // ellipsoidal) and dragged the whole pattern-work loop with it. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + let older = []; + let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet + for (const { lat, lon, baroAltitudeM } of parsed) { + const baroM = + baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon); + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // Ground/no-alt point with an unresolved floor: hold the previous + // waypoint's altitude (continuity — never a dive to a made-up depth). + // Leading points with nothing to carry keep the old low breadcrumb + // sentinel (an arbitrary placeholder, never a reported altitude). + if (altM == null) altM = lastAltM != null ? lastAltM : 50; + lastAltM = altM; + older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + } + + // Stride-thin the backfill so it plus the live fine segment fit the cap. + const budget = Math.max( + 1, + TRAIL_MAX_POINTS - flightState._trailPositions.length, + ); + if (older.length > budget) { + const stride = Math.ceil(older.length / budget); + const thinned = []; + for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); + older = thinned; + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if ( + flightState._trailHeadEntity && + flightState._viewer && + !flightState._viewer.isDestroyed() + ) { + try { + flightState._viewer.entities.remove(flightState._trailHeadEntity); + } catch { + /* already gone */ + } + } + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the current aircraft and clean up all tracking state. + * Restores the original billboard, removes the tracked Entity, invalidates + * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo + * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back + * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms + * way up and loses my context"). The camera stays at its current + * position/orientation, immediately free to orbit/zoom. Applies to every + * deselect path: click-empty-space, Escape, aged-out plane, layer disable, + * and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget('militaryFlights'); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget('militaryFlights', clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + const meta = flightState._flightData.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground-aware restore (a plane untracked while taxiing comes back at + // ground scale; tint is full amber on the ground and in the air). + bb.scale = + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1); + bb.color = MIL_ICON_COLOR; + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the + // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). + // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in + // place, so the view stays where the follow left it and the user can immediately orbit/zoom. + if (flightState._viewer && !skipViewerUntrack) { + flightState._viewer.trackedEntity = undefined; + } + if (flightState._trackedEntity) { + flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext('military'); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'military', + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache so a same-frame re-track cannot read + // the previous aircraft's cached position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._billboardCollection?.show || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** + * Begin tracking a specific aircraft. Clears any previous tracking, hides the + * billboard, creates a new Entity with dead-reckoning CallbackProperties for + * smooth continuous motion, and sets the viewer's trackedEntity so the camera + * follows. + * @param {string} icao24 - ICAO hex identifier of the aircraft to track + */ + + function _trackFlight(icao24, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard -- the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/rotation/trail-head callbacks, with + // discontinuity reconciliation). Falls back to the last billboard position when the + // aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Dead-reckoning position property evaluated every render frame. + // isConstant = false so CesiumJS re-evaluates each frame. + const positionProperty = new Cesium.CallbackProperty(() => { + return getTrackedPosition(); + }, false); + + // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D + // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is + // what makes the follow-camera's bounding sphere always ready (see _trackedModel). + flightState._trackedEntity = flightState._viewer.entities.add({ + position: positionProperty, + // Keep Cesium's EntityView in the same ENU frame as the close-range + // camera guard; AUTO may otherwise alternate with a velocity frame. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[ + flightState._flightData.get(flightState._trackedIcao)?.klass + ] || 1), + // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: new Cesium.CallbackProperty( + () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR, + false, + ), + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the amber icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: new Cesium.CallbackProperty(() => { + const tracked = flightState._flightData.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._viewer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, false), + }, + }); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `military:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(info, icao24), + '#ffd166', + ); + + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; + const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); + flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + 0, + -followRange * 0.8, + followRange * 0.55, + ); + + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked + // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires + // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. + // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a + // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the + // follow-camera anti-jitter contract; presentation that must weld to the aircraft you + // can see reads `gevVisualPosition`. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + flightState._viewer.camera.cancelFlight(); + // Camera follows the tracked entity + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + + // Track-history trail (PRD F2): seed from local history + async backfill. + // Ground traffic draws NO trail — if the plane takes off while tracked, the + // ground→air transition in update() starts one. + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select. Trail positions are floored + // at the surface (_trailFloorPosition), so ground legs drape, never dive. + _startTrail(icao24); + + const callsign = + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24; + _publishTrackedSelection(icao24, origin); + console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); + } + + /** + * Global keydown handler: pressing Escape clears the active aircraft tracking. + * @param {KeyboardEvent} e - The keyboard event + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Install a left-click handler on the scene canvas for aircraft selection. + * Clicking a military billboard starts tracking that aircraft; clicking empty + * space or a non-military object deselects. Also attaches the Escape key + * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + + function _installClickHandler(viewer) { + if (flightState._clickHandler) return; // already installed + + // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our + // tracking without touching viewer.trackedEntity (the new owner controls it). + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if ( + flightState._trackedIcao && + flightState._viewer && + flightState._viewer.trackedEntity && + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: + flightState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + // Camera drags never select or deselect, even if they finish over a plane. + // Duration alone is allowed through so a stationary long press can still + // select/switch contacts; the destructive empty-space branch below applies + // the full travel + duration click classifier. + if (!isTrackingSelectionGesture(gesture)) return; + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference. Empty globe clicks are inert until the user + // exits with C, Escape, or the dedicated button. + if (document.body.classList.contains('cockpit-mode')) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking the tracked entity itself -- ignore (don't deselect) + if (picked.id === flightState._trackedEntity) return; + + // Clicking the plane we're ALREADY tracking (its standalone 3D model or + // any pick carrying its icao) — same no-op as the 2D tracked-entity click + // above. H1: the model used to have no pick id, so this fell through to + // "empty space" and deselected the very plane being tracked. + if (flightState._trackedIcao) { + const rawPick = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if ( + picked.primitive === flightState._trackedModel || + rawPick === flightState._trackedIcao + ) + return; + } + + // BillboardCollection picks: CesiumJS may expose the billboard as + // picked.primitive (with .id = icao24) or directly as picked.id + const billboard = picked.primitive; + if ( + billboard && + billboard.id && + flightState._billboards.has(billboard.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(billboard.id, { origin: 'user' }); + return; + } + // Fallback: some CesiumJS versions surface the id string at picked.id + if ( + picked.id && + typeof picked.id === 'string' && + flightState._billboards.has(picked.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(picked.id, { origin: 'user' }); + return; + } + } + + // A pick that belongs to a sibling layer (commercial flight, satellite, + // vessel, station, CCTV camera…) is not "empty space" — leave tracking + // alone and let that layer handle it. resolvePickId String()-coerces the + // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the + // registry predicates can recognize them (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; + } + + // Clicked empty space -- deselect only for a clean, short click. A slow + // stationary press may select above, but cannot release existing tracking. + if (!isTrackingClickGesture(gesture)) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + }); + + document.addEventListener('keydown', _onKeyDown); + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _buildTrackedLabel, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + _trailFloorPosition, + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackFlight, + _onKeyDown, + _installClickHandler, + }; +} diff --git a/src/layers/radio/catalogModel.js b/src/layers/radio/catalogModel.js new file mode 100644 index 000000000..ef06b08c3 --- /dev/null +++ b/src/layers/radio/catalogModel.js @@ -0,0 +1,147 @@ +import { normalizeRadioCountryInput } from '../../data/radioCountry.js'; +import { RADIO_UUID_RE } from './policy.js'; + +export function createCatalogModel({ + state: layerState, + services, + parts, + source, +}) { + function isNonGlobalRadioIpv4(hostname) { + const pieces = hostname.split('.'); + if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) + return false; + const values = pieces.map(Number); + if (values.some((value) => value > 255)) return true; + const [a, b, c] = values; + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ); + } + + function isSafeRadioHttpsUrl(value) { + if (typeof value !== 'string' || !value) return false; + try { + const url = new URL(value); + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + !hostname + ) + return false; + return !( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + isNonGlobalRadioIpv4(hostname) || + hostname.includes(':') + ); + } catch { + return false; + } + } + + function isValidRadioDirectoryStation(station) { + const cleanText = (value, maxLength, { allowEmpty = true } = {}) => + typeof value === 'string' && + value.length <= maxLength && + (allowEmpty || value.trim().length > 0) && + !/[\u0000-\u001f\u007f]/.test(value) && + value === value.trim() && + !/\s{2,}/.test(value); + const textArray = (value, limit, itemMaxLength) => + Array.isArray(value) && + value.length <= limit && + value.every((item) => + cleanText(item, itemMaxLength, { allowEmpty: false }), + ); + return Boolean( + station && + RADIO_UUID_RE.test(station.id) && + cleanText(station.name, 140, { allowEmpty: false }) && + Number.isFinite(station.lat) && + station.lat >= -90 && + station.lat <= 90 && + Number.isFinite(station.lon) && + station.lon >= -180 && + station.lon <= 180 && + isSafeRadioHttpsUrl(station.streamUrl) && + (station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) && + textArray(station.tags, 24, 80) && + textArray(station.languages, 8, 40) && + cleanText(station.state, 80) && + cleanText(station.country, 80) && + cleanText(station.countryCode, 2) && + (station.countryCode === '' || + normalizeRadioCountryInput(station.countryCode).valid) && + station.metadataTrust === 'untrusted-community' && + cleanText(station.codec, 16, { allowEmpty: false }) && + /^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) && + (station.bitrate === null || + (Number.isInteger(station.bitrate) && + station.bitrate >= 8 && + station.bitrate <= 1024)), + ); + } + + function freezeRadioStation(station) { + return Object.freeze({ + id: station.id, + name: station.name, + lat: station.lat, + lon: station.lon, + streamUrl: station.streamUrl, + homepage: station.homepage, + tags: Object.freeze([...station.tags]), + languages: Object.freeze([...station.languages]), + state: station.state, + country: station.country, + countryCode: station.countryCode, + metadataTrust: station.metadataTrust, + codec: station.codec, + bitrate: station.bitrate, + }); + } + + function createAcceptedCatalogSnapshot( + instance, + generation, + updatedAt, + stations, + ) { + if (!Number.isSafeInteger(generation) || generation < 1) return null; + if (typeof instance !== 'string' || !instance) return null; + const frozenStations = Object.freeze(stations.map(freezeRadioStation)); + return Object.freeze({ + instance, + generation, + updatedAt, + stations: frozenStations, + stationIds: Object.freeze(frozenStations.map((station) => station.id)), + }); + } + return { + isNonGlobalRadioIpv4, + isSafeRadioHttpsUrl, + isValidRadioDirectoryStation, + freezeRadioStation, + createAcceptedCatalogSnapshot, + }; +} diff --git a/src/layers/radio/categories.js b/src/layers/radio/categories.js new file mode 100644 index 000000000..a7bfc182e --- /dev/null +++ b/src/layers/radio/categories.js @@ -0,0 +1,131 @@ +import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js'; + +export function createCategories({ + state: layerState, + services, + parts, + source, +}) { + /** Normalize one directory tag to a stable, lower-case display token. */ + + function normalizeRadioTag(value) { + return String(value ?? '') + .trim() + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .slice(0, 80); + } + + function stationTags(station) { + if (Array.isArray(station?.tags)) + return station.tags.map(normalizeRadioTag).filter(Boolean); + return String(station?.tags ?? '') + .split(',') + .map(normalizeRadioTag) + .filter(Boolean); + } + + function hasTag(station, needles) { + const tags = stationTags(station); + return needles.some((needle) => + tags.some((tag) => tag === needle || tag.includes(needle)), + ); + } + + function detectedGenres(station) { + return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map( + ([genre]) => genre, + ); + } + + /** Return whether a station belongs in a station-tag category. */ + + function stationMatchesRadioCategory(station, categoryId) { + if (categoryId === 'all') return true; + if (categoryId.startsWith('genre:')) { + return detectedGenres(station).includes( + categoryId.slice('genre:'.length), + ); + } + if (categoryId === 'music') { + return ( + detectedGenres(station).length > 0 || + hasTag(station, ['music', 'hits', 'songs']) + ); + } + if (categoryId === 'other') { + return ( + !Object.entries(CATEGORY_MATCHERS).some(([id]) => + stationMatchesRadioCategory(station, id), + ) && !stationMatchesRadioCategory(station, 'music') + ); + } + return hasTag(station, CATEGORY_MATCHERS[categoryId] || []); + } + + /** Build canonical and detected-genre categories from station-level tags. */ + + function buildRadioCategories(stations) { + const rows = Array.isArray(stations) ? stations : []; + const categories = [ + { id: 'all', label: 'All' }, + { id: 'news', label: 'News' }, + { id: 'talk', label: 'Talk' }, + { id: 'weather', label: 'Weather / Emergency' }, + { id: 'public-safety', label: 'Public Safety' }, + { id: 'aviation-marine', label: 'Aviation / Marine' }, + { id: 'traffic-transit', label: 'Traffic / Transit' }, + { id: 'music', label: 'Music' }, + ]; + + for (const [genre, label] of MUSIC_GENRES) { + const id = `genre:${genre}`; + if (rows.some((station) => stationMatchesRadioCategory(station, id))) { + categories.push({ id, label }); + } + } + categories.push({ id: 'other', label: 'Other' }); + return categories.map((category) => ({ + ...category, + color: parts.model.radioCategoryColor(category.id), + count: rows.filter((station) => + stationMatchesRadioCategory(station, category.id), + ).length, + })); + } + + /** Filter stations without changing the active stream or selection. */ + + function filterRadioStations(stations, categoryId = 'all') { + return (Array.isArray(stations) ? stations : []).filter((station) => + stationMatchesRadioCategory(station, categoryId), + ); + } + + /** Return whether Radio Browser metadata identifies a station as English-language. */ + + function isEnglishRadioStation(station) { + const languages = Array.isArray(station?.languages) + ? station.languages + : []; + return languages.some((language) => { + const normalized = normalizeRadioTag(language); + return ( + normalized === 'en' || + normalized === 'eng' || + normalized.startsWith('english') + ); + }); + } + return { + normalizeRadioTag, + stationTags, + hasTag, + detectedGenres, + stationMatchesRadioCategory, + buildRadioCategories, + filterRadioStations, + isEnglishRadioStation, + }; +} diff --git a/src/layers/radio/clustering.js b/src/layers/radio/clustering.js new file mode 100644 index 000000000..416276317 --- /dev/null +++ b/src/layers/radio/clustering.js @@ -0,0 +1,266 @@ +import { + GLOBAL_RADIO_ALTITUDE_M, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_OVERLAY_COHORT_LIMIT, +} from './policy.js'; + +export function createClustering({ + state: layerState, + services, + parts, + source, +}) { + /** Return the bounded singleton-label allowance for the current camera scale. */ + + function radioSingletonLabelLimit(cameraHeightM) { + const height = Math.max(0, Number(cameraHeightM) || 0); + if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT; + if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT; + return RADIO_SINGLETON_NEAR_LIMIT; + } + + /** Rank visible singleton stations by camera distance and stable station id. */ + + function selectRadioSingletonCandidates( + candidates, + limit = RADIO_SINGLETON_NEAR_LIMIT, + ) { + const distance = (candidate) => { + const value = Number(candidate?.distanceM); + return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY; + }; + return [...(candidates || [])] + .sort( + (a, b) => + distance(a) - distance(b) || + String(a?.station?.id || a?.id || '').localeCompare( + String(b?.station?.id || b?.id || ''), + ), + ) + .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); + } + + /** Rank and cap ambient Radio clusters before shared-host entry allocation. */ + + function selectRadioClusterCandidates( + candidates, + limit = RADIO_OVERLAY_COHORT_LIMIT, + ) { + return [...(candidates || [])] + .sort( + (a, b) => + (Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) || + String(a?.id || '').localeCompare(String(b?.id || '')), + ) + .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); + } + + /** + * Preserve one-to-one overlay identity for overlapping clusters. + * Cesium clustering is screen-space, so a small camera move may alter exact + * membership even when the same geographic cluster remains visible. Every + * positive overlap participates in greatest-contributor discovery, while + * inheritance remains mutual-best: a current cluster accepts only one of its + * greatest contributors and a prior identity transfers only to one of its + * strongest split children. + */ + + function reconcileRadioClusterCandidates( + candidates, + previous = [], + createId = null, + ) { + const current = (Array.isArray(candidates) ? candidates : []).map( + (candidate, index) => { + const stationIds = [ + ...new Set((candidate?.stationIds || []).map((id) => String(id))), + ].sort(); + const membershipId = String( + candidate?.id || `membership:${stationIds.join('|')}`, + ); + return { + ...candidate, + _reconcileIndex: index, + _reconcileMembershipId: membershipId, + _reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`, + stationIds, + }; + }, + ); + const priorMembershipsByIdentity = new Map(); + for (const candidate of Array.isArray(previous) ? previous : []) { + const identityId = String(candidate?.identityId || candidate?.id || ''); + if (!identityId) continue; + if (!priorMembershipsByIdentity.has(identityId)) { + priorMembershipsByIdentity.set(identityId, new Set()); + } + const membership = priorMembershipsByIdentity.get(identityId); + for (const stationId of candidate?.stationIds || []) + membership.add(String(stationId)); + } + const prior = [...priorMembershipsByIdentity] + .map(([identityId, stationIds]) => ({ + identityId, + stationIds: [...stationIds].sort(), + })) + .filter((candidate) => candidate.stationIds.length) + .sort((a, b) => a.identityId.localeCompare(b.identityId)); + const priorByStation = new Map(); + for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) { + for (const stationId of prior[priorIndex].stationIds) { + if (!priorByStation.has(stationId)) priorByStation.set(stationId, []); + priorByStation.get(stationId).push(priorIndex); + } + } + + const edges = []; + const greatestOverlapByCurrent = new Map(); + const greatestOverlapByPrior = new Map(); + for ( + let currentIndex = 0; + currentIndex < current.length; + currentIndex += 1 + ) { + const overlapByPrior = new Map(); + for (const stationId of current[currentIndex].stationIds) { + for (const priorIndex of priorByStation.get(stationId) || []) { + overlapByPrior.set( + priorIndex, + (overlapByPrior.get(priorIndex) || 0) + 1, + ); + } + } + for (const [priorIndex, overlap] of overlapByPrior) { + const union = + current[currentIndex].stationIds.length + + prior[priorIndex].stationIds.length - + overlap; + const smaller = Math.min( + current[currentIndex].stationIds.length, + prior[priorIndex].stationIds.length, + ); + const score = smaller > 0 ? overlap / smaller : 0; + const similarity = union > 0 ? overlap / union : 0; + edges.push({ currentIndex, priorIndex, overlap, score, similarity }); + greatestOverlapByCurrent.set( + currentIndex, + Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap), + ); + greatestOverlapByPrior.set( + priorIndex, + Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap), + ); + } + } + const eligibleEdges = edges.filter( + (edge) => + edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) && + edge.overlap === greatestOverlapByPrior.get(edge.priorIndex), + ); + eligibleEdges.sort( + (a, b) => + // Identity follows the contributor representing the largest share of the + // new cluster. Overlap-coefficient-first incorrectly lets a fully retained + // two-station minority beat a larger partial contributor during a merge. + b.overlap - a.overlap || + b.score - a.score || + b.similarity - a.similarity || + current[a.currentIndex]._reconcileCanonicalKey.localeCompare( + current[b.currentIndex]._reconcileCanonicalKey, + ) || + prior[a.priorIndex].identityId.localeCompare( + prior[b.priorIndex].identityId, + ), + ); + + const inheritedByCurrent = new Map(); + const usedPriorIdentities = new Set(); + for (const edge of eligibleEdges) { + const identityId = prior[edge.priorIndex].identityId; + if ( + inheritedByCurrent.has(edge.currentIndex) || + usedPriorIdentities.has(identityId) + ) + continue; + inheritedByCurrent.set( + edge.currentIndex, + prior[edge.priorIndex].identityId, + ); + usedPriorIdentities.add(identityId); + } + + const generatedByCurrent = new Map(); + if (typeof createId === 'function') { + const freshIndices = current + .map((candidate, index) => ({ candidate, index })) + .filter(({ index }) => !inheritedByCurrent.has(index)) + .sort((a, b) => + a.candidate._reconcileCanonicalKey.localeCompare( + b.candidate._reconcileCanonicalKey, + ), + ); + for (const { candidate, index } of freshIndices) { + generatedByCurrent.set(index, String(createId(candidate, index) || '')); + } + } + + return current.map((candidate, index) => { + const membershipId = candidate._reconcileMembershipId; + const inherited = inheritedByCurrent.get(index); + const generated = generatedByCurrent.get(index) || ''; + const identityId = inherited || generated || membershipId; + const { + _reconcileIndex, + _reconcileMembershipId, + _reconcileCanonicalKey, + ...rest + } = candidate; + return { ...rest, membershipId, identityId, id: identityId }; + }); + } + + function resetRadioClusterOverlayIdentities() { + layerState._clusterOverlayIdentities = []; + } + + function emptyRadioOverlayDiagnostics() { + return { + entryCount: 0, + selectedCount: 0, + singletonTexts: [], + singletonIds: [], + clusterTexts: [], + clusterIds: [], + clusterMemberships: [], + }; + } + + /** Retain refresh identities only while every represented station still exists. */ + + function retainRadioClusterIdentitiesForStations(previous, stations) { + const stationIds = new Set( + (stations || []) + .map((station) => String(station?.id || '')) + .filter(Boolean), + ); + return (previous || []).filter( + (candidate) => + Array.isArray(candidate?.stationIds) && + candidate.stationIds.length > 0 && + candidate.stationIds.every((stationId) => + stationIds.has(String(stationId)), + ), + ); + } + return { + radioSingletonLabelLimit, + selectRadioSingletonCandidates, + selectRadioClusterCandidates, + reconcileRadioClusterCandidates, + resetRadioClusterOverlayIdentities, + emptyRadioOverlayDiagnostics, + retainRadioClusterIdentitiesForStations, + }; +} diff --git a/src/layers/radio/controls.js b/src/layers/radio/controls.js new file mode 100644 index 000000000..6313ccf4d --- /dev/null +++ b/src/layers/radio/controls.js @@ -0,0 +1,119 @@ +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'radio', + + name: 'Radio', + + icon: '◉', + + source: 'Radio Browser', + + updateInterval: 45 * 60 * 1000, + + /** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */ + setLifecyclePresentation({ + lifecycleState = null, + enabled = false, + uncertain = false, + } = {}) { + const settledState = enabled ? 'enabled' : 'disabled'; + const normalizedState = [ + 'enabling', + 'enabled', + 'disabling', + 'disabled', + ].includes(lifecycleState) + ? lifecycleState + : settledState; + layerState._managerLifecyclePresentation = { + lifecycleState: normalizedState, + enabled: Boolean(enabled), + uncertain: Boolean(uncertain), + }; + parts.interaction.syncRadioLifecyclePresentation(); + parts.presentation.emitState(); + }, + + /** Layer statistics for HUD/debug surfaces. */ + getStats() { + return { + count: layerState._stations.length, + filtered: parts.queries.visibleStations().length, + selected: layerState._selectedId, + playing: layerState._audioStationId, + stale: layerState._stale, + degraded: layerState._degraded, + loading: layerState._loading, + error: layerState._error, + lastUpdate: layerState._updatedAt + ? Date.parse(layerState._updatedAt) + : null, + horizonScans: layerState._horizonScanCount, + overlayEntries: layerState._overlayDiagnostics.entryCount, + }; + }, + + subscribe: parts.presentation.subscribeToRadio, + + subscribePlaybackControls: + parts.presentation.subscribeToRadioPlaybackControls, + + getAcceptedCatalogSnapshot: + parts.presentation.getRadioAcceptedCatalogSnapshot, + + getUIState: parts.presentation.getRadioUIState, + + getParams: parts.selection.getRadioParams, + + setParams: parts.selection.setRadioParams, + + getOverlayDiagnostics: () => ({ + ...layerState._overlayDiagnostics, + singletonTexts: [...layerState._overlayDiagnostics.singletonTexts], + singletonIds: [...layerState._overlayDiagnostics.singletonIds], + clusterTexts: [...layerState._overlayDiagnostics.clusterTexts], + clusterIds: [...layerState._overlayDiagnostics.clusterIds], + clusterMemberships: layerState._overlayDiagnostics.clusterMemberships.map( + (entry) => ({ ...entry }), + ), + }), + + getTunerStations: parts.tuning.getRadioTunerStations, + + beginTuning: parts.tuning.beginRadioTuning, + + setTuningStatic: parts.tuning.setRadioTuningStatic, + + previewTuningStation: parts.tuning.previewRadioTuningStation, + + commitTuningStation: parts.tuning.commitRadioTuningStation, + + cancelTuning: parts.tuning.cancelRadioTuning, + + endTuning: parts.tuning.endRadioTuning, + + setFilter: parts.selection.setRadioFilter, + + selectStation: parts.selection.selectRadioStation, + + selectRequestedStation: parts.selection.selectRequestedRadioStation, + + cycleStation: parts.selection.cycleRadioStation, + + togglePlayback: parts.playback.toggleRadioPlayback, + + play: parts.playback.playSelectedRadio, + + playForVoice: parts.playback.playPreparedRadioForVoice, + + pause: parts.playback.pauseRadioPlayback, + + stopPlayback: parts.playback.stopRadioPlayback, + + setVolume: parts.volume.setRadioVolume, + + setVoiceDucked: parts.volume.setRadioVoiceDucking, + }; + + return { methods }; +} diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js new file mode 100644 index 000000000..fa84e1054 --- /dev/null +++ b/src/layers/radio/index.js @@ -0,0 +1,142 @@ +import { createCatalogModel } from './catalogModel.js'; +import { createLabels } from './labels.js'; +import { createClustering } from './clustering.js'; +import { createVolume } from './volume.js'; +import { createNavigation } from './navigation.js'; +import { createCategories } from './categories.js'; +import { createModel } from './model.js'; +import { createQueries } from './queries.js'; +import { createInteraction } from './interaction.js'; +import { createTuning } from './tuning.js'; +import { createTuningNoise } from './tuningNoise.js'; +import { createRendering } from './rendering.js'; +import { createPresentation } from './presentation.js'; +import { createPlayback } from './playback.js'; +import { createSelection } from './selection.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createRadioLayer({ services, source }) { + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.catalogModel = createCatalogModel(context); + parts.labels = createLabels(context); + parts.clustering = createClustering(context); + parts.volume = createVolume(context); + parts.navigation = createNavigation(context); + parts.categories = createCategories(context); + parts.model = createModel(context); + parts.queries = createQueries(context); + parts.interaction = createInteraction(context); + parts.tuning = createTuning(context); + parts.tuningNoise = createTuningNoise(context); + parts.rendering = createRendering(context); + parts.presentation = createPresentation(context); + parts.playback = createPlayback(context); + parts.selection = createSelection(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + radioGlobeLabel: parts.labels.radioGlobeLabel, + createRadioSelectedOverlayEntry: + parts.labels.createRadioSelectedOverlayEntry, + createRadioClusterOverlayEntry: + parts.labels.createRadioClusterOverlayEntry, + createRadioSingletonOverlayEntry: + parts.labels.createRadioSingletonOverlayEntry, + radioSingletonLabelLimit: parts.clustering.radioSingletonLabelLimit, + selectRadioSingletonCandidates: + parts.clustering.selectRadioSingletonCandidates, + selectRadioClusterCandidates: + parts.clustering.selectRadioClusterCandidates, + reconcileRadioClusterCandidates: + parts.clustering.reconcileRadioClusterCandidates, + radioCameraPositionChanged: parts.navigation.radioCameraPositionChanged, + normalizeRadioTag: parts.categories.normalizeRadioTag, + stationMatchesRadioCategory: parts.categories.stationMatchesRadioCategory, + radioCategoryColor: parts.model.radioCategoryColor, + radioClusterBadgeText: parts.model.radioClusterBadgeText, + radioClusterCategoryId: parts.model.radioClusterCategoryId, + radioSelectionBracketSvg: parts.labels.radioSelectionBracketSvg, + radioStationCategoryId: parts.model.radioStationCategoryId, + buildRadioCategories: parts.categories.buildRadioCategories, + filterRadioStations: parts.categories.filterRadioStations, + isEnglishRadioStation: parts.categories.isEnglishRadioStation, + rankRadioStationsForViewport: parts.queries.rankRadioStationsForViewport, + rankRadioStationsForRequest: parts.queries.rankRadioStationsForRequest, + radioViewIsGlobal: parts.navigation.radioViewIsGlobal, + radioRequestIsCurrent: parts.model.radioRequestIsCurrent, + radioStationIdFromPick: parts.interaction.radioStationIdFromPick, + radioStationCameraPlan: parts.navigation.radioStationCameraPlan, + radioGlobeNeedsRecentering: parts.navigation.radioGlobeNeedsRecentering, + radioGlobeRecenterHeight: parts.navigation.radioGlobeRecenterHeight, + buildRadioTunerBand: parts.tuning.buildRadioTunerBand, + radioTuningStaticShouldPlay: + parts.tuningNoise.radioTuningStaticShouldPlay, + getRadioAcceptedCatalogSnapshot: + parts.presentation.getRadioAcceptedCatalogSnapshot, + radioStationResolutionMatches: parts.model.radioStationResolutionMatches, + getRadioUIState: parts.presentation.getRadioUIState, + subscribeToRadio: parts.presentation.subscribeToRadio, + subscribeToRadioPlaybackControls: + parts.presentation.subscribeToRadioPlaybackControls, + getRadioTunerStations: parts.tuning.getRadioTunerStations, + beginRadioTuning: parts.tuning.beginRadioTuning, + setRadioTuningStatic: parts.tuning.setRadioTuningStatic, + radioCameraNavigationAllowed: + parts.navigation.radioCameraNavigationAllowed, + previewRadioTuningStation: parts.tuning.previewRadioTuningStation, + endRadioTuning: parts.tuning.endRadioTuning, + cancelRadioTuning: parts.tuning.cancelRadioTuning, + commitRadioTuningStation: parts.tuning.commitRadioTuningStation, + playSelectedRadio: parts.playback.playSelectedRadio, + confirmRadioPlayback: parts.playback.confirmRadioPlayback, + playPreparedRadioForVoice: parts.playback.playPreparedRadioForVoice, + stopRadioPlayback: parts.playback.stopRadioPlayback, + toggleRadioPlayback: parts.playback.toggleRadioPlayback, + pauseRadioPlayback: parts.playback.pauseRadioPlayback, + setRadioVolume: parts.volume.setRadioVolume, + setRadioParams: parts.selection.setRadioParams, + getRadioParams: parts.selection.getRadioParams, + setRadioVoiceDucking: parts.volume.setRadioVoiceDucking, + selectRadioStation: parts.selection.selectRadioStation, + cycleRadioStation: parts.selection.cycleRadioStation, + selectRequestedRadioStation: parts.selection.selectRequestedRadioStation, + setRadioFilter: parts.selection.setRadioFilter, + retainRadioClusterIdentitiesForStations: + parts.clustering.retainRadioClusterIdentitiesForStations, + }, + ); +} +export { + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, + GLOBAL_RADIO_ALTITUDE_M, +} from './policy.js'; + +export { createRadioSource } from './source.js'; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from '../../ui/radioTunerModel.js'; + +export { normalizeRadioCountryInput } from '../../data/radioCountry.js'; diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js new file mode 100644 index 000000000..8a89b444c --- /dev/null +++ b/src/layers/radio/ingestion.js @@ -0,0 +1,174 @@ +import { + RADIO_DIRECTORY_STALE_MS, + RADIO_DIRECTORY_FUTURE_SKEW_MS, +} from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + /** Refresh directory metadata through the hardened same-origin broker. */ + async update() { + if (!layerState._enabled) return; + const generation = ++layerState._requestGeneration; + const sessionGeneration = layerState._sessionGeneration; + layerState._abortController?.abort(); + layerState._abortController = new AbortController(); + layerState._loading = true; + layerState._error = null; + parts.presentation.emitState(); + try { + const body = await source.getDirectory({ + signal: layerState._abortController.signal, + }); + if ( + !parts.model.radioRequestIsCurrent( + generation, + layerState._requestGeneration, + layerState._enabled, + sessionGeneration, + layerState._sessionGeneration, + ) + ) + return; + if (!Array.isArray(body?.stations)) + throw new Error('Radio directory response was malformed'); + const updatedAt = + typeof body.updatedAt === 'string' && + Number.isFinite(Date.parse(body.updatedAt)) + ? body.updatedAt + : null; + const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN; + if ( + !updatedAt || + updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS || + updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS || + typeof body.stale !== 'boolean' || + typeof body.degraded !== 'boolean' + ) + throw new Error('Radio directory freshness metadata was malformed'); + const rows = body.stations; + const acceptedRows = rows.filter( + parts.catalogModel.isValidRadioDirectoryStation, + ); + if (!acceptedRows.length) + throw new Error('Radio directory returned no usable stations'); + if (acceptedRows.length !== rows.length) { + throw new Error( + 'Radio directory response contained malformed stations', + ); + } + const acceptedGeneration = body.acceptedGeneration; + if ( + acceptedGeneration !== null && + (!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1) + ) + throw new Error('Radio directory generation metadata was malformed'); + if (!body.stale && !body.degraded && acceptedGeneration === null) { + throw new Error('Radio directory omitted its accepted generation'); + } + const catalogInstance = body.catalogInstance; + if ( + !body.stale && + !body.degraded && + (typeof catalogInstance !== 'string' || !catalogInstance) + ) { + throw new Error('Radio directory omitted its catalog instance'); + } + const preservingWarmCatalog = + layerState._stations.length > 0 && (body.stale || body.degraded); + // Generations are only comparable within one producer instance. A new + // instance token (server restart, different proxy process) starts a fresh + // sequence: never a repeat and never a regression. + const sameCatalogInstance = + layerState._acceptedCatalogSnapshot?.instance === catalogInstance; + const currentAcceptedGeneration = sameCatalogInstance + ? layerState._acceptedCatalogSnapshot?.generation + : null; + if ( + !body.stale && + !body.degraded && + Number.isSafeInteger(currentAcceptedGeneration) && + acceptedGeneration < currentAcceptedGeneration + ) + throw new Error('Radio directory generation regressed'); + const repeatingAcceptedGeneration = + !body.stale && + !body.degraded && + Number.isSafeInteger(currentAcceptedGeneration) && + acceptedGeneration === currentAcceptedGeneration; + if (!preservingWarmCatalog) { + const immutableRows = acceptedRows.map( + parts.catalogModel.freezeRadioStation, + ); + if (repeatingAcceptedGeneration) { + layerState._updatedAt = + layerState._acceptedCatalogSnapshot.updatedAt; + if ( + !layerState._tuningActive && + layerState._cancelledTuningPresentationStation + ) { + layerState._cancelledTuningPresentationStation = null; + parts.rendering.updateSelectionEntity(); + } + } else if (!body.stale && !body.degraded) { + const acceptedSnapshot = + parts.catalogModel.createAcceptedCatalogSnapshot( + catalogInstance, + acceptedGeneration, + updatedAt, + immutableRows, + ); + if (!acceptedSnapshot) + throw new Error( + 'Radio directory generation metadata was malformed', + ); + layerState._acceptedCatalogSnapshot = acceptedSnapshot; + parts.rendering.reconcileStations(acceptedSnapshot.stations); + } else { + parts.rendering.reconcileStations(immutableRows); + } + if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; + } + layerState._degraded = body.degraded; + layerState._stale = body.stale; + layerState._error = preservingWarmCatalog + ? 'Directory refresh degraded; showing the previous station catalog.' + : layerState._degraded + ? 'Radio directory coverage is degraded.' + : null; + } catch (error) { + if ( + error?.name === 'AbortError' || + !parts.model.radioRequestIsCurrent( + generation, + layerState._requestGeneration, + layerState._enabled, + sessionGeneration, + layerState._sessionGeneration, + ) + ) + return; + layerState._error = layerState._stations.length + ? 'Directory refresh failed; showing the previous station catalog.' + : 'Radio directory is temporarily unavailable.'; + layerState._stale = layerState._stations.length > 0; + layerState._degraded = layerState._stations.length > 0; + } finally { + if ( + generation === layerState._requestGeneration && + sessionGeneration === layerState._sessionGeneration + ) { + layerState._loading = false; + layerState._abortController = null; + parts.presentation.emitState(); + } + } + }, + }; + + return { methods }; +} diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js new file mode 100644 index 000000000..da8ff558a --- /dev/null +++ b/src/layers/radio/interaction.js @@ -0,0 +1,159 @@ +import * as Cesium from 'cesium'; +import { + RADIO_PREFIX, + RADIO_PICK_OFFSETS, + RADIO_OVERLAY_SOURCE_ID, + HORIZON_TICK_MS, +} from './policy.js'; + +export function createInteraction({ + state: layerState, + services, + parts, + source, +}) { + const { + resolvePickId, + isOwnedByOtherLayer, + registerPickOwner, + unregisterPickOwner, + } = services.picking; + const { setOverlaySourceVisible } = services.overlays; + + /** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ + + function radioStationIdFromPick(picked) { + const pending = [picked?.id, picked?.primitive?.id]; + const seen = new Set(); + while (pending.length) { + const value = pending.shift(); + if (typeof value === 'string' || typeof value === 'number') { + const id = String(value); + if (id.startsWith(`${RADIO_PREFIX}selected:`)) + return id.slice(`${RADIO_PREFIX}selected:`.length); + if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length); + continue; + } + if (!value || typeof value !== 'object' || seen.has(value)) continue; + seen.add(value); + if (Array.isArray(value)) pending.push(...value); + else { + pending.push(value.id); + if (value.primitive) pending.push(value.primitive.id); + } + } + return null; + } + + function pickedRadioStationAt(position) { + const scene = layerState._viewer?.scene; + if (!scene || !position) return null; + + const stationFromPick = (picked) => { + const stationId = radioStationIdFromPick(picked); + return stationId && layerState._stationById.has(stationId) + ? stationId + : null; + }; + const primaryPick = scene.pick(position); + const primaryStationId = stationFromPick(primaryPick); + if (primaryStationId) return primaryStationId; + const primaryId = resolvePickId(primaryPick); + if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; + + if (typeof scene.drillPick === 'function') { + const drilled = scene.drillPick(position, 16) || []; + for (const picked of drilled) { + const stationId = stationFromPick(picked); + if (stationId) return stationId; + } + } + + for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { + const offsetPosition = new Cesium.Cartesian2( + position.x + offsetX, + position.y + offsetY, + ); + const picked = scene.pick(offsetPosition); + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; + const stationId = stationFromPick(picked); + if (stationId) return stationId; + } + return null; + } + + function radioPresentationAllowed() { + if (!layerState._managerLifecyclePresentation) return layerState._enabled; + return ( + layerState._enabled && + layerState._managerLifecyclePresentation.lifecycleState === 'enabled' && + layerState._managerLifecyclePresentation.enabled && + !layerState._managerLifecyclePresentation.uncertain + ); + } + + function syncRadioLifecyclePresentation() { + const visible = radioPresentationAllowed(); + if (layerState._dataSource) layerState._dataSource.show = visible; + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); + if (visible) { + installInteraction(); + parts.rendering.updateSelectionEntity(); + parts.rendering.updateRenderVisibility(); + parts.rendering.scheduleRadioOverlayPublish(); + } else { + removeInteraction(); + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + } + } + + function installInteraction() { + if (!layerState._viewer || layerState._clickHandler) return; + registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + layerState._viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + if (!radioPresentationAllowed()) return; + const stationId = pickedRadioStationAt(click.position); + if (!stationId) return; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + parts.selection.selectRadioStation(stationId, { + autoplay: true, + origin: 'user', + }); + if (typeof document !== 'undefined') { + document.dispatchEvent( + new CustomEvent('gev:radio-selected', { detail: { stationId } }), + ); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + // Polling remains bounded during flights, but a stationary camera no longer + // rewrites every station's visibility four times per second while voice and + // audio processing share the main thread. + layerState._horizonTimer = setInterval( + () => parts.rendering.updateRenderVisibility({ force: false }), + HORIZON_TICK_MS, + ); + } + + function removeInteraction() { + unregisterPickOwner('radio'); + layerState._clickHandler?.destroy(); + layerState._clickHandler = null; + if (layerState._horizonTimer) clearInterval(layerState._horizonTimer); + layerState._horizonTimer = null; + } + return { + radioStationIdFromPick, + pickedRadioStationAt, + radioPresentationAllowed, + syncRadioLifecyclePresentation, + installInteraction, + removeInteraction, + }; +} diff --git a/src/layers/radio/labels.js b/src/layers/radio/labels.js new file mode 100644 index 000000000..f801384ce --- /dev/null +++ b/src/layers/radio/labels.js @@ -0,0 +1,215 @@ +import { + RADIO_LABEL_SEGMENTER, + RADIO_GLOBE_LABEL_MAX_CHARS, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_CATEGORY_COLORS, +} from './policy.js'; + +export function createLabels({ state: layerState, services, parts, source }) { + function compactRadioLabelText(value, maxChars) { + const text = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const graphemes = RADIO_LABEL_SEGMENTER + ? [...RADIO_LABEL_SEGMENTER.segment(text)].map( + (segment) => segment.segment, + ) + : Array.from(text); + if (graphemes.length <= maxChars) return text; + return `${graphemes + .slice(0, Math.max(1, maxChars - 1)) + .join('') + .trimEnd()}…`; + } + + function cleanRadioLabelName(value) { + return String(value || '') + .replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '') + .replace(/\s+/g, ' ') + .replace(/\s+\([^()]{1,40}\)$/, '') + .replace(/\s+FM$/i, '') + .trim(); + } + + /** Return compact frequency-first text for a Radio globe label. */ + + function radioGlobeLabel(station) { + const fullName = String(station?.name || '') + .replace(/\s+/g, ' ') + .trim(); + if (!fullName) return ''; + + let frequency = null; + let labelName = ''; + const explicitMatches = [ + ...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi), + ]; + const explicit = explicitMatches.find((match) => { + const value = Number(match[1]); + return value >= 64 && value <= 108; + }); + if (explicit) { + frequency = explicit[1]; + const before = cleanRadioLabelName(fullName.slice(0, explicit.index)); + const after = cleanRadioLabelName( + fullName.slice(explicit.index + explicit[0].length), + ); + labelName = before || after; + } else { + // Infer only a leading decimal in the conventional FM band. Integers such + // as "80's" or "100 GREATEST" and domains such as "1.fm" stay names. + const leading = fullName.match( + /^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/, + ); + const value = Number(leading?.[1]); + if (leading && value >= 87.5 && value <= 108) { + frequency = leading[1]; + labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]); + } + } + + if (!frequency) + return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS); + const prefix = `${frequency} FM`; + if (!labelName) return prefix; + const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3; + return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`; + } + + /** Build the protected selected-station text published through WorldOverlay. */ + + function createRadioSelectedOverlayEntry(station, position) { + if (!station?.id || !station?.name || !position) return null; + return { + id: `selected:${station.id}`, + position, + variant: 'selected', + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-label', + priority: Number.MAX_SAFE_INTEGER, + title: radioGlobeLabel(station), + accent: parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ), + interactive: false, + anchorRadiusPx: 20, + minAnchorGapPx: 8, + verticalOnly: true, + placement: 'above', + gapPx: 8, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + }; + } + + /** Build one bounded ambient cluster badge for the shared overlay host. */ + + function createRadioClusterOverlayEntry({ + id, + position, + text, + accent, + stationCount, + }) { + if (!id || !position || !text) return null; + return { + id: `cluster:${id}`, + position, + variant: 'label', + paintLane: 'ambient-label', + collisionGroup: 'ambient-label', + priority: Math.max(1, Number(stationCount) || 1), + title: text, + accent: accent || RADIO_CATEGORY_COLORS.other, + interactive: false, + anchorRadiusPx: Math.min( + 13, + 6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8, + ), + minAnchorGapPx: 4, + verticalOnly: true, + placement: 'above', + gapPx: 6, + // Cluster membership/counts update continuously while the camera moves. + // Paint them as hard-opacity incumbents so a replacement count never + // cross-fades with its predecessor or dims merely for leaving the keyhole. + // Collision, viewport rejection, and horizon culling remain host-owned. + stateless: true, + edgeFade: 'none', + horizonCull: true, + terrainOcclusion: false, + // The camera can sit above 24,000 km in the supported full-globe view, and + // horizon clusters are farther from it than the camera's surface altitude. + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + distanceScale: { + near: 100_000, + nearValue: 1.08, + far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + farValue: 0.92, + }, + }; + } + + /** Build one ambient station label while Cesium retains its point and picking. */ + + function createRadioSingletonOverlayEntry({ + station, + position, + priority = 1, + }) { + if (!station?.id || !station?.name || !position) return null; + return { + id: `station:${station.id}`, + position, + variant: 'label', + paintLane: 'ambient-label', + collisionGroup: 'ambient-label', + priority: Math.max(1, Number(priority) || 1), + title: radioGlobeLabel(station), + accent: parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ), + interactive: false, + anchorRadiusPx: 9, + minAnchorGapPx: 4, + verticalOnly: true, + placement: 'above', + gapPx: 5, + // Camera/filter changes replace this bounded cohort. Prevent the prior + // cohort from fading over the current visible points. + stateless: true, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + distanceScale: { + near: 100_000, + nearValue: 1, + far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + farValue: 0.86, + }, + }; + } + + /** Build the code-native four-corner bracket used by the selected station. */ + + function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) { + const stroke = /^#[0-9a-f]{6}$/i.test(String(color)) + ? String(color) + : RADIO_CATEGORY_COLORS.other; + return ``; + } + return { + compactRadioLabelText, + cleanRadioLabelName, + radioGlobeLabel, + createRadioSelectedOverlayEntry, + createRadioClusterOverlayEntry, + createRadioSingletonOverlayEntry, + radioSelectionBracketSvg, + }; +} diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js new file mode 100644 index 000000000..414cfa5c0 --- /dev/null +++ b/src/layers/radio/lifecycle.js @@ -0,0 +1,122 @@ +import * as Cesium from 'cesium'; +import { + RADIO_OVERLAY_SOURCE_ID, + DEFAULT_RADIO_VOLUME, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, + DEFAULT_RADIO_FILTER, +} from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { clearOverlaySource, setOverlaySourceVisible } = services.overlays; + + const methods = { + /** Initialize the Cesium data source and the single audio element. */ + init(viewer) { + layerState._sessionGeneration += 1; + layerState._viewer = viewer; + parts.clustering.resetRadioClusterOverlayIdentities(); + parts.playback.installAudio(); + if (!layerState._dataSource) { + layerState._dataSource = new Cesium.CustomDataSource('Radio stations'); + viewer.dataSources.add(layerState._dataSource); + parts.rendering.installClusterStyling(); + } + layerState._dataSource.show = false; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + }, + + /** Show stations. Enabling or preset restoration never starts audio. */ + enable() { + layerState._enabled = true; + parts.interaction.syncRadioLifecyclePresentation(); + parts.presentation.emitState(); + }, + + /** Hide the layer and stop playback without forgetting the selected station. */ + disable() { + layerState._sessionGeneration += 1; + layerState._enabled = false; + parts.navigation.invalidateRadioCameraNavigation(); + layerState._requestGeneration += 1; + layerState._abortController?.abort(); + layerState._abortController = null; + layerState._loading = false; + layerState._selectionGeneration += 1; + if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer); + layerState._selectionTimer = null; + parts.interaction.removeInteraction(); + parts.tuning.endRadioTuning(); + layerState._cancelledTuningPresentationStation = null; + layerState._tuningUnavailableStationId = null; + parts.playback.stopRadioPlayback({ origin: 'layer-disable' }); + if (layerState._dataSource) layerState._dataSource.show = false; + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + if (layerState._overlayPublishTimer) + clearTimeout(layerState._overlayPublishTimer); + layerState._overlayPublishTimer = null; + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._overlayDiagnostics = + parts.clustering.emptyRadioOverlayDiagnostics(); + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + parts.presentation.emitState(); + }, + + /** Release rendering, event, request, and playback resources. */ + destroy() { + this.disable(); + parts.volume.cancelRadioVolumeTransition(); + layerState._voiceDucked = false; + layerState._voiceRestoring = false; + parts.tuning.endRadioTuning(); + const closedTuningNoise = layerState._tuningNoiseContext?.close?.(); + if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {}); + layerState._tuningNoiseContext = null; + if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME; + layerState._audio = null; + layerState._userVolume = DEFAULT_RADIO_VOLUME; + layerState._removeClusterListener?.(); + layerState._removeClusterListener = null; + if (layerState._dataSource && layerState._viewer) + layerState._viewer.dataSources.remove(layerState._dataSource, true); + layerState._dataSource = null; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + layerState._stations = []; + layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._stationById.clear(); + layerState._categories = Object.freeze([]); + layerState._filter = DEFAULT_RADIO_FILTER; + layerState._renderById.clear(); + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._lastHorizonCameraPosition = null; + layerState._horizonScanCount = 0; + layerState._selectedId = null; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + layerState._tuningUnavailableStationId = null; + layerState._cancelledTuningPresentationStation = null; + layerState._loading = false; + layerState._stale = false; + layerState._degraded = false; + layerState._error = null; + layerState._updatedAt = null; + layerState._managerLifecyclePresentation = null; + layerState._viewer = null; + parts.presentation.emitState(); + layerState._listeners.clear(); + layerState._playbackControlListeners.clear(); + }, + }; + + return { methods }; +} diff --git a/src/layers/radio/model.js b/src/layers/radio/model.js new file mode 100644 index 000000000..f969a63fe --- /dev/null +++ b/src/layers/radio/model.js @@ -0,0 +1,131 @@ +import { + RADIO_CATEGORY_COLORS, + RADIO_CLUSTER_LABELS, + RADIO_MARKER_CATEGORY_ORDER, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + /** Return the shared CSS color for a canonical or detected-genre category. */ + + function radioCategoryColor(categoryId = 'other') { + const normalized = String(categoryId || 'other'); + const canonical = normalized.startsWith('genre:') ? 'music' : normalized; + return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other; + } + + /** Format the concise count/category badge shown above a Radio cluster. */ + + function radioClusterBadgeText(categoryId = 'other', count = 0) { + const normalized = String(categoryId || 'other'); + const canonical = normalized.startsWith('genre:') ? 'music' : normalized; + const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other; + const stationCount = Math.max(0, Math.floor(Number(count) || 0)); + return `${stationCount} ${label}`; + } + + /** Choose the category advertised by a cluster in the active station-tag view. */ + + function radioClusterCategoryId(stations, activeFilter = 'all') { + const filter = String(activeFilter || 'all'); + if (filter !== 'all') { + if ( + filter.startsWith('genre:') || + RADIO_MARKER_CATEGORY_ORDER.includes(filter) || + filter === 'other' + ) { + return filter; + } + return 'other'; + } + const categoryCounts = new Map(); + for (const station of Array.isArray(stations) ? stations : []) { + const categoryId = radioStationCategoryId(station); + categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1); + } + let clusterCategory = 'other'; + let clusterCategoryCount = 0; + for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) { + const count = categoryCounts.get(categoryId) || 0; + if (count > clusterCategoryCount) { + clusterCategory = categoryId; + clusterCategoryCount = count; + } + } + if ((categoryCounts.get('other') || 0) > clusterCategoryCount) + return 'other'; + return clusterCategory; + } + + /** Choose one stable display category for a station that may match several filters. */ + + function radioStationCategoryId(station) { + return ( + RADIO_MARKER_CATEGORY_ORDER.find((categoryId) => + parts.categories.stationMatchesRadioCategory(station, categoryId), + ) || 'other' + ); + } + + /** Pure stale-response guard shared by the async directory update path. */ + + function radioRequestIsCurrent( + generation, + currentGeneration, + enabled, + sessionGeneration = null, + currentSessionGeneration = sessionGeneration, + ) { + return ( + generation === currentGeneration && + Boolean(enabled) && + sessionGeneration === currentSessionGeneration + ); + } + + /** Compare every station field that determines tuner presentation and playback. */ + + function radioStationResolutionMatches(frozenStation, currentStation) { + if (!frozenStation || !currentStation) return false; + for (const key of [ + 'id', + 'name', + 'lat', + 'lon', + 'streamUrl', + 'homepage', + 'state', + 'country', + 'countryCode', + 'metadataTrust', + 'codec', + 'bitrate', + ]) { + if (frozenStation[key] !== currentStation[key]) return false; + } + return ['tags', 'languages'].every((key) => { + const frozenValues = Array.isArray(frozenStation[key]) + ? frozenStation[key] + : []; + const currentValues = Array.isArray(currentStation[key]) + ? currentStation[key] + : []; + return ( + frozenValues.length === currentValues.length && + frozenValues.every((value, index) => value === currentValues[index]) + ); + }); + } + + function tuningResolutionStation(id) { + return id ? layerState._tuningStationById.get(String(id)) || null : null; + } + return { + radioCategoryColor, + radioClusterBadgeText, + radioClusterCategoryId, + radioStationCategoryId, + radioRequestIsCurrent, + radioStationResolutionMatches, + tuningResolutionStation, + }; +} diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js new file mode 100644 index 000000000..243a1941b --- /dev/null +++ b/src/layers/radio/navigation.js @@ -0,0 +1,395 @@ +import * as Cesium from 'cesium'; +import { + HORIZON_CAMERA_MOVE_EPSILON_M, + GLOBAL_RADIO_ALTITUDE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, +} from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + const { + isFullGlobeInsideKeyhole, + GLOBE_ENTER_CLEARANCE_PX, + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, + } = services.globe; + + /** Return whether camera translation materially changes horizon visibility. */ + + function radioCameraPositionChanged( + previous, + current, + epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M, + ) { + if (!previous || !current) return true; + const dx = Number(current.x) - Number(previous.x); + const dy = Number(current.y) - Number(previous.y); + const dz = Number(current.z) - Number(previous.z); + if (![dx, dy, dz].every(Number.isFinite)) return true; + const threshold = Math.max(0, Number(epsilonM) || 0); + return dx * dx + dy * dy + dz * dz > threshold * threshold; + } + + /** Classify a globe-scale Radio view without flapping on Cesium height round-off. */ + + function radioViewIsGlobal(altitudeM) { + return ( + Number.isFinite(altitudeM) && + Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M + ); + } + + /** Plan a station-centered camera move that preserves altitude and view angle. */ + + function radioStationCameraPlan(station, cameraState = {}) { + const targetLat = Number(station?.lat); + const targetLon = Number(station?.lon); + const height = Math.max(1, Number(cameraState.height) || 1); + const heading = Number.isFinite(cameraState.heading) + ? cameraState.heading + : 0; + const pitch = Number.isFinite(cameraState.pitch) + ? cameraState.pitch + : -Math.PI / 2; + const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0; + if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null; + + const downAngle = Math.min( + Math.PI / 2, + Math.max(0.08, Math.abs(Math.min(-0.001, pitch))), + ); + const groundOffsetM = + downAngle > Math.PI / 2 - 1e-6 + ? 0 + : Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle))); + const angularDistance = groundOffsetM / 6_378_137; + const targetLatRad = (targetLat * Math.PI) / 180; + const cameraBearing = heading + Math.PI; + const cameraLatRad = Math.asin( + Math.sin(targetLatRad) * Math.cos(angularDistance) + + Math.cos(targetLatRad) * + Math.sin(angularDistance) * + Math.cos(cameraBearing), + ); + const cameraLonRad = + (targetLon * Math.PI) / 180 + + Math.atan2( + Math.sin(cameraBearing) * + Math.sin(angularDistance) * + Math.cos(targetLatRad), + Math.cos(angularDistance) - + Math.sin(targetLatRad) * Math.sin(cameraLatRad), + ); + const cameraLon = (((cameraLonRad * 180) / Math.PI + 540) % 360) - 180; + return { + lat: (cameraLatRad * 180) / Math.PI, + lon: cameraLon, + height, + heading, + pitch, + roll, + }; + } + + /** + * Decide whether Radio navigation should first restore a centered Earth view. + * Fit-capable clipped discs and closer views whose optical center has reached + * the Earth limb use the staged path; ordinary centered local views stay direct. + */ + + function radioGlobeNeedsRecentering(geometry) { + if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false; + const centeredGeometry = { + ...geometry, + earthCenterX: geometry.keyholeCenterX, + earthCenterY: geometry.keyholeCenterY, + }; + if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true; + const values = [ + geometry.earthCenterX, + geometry.earthCenterY, + geometry.earthRadius, + geometry.keyholeCenterX, + geometry.keyholeCenterY, + ]; + if (!values.every(Number.isFinite) || geometry.earthRadius <= 0) + return false; + const centerOffset = Math.hypot( + geometry.earthCenterX - geometry.keyholeCenterX, + geometry.earthCenterY - geometry.keyholeCenterY, + ); + return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX; + } + + /** Preserve closer zoom while capping an extreme full-globe recovery. */ + + function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) { + if (!Number.isFinite(currentHeight) || currentHeight < 0) return null; + return fullGlobeCapable + ? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M) + : currentHeight; + } + + function radioCameraState(camera = layerState._viewer?.camera) { + if (!camera) return null; + return { + height: camera.positionCartographic?.height, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }; + } + + /** Radio may move the globe only while no tracked entity owns the follow camera. */ + + function radioCameraNavigationAllowed(viewer = layerState._viewer) { + return Boolean(viewer?.camera) && !viewer.trackedEntity; + } + + function radioGlobeRecenterPlan(viewer = layerState._viewer) { + const camera = viewer?.camera; + const canvas = viewer?.scene?.canvas; + const width = canvas?.clientWidth || canvas?.width; + const height = canvas?.clientHeight || canvas?.height; + const cartographic = camera?.positionCartographic; + if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null; + const geometry = projectEarthDiscToViewport( + viewer, + width, + height, + layerState._radioEarthScreenCenter, + layerState._radioEarthToCenter, + ); + let fullGlobeCapable = false; + if (geometry) { + if (!radioGlobeNeedsRecentering(geometry)) return null; + fullGlobeCapable = isFullGlobeInsideKeyhole( + { + ...geometry, + earthCenterX: geometry.keyholeCenterX, + earthCenterY: geometry.keyholeCenterY, + }, + false, + ); + } else { + const earthRadius = earthDiscScreenRadius( + Cesium.Cartesian3.magnitude(camera.positionWC), + height, + camera.frustum?.fovy, + ); + const facingEarthCenter = + Cesium.Cartesian3.dot( + camera.directionWC, + layerState._radioEarthToCenter, + ) > 0; + if (!earthRadius || facingEarthCenter) return null; + const keyhole = getKeyholeGeometry(width, height); + fullGlobeCapable = + earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius; + } + const recenterHeight = radioGlobeRecenterHeight( + cartographic.height, + fullGlobeCapable, + ); + if (recenterHeight == null) return null; + return { + destination: Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + recenterHeight, + ), + cameraState: { + ...radioCameraState(camera), + height: recenterHeight, + heading: 0, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }; + } + + function radioCameraNavigationIsCurrent(navigation) { + return Boolean( + navigation && + navigation.generation === layerState._radioCameraNavigationGeneration && + layerState._enabled && + radioCameraNavigationAllowed(layerState._viewer), + ); + } + + function cancelActiveRadioCameraFlight() { + if ( + !layerState._activeRadioCameraFlight || + !radioCameraNavigationAllowed(layerState._viewer) + ) + return; + layerState._activeRadioCameraFlight = null; + layerState._viewer.camera.cancelFlight(); + } + + function invalidateRadioCameraNavigation() { + layerState._radioCameraNavigationGeneration += 1; + cancelActiveRadioCameraFlight(); + layerState._activeRadioCameraFlight = null; + } + + function radioCameraNavigationOwnsSelection(navigation, station) { + return Boolean( + navigation && + station && + navigation.generation === layerState._radioCameraNavigationGeneration && + navigation.target?.id === station.id, + ); + } + + function startRadioCameraFlight(navigation, options, onComplete) { + if (!radioCameraNavigationIsCurrent(navigation)) return false; + const token = ++layerState._radioCameraFlightSequence; + layerState._activeRadioCameraFlight = { + generation: navigation.generation, + token, + }; + const finish = (completed) => { + if (layerState._activeRadioCameraFlight?.token === token) + layerState._activeRadioCameraFlight = null; + if (completed && radioCameraNavigationIsCurrent(navigation)) + onComplete?.(); + }; + layerState._viewer.camera.flyTo({ + ...options, + complete: () => finish(true), + cancel: () => finish(false), + }); + return true; + } + + function focusRadioNavigationTarget(navigation) { + if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target) + return false; + const plan = radioStationCameraPlan( + navigation.target, + navigation.cameraState, + ); + if (!plan) return false; + navigation.phase = 'focusing'; + return startRadioCameraFlight( + navigation, + { + destination: Cesium.Cartesian3.fromDegrees( + plan.lon, + plan.lat, + plan.height, + ), + orientation: { + heading: plan.heading, + pitch: plan.pitch, + roll: plan.roll, + }, + duration: navigation.duration, + }, + () => { + navigation.phase = 'settled'; + }, + ); + } + + function beginRadioCameraNavigation(cameraState = null) { + const generation = ++layerState._radioCameraNavigationGeneration; + if ( + !radioCameraNavigationAllowed(layerState._viewer) || + !layerState._enabled + ) + return null; + cancelActiveRadioCameraFlight(); + const recenterPlan = radioGlobeRecenterPlan(layerState._viewer); + return { + generation, + phase: 'idle', + recentered: false, + recenterPlan, + cameraState: + recenterPlan?.cameraState || cameraState || radioCameraState(), + target: null, + duration: 0.35, + }; + } + + function rotateRadioStationIntoView( + station, + duration = 0.35, + cameraState = null, + navigation = null, + ) { + const activeNavigation = + navigation || beginRadioCameraNavigation(cameraState); + if (!station || !radioCameraNavigationIsCurrent(activeNavigation)) + return false; + activeNavigation.target = station; + activeNavigation.duration = duration; + if (activeNavigation.phase === 'recentering') return true; + if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight(); + if (activeNavigation.recenterPlan && !activeNavigation.recentered) { + activeNavigation.phase = 'recentering'; + const recenterDuration = Math.min(0.9, Math.max(0.65, duration)); + return startRadioCameraFlight( + activeNavigation, + { + destination: activeNavigation.recenterPlan.destination, + orientation: { + heading: activeNavigation.cameraState.heading, + pitch: activeNavigation.cameraState.pitch, + roll: activeNavigation.cameraState.roll, + }, + duration: recenterDuration, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + () => { + activeNavigation.recentered = true; + activeNavigation.phase = 'idle'; + focusRadioNavigationTarget(activeNavigation); + }, + ); + } + return focusRadioNavigationTarget(activeNavigation); + } + + function focusStation(station) { + layerState._radioCameraNavigationGeneration += 1; + if (!station || !radioCameraNavigationAllowed(layerState._viewer)) + return false; + cancelActiveRadioCameraFlight(); + layerState._viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + station.lon, + station.lat, + 85_000, + ), + duration: 1.1, + }); + return true; + } + return { + radioCameraPositionChanged, + radioViewIsGlobal, + radioStationCameraPlan, + radioGlobeNeedsRecentering, + radioGlobeRecenterHeight, + radioCameraState, + radioCameraNavigationAllowed, + radioGlobeRecenterPlan, + radioCameraNavigationIsCurrent, + cancelActiveRadioCameraFlight, + invalidateRadioCameraNavigation, + radioCameraNavigationOwnsSelection, + startRadioCameraFlight, + focusRadioNavigationTarget, + beginRadioCameraNavigation, + rotateRadioStationIntoView, + focusStation, + }; +} diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js new file mode 100644 index 000000000..cbb4b8be5 --- /dev/null +++ b/src/layers/radio/playback.js @@ -0,0 +1,382 @@ +import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js'; + +export function createPlayback({ state: layerState, services, parts, source }) { + function audioEventBelongsToActiveAttempt(audio) { + const attempt = layerState._activePlaybackAttempt; + return ( + layerState._audio === audio && + Boolean(attempt) && + attempt.generation === layerState._playGeneration && + attempt.stationId === layerState._audioStationId + ); + } + + function installAudio({ replace = false } = {}) { + if (layerState._audio && !replace) return; + if (typeof Audio === 'undefined') return; + const previousAudio = layerState._audio; + layerState._audio = null; + if (previousAudio) { + try { + previousAudio.pause(); + } catch { + /* already stopped */ + } + try { + previousAudio.removeAttribute('src'); + } catch { + /* no source */ + } + try { + previousAudio.load(); + } catch { + /* detached media */ + } + } + const audio = new Audio(); + layerState._audio = audio; + audio.preload = 'none'; + audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume; + audio.addEventListener('playing', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + layerState._audioState = 'playing'; + layerState._audioError = null; + if (layerState._tuningAwaitingStationId === layerState._audioStationId) + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + parts.presentation.emitState(); + }); + audio.addEventListener('pause', () => { + if (layerState._audio !== audio) return; + if ( + layerState._audioState === 'stopped' || + layerState._audioState === 'loading' + ) + return; + layerState._audioState = 'paused'; + parts.presentation.emitState(); + }); + audio.addEventListener('waiting', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + layerState._audioState = 'buffering'; + parts.presentation.emitState(); + }); + audio.addEventListener('error', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + const failedId = layerState._audioStationId; + const attempt = layerState._activePlaybackAttempt; + if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return; + layerState._audioState = 'error'; + layerState._audioError = + 'Broadcaster stream is unavailable or blocked by the browser.'; + parts.presentation.emitState(); + }); + } + + function tryRadioFallback( + failedId, + origin = layerState._playFallbackOrigin || 'programmatic', + attemptId = layerState._playFallbackAttemptId, + ) { + const fallbackId = layerState._playFallbackId; + const fallbackStation = fallbackId + ? layerState._stationById.get(fallbackId) + : null; + const fallbackFocusPolicy = layerState._playFallbackFocus; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + if ( + !fallbackStation || + fallbackId === failedId || + layerState._selectedId !== failedId + ) { + return false; + } + const fallbackFocusResult = + typeof fallbackFocusPolicy === 'function' + ? fallbackFocusPolicy(fallbackStation) + : fallbackFocusPolicy; + const fallbackCameraNavigation = + fallbackFocusResult && typeof fallbackFocusResult === 'object' + ? fallbackFocusResult + : null; + const fallbackFocus = fallbackCameraNavigation + ? false + : Boolean(fallbackFocusResult); + parts.selection.selectRadioStation(fallbackId, { + autoplay: true, + focus: fallbackFocus, + origin: origin || 'programmatic', + attemptId, + cameraNavigation: fallbackCameraNavigation, + }); + return true; + } + + function recordDirectoryClick(id) { + Promise.resolve() + .then(() => source.recordClick(id)) + .catch(() => {}); + } + + /** Play the selected broadcaster stream after an explicit user action. */ + + async function playSelectedRadio({ + origin = 'programmatic', + attemptId = null, + } = {}) { + const station = parts.queries.selectedStation(); + if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl) + return false; + if (layerState._tuningActive) parts.tuning.endRadioTuning(); + // A media event carries no reliable attempt identity. Give every explicit + // play/resume/replacement its own element so queued events from the retired + // attempt remain bound to the discarded element and cannot mutate this one. + installAudio({ replace: true }); + if (!layerState._audio) return false; + + const generation = ++layerState._playGeneration; + const ownedAttemptId = + attemptId || `radio-play-${++layerState._playAttemptSequence}`; + layerState._activePlaybackAttempt = { + id: ownedAttemptId, + origin, + stationId: station.id, + streamUrl: station.streamUrl, + generation, + }; + if (layerState._playFallbackId) { + layerState._playFallbackOrigin = origin; + layerState._playFallbackAttemptId = ownedAttemptId; + } + layerState._audioError = null; + layerState._audioState = 'loading'; + if ( + layerState._audioStationId !== station.id || + layerState._audio.src !== station.streamUrl + ) { + layerState._audio.pause(); + layerState._audio.src = station.streamUrl; + layerState._audioStationId = station.id; + } + parts.presentation.emitState(); + + try { + const playback = layerState._audio.play(); + if (playback?.then) await playback; + if ( + generation !== layerState._playGeneration || + layerState._audioStationId !== station.id || + layerState._activePlaybackAttempt?.id !== ownedAttemptId + ) + return false; + layerState._audioState = 'playing'; + if (layerState._tuningAwaitingStationId === station.id) + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + recordDirectoryClick(station.id); + parts.presentation.emitState(); + if (origin === 'user') + parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId); + return true; + } catch (error) { + if ( + generation !== layerState._playGeneration || + layerState._activePlaybackAttempt?.id !== ownedAttemptId + ) + return false; + if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false; + layerState._audioState = 'error'; + layerState._audioError = + error?.name === 'NotAllowedError' + ? 'Playback requires a direct click or tap.' + : 'Broadcaster stream could not be started.'; + parts.presentation.emitState(); + return false; + } + } + + /** + * Wait for confirmed Radio playback while voice owns a hard mute. + * A fallback station may replace the first stream during this wait, so the + * state subscription—not the first play() promise—is authoritative. + */ + + function confirmRadioPlayback({ + startPlayback, + subscribe, + getState, + timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS, + } = {}) { + if ( + typeof startPlayback !== 'function' || + typeof subscribe !== 'function' || + typeof getState !== 'function' + ) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + let started = false; + let unsubscribe = () => {}; + let timer = null; + const finish = (ok) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + unsubscribe(); + resolve(Boolean(ok)); + }; + const inspect = (state) => { + if (!started || !state) return; + if ( + !state.voiceDucked && + ['loading', 'buffering', 'playing'].includes(state.audioState) + ) { + finish(false); + return; + } + if ( + state.audioState === 'playing' && + state.playingStationId && + state.voiceDucked + ) { + finish(true); + } else if (state.audioState === 'error') { + finish(false); + } + }; + unsubscribe = subscribe(inspect); + started = true; + timer = setTimeout( + () => finish(false), + Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS), + ); + Promise.resolve() + .then(startPlayback) + .then((playStarted) => { + const state = getState(); + inspect(state); + if ( + !playStarted && + !['loading', 'buffering', 'playing'].includes(state?.audioState) + ) + finish(false); + }) + .catch(() => finish(false)); + }); + } + + /** Start and verify a prepared station without ever making it audible under voice. */ + + function playPreparedRadioForVoice(options = {}) { + if (!layerState._voiceDucked) return Promise.resolve(false); + return confirmRadioPlayback({ + startPlayback: () => + playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }), + subscribe: parts.presentation.subscribeToRadio, + getState: parts.presentation.getRadioUIState, + timeoutMs: options.timeoutMs, + }); + } + + /** Stop the shared stream and release its network resource. */ + + function stopRadioPlayback({ + origin = 'programmatic', + attemptId = null, + } = {}) { + if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId) + return false; + const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null; + parts.tuning.endRadioTuning(); + layerState._playGeneration += 1; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + layerState._activePlaybackAttempt = null; + if (layerState._audio) { + layerState._audio.pause(); + layerState._audio.removeAttribute('src'); + layerState._audio.load(); + } + layerState._audioStationId = null; + layerState._audioState = 'stopped'; + layerState._audioError = null; + parts.presentation.emitState(); + if (origin === 'user' || origin === 'voice') { + parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId); + } + return true; + } + + /** Pause or resume the selected stream. Resuming is still click initiated. */ + + function toggleRadioPlayback({ origin = 'programmatic' } = {}) { + if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) { + return Promise.resolve(pauseRadioPlayback({ origin })); + } + if (!parts.interaction.radioPresentationAllowed()) + return Promise.resolve(false); + if (!layerState._selectedId) { + const ranked = parts.queries.rankedVisibleStations(); + if (!ranked.length) return Promise.resolve(false); + layerState._playFallbackId = ranked[1]?.id || null; + layerState._playFallbackFocus = false; + parts.selection.selectRadioStation(ranked[0].id, { + autoplay: false, + focus: false, + }); + } + return playSelectedRadio({ origin }); + } + + /** Pause Radio without toggling a stopped or already-paused stream back on. */ + + function pauseRadioPlayback({ origin = 'programmatic' } = {}) { + if (!['loading', 'playing', 'buffering'].includes(layerState._audioState)) + return false; + const pausedAttemptId = layerState._activePlaybackAttempt?.id || null; + if (layerState._tuningAwaitingStationId && !layerState._tuningActive) + parts.tuning.endRadioTuning(); + layerState._playGeneration += 1; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + layerState._activePlaybackAttempt = null; + layerState._audio?.pause(); + layerState._audioState = 'paused'; + parts.presentation.emitState(); + if (origin === 'user' || origin === 'voice') { + parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId); + } + return true; + } + return { + audioEventBelongsToActiveAttempt, + installAudio, + tryRadioFallback, + recordDirectoryClick, + playSelectedRadio, + confirmRadioPlayback, + playPreparedRadioForVoice, + stopRadioPlayback, + toggleRadioPlayback, + pauseRadioPlayback, + }; +} diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js new file mode 100644 index 000000000..21d987473 --- /dev/null +++ b/src/layers/radio/policy.js @@ -0,0 +1,170 @@ +export const RADIO_PREFIX = 'radio:'; + +export const DIRECTORY_ENDPOINT = '/api/radio/stations'; + +export const HORIZON_TICK_MS = 250; + +export const HORIZON_CAMERA_MOVE_EPSILON_M = 1; + +export const MARKER_LIFT_M = 2.5; + +export const SELECTED_LIFT_M = 5; + +export const RADIO_PICK_TOLERANCE_PX = 8; + +export const RADIO_TUNER_DIRECTORY_LIMIT = 750; + +export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT; + +export const RADIO_TUNER_STATIC_MAX_GAIN = 0.018; + +export const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000; + +export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; + +export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; + +export const RADIO_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const RADIO_OVERLAY_SOURCE_ID = 'radio'; + +export const RADIO_OVERLAY_COHORT_LIMIT = 64; + +export const RADIO_SINGLETON_GLOBAL_LIMIT = 16; + +export const RADIO_SINGLETON_MID_LIMIT = 32; + +export const RADIO_SINGLETON_NEAR_LIMIT = 48; + +export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000; + +export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000; + +export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: RADIO_OVERLAY_COHORT_LIMIT, + collisionCapacity: 96, + moving: false, +}); + +export const RADIO_PICK_OFFSETS = Object.freeze([ + [0, -RADIO_PICK_TOLERANCE_PX], + [RADIO_PICK_TOLERANCE_PX, 0], + [0, RADIO_PICK_TOLERANCE_PX], + [-RADIO_PICK_TOLERANCE_PX, 0], + [6, -6], + [6, 6], + [-6, 6], + [-6, -6], +]); + +export const DEFAULT_RADIO_FILTER = 'all'; + +export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; + +export const MUSIC_GENRES = Object.freeze([ + ['alternative', 'Alternative'], + ['ambient', 'Ambient'], + ['blues', 'Blues'], + ['classical', 'Classical'], + ['country', 'Country'], + ['dance', 'Dance'], + ['electronic', 'Electronic'], + ['folk', 'Folk'], + ['funk', 'Funk'], + ['hip hop', 'Hip-Hop'], + ['house', 'House'], + ['indie', 'Indie'], + ['jazz', 'Jazz'], + ['latin', 'Latin'], + ['metal', 'Metal'], + ['oldies', 'Oldies'], + ['pop', 'Pop'], + ['punk', 'Punk'], + ['r&b', 'R&B'], + ['reggae', 'Reggae'], + ['rock', 'Rock'], + ['soul', 'Soul'], + ['techno', 'Techno'], + ['trance', 'Trance'], + ['world', 'World'], +]); + +export const CATEGORY_MATCHERS = Object.freeze({ + news: ['news', 'current affairs', 'journalism'], + talk: ['talk', 'spoken word', 'interview', 'podcast'], + weather: ['weather', 'emergency', 'noaa'], + 'public-safety': [ + 'public safety', + 'scanner', + 'police', + 'fire', + 'ems', + 'dispatch', + 'emergency', + ], + 'aviation-marine': [ + 'aviation', + 'air traffic', + 'atc', + 'airport', + 'marine', + 'maritime', + 'coast guard', + ], + 'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'], +}); + +export const RADIO_CATEGORY_COLORS = Object.freeze({ + all: '#b9fbff', + news: '#44adff', + talk: '#f2b84b', + weather: '#ff5c78', + 'public-safety': '#ff8b4a', + 'aviation-marine': '#a87cff', + 'traffic-transit': '#ffd166', + music: '#54d17a', + other: '#9aa7b3', +}); + +export const RADIO_CLUSTER_LABELS = Object.freeze({ + news: 'NEWS', + talk: 'TALK', + weather: 'WEATHER', + 'public-safety': 'SAFETY', + 'aviation-marine': 'AIR / SEA', + 'traffic-transit': 'TRANSIT', + music: 'MUSIC', + other: 'OTHER', +}); + +export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([ + 'news', + 'public-safety', + 'weather', + 'aviation-marine', + 'traffic-transit', + 'talk', + 'music', +]); + +export const DEFAULT_RADIO_VOLUME = 0.8; + +export const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({ + instance: null, + generation: null, + updatedAt: null, + stations: Object.freeze([]), + stationIds: Object.freeze([]), +}); + +export const VOICE_RESTORE_DELAY_MS = 650; + +export const VOICE_RESTORE_DURATION_MS = 1800; + +export const RADIO_GLOBE_LABEL_MAX_CHARS = 30; + +export const RADIO_LABEL_SEGMENTER = + typeof Intl?.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null; diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js new file mode 100644 index 000000000..5d6763ca4 --- /dev/null +++ b/src/layers/radio/presentation.js @@ -0,0 +1,105 @@ +export function createPresentation({ + state: layerState, + services, + parts, + source, +}) { + /** Return the deeply immutable healthy catalog generation shared with tuner consumers. */ + + function getRadioAcceptedCatalogSnapshot() { + return layerState._acceptedCatalogSnapshot; + } + + /** Return an immutable snapshot consumed by the right-rail UI. */ + + function getRadioUIState() { + const visible = parts.queries.visibleStations(); + const selected = parts.queries.selectedStation(); + return Object.freeze({ + enabled: layerState._enabled, + loading: layerState._loading, + stale: layerState._stale, + degraded: layerState._degraded, + error: layerState._error, + updatedAt: layerState._updatedAt, + filter: layerState._filter, + categories: layerState._categories, + acceptedCatalogGeneration: layerState._acceptedCatalogSnapshot.generation, + presentationActive: parts.interaction.radioPresentationAllowed(), + stationCount: layerState._stations.length, + filteredCount: visible.length, + selected, + selectedIndex: selected + ? visible.findIndex((station) => station.id === selected.id) + : -1, + audioState: layerState._audioState, + audioError: layerState._audioError, + playingStationId: layerState._audioStationId, + volume: layerState._userVolume, + effectiveVolume: + layerState._audio?.volume ?? + (layerState._voiceDucked ? 0 : layerState._userVolume), + voiceDucked: layerState._voiceDucked, + voiceRestoring: layerState._voiceRestoring, + tuningActive: layerState._tuningActive, + tuningStatic: layerState._tuningStatic, + tuningAwaitingStationId: layerState._tuningAwaitingStationId, + tuningPreviewStationId: layerState._tuningPreviewId, + tuningRestoredStationId: + layerState._cancelledTuningPresentationStation?.id || null, + tuningCatalogGeneration: layerState._tuningResolutionSnapshot.generation, + tuningUnavailableStationId: layerState._tuningUnavailableStationId, + }); + } + + function emitState() { + const snapshot = getRadioUIState(); + for (const listener of layerState._listeners) { + try { + listener(snapshot); + } catch { + // A broken consumer must not break playback or rendering. + } + } + } + + /** Subscribe to radio state; the current state is delivered immediately. */ + + function subscribeToRadio(listener) { + if (typeof listener !== 'function') return () => {}; + layerState._listeners.add(listener); + listener(getRadioUIState()); + return () => layerState._listeners.delete(listener); + } + + /** Subscribe to explicit playback controls so voice handoffs cannot undo them. */ + + function subscribeToRadioPlaybackControls(listener) { + if (typeof listener !== 'function') return () => {}; + layerState._playbackControlListeners.add(listener); + return () => layerState._playbackControlListeners.delete(listener); + } + + function emitPlaybackControl( + action, + origin, + attemptId = layerState._activePlaybackAttempt?.id || null, + ) { + const event = { action, origin, attemptId }; + for (const listener of layerState._playbackControlListeners) { + try { + listener(event); + } catch { + // Playback controls must remain usable if an observer fails. + } + } + } + return { + getRadioAcceptedCatalogSnapshot, + getRadioUIState, + emitState, + subscribeToRadio, + subscribeToRadioPlaybackControls, + emitPlaybackControl, + }; +} diff --git a/src/layers/radio/queries.js b/src/layers/radio/queries.js new file mode 100644 index 000000000..5bc4cc573 --- /dev/null +++ b/src/layers/radio/queries.js @@ -0,0 +1,152 @@ +import * as Cesium from 'cesium'; +import { normalizeRadioCountryInput } from '../../data/radioCountry.js'; + +export function createQueries({ state: layerState, services, parts, source }) { + function radioAngularDistance(station, anchor) { + const lat1 = Cesium.Math.toRadians(Number(anchor?.lat)); + const lon1 = Cesium.Math.toRadians(Number(anchor?.lon)); + const lat2 = Cesium.Math.toRadians(Number(station?.lat)); + const lon2 = Cesium.Math.toRadians(Number(station?.lon)); + if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) + return Number.POSITIVE_INFINITY; + const deltaLat = lat2 - lat1; + const deltaLon = lon2 - lon1; + const haversine = + Math.sin(deltaLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2; + return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine)))); + } + + /** Rank a copied station list by viewport distance, with an optional English-first tier. */ + + function rankRadioStationsForViewport( + stations, + anchor, + { preferEnglish = false } = {}, + ) { + return (Array.isArray(stations) ? stations : []) + .map((station, index) => ({ + station, + index, + distance: radioAngularDistance(station, anchor), + languageTier: + preferEnglish && !parts.categories.isEnglishRadioStation(station) + ? 1 + : 0, + })) + .sort( + (a, b) => + a.languageTier - b.languageTier || + a.distance - b.distance || + a.index - b.index, + ) + .map(({ station }) => station); + } + + /** Rank stations for an explicit voice/player request without moving the camera. */ + + function rankRadioStationsForRequest( + stations, + { categoryId = 'all', anchor = null, country = '', stationQuery = '' } = {}, + ) { + const countryFilter = normalizeRadioCountryInput(country); + if (!countryFilter.valid) return []; + const query = parts.categories.normalizeRadioTag(stationQuery); + let matches = parts.categories.filterRadioStations(stations, categoryId); + if (countryFilter.code || countryFilter.name) { + matches = matches.filter((station) => { + const stationCode = String(station?.countryCode || '') + .trim() + .toUpperCase(); + const stationCountry = normalizeRadioCountryInput(station?.country); + return ( + (countryFilter.code && stationCode === countryFilter.code) || + (countryFilter.code && + stationCountry.valid && + stationCountry.code === countryFilter.code) + ); + }); + } + if (query) { + matches = matches.filter((station) => + [ + station?.id, + station?.name, + station?.state, + station?.country, + station?.countryCode, + ...(Array.isArray(station?.tags) ? station.tags : []), + ].some((value) => + parts.categories.normalizeRadioTag(value).includes(query), + ), + ); + } + return anchor + ? rankRadioStationsForViewport(matches, anchor) + : matches.slice(); + } + + function selectedStation() { + return layerState._selectedId + ? layerState._stationById.get(layerState._selectedId) || null + : null; + } + + function selectedPresentationStation() { + if (layerState._tuningActive) + return parts.model.tuningResolutionStation(layerState._tuningPreviewId); + return layerState._cancelledTuningPresentationStation || selectedStation(); + } + + function visibleStations() { + return parts.categories.filterRadioStations( + layerState._stations, + layerState._filter, + ); + } + + function viewportRadioAnchor() { + const camera = layerState._viewer?.camera; + const scene = layerState._viewer?.scene; + if (!camera) return null; + const altitudeM = Number(camera.positionCartographic?.height); + let cartographic = null; + const canvas = scene?.canvas; + if (canvas && typeof camera.pickEllipsoid === 'function') { + const center = new Cesium.Cartesian2( + canvas.clientWidth / 2, + canvas.clientHeight / 2, + ); + const position = camera.pickEllipsoid( + center, + scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + ); + if (position) cartographic = Cesium.Cartographic.fromCartesian(position); + } + cartographic ||= camera.positionCartographic || null; + if (!cartographic) return null; + return { + lat: Cesium.Math.toDegrees(cartographic.latitude), + lon: Cesium.Math.toDegrees(cartographic.longitude), + altitudeM, + globalView: parts.navigation.radioViewIsGlobal(altitudeM), + }; + } + + function rankedVisibleStations() { + const anchor = viewportRadioAnchor(); + return rankRadioStationsForViewport(visibleStations(), anchor, { + preferEnglish: Boolean(anchor?.globalView), + }); + } + return { + radioAngularDistance, + rankRadioStationsForViewport, + rankRadioStationsForRequest, + selectedStation, + selectedPresentationStation, + visibleStations, + viewportRadioAnchor, + rankedVisibleStations, + }; +} diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js new file mode 100644 index 000000000..36cbc5050 --- /dev/null +++ b/src/layers/radio/rendering.js @@ -0,0 +1,411 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { + MARKER_LIFT_M, + RADIO_PREFIX, + SELECTED_LIFT_M, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { cachedGroundFloor } = services.ground; + const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } = + services.overlays; + const { governorRequestRender } = services.render; + + function markerPosition(station, liftM = MARKER_LIFT_M) { + const floor = cachedGroundFloor(station.lat, station.lon); + return Cesium.Cartesian3.fromDegrees( + station.lon, + station.lat, + (Number.isFinite(floor) ? floor : 0) + liftM, + ); + } + + function updateSelectionEntity() { + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + const station = parts.queries.selectedPresentationStation(); + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._viewer || + !station + ) { + scheduleRadioOverlayPublish(); + return; + } + const selectionColor = parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ); + const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`; + + layerState._selectedEntity = layerState._viewer.entities.add({ + id: `${RADIO_PREFIX}selected:${station.id}`, + position: markerPosition(station, SELECTED_LIFT_M), + point: { + pixelSize: 14, + color: Cesium.Color.fromCssColorString(selectionColor), + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + }, + billboard: { + image: bracketImage, + width: 40, + height: 40, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72), + }, + }); + scheduleRadioOverlayPublish(); + } + + function clusterPointCollection() { + return layerState._dataSource?.clustering?._clusterPointCollection || null; + } + + function publishRadioOverlayEntries() { + layerState._overlayPublishTimer = null; + if (!parts.interaction.radioPresentationAllowed()) { + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._overlayDiagnostics = + parts.clustering.emptyRadioOverlayDiagnostics(); + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + return; + } + + const entries = []; + const station = parts.queries.selectedPresentationStation(); + if (station) { + const selectedEntry = parts.labels.createRadioSelectedOverlayEntry( + station, + markerPosition(station, SELECTED_LIFT_M), + ); + if (selectedEntry) entries.push(selectedEntry); + } + + const clusterCandidates = []; + const clusteredStationIds = new Set(); + const points = clusterPointCollection(); + for (let index = 0; index < (points?.length || 0); index += 1) { + const point = points.get(index); + if ( + !point?.show || + !point.position || + !Array.isArray(point.id) || + point.id.length < 3 + ) + continue; + const stationIds = point.id + .map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length)) + .filter((id) => { + const station = layerState._stationById.get(id); + return ( + station && + parts.categories.stationMatchesRadioCategory( + station, + layerState._filter, + ) + ); + }) + .sort(); + if (stationIds.length < 3) continue; + for (const stationId of stationIds) clusteredStationIds.add(stationId); + const clusteredStations = stationIds.map((id) => + layerState._stationById.get(id), + ); + const category = parts.model.radioClusterCategoryId( + clusteredStations, + layerState._filter, + ); + clusterCandidates.push({ + id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`, + stationIds, + point, + text: parts.model.radioClusterBadgeText(category, stationIds.length), + accent: parts.model.radioCategoryColor(category), + stationCount: stationIds.length, + }); + } + const selectedClusterCandidates = + parts.clustering.selectRadioClusterCandidates(clusterCandidates); + layerState._clusterOverlayIdentities = + parts.clustering.reconcileRadioClusterCandidates( + selectedClusterCandidates, + layerState._clusterOverlayIdentities, + () => `stable:${++layerState._clusterOverlayIdentitySequence}`, + ); + for (const candidate of layerState._clusterOverlayIdentities) { + const clusterEntry = parts.labels.createRadioClusterOverlayEntry({ + ...candidate, + position: () => candidate.point.position, + }); + if (clusterEntry) entries.push(clusterEntry); + } + + const cameraPosition = layerState._viewer?.camera?.positionWC; + const singletonAllowance = Math.min( + parts.clustering.radioSingletonLabelLimit( + layerState._viewer?.camera?.positionCartographic?.height, + ), + Math.max( + 0, + RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length, + ), + ); + const singletonCandidates = parts.clustering.selectRadioSingletonCandidates( + [...layerState._renderById.values()] + .filter( + (record) => + record.entity?.show && + record.station?.id !== station?.id && + !clusteredStationIds.has(record.station?.id), + ) + .map((record) => ({ + ...record, + distanceM: cameraPosition + ? Cesium.Cartesian3.distance(cameraPosition, record.position) + : Number.POSITIVE_INFINITY, + })), + singletonAllowance, + ); + for (let index = 0; index < singletonCandidates.length; index += 1) { + const candidate = singletonCandidates[index]; + const singletonEntry = parts.labels.createRadioSingletonOverlayEntry({ + station: candidate.station, + position: candidate.position, + // Cluster priorities begin at three. Keep clusters authoritative while + // retaining nearest-first singleton ordering inside the ambient lane. + priority: 2 - index / Math.max(1, singletonCandidates.length + 1), + }); + if (singletonEntry) entries.push(singletonEntry); + } + + setOverlayEntries( + RADIO_OVERLAY_SOURCE_ID, + entries, + RADIO_OVERLAY_SOURCE_OPTIONS, + ); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true); + layerState._overlayDiagnostics = { + entryCount: entries.length, + selectedCount: entries.filter((entry) => entry.selected).length, + singletonTexts: entries + .filter((entry) => entry.id.startsWith('station:')) + .map((entry) => entry.title), + singletonIds: entries + .filter((entry) => entry.id.startsWith('station:')) + .map((entry) => entry.id), + clusterTexts: entries + .filter((entry) => entry.id.startsWith('cluster:')) + .map((entry) => entry.title), + clusterIds: entries + .filter((entry) => entry.id.startsWith('cluster:')) + .map((entry) => entry.id), + clusterMemberships: layerState._clusterOverlayIdentities.map( + (candidate) => ({ + membershipId: candidate.membershipId, + entryId: `cluster:${candidate.id}`, + }), + ), + }; + } + + function scheduleRadioOverlayPublish() { + if (layerState._overlayPublishTimer) + clearTimeout(layerState._overlayPublishTimer); + const sessionGeneration = layerState._sessionGeneration; + layerState._overlayPublishTimer = setTimeout(() => { + if (sessionGeneration === layerState._sessionGeneration) + publishRadioOverlayEntries(); + }, 0); + } + + function updateRenderVisibility({ force = true } = {}) { + if (!layerState._viewer || !layerState._dataSource) return; + const cameraPosition = layerState._viewer.camera?.positionWC; + if ( + !force && + !parts.navigation.radioCameraPositionChanged( + layerState._lastHorizonCameraPosition, + cameraPosition, + ) + ) + return; + layerState._lastHorizonCameraPosition = cameraPosition + ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z } + : null; + layerState._horizonScanCount += 1; + const occluder = horizonOccluder(layerState._viewer.camera); + let visibilityChanged = false; + for (const [id, record] of layerState._renderById) { + const matches = parts.categories.stationMatchesRadioCategory( + record.station, + layerState._filter, + ); + const visible = matches && occluder.isPointVisible(record.position); + if (record.entity.show !== visible) visibilityChanged = true; + record.entity.show = visible; + } + if (layerState._selectedEntity) { + const position = layerState._selectedEntity.position?.getValue?.( + Cesium.JulianDate.now(), + ); + const selectedVisible = !position || occluder.isPointVisible(position); + if (layerState._selectedEntity.show !== selectedVisible) + visibilityChanged = true; + layerState._selectedEntity.show = selectedVisible; + } + scheduleRadioOverlayPublish(); + // The horizon timer can commit AFTER the camera settles and the governor + // parks the scene — a changed show flag needs one frame. (perf wave 2 fix) + if (visibilityChanged) governorRequestRender('radio-horizon'); + } + + function reconcileStations(stations) { + if (!layerState._tuningActive) + layerState._cancelledTuningPresentationStation = null; + layerState._clusterOverlayIdentities = + parts.clustering.retainRadioClusterIdentitiesForStations( + layerState._clusterOverlayIdentities, + stations, + ); + layerState._stations = Object.freeze([...stations]); + layerState._stationById = new Map( + stations.map((station) => [station.id, station]), + ); + layerState._categories = Object.freeze( + parts.categories + .buildRadioCategories(stations) + .map((category) => Object.freeze(category)), + ); + if ( + layerState._filter === DEFAULT_RADIO_FILTER && + !parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER) + .length + ) { + layerState._filter = 'all'; + } + if ( + layerState._selectedId && + !layerState._stationById.has(layerState._selectedId) + ) { + if (layerState._audioStationId === layerState._selectedId) + parts.playback.stopRadioPlayback(); + layerState._selectedId = null; + } + + if (layerState._dataSource) layerState._dataSource.entities.removeAll(); + layerState._renderById.clear(); + for (const station of stations) { + const position = markerPosition(station); + const markerColor = parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ); + const entity = layerState._dataSource.entities.add({ + id: `${RADIO_PREFIX}${station.id}`, + position, + point: { + pixelSize: 13, + color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86), + outlineColor: Cesium.Color.fromCssColorString('#071b25'), + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar( + 100_000, + 1.15, + 12_000_000, + 1, + ), + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + }, + }); + layerState._renderById.set(station.id, { station, entity, position }); + } + updateSelectionEntity(); + updateRenderVisibility(); + } + + function installClusterStyling() { + if (!layerState._dataSource || layerState._removeClusterListener) return; + const clustering = layerState._dataSource.clustering; + clustering.enabled = true; + clustering.pixelRange = 42; + clustering.minimumClusterSize = 3; + clustering.clusterPoints = true; + clustering.clusterLabels = false; + clustering.clusterBillboards = false; + layerState._removeClusterListener = + clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { + const clusteredStations = []; + for (const entity of clusteredEntities) { + const stationId = String(entity.id || '').slice(RADIO_PREFIX.length); + const station = layerState._renderById.get(stationId)?.station; + if (station) clusteredStations.push(station); + } + const clusterCategory = parts.model.radioClusterCategoryId( + clusteredStations, + layerState._filter, + ); + const clusterColor = Cesium.Color.fromCssColorString( + parts.model.radioCategoryColor(clusterCategory), + ); + // Cesium assigns the entity-id array only to the generated cluster label. + // Mirror it to the visible point so clicking either part of the callout + // resolves the first station and remains a direct playback gesture. + cluster.point.id = clusteredEntities; + cluster.billboard.id = clusteredEntities; + cluster.label.show = false; + cluster.label.text = ''; + cluster.point.show = true; + cluster.point.pixelSize = Math.min( + 26, + 12 + Math.log2(clusteredEntities.length) * 1.6, + ); + cluster.point.color = clusterColor.withAlpha(0.9); + cluster.point.outlineColor = Cesium.Color.BLACK; + cluster.point.outlineWidth = 2; + cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY; + cluster.point.distanceDisplayCondition = + new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ); + scheduleRadioOverlayPublish(); + }); + } + return { + markerPosition, + updateSelectionEntity, + clusterPointCollection, + publishRadioOverlayEntries, + scheduleRadioOverlayPublish, + updateRenderVisibility, + reconcileStations, + installClusterStyling, + }; +} diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js new file mode 100644 index 000000000..bffc7de04 --- /dev/null +++ b/src/layers/radio/selection.js @@ -0,0 +1,269 @@ +import { normalizeRadioFilter } from '../../data/layerState.js'; +import { RADIO_TUNER_DIRECTORY_LIMIT } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { warmGroundFloor } = services.ground; + + /** Durable Radio preferences; this surface never creates or plays audio. */ + + function setRadioParams(params = {}) { + const nextFilter = Object.hasOwn(params, 'filter') + ? normalizeRadioFilter(params.filter) + : null; + if (Object.hasOwn(params, 'filter') && !nextFilter) return false; + const numericVolume = Object.hasOwn(params, 'volume') + ? Number(params.volume) + : null; + if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume)) + return false; + const nextVolume = + numericVolume === null + ? null + : parts.volume.clampRadioVolume(numericVolume); + + let changed = false; + let filterChanged = false; + let clearedCancelledPresentation = false; + if (nextFilter !== null) { + filterChanged = nextFilter !== layerState._filter; + if ( + filterChanged && + (layerState._tuningActive || layerState._tuningAwaitingStationId) + ) + parts.tuning.endRadioTuning(); + clearedCancelledPresentation = Boolean( + layerState._cancelledTuningPresentationStation, + ); + layerState._cancelledTuningPresentationStation = null; + layerState._filter = nextFilter; + if (clearedCancelledPresentation) parts.rendering.updateSelectionEntity(); + if (filterChanged) parts.clustering.resetRadioClusterOverlayIdentities(); + changed ||= filterChanged || clearedCancelledPresentation; + } + if (nextVolume !== null && nextVolume !== layerState._userVolume) { + layerState._userVolume = nextVolume; + if (layerState._audio && !layerState._voiceDucked) { + parts.volume.cancelRadioVolumeTransition(); + layerState._voiceRestoring = false; + layerState._audio.volume = nextVolume; + } + parts.tuningNoise.syncTuningNoiseGain(); + changed = true; + } + if (changed && parts.interaction.radioPresentationAllowed()) { + parts.rendering.updateRenderVisibility(); + if (filterChanged && layerState._dataSource?.clustering?.enabled) { + const clusterPoints = layerState._dataSource.clustering.clusterPoints; + layerState._dataSource.clustering.clusterPoints = !clusterPoints; + layerState._dataSource.clustering.clusterPoints = clusterPoints; + layerState._viewer?.scene?.requestRender(); + } + parts.rendering.scheduleRadioOverlayPublish(); + } + parts.presentation.emitState(); + return true; + } + + function getRadioParams() { + return { filter: layerState._filter, volume: layerState._userVolume }; + } + + /** Select a station. Playback occurs only when autoplay is explicitly true. */ + + function selectRadioStation( + id, + { + autoplay = false, + focus = false, + origin = 'programmatic', + attemptId = null, + cameraNavigation = null, + } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const station = layerState._stationById.get(String(id)); + if (!station) return false; + layerState._tuningUnavailableStationId = null; + if ( + !parts.navigation.radioCameraNavigationOwnsSelection( + cameraNavigation, + station, + ) + ) { + parts.navigation.invalidateRadioCameraNavigation(); + } + if ( + layerState._tuningActive || + (layerState._tuningAwaitingStationId && + layerState._tuningAwaitingStationId !== station.id) + ) + parts.tuning.endRadioTuning(); + layerState._cancelledTuningPresentationStation = null; + layerState._selectedId = station.id; + const generation = ++layerState._selectionGeneration; + const sessionGeneration = layerState._sessionGeneration; + parts.rendering.updateSelectionEntity(); + warmGroundFloor([{ lat: station.lat, lon: station.lon }]); + if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer); + layerState._selectionTimer = setTimeout(() => { + layerState._selectionTimer = null; + if ( + sessionGeneration === layerState._sessionGeneration && + generation === layerState._selectionGeneration && + layerState._selectedId === station.id + ) + parts.rendering.updateSelectionEntity(); + }, 1300); + if (focus) parts.navigation.focusStation(station); + parts.presentation.emitState(); + if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId }); + return true; + } + + /** Select the previous or next station and optionally retain a UI-owned band order. */ + + function cycleRadioStation( + direction = 1, + { + rotate = false, + stationIds = null, + autoplay = true, + origin = 'programmatic', + } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const ranked = + Array.isArray(stationIds) && stationIds.length + ? stationIds + .slice(0, RADIO_TUNER_DIRECTORY_LIMIT) + .map((id) => layerState._stationById.get(String(id))) + .filter( + (station) => + station && + parts.categories.stationMatchesRadioCategory( + station, + layerState._filter, + ), + ) + : parts.queries.rankedVisibleStations(); + if (!ranked.length) return false; + const current = ranked.findIndex( + (station) => station.id === layerState._selectedId, + ); + const nextIndex = + current < 0 + ? 0 + : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; + layerState._playFallbackId = + ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; + const rotationCameraState = rotate + ? parts.navigation.radioCameraState() + : null; + const rotationNavigation = rotate + ? parts.navigation.beginRadioCameraNavigation(rotationCameraState) + : null; + layerState._playFallbackFocus = rotate + ? (fallbackStation) => { + parts.navigation.rotateRadioStationIntoView( + fallbackStation, + 0.65, + rotationCameraState, + rotationNavigation, + ); + return rotationNavigation; + } + : false; + const station = ranked[nextIndex]; + if (rotate) + parts.navigation.rotateRadioStationIntoView( + station, + 0.65, + rotationCameraState, + rotationNavigation, + ); + return selectRadioStation(station.id, { + autoplay, + focus: false, + origin, + cameraNavigation: rotationNavigation, + }); + } + + /** Select and optionally play the best station for a location/category request. */ + + function selectRequestedRadioStation( + criteria = {}, + { autoplay = true, origin = 'programmatic' } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return null; + const requestedCategory = String(criteria.categoryId || 'all'); + const categoryId = layerState._categories.some( + (category) => category.id === requestedCategory, + ) + ? requestedCategory + : 'all'; + setRadioFilter(categoryId); + const ranked = parts.queries.rankRadioStationsForRequest( + layerState._stations, + { + ...criteria, + categoryId, + anchor: criteria.anchor || parts.queries.viewportRadioAnchor(), + }, + ); + if (!ranked.length) return null; + layerState._playFallbackId = ranked[1]?.id || null; + layerState._playFallbackFocus = false; + selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); + return ranked[0]; + } + + /** Change marker/list category without interrupting an active station. */ + + function setRadioFilter(categoryId) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const valid = layerState._categories.some( + (category) => category.id === categoryId, + ); + const nextFilter = valid ? categoryId : 'all'; + const changed = nextFilter !== layerState._filter; + if ( + changed && + (layerState._tuningActive || layerState._tuningAwaitingStationId) + ) + parts.tuning.endRadioTuning(); + const clearsCancelledPresentation = Boolean( + layerState._cancelledTuningPresentationStation, + ); + layerState._cancelledTuningPresentationStation = null; + layerState._filter = nextFilter; + if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity(); + if (changed) parts.clustering.resetRadioClusterOverlayIdentities(); + parts.rendering.updateRenderVisibility(); + if (changed && layerState._dataSource?.clustering?.enabled) { + // Entity visibility changes do not invalidate Cesium's existing cluster + // primitives. Toggle a public clustering input twice to mark the current + // cluster set dirty without changing its effective configuration. + const clusterPoints = layerState._dataSource.clustering.clusterPoints; + layerState._dataSource.clustering.clusterPoints = !clusterPoints; + layerState._dataSource.clustering.clusterPoints = clusterPoints; + layerState._viewer?.scene?.requestRender(); + } + parts.rendering.scheduleRadioOverlayPublish(); + parts.presentation.emitState(); + return true; + } + return { + setRadioParams, + getRadioParams, + selectRadioStation, + cycleRadioStation, + selectRequestedRadioStation, + setRadioFilter, + }; +} diff --git a/src/layers/radio/source.js b/src/layers/radio/source.js new file mode 100644 index 000000000..818e410f5 --- /dev/null +++ b/src/layers/radio/source.js @@ -0,0 +1,33 @@ +import { DIRECTORY_ENDPOINT, RADIO_UUID_RE } from './policy.js'; + +/** Supply directory metadata and click reporting; audio stays with the broadcaster. */ +export function createRadioSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getDirectory({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(DIRECTORY_ENDPOINT, { signal }); + if (!response.ok) + throw new Error(`Radio directory returned ${response.status}`); + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + async recordClick(id, { signal } = {}) { + if (typeof id !== 'string' || !RADIO_UUID_RE.test(id)) + throw new Error('Invalid radio station id'); + signal?.throwIfAborted(); + const response = await fetchImpl( + `/api/radio/click/${encodeURIComponent(id)}`, + { + method: 'POST', + signal, + }, + ); + signal?.throwIfAborted(); + if (!response.ok) + throw new Error(`Radio click returned ${response.status}`); + }, + }; +} diff --git a/src/layers/radio/source.test.mjs b/src/layers/radio/source.test.mjs new file mode 100644 index 000000000..8d28e1485 --- /dev/null +++ b/src/layers/radio/source.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRadioSource, createRadioLayer } from './index.js'; + +const id = '00000000-0000-4000-8000-000000000001'; + +test('radio source confines directory and click requests to their existing routes', async () => { + const calls = []; + const body = { stations: [], stale: false }; + const source = createRadioSource({ + fetchImpl: async (path, options) => { + calls.push({ path, options }); + return new Response(JSON.stringify(body)); + }, + }); + const controller = new AbortController(); + assert.deepEqual( + await source.getDirectory({ signal: controller.signal }), + body, + ); + await source.recordClick(id, { signal: controller.signal }); + assert.deepEqual( + calls.map((call) => call.path), + ['/api/radio/stations', `/api/radio/click/${id}`], + ); + assert.equal(calls[1].options.method, 'POST'); + assert.ok(calls.every((call) => call.options.signal === controller.signal)); + for (const invalid of [ + '../stations', + 'https://example.com', + '', + null, + `${id}?x=1`, + ]) + await assert.rejects( + source.recordClick(invalid), + /Invalid radio station id/, + ); + assert.equal(calls.length, 2); +}); + +test('radio source propagates denial and cancels completed body parsing', async () => { + const denied = createRadioSource({ + fetchImpl: async () => new Response('', { status: 403 }), + }); + await assert.rejects(denied.getDirectory(), /403/); + await assert.rejects(denied.recordClick(id), /403/); + const controller = new AbortController(); + const source = createRadioSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { stations: [] }; + }, + }), + }); + await assert.rejects(source.getDirectory({ signal: controller.signal }), { + name: 'AbortError', + }); + let called = false; + const idle = createRadioSource({ + fetchImpl: async () => { + called = true; + }, + }); + await assert.rejects(idle.recordClick(id, { signal: controller.signal }), { + name: 'AbortError', + }); + assert.equal(called, false); +}); + +test('radio factories keep settings and subscriptions independent without fetching or audio startup', () => { + let requested = false; + const source = { + getDirectory() { + requested = true; + }, + recordClick() { + requested = true; + }, + }; + const services = { + ground: {}, + picking: { unregisterPickOwner() {} }, + globe: {}, + render: { governorRequestRender() {} }, + overlays: { + clearOverlaySource() {}, + setOverlaySourceVisible() {}, + setOverlayEntries() {}, + }, + }; + const first = createRadioLayer({ source, services }); + const second = createRadioLayer({ source, services }); + let changes = 0; + const off = second.subscribeToRadio(() => changes++); + const initialChanges = changes; + first.setRadioParams({ volume: 0.3 }); + assert.equal(first.getRadioParams().volume, 0.3); + assert.equal(second.getRadioParams().volume, 0.8); + assert.equal(changes, initialChanges); + assert.equal(requested, false); + off(); + first.destroy(); + assert.equal(second.getRadioParams().volume, 0.8); + second.destroy(); +}); diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js new file mode 100644 index 000000000..dca461e03 --- /dev/null +++ b/src/layers/radio/state.js @@ -0,0 +1,157 @@ +import * as Cesium from 'cesium'; +import { + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, + DEFAULT_RADIO_FILTER, + DEFAULT_RADIO_VOLUME, +} from './policy.js'; + +export function createState({ services }) { + const layerState = {}; + + layerState._radioEarthScreenCenter = new Cesium.Cartesian2(); + + layerState._radioEarthToCenter = new Cesium.Cartesian3(); + + layerState._viewer = null; + + layerState._dataSource = null; + + layerState._enabled = false; + + layerState._managerLifecyclePresentation = null; + + layerState._loading = false; + + layerState._stale = false; + + layerState._degraded = false; + + layerState._error = null; + + layerState._updatedAt = null; + + layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + + layerState._stations = []; + + layerState._stationById = new Map(); + + layerState._categories = []; + + layerState._renderById = new Map(); + + layerState._filter = DEFAULT_RADIO_FILTER; + + layerState._selectedId = null; + + layerState._selectedEntity = null; + + layerState._selectionGeneration = 0; + + layerState._selectionTimer = null; + + layerState._radioCameraNavigationGeneration = 0; + + layerState._radioCameraFlightSequence = 0; + + layerState._activeRadioCameraFlight = null; + + layerState._audio = null; + + layerState._audioStationId = null; + + layerState._audioState = 'stopped'; + + layerState._audioError = null; + + layerState._userVolume = DEFAULT_RADIO_VOLUME; + + layerState._tuningActive = false; + + layerState._tuningStatic = false; + + layerState._tuningAwaitingStationId = null; + + layerState._tuningPreviewId = null; + + layerState._tuningStartStationId = null; + + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + + layerState._tuningStationById = new Map(); + + layerState._tuningUnavailableStationId = null; + + layerState._cancelledTuningPresentationStation = null; + + layerState._tuningCameraNavigation = null; + + layerState._tuningNoiseContext = null; + + layerState._tuningNoiseSource = null; + + layerState._tuningNoiseFilter = null; + + layerState._tuningNoiseGain = null; + + layerState._voiceDucked = false; + + layerState._voiceRestoring = false; + + layerState._voiceRestoreTimer = null; + + layerState._volumeFadeFrame = null; + + layerState._volumeTransitionGeneration = 0; + + layerState._playGeneration = 0; + + layerState._playAttemptSequence = 0; + + layerState._activePlaybackAttempt = null; + + layerState._playFallbackId = null; + + layerState._playFallbackFocus = null; + + layerState._playFallbackOrigin = 'programmatic'; + + layerState._playFallbackAttemptId = null; + + layerState._clickHandler = null; + + layerState._horizonTimer = null; + + layerState._lastHorizonCameraPosition = null; + + layerState._horizonScanCount = 0; + + layerState._abortController = null; + + layerState._requestGeneration = 0; + + layerState._sessionGeneration = 0; + + layerState._removeClusterListener = null; + + layerState._overlayPublishTimer = null; + + layerState._clusterOverlayIdentitySequence = 0; + + layerState._clusterOverlayIdentities = []; + + layerState._overlayDiagnostics = { + entryCount: 0, + selectedCount: 0, + singletonTexts: [], + singletonIds: [], + clusterTexts: [], + clusterIds: [], + clusterMemberships: [], + }; + + layerState._listeners = new Set(); + + layerState._playbackControlListeners = new Set(); + return layerState; +} diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js new file mode 100644 index 000000000..ab5c56bb5 --- /dev/null +++ b/src/layers/radio/tuning.js @@ -0,0 +1,233 @@ +import { + RADIO_TUNER_STATION_LIMIT, + RADIO_TUNER_DIRECTORY_LIMIT, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, +} from './policy.js'; + +export function createTuning({ state: layerState, services, parts, source }) { + /** Bound one already ordered filtered directory without injecting outside selections. */ + + function buildRadioTunerBand( + rankedStations, + selected, + limit = RADIO_TUNER_STATION_LIMIT, + ) { + const boundedLimit = Math.min( + RADIO_TUNER_DIRECTORY_LIMIT, + Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)), + ); + return (Array.isArray(rankedStations) ? rankedStations : []).slice( + 0, + boundedLimit, + ); + } + + function captureRadioTuningResolutionSnapshot() { + return layerState._acceptedCatalogSnapshot; + } + + /** Return the complete accepted filtered directory in stable catalog order. */ + + function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._acceptedCatalogSnapshot.stations.length + ) + return []; + return buildRadioTunerBand( + parts.queries.visibleStations(), + parts.queries.selectedStation(), + limit, + ); + } + + /** Begin a direct-manipulation tuning gesture and pause the current stream. */ + + function beginRadioTuning() { + if ( + !parts.interaction.radioPresentationAllowed() || + !parts.queries.visibleStations().length + ) + return false; + const snapshot = captureRadioTuningResolutionSnapshot(); + if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length) + return false; + layerState._tuningResolutionSnapshot = snapshot; + layerState._tuningStationById = new Map( + layerState._tuningResolutionSnapshot.stations.map((station) => [ + station.id, + station, + ]), + ); + layerState._tuningUnavailableStationId = null; + layerState._tuningActive = true; + layerState._tuningStatic = false; + layerState._tuningAwaitingStationId = null; + layerState._cancelledTuningPresentationStation = null; + layerState._tuningStartStationId = layerState._selectedId; + layerState._tuningPreviewId = layerState._selectedId; + parts.tuningNoise.installTuningNoise(); + parts.playback.pauseRadioPlayback({ origin: 'user' }); + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** Toggle low-volume synthesized static for tuner preview and stream handoff. */ + + function setRadioTuningStatic(active) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive + ) + return false; + const next = Boolean(active); + if (next === layerState._tuningStatic) return true; + layerState._tuningStatic = next; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** Preview a tuner station bracket and camera orientation without starting audio. */ + + function previewRadioTuningStation(id, { rotate = true } = {}) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive + ) + return false; + const station = parts.model.tuningResolutionStation(id); + const nextId = station?.id || null; + const changed = nextId !== layerState._tuningPreviewId; + layerState._tuningPreviewId = nextId; + layerState._tuningStatic = !station; + if (changed) { + parts.rendering.updateSelectionEntity(); + if (station && rotate) { + const cameraState = parts.navigation.radioCameraState(); + layerState._tuningCameraNavigation = + parts.navigation.beginRadioCameraNavigation(cameraState); + parts.navigation.rotateRadioStationIntoView( + station, + 0.35, + cameraState, + layerState._tuningCameraNavigation, + ); + } else if (rotate) { + layerState._tuningCameraNavigation = null; + parts.navigation.invalidateRadioCameraNavigation(); + } + } + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return Boolean(station); + } + + /** Finish a tuning gesture and release its synthesized noise source. */ + + function endRadioTuning() { + if ( + !layerState._tuningActive && + !layerState._tuningStatic && + !layerState._tuningAwaitingStationId && + !layerState._tuningNoiseSource && + !layerState._cancelledTuningPresentationStation + ) + return; + parts.tuningNoise.clearRadioTuningNoise(); + } + + /** Cancel a tuning gesture and restore its frozen start marker for presentation only. */ + + function cancelRadioTuning() { + if (!layerState._tuningActive) return false; + const restoredStation = parts.model.tuningResolutionStation( + layerState._tuningStartStationId, + ); + parts.tuningNoise.clearRadioTuningNoise({ restoredStation }); + return true; + } + + /** Commit the exact frozen drag resolution or report that it is unavailable. */ + + function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) { + const frozenStation = parts.model.tuningResolutionStation(id); + const currentStation = layerState._stationById.get(String(id)) || null; + const generation = layerState._tuningResolutionSnapshot.generation; + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive || + !frozenStation + ) { + endRadioTuning(); + return Object.freeze({ + ok: false, + reason: 'not-tuning', + stationId: String(id || ''), + generation, + }); + } + if ( + !parts.model.radioStationResolutionMatches(frozenStation, currentStation) + ) { + const stationId = frozenStation.id; + // A failed exact release owns the visible outcome. Do not let the previous + // selection—or changed metadata under the same ID—reappear between the + // frozen preview and the unavailable state. + layerState._selectedId = null; + layerState._selectionGeneration += 1; + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + layerState._tuningUnavailableStationId = stationId; + parts.presentation.emitState(); + return Object.freeze({ + ok: false, + reason: 'station-unavailable', + stationId, + generation, + }); + } + layerState._tuningActive = false; + layerState._tuningStatic = true; + layerState._tuningAwaitingStationId = frozenStation.id; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningUnavailableStationId = null; + const cameraNavigation = layerState._tuningCameraNavigation; + layerState._tuningCameraNavigation = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + // An exact tuner release owns its frozen target. A fallback armed by an + // earlier non-playing selection must never retarget this gesture if the + // broadcaster fails after release. + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + const selected = parts.selection.selectRadioStation(frozenStation.id, { + autoplay: true, + focus: false, + origin, + cameraNavigation, + }); + return Object.freeze({ + ok: selected, + reason: selected ? null : 'station-unavailable', + stationId: frozenStation.id, + generation, + }); + } + return { + buildRadioTunerBand, + captureRadioTuningResolutionSnapshot, + getRadioTunerStations, + beginRadioTuning, + setRadioTuningStatic, + previewRadioTuningStation, + endRadioTuning, + cancelRadioTuning, + commitRadioTuningStation, + }; +} diff --git a/src/layers/radio/tuningNoise.js b/src/layers/radio/tuningNoise.js new file mode 100644 index 000000000..dfbc3fe3b --- /dev/null +++ b/src/layers/radio/tuningNoise.js @@ -0,0 +1,145 @@ +import { + RADIO_TUNER_STATIC_MAX_GAIN, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, +} from './policy.js'; + +export function createTuningNoise({ + state: layerState, + services, + parts, + source, +}) { + /** Decide whether tuner static should be audible for the current handoff state. */ + + function radioTuningStaticShouldPlay({ + tuningActive = false, + tuningStatic = false, + awaitingStationId = null, + voiceDucked = false, + } = {}) { + return Boolean( + tuningStatic && !voiceDucked && (tuningActive || awaitingStationId), + ); + } + + function stopTuningNoiseSource() { + if (layerState._tuningNoiseSource) { + try { + layerState._tuningNoiseSource.stop(); + } catch { + /* already stopped */ + } + try { + layerState._tuningNoiseSource.disconnect(); + } catch { + /* already disconnected */ + } + } + try { + layerState._tuningNoiseFilter?.disconnect(); + } catch { + /* already disconnected */ + } + try { + layerState._tuningNoiseGain?.disconnect(); + } catch { + /* already disconnected */ + } + layerState._tuningNoiseSource = null; + layerState._tuningNoiseFilter = null; + layerState._tuningNoiseGain = null; + } + + function syncTuningNoiseGain() { + if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; + const audible = radioTuningStaticShouldPlay({ + tuningActive: layerState._tuningActive, + tuningStatic: layerState._tuningStatic, + awaitingStationId: layerState._tuningAwaitingStationId, + voiceDucked: layerState._voiceDucked, + }); + const target = audible + ? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, layerState._userVolume * 0.03) + : 0; + const now = layerState._tuningNoiseContext.currentTime; + layerState._tuningNoiseGain.gain.cancelScheduledValues(now); + layerState._tuningNoiseGain.gain.setValueAtTime( + layerState._tuningNoiseGain.gain.value, + now, + ); + layerState._tuningNoiseGain.gain.linearRampToValueAtTime( + target, + now + 0.025, + ); + } + + function installTuningNoise() { + const AudioContextClass = + globalThis.AudioContext || globalThis.webkitAudioContext; + if (!AudioContextClass) return false; + if (!layerState._tuningNoiseContext) + layerState._tuningNoiseContext = new AudioContextClass(); + const resumed = layerState._tuningNoiseContext.resume?.(); + if (resumed?.catch) void resumed.catch(() => {}); + if (layerState._tuningNoiseSource) return true; + + const frameCount = Math.max( + 1, + Math.floor(layerState._tuningNoiseContext.sampleRate * 0.75), + ); + const buffer = layerState._tuningNoiseContext.createBuffer( + 1, + frameCount, + layerState._tuningNoiseContext.sampleRate, + ); + const channel = buffer.getChannelData(0); + for (let index = 0; index < channel.length; index += 1) + channel[index] = Math.random() * 2 - 1; + + layerState._tuningNoiseSource = + layerState._tuningNoiseContext.createBufferSource(); + layerState._tuningNoiseFilter = + layerState._tuningNoiseContext.createBiquadFilter(); + layerState._tuningNoiseGain = layerState._tuningNoiseContext.createGain(); + layerState._tuningNoiseSource.buffer = buffer; + layerState._tuningNoiseSource.loop = true; + layerState._tuningNoiseFilter.type = 'bandpass'; + layerState._tuningNoiseFilter.frequency.value = 1_650; + layerState._tuningNoiseFilter.Q.value = 0.55; + layerState._tuningNoiseGain.gain.value = 0; + layerState._tuningNoiseSource.connect(layerState._tuningNoiseFilter); + layerState._tuningNoiseFilter.connect(layerState._tuningNoiseGain); + layerState._tuningNoiseGain.connect( + layerState._tuningNoiseContext.destination, + ); + layerState._tuningNoiseSource.start(); + return true; + } + + function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) { + if (layerState._tuningCameraNavigation) + parts.navigation.invalidateRadioCameraNavigation(); + layerState._tuningActive = false; + layerState._tuningStatic = false; + layerState._tuningAwaitingStationId = null; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + layerState._tuningCameraNavigation = null; + layerState._cancelledTuningPresentationStation = restoredStation; + parts.rendering.updateSelectionEntity(); + syncTuningNoiseGain(); + stopTuningNoiseSource(); + const suspended = layerState._tuningNoiseContext?.suspend?.(); + if (suspended?.catch) void suspended.catch(() => {}); + if (emit) parts.presentation.emitState(); + } + return { + radioTuningStaticShouldPlay, + stopTuningNoiseSource, + syncTuningNoiseGain, + installTuningNoise, + clearRadioTuningNoise, + }; +} diff --git a/src/layers/radio/volume.js b/src/layers/radio/volume.js new file mode 100644 index 000000000..1be63cc53 --- /dev/null +++ b/src/layers/radio/volume.js @@ -0,0 +1,148 @@ +import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js'; + +export function createVolume({ state: layerState, services, parts, source }) { + function clampRadioVolume(value) { + return Math.min(1, Math.max(0, Number(value) || 0)); + } + + function cancelRadioVolumeTransition() { + layerState._volumeTransitionGeneration += 1; + if (layerState._voiceRestoreTimer) + clearTimeout(layerState._voiceRestoreTimer); + layerState._voiceRestoreTimer = null; + if (layerState._volumeFadeFrame) { + if (typeof cancelAnimationFrame === 'function') + cancelAnimationFrame(layerState._volumeFadeFrame); + else clearTimeout(layerState._volumeFadeFrame); + } + layerState._volumeFadeFrame = null; + } + + function scheduleVolumeFrame(callback) { + if (typeof requestAnimationFrame === 'function') + return requestAnimationFrame(callback); + return setTimeout(() => callback(Date.now()), 16); + } + + function volumeClock() { + return typeof performance !== 'undefined' && + typeof performance.now === 'function' + ? performance.now() + : Date.now(); + } + + /** Set shared audio volume, clamped to [0, 1]. */ + + function setRadioVolume(value) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const volume = clampRadioVolume(value); + layerState._userVolume = volume; + parts.playback.installAudio(); + if (!layerState._voiceDucked) { + cancelRadioVolumeTransition(); + layerState._voiceRestoring = false; + if (layerState._audio) layerState._audio.volume = volume; + } + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** + * Mute Radio during a live voice turn, then gently restore the user-owned + * volume after voice returns to standby. Repeated state sync is idempotent. + */ + + function setRadioVoiceDucking( + ducked, + { + restoreDelayMs = VOICE_RESTORE_DELAY_MS, + restoreDurationMs = VOICE_RESTORE_DURATION_MS, + } = {}, + ) { + const shouldDuck = Boolean(ducked); + if ( + shouldDuck === layerState._voiceDucked && + (shouldDuck || layerState._voiceRestoring) + ) + return; + if ( + !shouldDuck && + !layerState._voiceDucked && + !layerState._voiceRestoring && + (!layerState._audio || + Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001) + ) + return; + + cancelRadioVolumeTransition(); + layerState._voiceDucked = shouldDuck; + layerState._voiceRestoring = false; + + if (shouldDuck) { + if (layerState._audio) layerState._audio.volume = 0; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return; + } + if (!layerState._audio) { + parts.tuningNoise.syncTuningNoiseGain(); + layerState._voiceRestoring = false; + parts.presentation.emitState(); + return; + } + + const generation = layerState._volumeTransitionGeneration; + parts.tuningNoise.syncTuningNoiseGain(); + layerState._voiceRestoring = true; + parts.presentation.emitState(); + const beginRestore = () => { + layerState._voiceRestoreTimer = null; + if ( + generation !== layerState._volumeTransitionGeneration || + layerState._voiceDucked || + !layerState._audio + ) + return; + const startedAt = volumeClock(); + const initialVolume = layerState._audio.volume; + const duration = Math.max(0, Number(restoreDurationMs) || 0); + const step = (now) => { + if ( + generation !== layerState._volumeTransitionGeneration || + layerState._voiceDucked || + !layerState._audio + ) + return; + const progress = + duration === 0 + ? 1 + : Math.min(1, Math.max(0, (now - startedAt) / duration)); + const eased = progress * progress * (3 - 2 * progress); + layerState._audio.volume = clampRadioVolume( + initialVolume + (layerState._userVolume - initialVolume) * eased, + ); + if (progress < 1) { + layerState._volumeFadeFrame = scheduleVolumeFrame(step); + return; + } + layerState._volumeFadeFrame = null; + layerState._voiceRestoring = false; + parts.presentation.emitState(); + }; + layerState._volumeFadeFrame = scheduleVolumeFrame(step); + }; + const delay = Math.max(0, Number(restoreDelayMs) || 0); + if (delay > 0) + layerState._voiceRestoreTimer = setTimeout(beginRestore, delay); + else beginRestore(); + } + return { + clampRadioVolume, + cancelRadioVolumeTransition, + scheduleVolumeFrame, + volumeClock, + setRadioVolume, + setRadioVoiceDucking, + }; +} diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js new file mode 100644 index 000000000..25eaf9b2c --- /dev/null +++ b/src/layers/satellites/catalog.js @@ -0,0 +1,239 @@ +import * as Cesium from 'cesium'; +import { twoline2satrec } from 'satellite.js'; +import { + DENSE_REFRESH_FRAMES, + DENSE_GROUP_PATH, + POINT_STYLES, + DENSE_CREATE_CHUNK, +} from './policy.js'; + +export function createCatalog({ state: layerState, services, parts, source }) { + function _abortActiveUpdates() { + for (const controller of layerState._activeUpdateControllers) + controller.abort(); + layerState._activeUpdateControllers.clear(); + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + } + + /** + * Re-propagate a small per-frame slice of the dense extras (round-robin). + * Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES + * frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays + * ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the + * work per frame avoids the once-per-second spike a tick-sized chunk + * (~2K props ≈ 4ms) would cause. + */ + + function _propagateDenseChunk() { + if (layerState._denseIds.length === 0) return; + const perFrame = Math.max( + 1, + Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), + ); + const now = new Date(); + for (let i = 0; i < perFrame; i++) { + if (layerState._denseCursor >= layerState._denseIds.length) + layerState._denseCursor = 0; + const noradId = layerState._denseIds[layerState._denseCursor++]; + if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it + const sat = layerState._catalog.get(noradId); + const point = layerState._points.get(noradId); + if (!sat || !point) continue; + const pos = parts.orbits.propagatePosition(sat.satrec, now); + if (pos) { + point.position = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + } + } + } + + /** + * Load the dense catalog extras (Starlink) as points-only satellites. + * Chunked so ~10K twoline2satrec builds + initial propagations never block a + * frame; a token guards against mode flips / catalog rebuilds mid-load. + */ + + async function _loadDenseCatalog({ signal = null } = {}) { + if (!layerState._viewer || !layerState._pointCollection) + return { status: 'source-unavailable', reason: 'layer-unavailable' }; + layerState._denseLoadController?.abort(); + const resourceController = new AbortController(); + layerState._denseLoadController = resourceController; + const loadSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + const token = ++layerState._denseLoadToken; + layerState._denseStatus = 'loading'; + layerState._denseError = null; + parts.controls._notifyRowControls(); + try { + loadSignal.throwIfAborted(); + const res = await source.readGroup(DENSE_GROUP_PATH, { + signal: loadSignal, + }); + if (!res.ok) { + console.warn( + `[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' fetch failed (${res.status})`, + ); + _denseLoadFailed(token, `feed unavailable (${res.status})`); + return { + status: 'source-unavailable', + reason: `feed unavailable (${res.status})`, + }; + } + const text = res.text; + loadSignal.throwIfAborted(); + if ( + token !== layerState._denseLoadToken || + layerState._params.catalog !== 'dense' + ) { + return { status: 'superseded', reason: 'dense-load-superseded' }; + } + + const entries = parts.orbits.parseTLE(text); + const style = POINT_STYLES.dense; + const now = new Date(); + let added = 0; + + for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { + loadSignal.throwIfAborted(); + if ( + token !== layerState._denseLoadToken || + layerState._params.catalog !== 'dense' || + !layerState._pointCollection + ) { + return { status: 'superseded', reason: 'dense-load-superseded' }; + } + const end = Math.min(start + DENSE_CREATE_CHUNK, entries.length); + for (let i = start; i < end; i++) { + const entry = entries[i]; + const satrec = twoline2satrec(entry.line1, entry.line2); + if (!satrec || satrec.error !== 0) continue; + const noradId = Number(satrec.satnum); + if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority + const pos = parts.orbits.propagatePosition(satrec, now); + if (!pos) continue; + + layerState._catalog.set(noradId, { + name: entry.name, + satrec, + group: 'dense', + }); + const point = layerState._pointCollection.add({ + position: Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ), + pixelSize: style.pixelSize, + color: style.color, + outlineColor: style.outlineColor, + outlineWidth: style.outlineWidth, + scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + id: noradId, + }); + layerState._points.set(noradId, point); + layerState._denseIds.push(noradId); + added++; + } + // Yield to the event loop between chunks. + await new Promise((resolve) => setTimeout(resolve, 0)); + loadSignal.throwIfAborted(); + } + + // A 200 that yields nothing usable is still a failed load — an empty body, + // an HTML error page the proxy passed through, or a feed of TLEs the core + // catalog already owns. Treating it as success is the same lie as treating + // a 502 as success, just through a different door. + if (added === 0) { + console.warn( + `[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' returned no usable satellites`, + ); + _denseLoadFailed(token, 'feed returned no satellites'); + return { + status: 'source-unavailable', + reason: 'feed returned no satellites', + }; + } + + layerState._count = layerState._points.size; + layerState._catalogRevision++; + layerState._denseStatus = 'ready'; + console.log( + `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, + ); + // The panel would otherwise keep the pre-load count and legend until the + // next natural refresh — up to the 5-minute catalog interval. + parts.controls._notifyRowControls(); + parts.tracking._applyPendingTrackingRestore(); + return { status: 'ready', added }; + } catch (e) { + if (loadSignal.aborted || e?.name === 'AbortError') { + return { + status: 'cancelled', + reason: String(loadSignal.reason || 'aborted'), + }; + } + console.warn('[Data:Satellites] Dense catalog load failed:', e); + _denseLoadFailed(token, 'feed unreachable'); + return { status: 'source-unavailable', reason: 'feed unreachable' }; + } finally { + if (layerState._denseLoadController === resourceController) + layerState._denseLoadController = null; + } + } + + /** + * Settle a failed dense load: drop any partial chunk, return the layer to the + * core catalog, and leave the reason on the chip. Reverting the param is the + * point — a chip that reads ACTIVE over an empty sky is a lie. + * @param {number} token The load token that failed. + * @param {string} reason Short operator-facing cause. + */ + + function _denseLoadFailed(token, reason) { + // A newer load (or a mode flip) already owns the state — say nothing. + if (token !== layerState._denseLoadToken) return; + layerState._params.catalog = 'core'; + _removeDenseCatalog(); + layerState._denseStatus = 'failed'; + layerState._denseError = reason; + parts.controls._notifyRowControls(); + } + + /** Remove all dense extras (catalog entries, points, tracking if needed). */ + + function _removeDenseCatalog() { + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + layerState._denseLoadToken++; // cancel any in-flight dense load + if ( + layerState._trackedNorad !== null && + layerState._catalog.get(layerState._trackedNorad)?.group === 'dense' + ) { + parts.tracking._clearTracking(); + } + for (const noradId of layerState._denseIds) { + const point = layerState._points.get(noradId); + if (point && layerState._pointCollection) + layerState._pointCollection.remove(point); + layerState._points.delete(noradId); + layerState._catalog.delete(noradId); + } + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._count = layerState._points.size; + layerState._catalogRevision++; + } + return { + _abortActiveUpdates, + _propagateDenseChunk, + _loadDenseCatalog, + _denseLoadFailed, + _removeDenseCatalog, + }; +} diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js new file mode 100644 index 000000000..f4910f754 --- /dev/null +++ b/src/layers/satellites/controls.js @@ -0,0 +1,562 @@ +import { + tallySatelliteClasses, + satelliteClassLabel, + satelliteClassLegend, +} from '../../data/satelliteClass.js'; +import * as Cesium from 'cesium'; +import { ISS_NORAD, POINT_STYLES } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { isExplicitLayerStateOrigin } = services.layerState; + + /** + * Resolve the canonical point style for a satellite. + * @param {number} noradId NORAD catalog number. + * @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense'). + * @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }} + */ + + function _pointStyleFor(noradId, group) { + if (noradId === ISS_NORAD) return POINT_STYLES.iss; + return POINT_STYLES[group] || POINT_STYLES.visual; + } + + /** Tell the manager to re-render this layer's row (chip state / legend counts). */ + + function _notifyRowControls() { + try { + layerState._rowControlsListener?.(); + } catch (error) { + console.warn('[Data:Satellites] row-controls listener failed:', error); + } + } + + /** + * Per-class tally for the row legend, cached against the catalog revision. + * Without the cache this scans ~10.7k entries in dense mode on every panel + * refresh, and any layer polling on the 1s stats timer refreshes the panel. + * @returns {Record} Class key → count. + */ + + function _classTally() { + if ( + layerState._classTallyCache.counts && + layerState._classTallyCache.revision === layerState._catalogRevision + ) { + return layerState._classTallyCache.counts; + } + const entries = []; + // Pass the ISS flag, not just the group: during a stations-feed outage the + // ISS is ingested as `visual`, and the legend must file it exactly where its + // card does (STATION) rather than letting STATION vanish from the legend. + for (const [noradId, sat] of layerState._catalog) { + entries.push({ group: sat.group, isIss: noradId === ISS_NORAD }); + } + const counts = tallySatelliteClasses(entries); + layerState._classTallyCache = { + revision: layerState._catalogRevision, + counts, + }; + return counts; + } + + /** + * Satellite preferences may be restored while the layer is disabled (for + * example, when Space Missions releases its dependency). Preferences must not + * revive render primitives until the layer is explicitly enabled again. + * @param {boolean} layerEnabled Whether the Satellite layer is enabled. + * @param {boolean} requestedVisible Whether the current presentation requests visibility. + * @returns {boolean} Whether a primitive should be visible now. + */ + + function satelliteVisualsVisible(layerEnabled, requestedVisible) { + return Boolean(layerEnabled) && Boolean(requestedVisible); + } + + /** + * Decide whether a valid catalog request represents a real mode transition. + * Reapplying an already-active mode must not restart the dense TLE load. + * @param {'core'|'dense'} currentCatalog Current catalog mode. + * @param {string|undefined} requestedCatalog Requested catalog mode. + * @returns {boolean} Whether the catalog mode should change. + */ + + function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) { + return ( + (requestedCatalog === 'core' || requestedCatalog === 'dense') && + requestedCatalog !== currentCatalog + ); + } + const methods = { + id: 'satellites', + + name: 'Satellites', + + icon: '🛰️', + + source: 'CelesTrak', + + updateInterval: 0, + // We use preRender for real-time updates, not interval polling + refreshInterval: 5 * 60 * 1000, + + getDetectableObjects(options = {}) { + if (!layerState._pointCollection || !layerState._pointCollection.show) + return []; + // Dense extras are points-only: excluded from the detection overlay. + const eligibleCount = Math.max( + 1, + layerState._points.size - layerState._denseIds.length, + ); + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : eligibleCount; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max(1, Math.ceil(eligibleCount / maxCount)); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [noradId, point] of layerState._points) { + if ( + layerState._denseIds.length > 0 && + layerState._catalog.get(noradId)?.group === 'dense' + ) + continue; + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if (!point.position) continue; + const isTracked = noradId === layerState._trackedNorad; + // A docked companion sits at the tracked subject's own position, so its + // mark and label would stack underneath the tracked card. It is listed on + // that card instead. Only members of the tracked cluster are affected — + // unrelated nearby satellites are never suppressed. + if (!isTracked && layerState._dockedCompanions.has(noradId)) continue; + const cat = layerState._catalog.get(noradId); + let object = layerState._detectionObjects.get(noradId); + if (!object) { + object = { + sourceId: noradId, + id: cat?.name || `SAT-${noradId}`, + type: 'SAT', + // Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS"). + // The detection canvas composites ABOVE the post-FX chain, so this + // is how class survives NVG/FLIR once the dot colors are collapsed. + klass: satelliteClassLabel(cat?.group, { + isIss: noradId === ISS_NORAD, + }), + }; + layerState._detectionObjects.set(noradId, object); + } + object.position = point.position; + object.skipLabel = isTracked; + result.push(object); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Find a satellite by exact NORAD id (numeric string) or case-insensitive + * name substring. Position is freshly propagated via SGP4. + * @param {string|number} query NORAD id or partial name. + * @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null} + */ + findByQuery(query) { + if ( + query === null || + query === undefined || + !layerState._catalog || + layerState._catalog.size === 0 + ) + return null; + const q = String(query).trim(); + if (!q) return null; + + let noradId = null; + if (/^\d+$/.test(q) && layerState._catalog.has(Number(q))) { + noradId = Number(q); + } else { + const lower = q.toLowerCase(); + for (const [id, sat] of layerState._catalog) { + if (sat.name.toLowerCase().includes(lower)) { + noradId = id; + break; + } + } + } + if (noradId === null) return null; + + const sat = layerState._catalog.get(noradId); + const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + if (!pos) return null; + + return { + noradId, + name: sat.name.trim(), + position: Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ), + latitude: pos.latitude, + longitude: pos.longitude, + altitudeM: pos.altitude, + }; + }, + + /** + * Get positions of currently rendered satellites from per-point state + * (no SGP4 re-propagation). + * @param {number} [maxCount=300] Maximum entries to return. + * @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>} + */ + getAllPositions(maxCount = 300) { + const result = []; + if (!layerState._points || layerState._points.size === 0) return result; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300; + + for (const [noradId, point] of layerState._points) { + if (result.length >= cap) break; + if (!point.position) continue; + // Dense extras are points-only — keep voice/framing lists to the core catalog. + if ( + layerState._denseIds.length > 0 && + layerState._catalog.get(noradId)?.group === 'dense' + ) + continue; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) continue; + const sat = layerState._catalog.get(noradId); + result.push({ + id: noradId, + label: sat ? sat.name.trim() : String(noradId), + position: point.position, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + }); + } + return result; + }, + + /** + * Track a satellite by NORAD id (camera follow + orbit path + highlight), + * same path as clicking its point. + * @param {string|number} noradId NORAD catalog number. + * @returns {boolean} True if tracking started. + */ + trackById(noradId, { origin = 'programmatic' } = {}) { + const id = Number(noradId); + if ( + !Number.isFinite(id) || + !layerState._viewer || + !layerState._catalog.has(id) || + !layerState._points.has(id) + ) + return false; + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(id, { origin }); + return layerState._trackedNorad === id; + }, + + /** + * Resolve a shared Follow target only after the applicable CelesTrak + * catalog has settled. A partial catalog can prove presence, never absence. + */ + async resolveTrackingRestoreTarget( + noradId, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = parts.tracking._normalizeTrackedNorad(noradId); + if (id === null) return { status: 'missing', reason: 'invalid-target' }; + const outcome = layerState._lastTrackingRefreshOutcome; + const found = () => + layerState._catalog.has(id) && layerState._points.has(id); + const follow = () => { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + return this.trackById(id, { origin }) + ? { status: 'found', refreshEpoch: outcome.epoch } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }; + + if (found()) return follow(); + if (outcome.status !== 'accepted' && outcome.status !== 'partial') { + return { + status: 'source-unavailable', + reason: 'CelesTrak catalog unavailable', + refreshEpoch: outcome.epoch, + }; + } + + if (layerState._params.catalog === 'dense') { + const dense = await (layerState._denseLoadPromise || + Promise.resolve({ status: 'source-unavailable' })); + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + if (layerState._lastTrackingRefreshOutcome.epoch !== outcome.epoch) { + return { status: 'superseded', reason: 'newer-catalog-refresh' }; + } + if (found()) return follow(); + if (dense?.status !== 'ready') { + return { + status: 'source-unavailable', + reason: dense?.reason || 'dense catalog unavailable', + refreshEpoch: outcome.epoch, + }; + } + } + + if (outcome.status === 'partial') { + return { + status: 'source-unavailable', + reason: 'partial CelesTrak catalog cannot prove absence', + refreshEpoch: outcome.epoch, + failedGroups: [...outcome.failedGroups], + }; + } + return { + status: 'missing', + reason: 'target-absent-from-catalog', + refreshEpoch: outcome.epoch, + }; + }, + + /** + * Stop tracking the currently tracked satellite (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Get info about the currently tracked satellite. + * @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null} + */ + getTrackedInfo() { + if ( + layerState._trackedNorad === null || + !layerState._catalog.has(layerState._trackedNorad) + ) + return null; + const sat = layerState._catalog.get(layerState._trackedNorad); + // Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame. + const pos = parts.tracking._getTrackedFramePosition(); + if (!pos) return null; + return { + noradId: layerState._trackedNorad, + name: sat.name.trim(), + latitude: pos.latitude, + longitude: pos.longitude, + altitudeM: pos.altitude, + }; + }, + + /** + * Runtime params (DataLayerManager.setLayerParams path). + * catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell + * as points-only extras on a relaxed propagation budget). + * @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params] + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedSatTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + const catalog = params.catalog; + if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense') + return false; + const catalogChanged = satelliteCatalogModeChanged( + layerState._params.catalog, + catalog, + ); + if (catalogChanged) { + layerState._params.catalog = catalog; + } + if (params.showPoints !== undefined) { + layerState._params.showPoints = params.showPoints !== false; + if (layerState._pointCollection) + layerState._pointCollection.show = satelliteVisualsVisible( + layerState._enabled, + layerState._params.showPoints, + ); + } + if (params.showOrbits !== undefined) { + layerState._params.showOrbits = params.showOrbits !== false; + for (const path of layerState._orbitPaths.values()) + path.primitive.show = satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); + parts.labels._syncIssOverlay(); + } + if (catalogChanged && catalog === 'dense') { + layerState._denseLoadPromise = parts.catalog._loadDenseCatalog(); + } else if (catalog === 'core') { + if (catalogChanged) parts.catalog._removeDenseCatalog(); + // Any explicit request for core clears the error, even when the mode did + // NOT change: a failed dense load already reverted the param to core, so + // a Space Missions restore of an already-core snapshot would otherwise + // leave the user staring at a DENSE ✕ they never caused. + layerState._denseStatus = 'idle'; + layerState._denseError = null; + } + if (catalogChanged) + console.log(`[Data:Satellites] Catalog mode: ${catalog}`); + if (Object.hasOwn(params, 'selectedSatTrackingId')) { + const requested = parts.tracking._normalizeTrackedNorad( + params.selectedSatTrackingId, + ); + if (requested === layerState._trackedNorad) { + layerState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (layerState._trackedNorad !== null) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++layerState._trackingIntentGeneration; + layerState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (layerState._trackedNorad !== null) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + /** @returns {{ catalog: string }} Current runtime params. */ + getParams() { + return { + catalog: layerState._params.catalog, + showPoints: layerState._params.showPoints, + showOrbits: layerState._params.showOrbits, + selectedSatTrackingId: layerState._trackedNorad, + }; + }, + + /** + * Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE + * catalog chip plus a class legend so the point colors are learnable without + * a new panel. + * + * The chip is stateless — it declares the params to apply and the manager + * owns the write, so the Space Missions snapshot/restore path (which drives + * the same `catalog` param) stays the single source of truth and the chip + * always renders whatever the layer actually has. + * + * The chip reports the dense LOAD state, not the catalog param: the param + * flips synchronously while the Starlink shell takes seconds to arrive and + * frequently 502s, so ACTIVE means "dense points are on screen" and nothing + * less. The legend tally is cached against the catalog revision. + * @returns {{ chips: Array, legend: Array }} Row controls. + */ + getRowControls() { + // A dependency owner (Space Missions) borrows this layer for TLE lookup + // with showPoints:false. Nothing is rendered, so a legend would describe an + // empty sky and a chip write would be silently reverted by that owner's + // restore. Surrender the row rather than lie about it. + if (!layerState._params.showPoints) return { chips: [], legend: [] }; + + const loading = layerState._denseStatus === 'loading'; + const failed = layerState._denseStatus === 'failed'; + const active = + layerState._params.catalog === 'dense' && + layerState._denseStatus === 'ready'; + let title = + 'Add the full Starlink broadband shell (thousands of extra points)'; + if (loading) title = 'Loading the Starlink shell…'; + else if (failed) + title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`; + else if (active) + title = + 'Showing the full Starlink shell — click for the core catalog only'; + return { + chips: [ + { + id: 'catalog', + label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE', + active, + busy: loading, + disabled: loading, + state: loading + ? 'loading' + : failed + ? 'error' + : active + ? 'active' + : 'idle', + title, + params: { catalog: active ? 'core' : 'dense' }, + }, + ], + legend: satelliteClassLegend(_classTally()), + }; + }, + + /** + * Install the manager's "row controls changed" callback. The dense load is + * asynchronous, so completion and failure have to push a re-render — nothing + * else would repaint this row before the 5-minute catalog refresh. + * @param {(() => void)|null} listener Callback, or null to detach. + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + + getStats() { + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + stale: false, + status: + layerState._lastError === 'CelesTrak unreachable' + ? 'unavailable' + : layerState._lastError + ? 'degraded' + : 'nominal', + error: layerState._lastError, + }; + }, + }; + + return { + _pointStyleFor, + _notifyRowControls, + _classTally, + satelliteVisualsVisible, + satelliteCatalogModeChanged, + methods, + }; +} diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js new file mode 100644 index 000000000..7bec672c0 --- /dev/null +++ b/src/layers/satellites/index.js @@ -0,0 +1,73 @@ +import { createControls } from './controls.js'; +import { createCatalog } from './catalog.js'; +import { createLabels } from './labels.js'; +import { createOrbits } from './orbits.js'; +import { createRendering } from './rendering.js'; +import { createTracking } from './tracking.js'; +import { createTesting } from './testing.js'; +import { createInteraction } from './interaction.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createSatellitesLayer({ services, source }) { + if (typeof source?.readGroup !== 'function') + throw new TypeError('A satellites source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.controls = createControls(context); + parts.catalog = createCatalog(context); + parts.labels = createLabels(context); + parts.orbits = createOrbits(context); + parts.rendering = createRendering(context); + parts.tracking = createTracking(context); + parts.testing = createTesting(context); + parts.interaction = createInteraction(context); + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + { + satelliteVisualsVisible: parts.controls.satelliteVisualsVisible, + satelliteCatalogModeChanged: parts.controls.satelliteCatalogModeChanged, + createIssOverlayEntry: parts.labels.createIssOverlayEntry, + orbitFrameModelMatrix: parts.orbits.orbitFrameModelMatrix, + _setTrackedSatelliteRefreshStateForTest: + parts.testing._setTrackedSatelliteRefreshStateForTest, + _setSatelliteTrackingRefreshOutcomeForTest: + parts.testing._setSatelliteTrackingRefreshOutcomeForTest, + _trackedFrameCartesianForTest: + parts.testing._trackedFrameCartesianForTest, + _runSatellitePreRenderForTest: + parts.testing._runSatellitePreRenderForTest, + _setDenseCatalogStateForTest: parts.testing._setDenseCatalogStateForTest, + _clearDenseCatalogStateForTest: + parts.testing._clearDenseCatalogStateForTest, + _catalogGroupForTest: parts.testing._catalogGroupForTest, + _setSatelliteLabelLifecycleStateForTest: + parts.testing._setSatelliteLabelLifecycleStateForTest, + _trackIssForTest: parts.testing._trackIssForTest, + _pendingSatelliteTrackingRestoreForTest: + parts.testing._pendingSatelliteTrackingRestoreForTest, + _applyPendingSatelliteTrackingRestoreForTest: + parts.testing._applyPendingSatelliteTrackingRestoreForTest, + _removeSatelliteTrackingCandidateForTest: + parts.testing._removeSatelliteTrackingCandidateForTest, + _clearSatelliteLabelLifecycleForTest: + parts.testing._clearSatelliteLabelLifecycleForTest, + applySatellitePointFocusDeemphasis: + parts.rendering.applySatellitePointFocusDeemphasis, + getNextIssPass: parts.orbits.getNextIssPass, + scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch, + findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle, + getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack, + }, + ); +} +export { ISS_OVERLAY_SOURCE_ID, ISS_OVERLAY_SOURCE_OPTIONS } from './policy.js'; +export { createSatelliteSource } from './source.js'; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js new file mode 100644 index 000000000..7fb36bb00 --- /dev/null +++ b/src/layers/satellites/ingestion.js @@ -0,0 +1,193 @@ +import { twoline2satrec } from 'satellite.js'; +import * as Cesium from 'cesium'; +import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + async update(viewer, { signal = null } = {}) { + const trackingRefreshEpoch = ++layerState._trackingRefreshEpoch; + layerState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + failedGroups: [], + }; + const resourceController = new AbortController(); + layerState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + // Load all core groups in parallel; a failed/empty group degrades + // gracefully (parseTLE of an upstream error body yields []). + const results = await Promise.all( + CATALOG_GROUPS.map(async (groupDef) => { + try { + const res = await source.readGroup(groupDef.path, { + signal: updateSignal, + }); + if (!res.ok) return { ...groupDef, entries: [], ok: false }; + const entries = parts.orbits.parseTLE(res.text); + updateSignal.throwIfAborted(); + return { ...groupDef, entries, ok: entries.length > 0 }; + } catch (error) { + if (updateSignal.aborted || error?.name === 'AbortError') + throw error; + return { ...groupDef, entries: [], ok: false }; + } + }), + ); + updateSignal.throwIfAborted(); + + const failed = results.filter((r) => !r.ok).map((r) => r.path); + if (failed.length > 0) { + console.warn( + `[Data:Satellites] Groups failed or empty: ${failed.join(', ')}`, + ); + } + console.log( + `[Data:Satellites] Loaded ${results.map((r) => `${r.tag}:${r.entries.length}`).join(' ')}`, + ); + + // CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing. + // Wiping the collection + catalog here would blank all 838 satellites while + // the chip still read "just now". Keep the existing (stale) catalog on + // screen and surface the outage instead — do NOT stamp _lastUpdate. + if (results.every((r) => !r.ok)) { + layerState._lastError = 'CelesTrak unreachable'; + console.warn( + '[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage', + ); + // Re-apply dense mode is skipped (no fresh core catalog); tracking untouched. + return; + } + + // At least one group loaded — proceed with a fresh rebuild, but keep the + // partial outage visible at the layer control instead of presenting the + // reduced catalog as a fully healthy refresh. + layerState._lastError = failed.length + ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` + : null; + + // Clear existing + layerState._pointCollection.removeAll(); + layerState._points.clear(); + for (const path of layerState._orbitPaths.values()) + viewer.scene.primitives.remove(path.primitive); + layerState._orbitPaths.clear(); + layerState._catalog.clear(); + // The detection overlay caches one record per satellite and stamps its + // id/class at creation only. A rebuild can re-tag a satellite (a failed + // group shifts which one wins dedupe), so the cache must go with the + // catalog or those labels stay stale for the life of the session. + layerState._detectionObjects.clear(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog + parts.labels._syncIssOverlay(); + + const now = new Date(); + + // Process all TLE entries in CATALOG_GROUPS order + const allEntries = []; + for (const r of results) { + for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); + } + + // Deduplicate by NORAD ID — first (most specific) group tag wins + const seen = new Set(); + + for (const entry of allEntries) { + const satrec = twoline2satrec(entry.line1, entry.line2); + if (!satrec || satrec.error !== 0) continue; + + const noradId = Number(satrec.satnum); + if (seen.has(noradId)) continue; + seen.add(noradId); + + // Store in catalog + layerState._catalog.set(noradId, { + name: entry.name, + satrec, + group: entry.group, + }); + + // Propagate initial position + const pos = parts.orbits.propagatePosition(satrec, now); + if (!pos) continue; + + const cartesian = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + + // Add point primitive (styling from the shared table, WS-D3) + const style = parts.controls._pointStyleFor(noradId, entry.group); + const point = layerState._pointCollection.add({ + position: cartesian, + pixelSize: style.pixelSize, + color: style.color, + outlineColor: style.outlineColor, + outlineWidth: style.outlineWidth, + scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + id: noradId, + }); + + layerState._points.set(noradId, point); + } + + // Show ISS orbital path by default + if (layerState._catalog.has(ISS_NORAD)) { + parts.rendering._showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color); + const issPath = layerState._orbitPaths.get(ISS_NORAD); + if (issPath) issPath.primitive.show = layerState._params.showOrbits; + + parts.labels._syncIssOverlay(); + } + + layerState._count = layerState._points.size; + layerState._catalogRevision++; + layerState._lastUpdate = Date.now(); + layerState._lastPropagation = Date.now(); + layerState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: failed.length ? 'partial' : 'accepted', + failedGroups: [...failed], + }; + console.log( + `[Data:Satellites] ${layerState._count} satellites active, ISS path shown`, + ); + + // Re-apply dense mode after a full catalog rebuild (fire-and-forget — + // _loadDenseCatalog handles its own errors and token invalidation). + layerState._denseLoadPromise = + layerState._params.catalog === 'dense' + ? parts.catalog._loadDenseCatalog({ signal: updateSignal }) + : Promise.resolve({ status: 'not-requested' }); + // The catalog the published voice subject was resolved against is gone. + // Re-resolve it, or release the slot if the subject provably did not + // survive. Deliberately not awaited: it waits on dense settlement, and + // the rebuild must not block on that. + void parts.tracking._reconcileTrackedSubjectContext(); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Satellite update aborted', 'AbortError'); + } + console.warn('[Data:Satellites] Fetch error:', e); + } finally { + layerState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js new file mode 100644 index 000000000..5944beaa5 --- /dev/null +++ b/src/layers/satellites/interaction.js @@ -0,0 +1,86 @@ +import * as Cesium from 'cesium'; + +export function createInteraction({ + state: layerState, + services, + parts, + source, +}) { + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + + function _onKeyDown(e) { + if (layerState._enabled && e.key === 'Escape' && layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + } + + function _installClickHandler(viewer) { + if (layerState._clickHandler) return; // already installed + + // Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights, + // military, …) grabs the follow-camera, drop our tracking so the orbit ring / + // tracked entity don't orphan — without touching viewer.trackedEntity (the + // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity + // briefly undefined mid-_trackSatellite) doesn't self-clear. + if (!layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if (!layerState._enabled) return; + if ( + layerState._trackedNorad && + layerState._viewer && + layerState._viewer.trackedEntity && + layerState._viewer.trackedEntity !== layerState._trackedEntity + ) { + parts.tracking._clearTracking(true, { + origin: + layerState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + if (!layerState._enabled) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking tracked entity itself — ignore + if (picked.id === layerState._trackedEntity) return; + + // Check if it's a satellite point (id is NORAD catalog number) + const prim = picked.primitive; + if (prim && prim.id != null) { + const noradId = Number(prim.id); + if (!isNaN(noradId) && layerState._catalog.has(noradId)) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(noradId, { origin: 'user' }); + return; + } + } + } + + // A pick that belongs to a sibling layer (plane, vessel, station, CCTV + // camera…) is not "empty space" — leave OUR tracking (and crucially + // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; + } + + // Clicked empty space — deselect + if (layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + document.addEventListener('keydown', _onKeyDown); + } + return { _onKeyDown, _installClickHandler }; +} diff --git a/src/layers/satellites/labels.js b/src/layers/satellites/labels.js new file mode 100644 index 000000000..95169be35 --- /dev/null +++ b/src/layers/satellites/labels.js @@ -0,0 +1,125 @@ +import { + ISS_NORAD, + DOCKED_SCAN_INTERVAL_MS, + DOCKED_COMPANION_RADIUS_M, + ISS_OVERLAY_SOURCE_ID, + ISS_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createLabels({ state: layerState, services, parts, source }) { + /** Build the persistent ISS ambient label from the cached point position. */ + + function createIssOverlayEntry(position) { + return { + id: String(ISS_NORAD), + position, + variant: 'label', + title: 'ISS', + accent: '#ff4444', + priority: 1000, + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + distanceScale: { + near: 1_000_000, + nearValue: 1, + far: 30_000_000, + farValue: 0.4, + }, + gapPx: 14, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** Cached ISS point only; never performs a fresh SGP4 propagation. */ + + function _issDisplayCached() { + return layerState._points.get(ISS_NORAD)?.position || null; + } + + /** + * Rebuild the docked-companion set for the tracked satellite. + * @returns {boolean} true when membership changed (callers resync presentation). + */ + + function _refreshDockedCompanions(nowMs) { + const trackedPosition = + layerState._trackedNorad === null + ? null + : parts.tracking._trackedDisplayCached(); + if (!trackedPosition) { + if (layerState._dockedCompanions.size === 0) return false; + layerState._dockedCompanions = new Set(); + return true; + } + if (nowMs - layerState._lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS) + return false; + layerState._lastDockedScanMs = nowMs; + const radiusSq = DOCKED_COMPANION_RADIUS_M * DOCKED_COMPANION_RADIUS_M; + let changed = false; + let found = 0; + const next = new Set(); + for (const [noradId, point] of layerState._points) { + if (noradId === layerState._trackedNorad || !point?.position) continue; + const dx = point.position.x - trackedPosition.x; + const dy = point.position.y - trackedPosition.y; + const dz = point.position.z - trackedPosition.z; + if (dx * dx + dy * dy + dz * dz > radiusSq) continue; + next.add(noradId); + found++; + if (!layerState._dockedCompanions.has(noradId)) changed = true; + } + if (found !== layerState._dockedCompanions.size) changed = true; + if (changed) layerState._dockedCompanions = next; + return changed; + } + + /** Names of the docked companions, stable-sorted so the card text never churns. */ + + function _dockedCompanionNames() { + const names = []; + for (const noradId of layerState._dockedCompanions) { + names.push( + layerState._catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`, + ); + } + return names.sort(); + } + + /** Keep persistent ISS text mutually exclusive with the tracked host card. */ + + function _syncIssOverlay() { + // Hidden when ISS is the tracked subject, and equally when ISS is DOCKED to + // whatever is tracked: its ambient label would otherwise sit underneath the + // tracked card at the same position. + const visible = + layerState._enabled && + layerState._params.showOrbits && + layerState._trackedNorad !== ISS_NORAD && + !layerState._dockedCompanions.has(ISS_NORAD) && + layerState._catalog.has(ISS_NORAD) && + _issDisplayCached(); + if (!visible) { + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + return; + } + layerState._overlayHost.setEntries( + ISS_OVERLAY_SOURCE_ID, + [createIssOverlayEntry(_issDisplayCached)], + ISS_OVERLAY_SOURCE_OPTIONS, + ); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true); + } + return { + createIssOverlayEntry, + _issDisplayCached, + _refreshDockedCompanions, + _dockedCompanionNames, + _syncIssOverlay, + }; +} diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js new file mode 100644 index 000000000..7c0d36670 --- /dev/null +++ b/src/layers/satellites/lifecycle.js @@ -0,0 +1,179 @@ +import * as Cesium from 'cesium'; +import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { clearFocusTarget } = services.focus; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + // Catalog data refresh; propagation remains preRender-owned. + + async init(viewer) { + parts.catalog._abortActiveUpdates(); + clearFocusTarget('satellites'); + layerState._viewer = viewer; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._detectionObjects = new Map(); + layerState._orbitPaths = new Map(); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._trackedNorad = null; + parts.tracking._cancelPendingTrackingRestore(); + layerState._trackedEntity = null; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._trackedFrameNowForTest = null; + layerState._lastFocusUpdate = 0; + layerState._activeFocusCount = 0; + layerState._enabled = false; + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + // Dense extras rebuild via update() when _params.catalog === 'dense' + // (the catalog-mode preference itself is sticky across init/destroy). + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadPromise = null; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + + // Point primitives for satellite dots + layerState._pointCollection = new Cesium.PointPrimitiveCollection(); + viewer.scene.primitives.add(layerState._pointCollection); + + parts.interaction._installClickHandler(viewer); + + // Pre-render listener for real-time position updates + // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) + layerState._preRenderListener = viewer.scene.preRender.addEventListener( + parts.rendering._preRenderTick, + ); + + console.log('[Data:Satellites] Initialized'); + }, + + enable(viewer) { + layerState._enabled = true; + holdContinuousRender('satellites'); // per-frame animator (perf wave 2) + if (layerState._pointCollection) + layerState._pointCollection.show = + parts.controls.satelliteVisualsVisible( + layerState._enabled, + layerState._params.showPoints, + ); + // Orbit ring primitives + persistent ISS host label — show them + for (const path of layerState._orbitPaths.values()) + path.primitive.show = parts.controls.satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); + parts.labels._syncIssOverlay(); + // Re-attach input handlers and preRender propagation + parts.interaction._installClickHandler(viewer); + // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; + // the registry hands predicates String()-coerced ids, so match via Number(). + registerPickOwner('satellites', (pickedId) => { + const norad = Number(pickedId); + return Number.isFinite(norad) && layerState._points.has(norad); + }); + if (!layerState._preRenderListener && viewer) { + layerState._preRenderListener = viewer.scene.preRender.addEventListener( + parts.rendering._preRenderTick, + ); + } + parts.tracking._applyPendingTrackingRestore(); + }, + + disable(viewer) { + parts.catalog._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + layerState._enabled = false; + releaseContinuousRender('satellites'); + if (layerState._pointCollection) layerState._pointCollection.show = false; + for (const path of layerState._orbitPaths.values()) + path.primitive.show = false; + parts.tracking._clearTracking(); + parts.labels._syncIssOverlay(); + // Remove click handler + keydown listener + preRender propagation while disabled + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove(); + layerState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.interaction._onKeyDown); + unregisterPickOwner('satellites'); + if (layerState._preRenderListener) { + layerState._preRenderListener(); + layerState._preRenderListener = null; + } + }, + + destroy(viewer) { + parts.catalog._abortActiveUpdates(); + releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) + layerState._enabled = false; + parts.tracking._clearTracking(); + parts.tracking._cancelPendingTrackingRestore(); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove(); + layerState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.interaction._onKeyDown); + unregisterPickOwner('satellites'); + if (layerState._preRenderListener) { + layerState._preRenderListener(); + layerState._preRenderListener = null; + } + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + // Orbit ring primitives are removed (and destroyed) here + for (const path of layerState._orbitPaths.values()) { + viewer.scene.primitives.remove(path.primitive); + } + layerState._points.clear(); + layerState._detectionObjects.clear(); + layerState._orbitPaths.clear(); + layerState._catalog.clear(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + layerState._rowControlsListener = null; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastError = null; + layerState._lastFocusUpdate = 0; + layerState._activeFocusCount = 0; + layerState._trackingRefreshEpoch += 1; + layerState._lastTrackingRefreshOutcome = { + epoch: layerState._trackingRefreshEpoch, + status: 'destroyed', + failedGroups: [], + }; + layerState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js new file mode 100644 index 000000000..943e5af14 --- /dev/null +++ b/src/layers/satellites/orbits.js @@ -0,0 +1,327 @@ +import * as Cesium from 'cesium'; +import { + gstime, + propagate, + eciToGeodetic, + degreesLong, + degreesLat, + twoline2satrec, +} from 'satellite.js'; +import { findNextIssPass } from '../../data/issPass.js'; +import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; + +export function createOrbits({ state: layerState, services, parts, source }) { + /** + * Build the rigid ECEF transform that keeps an orbit path baked at one GMST + * aligned with live SGP4 positions propagated at another epoch. + * @param {number} gmstAtBake GMST used when the path positions were baked. + * @param {Date} nowDate Epoch whose rotating-Earth frame should be displayed. + * @param {Cesium.Matrix4} [result] Optional matrix to update in place. + * @returns {Cesium.Matrix4} Z-rotation from bake-time ECEF to current ECEF. + */ + + function orbitFrameModelMatrix( + gmstAtBake, + nowDate, + result = new Cesium.Matrix4(), + ) { + const deltaGmst = gstime(nowDate) - gmstAtBake; + const rotation = Cesium.Matrix3.fromRotationZ( + -deltaGmst, + layerState._scratchRingRotation, + ); + return Cesium.Matrix4.fromRotationTranslation( + rotation, + Cesium.Cartesian3.ZERO, + result, + ); + } + + /** + * Parse TLE text into array of { name, line1, line2 } objects. + */ + + function parseTLE(text) { + const lines = text + .trim() + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const result = []; + for (let i = 0; i < lines.length - 2; i += 3) { + const name = lines[i]; + const line1 = lines[i + 1]; + const line2 = lines[i + 2]; + if (line1.startsWith('1 ') && line2.startsWith('2 ')) { + result.push({ name, line1, line2 }); + } + } + return result; + } + + /** + * Propagate satellite position at a given JS Date. + * Returns geodetic position plus inertial speed from the same SGP4 propagation + * epoch, or null on error. + */ + + function propagatePosition(satrec, date) { + try { + const posVel = propagate(satrec, date); + if (!posVel.position || typeof posVel.position === 'boolean') return null; + + const gmst = gstime(date); + const geo = eciToGeodetic(posVel.position, gmst); + const velocity = + posVel.velocity && typeof posVel.velocity !== 'boolean' + ? posVel.velocity + : null; + const speedMps = velocity + ? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000 + : null; + + return { + longitude: degreesLong(geo.longitude), + latitude: degreesLat(geo.latitude), + altitude: geo.height * 1000, // km → meters + speedMps: Number.isFinite(speedMps) ? speedMps : null, + }; + } catch { + return null; + } + } + + function orbitalPeriodSeconds(satrec) { + const meanMotion = satrec.no * (1440 / (2 * Math.PI)); + return 86400 / Math.max(meanMotion, 0.1); + } + + /** + * Compute full orbital path as array of Cartesian3 positions. + * Steps around one full orbit based on the satellite's mean motion. + */ + + function computeOrbitPath(satrec, referenceDate) { + const periodSec = orbitalPeriodSeconds(satrec); + const stepSec = periodSec / ORBIT_PATH_STEPS; + + const positions = []; + const baseTime = referenceDate.getTime(); + // Fix GMST to reference time so the orbital ring closes. + // Without this, Earth rotation during the orbit period (~24° for LEO) + // shifts the end point west of the start, leaving a visible gap. + const fixedGmst = gstime(referenceDate); + + for (let i = 0; i <= ORBIT_PATH_STEPS; i++) { + const t = new Date(baseTime + i * stepSec * 1000); + try { + const posVel = propagate(satrec, t); + if (!posVel.position || typeof posVel.position === 'boolean') continue; + const geo = eciToGeodetic(posVel.position, fixedGmst); + positions.push( + Cesium.Cartesian3.fromDegrees( + degreesLong(geo.longitude), + degreesLat(geo.latitude), + geo.height * 1000, + ), + ); + } catch { + continue; + } + } + + return positions; + } + + /** + * Next ISS pass for an observer. Requires the catalog to have loaded (the + * satellites layer enabled at least once this session). + * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} + */ + + function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { + const sat = layerState._catalog.get(ISS_NORAD); + if (!sat || !sat.satrec) return { status: 'no-tle' }; + const pass = findNextIssPass({ + satrec: sat.satrec, + latDeg, + lonDeg, + fromMs: Date.now(), + minElevDeg, + }); + return pass ? { status: 'ok', pass } : { status: 'none' }; + } + + /** + * Score a mission-to-catalog name match. Compact identifier containment handles + * names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as + * "Starlink Group 17-40" → an arbitrary "STARLINK-1008" remain below the + * acceptance threshold. + * @param {string} query Mission or payload name. + * @param {string} catalogName Satellite catalog name. + * @returns {number} Match score; 0 means no useful relationship. + */ + + function scoreSatelliteNameMatch(query, catalogName) { + const q = String(query || '') + .trim() + .toLowerCase(); + const name = String(catalogName || '') + .trim() + .toLowerCase(); + if (!q || !name) return 0; + const qCompact = q.replace(/[^a-z0-9]/g, ''); + const nameCompact = name.replace(/[^a-z0-9]/g, ''); + if (qCompact === nameCompact) return 1000; + let score = 0; + if ( + Math.min(qCompact.length, nameCompact.length) >= 5 && + (qCompact.includes(nameCompact) || nameCompact.includes(qCompact)) + ) { + score += 200 + Math.min(qCompact.length, nameCompact.length); + } + const ignored = new Set([ + 'group', + 'block', + 'mission', + 'launch', + 'falcon', + 'rocket', + ]); + const tokens = q + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 4 && !ignored.has(token)); + score += tokens.reduce( + (total, token) => total + (name.includes(token) ? token.length : 0), + 0, + ); + return score; + } + + function internationalDesignatorYear(satrec) { + const match = String(satrec?.intldesg || '').match(/^(\d{2})/); + if (!match) return null; + const shortYear = Number(match[1]); + return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; + } + + function lookupTleEntries(tleText) { + if (tleText !== layerState._lookupTleText) { + layerState._lookupTleText = tleText; + layerState._lookupTleEntries = parseTLE(tleText); + } + return layerState._lookupTleEntries; + } + + function tleLineLaunchYear(line1) { + const shortYear = Number(String(line1 || '').slice(9, 11)); + if (!Number.isFinite(shortYear)) return null; + return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; + } + + function orbitTrackFromRecord(name, satrec) { + const referenceDate = new Date(); + const current = propagatePosition(satrec, referenceDate); + if (!current) return null; + return { + noradId: Number(satrec.satnum), + name: String(name || '').trim(), + current, + periodSec: orbitalPeriodSeconds(satrec), + orbitPath: computeOrbitPath(satrec, referenceDate), + gmstAtBake: gstime(referenceDate), + positionAt: (date) => propagatePosition(satrec, date), + }; + } + + /** + * Find and propagate a mission payload directly from a TLE catalog. + * This supports newly launched payloads that are present in CelesTrak's active + * feed but have not yet moved into a narrower operational group. + * @param {string} tleText Three-line-element catalog text. + * @param {string} query Mission or payload name. + * @param {{launchTime?: string|null}} [options] Optional launch epoch for namesake rejection. + * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} + */ + + function findSatelliteOrbitTrackInTle(tleText, query, options = {}) { + const launchYear = Number.isFinite(Date.parse(options.launchTime)) + ? new Date(options.launchTime).getUTCFullYear() + : null; + let bestEntry = null; + let bestScore = 0; + const catalogText = String(tleText || ''); + for (const entry of lookupTleEntries(catalogText)) { + const designatorYear = tleLineLaunchYear(entry.line1); + if ( + launchYear !== null && + designatorYear !== null && + designatorYear !== launchYear + ) + continue; + const score = scoreSatelliteNameMatch(query, entry.name); + if (score > bestScore) { + bestEntry = entry; + bestScore = score; + } + } + if (!bestEntry || bestScore < 12) return null; + const satrec = twoline2satrec(bestEntry.line1, bestEntry.line2); + if (!satrec || satrec.error !== 0) return null; + return orbitTrackFromRecord(bestEntry.name, satrec); + } + + /** + * Return the current propagated position and one-orbit path for a catalog satellite. + * @param {string|number} query NORAD id or mission/payload name. + * @param {{launchTime?: string|null}} [options] Optional launch epoch used to reject namesakes from another launch year. + * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} + */ + + function getSatelliteOrbitTrack(query, options = {}) { + if (query === null || query === undefined || !layerState._catalog?.size) + return null; + const q = String(query).trim().toLowerCase(); + let noradId = /^\d+$/.test(q) ? Number(q) : null; + if (noradId === null || !layerState._catalog.has(noradId)) { + noradId = null; + const launchYear = Number.isFinite(Date.parse(options.launchTime)) + ? new Date(options.launchTime).getUTCFullYear() + : null; + let bestScore = 0; + for (const [id, sat] of layerState._catalog) { + const designatorYear = internationalDesignatorYear(sat.satrec); + if ( + launchYear !== null && + designatorYear !== null && + designatorYear !== launchYear + ) + continue; + const score = scoreSatelliteNameMatch(q, sat.name); + if (score > bestScore) { + bestScore = score; + noradId = id; + } + } + if (bestScore < 12) noradId = null; + } + if (noradId === null) return null; + const sat = layerState._catalog.get(noradId); + return orbitTrackFromRecord(sat.name, sat.satrec); + } + return { + orbitFrameModelMatrix, + parseTLE, + propagatePosition, + orbitalPeriodSeconds, + computeOrbitPath, + getNextIssPass, + scoreSatelliteNameMatch, + internationalDesignatorYear, + lookupTleEntries, + tleLineLaunchYear, + orbitTrackFromRecord, + findSatelliteOrbitTrackInTle, + getSatelliteOrbitTrack, + }; +} diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js new file mode 100644 index 000000000..656374fc6 --- /dev/null +++ b/src/layers/satellites/policy.js @@ -0,0 +1,182 @@ +import * as Cesium from 'cesium'; +import { satelliteClassColor } from '../../data/satelliteClass.js'; + +/** + * Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation. + * + * Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS, + * Galileo, and the geosynchronous belt. Optional dense mode (setParams + * catalog:'dense') adds the Starlink shell as points-only extras. + * Renders positions via PointPrimitiveCollection, orbital paths as polylines. + * Click any satellite to track it with camera follow + orbital path. + * + * ISS gets special treatment: larger point, persistent host label, path shown by default. + */ + +export const ISS_NORAD = 25544; + +export const ISS_OVERLAY_SOURCE_ID = 'satellites-iss'; + +export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: true, + solveIntervalMs: 125, +}); + +export const ORBIT_PATH_STEPS = 180; +// points per orbital path + +export const POSITION_UPDATE_MS = 1000; +// re-propagate every 1s (SGP4 is smooth at this rate) + +export const RING_ROTATION_MS = 1000; +// re-align baked orbit rings to current GMST every 1s + +/** + * CelesTrak groups loaded as the core catalog, in dedupe-priority order: + * a satellite that appears in multiple groups keeps the FIRST (most specific) + * tag. `path` is the upstream GROUP name forwarded by the /api/celestrak + * proxy; `tag` is the internal group key used for POINT_STYLES lookup. + * Note: CelesTrak's GLONASS group is named 'glo-ops' (not + * 'glonass-operational' — that name 404s upstream). + */ + +export const CATALOG_GROUPS = [ + { tag: 'stations', path: 'stations' }, + { tag: 'visual', path: 'visual' }, + { tag: 'gps-ops', path: 'gps-ops' }, + { tag: 'glonass', path: 'glo-ops' }, + { tag: 'galileo', path: 'galileo' }, + { tag: 'geo', path: 'geo' }, +]; + +// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as +// points-only extras — no labels, no detection-overlay participation, and a +// relaxed propagation budget (round-robin, ~1/5 of core cadence per sat). + +export const DENSE_GROUP_PATH = 'starlink'; + +export const DENSE_REFRESH_FRAMES = 300; +// full dense pass spread over ~300 frames (~5s @ 60fps) + +export const DENSE_CREATE_CHUNK = 1500; +// satrec builds per macro-task while loading + +/** + * Tracked-entity camera offset, east-north-up meters (Entity.viewFrom). + * Magnitude ≈ 726 km — the user-validated "slightly zoomed out" framing for + * LEO: far enough that per-frame satellite motion doesn't stutter the camera + * or smear the label, with +Z biasing the view down onto the satellite. + * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. + */ + +export const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3( + -450000, + -450000, + 350000, +); + +export const HIGH_ORBIT_ALTITUDE_M = 2000000; + +export const TRACK_VIEW_FROM_HIGH_SCALE = 4; +// ≈ 2900 km back for MEO/GEO + +/** + * Shared per-group point styling — single source of truth used by BOTH the + * creation site (update) and tracking restore (_clearTracking) so deselecting + * a satellite never loses the original palette (WS-D3). + * + * Colors come from `satelliteClass.js` so the dot, the class label on the + * card, and the legend swatch on the layer row can never disagree. Only + * pixelSize/outline live here — those encode per-group prominence, not class. + * Converted once at module load; per-point color is a plain primitive + * attribute, so classification costs nothing per frame. + */ + +export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); + +export const _classColor = (group) => + Cesium.Color.fromCssColorString(satelliteClassColor(group)); + +export const POINT_STYLES = { + // The ISS keeps its own long-standing red hero styling rather than the + // STATION class color: it is the object most users open this layer for, it + // carries a permanent name label, and its size/outline already set it apart. + // Its card still reads "STATION · ISS", so the class stays legible. + iss: { + pixelSize: 12, + color: Cesium.Color.fromCssColorString('#ff4444'), + outlineColor: POINT_OUTLINE, + outlineWidth: 2, + }, + stations: { + pixelSize: 8, + color: _classColor('stations'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + visual: { + pixelSize: 6, + color: _classColor('visual'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + // Nav constellations (GPS / GLONASS / Galileo) resolve to one shared NAV + // color; 6px so the MEO shells read as clearly as the old visual group did. + 'gps-ops': { + pixelSize: 6, + color: _classColor('gps-ops'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + glonass: { + pixelSize: 6, + color: _classColor('glonass'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + galileo: { + pixelSize: 6, + color: _classColor('galileo'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + geo: { + pixelSize: 5, + color: _classColor('geo'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + // Dense-mode extras (Starlink): dim, small, points-only. + dense: { + pixelSize: 3, + color: _classColor('dense').withAlpha(0.9), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, +}; + +/** + * Physically DOCKED vehicles are separate real tracks sharing one position: the + * station and everything berthed to it sit within metres of each other. Their + * ambient labels therefore stack underneath the tracked card, which is what the + * owner saw. This radius is deliberately tight — it must catch a docked stack + * and nothing else, so an unrelated satellite in a similar orbit is never + * suppressed. Formation-flying pairs are km apart; a docked stack is ~100 m. + */ + +export const DOCKED_COMPANION_RADIUS_M = 2000; + +/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */ + +export const DOCKED_SCAN_INTERVAL_MS = 1000; + +/** + * Shared-context refresh interval. The tracked satellite is re-propagated + * every rendered frame; the voice context only needs to be current to about + * the propagation cadence, so this refreshes on the same 1 s beat instead of + * allocating a record 60 times a second. + */ + +export const CONTEXT_REFRESH_INTERVAL_MS = 1000; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js new file mode 100644 index 000000000..08e4396bc --- /dev/null +++ b/src/layers/satellites/rendering.js @@ -0,0 +1,320 @@ +import * as Cesium from 'cesium'; +import { gstime } from 'satellite.js'; +import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + nearFarScalarValueAtDistance, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + /** + * Show orbital path for a satellite. + * + * RING FLICKER FIX: this is a one-instance Cesium.Primitive built + * synchronously ONCE (asynchronous: false), then re-aligned to current GMST + * each tick via its modelMatrix. The previous Entity-polyline approach + * rebuilt geometry ASYNCHRONOUSLY on every positions assignment, which made + * both the selected ring and the ISS ring blink once per second while the + * rebuild was in flight. A rigid Z-rotation needs no rebuild at all. + * + * Why not a CallbackProperty entity (dynamic mode)? Verified in Cesium + * 1.138 source: the dynamic polyline updater renders through a shared + * PolylineCollection and applies only the fill material — + * `depthFailMaterial` is silently dropped, losing the dimmed behind-Earth + * segment. The Primitive keeps it via depthFailAppearance + the per-instance + * depthFailColor attribute (same mechanism the entity STATIC batch uses): + * bright where above the horizon, dimmed where behind the globe. + */ + + function _showOrbitPath(noradId, color) { + if (layerState._orbitPaths.has(noradId)) return; // already showing + + const sat = layerState._catalog.get(noradId); + if (!sat || !layerState._viewer) return; + + const bakeDate = new Date(); + const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); + if (basePositions.length < 2) return; + + const pathColor = color || Cesium.Color.CYAN; + + const primitive = new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions: basePositions, + width: noradId === ISS_NORAD ? 2.5 : 2.0, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + pathColor.withAlpha(0.6), + ), + depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( + pathColor.withAlpha(0.35), + ), + }, + }), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + depthFailAppearance: new Cesium.PolylineColorAppearance({ + translucent: true, + }), + asynchronous: false, // build this frame — no async-rebuild blink window + allowPicking: false, // ring clicks fall through to satellites/deselect + }); + layerState._viewer.scene.primitives.add(primitive); + + layerState._orbitPaths.set(noradId, { + primitive, + // Same Date object as computeOrbitPath's internal fixedGmst → identical + // GMST value (gstime is pure), so delta starts at exactly 0 and the + // initial identity modelMatrix is correct until the first ring tick. + gmstAtBake: gstime(bakeDate), + }); + } + + /** + * Rotate every baked orbit ring from its bake-time ECEF snapshot to current + * GMST (WS-D1). The baked ring is an inertial-frame snapshot frozen at + * gmstAtBake; the live dot lives in true rotating-frame ECEF, so without this + * the dot slides west off the ring at Earth-rotation rate (~0.25°/min). + * + * Sign derivation: a point fixed in inertial space keeps its right ascension + * α, so its ECEF longitude λ = α − gmst DECREASES by ΔGMST as time advances + * (it drifts WEST). Cesium.Matrix3.fromRotationZ(θ) rotates +X toward +Y, + * i.e. INCREASES longitude by θ — so we rotate the baked points by −ΔGMST. + * Mental check: ISS baked over Austin at t0; 10 min later Austin has rotated + * east under the (inertial) ring, so the ring must sit further WEST in ECEF. + * + * Wraparound: gstime returns radians in [0, 2π), so the raw difference can be + * off from the continuous ΔGMST — but only by exact multiples of 2π, which a + * rotation cannot distinguish. Tiny negative deltas right after bake are + * likewise harmless. No unwrapping needed. + * + * No SGP4 here — the rotation is applied as the ring primitive's modelMatrix + * (exact compensation; WGS84 is rotationally symmetric about Z, so rotating + * the baked curve rigidly equals rebaking from rotated points). modelMatrix + * updates are synchronous uniforms — no geometry rebuild, no flicker. Post- + * creation modelMatrix changes are supported for one-instance primitives in + * 3D mode, which these rings are. In-place mutation is safe: Primitive.update + * diffs modelMatrix against an internal clone. + * @param {Date} nowDate Epoch used for current GMST. + */ + + function _updateOrbitPathRotations(nowDate) { + if (layerState._orbitPaths.size === 0) return; + + for (const path of layerState._orbitPaths.values()) { + if (!path.primitive) continue; + parts.orbits.orbitFrameModelMatrix( + path.gmstAtBake, + nowDate, + path.primitive.modelMatrix, + ); + } + } + + /** + * Remove orbital path for a satellite. + */ + + function _hideOrbitPath(noradId) { + const path = layerState._orbitPaths.get(noradId); + if (path) { + if (layerState._viewer) + layerState._viewer.scene.primitives.remove(path.primitive); // remove() destroys + layerState._orbitPaths.delete(noradId); + } + } + + /** + * Propagate all CORE satellite positions and update point primitives. + * (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are + * excluded: they refresh on the round-robin budget in _propagateDenseChunk. + */ + + function _propagateAll() { + const now = new Date(); + let updated = 0; + + for (const [noradId, sat] of layerState._catalog) { + if (sat.group === 'dense') continue; + const pos = parts.orbits.propagatePosition(sat.satrec, now); + if (!pos) continue; + + const cartesian = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + const point = layerState._points.get(noradId); + if (point) { + point.position = cartesian; + updated++; + } + } + + return updated; + } + + /** + * Shared scene.preRender tick (single definition for init + enable): + * - core fleet propagation at 200ms-tracked / 1s-idle cadence, + * - dense extras on a per-frame round-robin budget, + * - tracked satellite's point primitive per frame (WS-D2), + * - orbit ring GMST re-alignment every ~1s (WS-D1). + */ + + function _preRenderTick() { + if (!layerState._enabled) return; + const now = focusNowMs(Date.now()); + + const interval = layerState._trackedNorad ? 200 : POSITION_UPDATE_MS; + // Space Missions keeps this layer enabled for TLE lookup while deliberately + // hiding its standalone fleet. Do not rebuild hidden point buffers on the + // one-second propagation cadence: that GPU upload presented as a periodic + // whole-globe pulse even though the camera remained stationary. + if ( + layerState._params.showPoints && + now - layerState._lastPropagation >= interval + ) { + _propagateAll(); + layerState._lastPropagation = now; + } + + if (layerState._params.showPoints) parts.catalog._propagateDenseChunk(); + + // Keep the tracked dot on the per-frame epoch shared with label + camera — + // runs after _propagateAll so the per-frame sample wins over the 200ms one. + if (layerState._trackedNorad !== null) { + const pos = parts.tracking._getTrackedFramePosition(); + const point = layerState._points.get(layerState._trackedNorad); + if (pos && point) { + point.position = layerState._trackedFrameCartesian; // primitive setter clones + } + } + + _updatePointFocus(now); + + // Hidden standalone orbit primitives do not need GMST matrix writes while + // Space Missions draws the selected mission orbit itself. + if ( + layerState._params.showOrbits && + now - layerState._lastRingRotation >= RING_ROTATION_MS + ) { + _updateOrbitPathRotations(new Date(now)); + layerState._lastRingRotation = now; + } + } + + /** Focus alpha for satellite points, inside the existing shared preRender tick. */ + + function _updatePointFocus(nowMs) { + const target = getFocusTarget(); + if ( + !layerState._params.showPoints || + !focusPassIsNeeded(target, layerState._activeFocusCount) + ) + return; + if (nowMs - layerState._lastFocusUpdate < 80) return; + layerState._lastFocusUpdate = nowMs; + const scene = layerState._viewer.scene; + const camera = layerState._viewer.camera; + const result = applySatellitePointFocusDeemphasis({ + points: layerState._points, + trackedId: layerState._trackedNorad, + target, + previousActiveCount: layerState._activeFocusCount, + nowMs, + screenPositionFor: (position) => + Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + position, + layerState._scratchFocusScreen, + ), + cameraDistanceFor: (position) => + Cesium.Cartesian3.distance(camera.positionWC, position), + baseColorFor: (noradId) => + parts.controls._pointStyleFor( + noradId, + layerState._catalog.get(noradId)?.group, + ).color, + }); + layerState._activeFocusCount = result.activeCount; + } + + /** + * Apply the gated satellite-point focus pass through the production color path. + * @param {object} input + * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} + */ + + function applySatellitePointFocusDeemphasis({ + points, + trackedId, + target, + previousActiveCount = 0, + nowMs, + screenPositionFor, + cameraDistanceFor, + baseColorFor, + 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 [noradId, point] of points || []) { + if (noradId === trackedId || !point?.position) continue; + const cameraDistance = cameraDistanceFor(point.position); + const distanceScale = nearFarScalarValueAtDistance( + point.scaleByDistance, + cameraDistance, + ); + const halfExtentPx = (point.pixelSize || 5) * distanceScale * 0.5; + const focus = advanceSpriteFocus(point, { + // Hidden points still release toward identity, preventing stale dim + // alpha if a catalog/presentation toggle later makes them visible. + screenPosition: + point.show === false ? null : screenPositionFor(point.position), + cameraDistance, + nowMs, + target, + params, + spriteHalfWidthPx: halfExtentPx, + spriteHalfHeightPx: halfExtentPx, + }); + transitioning ||= focus.transitioning; + if (focus.active) activeCount += 1; + const base = baseColorFor(noradId); + const alpha = base.alpha * focus.factor; + if (focusAlphaNeedsWrite(point.color?.alpha, alpha, params)) { + // Point stays continuously present at the non-zero emphasis floor; its + // own alpha yields around the tracked target, independent of draw order. + point.color = base.withAlpha(alpha); + writes += 1; + } + } + return { writes, transitioning, activeCount, ran: true }; + } + return { + _showOrbitPath, + _updateOrbitPathRotations, + _hideOrbitPath, + _propagateAll, + _preRenderTick, + _updatePointFocus, + applySatellitePointFocusDeemphasis, + }; +} diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js new file mode 100644 index 000000000..8bcab7b02 --- /dev/null +++ b/src/layers/satellites/source.js @@ -0,0 +1,25 @@ +const GROUPS = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', +]); + +/** Read catalog text from the existing group endpoint using a supplied transport. */ +export function createSatelliteSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async readGroup(group, { signal } = {}) { + if (!GROUPS.has(group)) throw new TypeError('Unknown satellite group'); + signal?.throwIfAborted(); + const response = await fetchImpl(`/api/celestrak/${group}`, { signal }); + const text = response.ok ? await response.text() : ''; + signal?.throwIfAborted(); + return { ok: response.ok, status: response.status, text }; + }, + }; +} diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs new file mode 100644 index 000000000..a44ad565b --- /dev/null +++ b/src/layers/satellites/source.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSatelliteSource } from './source.js'; +import { createSatellitesLayer } from './index.js'; + +test('satellite sources confine catalog groups and reject a cancelled body', async () => { + const controller = new AbortController(); + let requests = 0; + const source = createSatelliteSource({ + fetchImpl: async (url) => { + requests++; + assert.equal(url, '/api/celestrak/stations'); + return { + ok: true, + status: 200, + text: async () => { + controller.abort(); + return 'late catalog'; + }, + }; + }, + }); + await assert.rejects( + source.readGroup('../active'), + /Unknown satellite group/, + ); + assert.equal(requests, 0); + await assert.rejects( + source.readGroup('stations', { signal: controller.signal }), + { name: 'AbortError' }, + ); +}); + +test('satellite factories keep control state separate and construct without requests', () => { + const source = { + readGroup() { + assert.fail('construction fetched a catalog'); + }, + }; + const services = Object.fromEntries( + [ + 'picking', + 'focus', + 'readout', + 'overlays', + 'context', + 'render', + 'layerState', + ].map((key) => [key, {}]), + ); + services.layerState.isExplicitLayerStateOrigin = () => false; + const first = createSatellitesLayer({ source, services }); + const second = createSatellitesLayer({ source, services }); + first.setParams({ showPoints: false }); + assert.equal(first.getParams().showPoints, false); + assert.equal(second.getParams().showPoints, true); + assert.notEqual(first.getStats(), second.getStats()); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js new file mode 100644 index 000000000..106802ba6 --- /dev/null +++ b/src/layers/satellites/state.js @@ -0,0 +1,165 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const state = {}; + + state.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + state._overlayHost = state.DEFAULT_OVERLAY_HOST; + + // Satellite catalog: { noradId → { name, satrec, group } } + + state._catalog = new Map(); + + state._pointCollection = null; + + state._points = new Map(); + // noradId → point primitive + /** Stable lightweight records reused by the detection overlay between updates. */ + + state._detectionObjects = new Map(); + + // noradId → { primitive, gmstAtBake } + // primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF + // at gmstAtBake; the preRender tick re-aligns it to current GMST by updating + // its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1). + + state._orbitPaths = new Map(); + + state._count = 0; + + state._lastUpdate = null; + + /** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */ + + state._lastError = null; + + state._activeUpdateControllers = new Set(); + + state._denseLoadController = null; + + state._viewer = null; + + state._preRenderListener = null; + + state._lastPropagation = 0; + + state._lastRingRotation = 0; + + state._lastFocusUpdate = 0; + + /** Points whose animated emphasis remains outside the 1.0 deadband. */ + + state._activeFocusCount = 0; + + state._scratchFocusScreen = new Cesium.Cartesian2(); + + state._enabled = false; + + // Click-to-track state + + state._trackedNorad = null; + + state._pendingTrackingRestore = null; + + state._trackingIntentGeneration = 0; + + state._trackingRefreshEpoch = 0; + + state._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + failedGroups: [], + }; + + state._trackedEntity = null; + + state._clickHandler = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + state._trackedEntityChangedRemove = null; + + // Runtime params (DataLayerManager.setLayerParams path) + + state._params = { catalog: 'core', showPoints: true, showOrbits: true }; + // 'core' | 'dense' + + state._denseIds = []; + // norad ids of dense extras, round-robin order + + state._denseCursor = 0; + // next dense id to re-propagate + + state._denseLoadToken = 0; + // invalidates in-flight dense loads on mode flip/reload + + state._denseLoadPromise = null; + + /** + * Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'. + * The catalog param flips synchronously but the Starlink shell arrives over + * seconds and can fail outright (CelesTrak 502s this feed regularly), so the + * row chip reports THIS, not the param. An active chip must mean dense points + * are actually on screen. + */ + + state._denseStatus = 'idle'; + + /** @type {string|null} Why the last dense load failed, for the chip tooltip. */ + + state._denseError = null; + + /** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */ + + state._catalogRevision = 0; + + state._classTallyCache = { revision: -1, counts: null }; + + /** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */ + + state._rowControlsListener = null; + + /** @type {Set} NORAD ids co-located with the tracked satellite. */ + + state._dockedCompanions = new Set(); + + state._lastDockedScanMs = Number.NEGATIVE_INFINITY; + + // Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and + // getTrackedInfo all read one SGP4 sample per rendered frame, keyed on + // scene.frameState.frameNumber, so they never diverge in epoch. + + state._trackedFrameNumber = -1; + + state._trackedFrameGeo = null; + // { longitude, latitude, altitude } or null + + state._trackedFrameCartesian = new Cesium.Cartesian3(); + + /** Optional deterministic clock used only by the production-frame test seam. */ + + state._trackedFrameNowForTest = null; + + // Scratch variables + + state._scratchCartesian = new Cesium.Cartesian3(); + + state._scratchRingRotation = new Cesium.Matrix3(); + + /** Update the explicit model only when the rounded altitude line changes. */ + /** Epoch of the last shared-context refresh for the tracked satellite. */ + + state._contextRefreshedAtMs = 0; + + state._lookupTleText = null; + + state._lookupTleEntries = []; + return state; +} diff --git a/src/layers/satellites/testing.js b/src/layers/satellites/testing.js new file mode 100644 index 000000000..e0f394a50 --- /dev/null +++ b/src/layers/satellites/testing.js @@ -0,0 +1,251 @@ +import * as Cesium from 'cesium'; +import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createTesting({ state: layerState, services, parts, source }) { + /** + * Seed a single tracked satellite so tests can invoke the exact production + * pre-render callback without constructing a WebGL viewer. + * @param {object} state + * @param {number} state.noradId + * @param {string} state.name + * @param {object} state.satrec + * @param {object} state.entity + * @param {object} state.point + * @param {object} state.viewer + * @param {() => (number|Date)} state.now + */ + + function _setTrackedSatelliteRefreshStateForTest({ + noradId, + name, + satrec, + entity, + point, + viewer, + now, + // Extra catalog/point rows so a test can exercise a docked cluster (and a + // control satellite that must NOT be treated as part of it). + neighbours = [], + }) { + layerState._viewer = viewer; + layerState._catalog = new Map([ + [noradId, { name, satrec, group: 'stations' }], + ]); + layerState._points = new Map([[noradId, point]]); + for (const neighbour of neighbours) { + layerState._catalog.set(neighbour.noradId, { + name: neighbour.name, + satrec: neighbour.satrec || satrec, + group: neighbour.group || 'stations', + }); + layerState._points.set(neighbour.noradId, neighbour.point); + } + layerState._dockedCompanions = new Set(); + layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY; + layerState._trackedNorad = noradId; + layerState._trackedEntity = entity; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._trackedFrameNowForTest = now; + layerState._params = { + catalog: 'core', + showPoints: false, + showOrbits: false, + }; + layerState._enabled = true; + } + + /** Seed catalog authority and optional dense settlement for share-Follow tests. */ + + function _setSatelliteTrackingRefreshOutcomeForTest({ + status = 'accepted', + failedGroups = [], + catalog = 'core', + densePromise = null, + } = {}) { + const epoch = ++layerState._trackingRefreshEpoch; + layerState._lastTrackingRefreshOutcome = { + epoch, + status, + failedGroups: [...failedGroups], + }; + layerState._params.catalog = catalog; + layerState._denseLoadPromise = + densePromise || Promise.resolve({ status: 'not-requested' }); + } + + /** The tracked satellite's current ECEF sample — the value the docked-cluster + * scan measures against. Exposed so a test can place neighbours around it. */ + + function _trackedFrameCartesianForTest() { + return layerState._trackedFrameCartesian; + } + + /** Invoke the same callback registered on `scene.preRender` in production. */ + + function _runSatellitePreRenderForTest() { + parts.rendering._preRenderTick(); + } + + /** + * Seed the minimum render state a dense-catalog load needs, so a test can + * exercise the real async load/settle/fail path (and the row-control states it + * drives) without constructing a WebGL viewer. + * @param {{ catalog?: 'core'|'dense', showPoints?: boolean }} [options] + */ + + function _setDenseCatalogStateForTest({ + catalog = 'core', + showPoints = true, + } = {}) { + layerState._viewer = { + scene: { primitives: { add: (p) => p, remove() {} } }, + }; + // Neutralize the shared world-overlay host: these tests exercise catalog and + // row-control logic, not the ISS callout. + layerState._overlayHost = { + setEntries() {}, + setVisible() {}, + clearSource() {}, + }; + layerState._pointCollection = { + show: true, + add: (opts) => ({ ...opts }), + remove() {}, + removeAll() {}, + }; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._detectionObjects = new Map(); + layerState._orbitPaths = new Map(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + layerState._trackedNorad = null; + parts.tracking._cancelPendingTrackingRestore(); + layerState._params = { catalog, showPoints, showOrbits: false }; + layerState._enabled = true; + } + + /** Tear the dense seam back down so ordering cannot leak into other tests. */ + + function _clearDenseCatalogStateForTest() { + layerState._rowControlsListener = null; + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + layerState._orbitPaths = new Map(); + layerState._viewer = null; + layerState._pointCollection = null; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._denseIds = []; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._params = { + catalog: 'core', + showPoints: true, + showOrbits: true, + }; + parts.tracking._cancelPendingTrackingRestore(); + layerState._enabled = false; + } + + /** Catalog group tag recorded for a satellite, for ingestion-path assertions. */ + + function _catalogGroupForTest(noradId) { + return layerState._catalog.get(Number(noradId))?.group; + } + + /** Seed ISS/tracking state while retaining the production track and host paths. */ + + function _setSatelliteLabelLifecycleStateForTest({ + viewer, + satrec, + point, + overlayHost, + preservePending = false, + }) { + layerState._viewer = viewer; + layerState._catalog = new Map([ + [ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }], + ]); + layerState._points = new Map([[ISS_NORAD, point]]); + layerState._orbitPaths = new Map([ + [ + ISS_NORAD, + { + primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, + gmstAtBake: 0, + }, + ], + ]); + layerState._trackedNorad = null; + layerState._trackedEntity = null; + if (!preservePending) parts.tracking._cancelPendingTrackingRestore(); + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._enabled = true; + layerState._params = { + catalog: 'core', + showPoints: true, + showOrbits: true, + }; + layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST; + parts.labels._syncIssOverlay(); + } + + /** Exercise production ISS tracking from the cached catalog and point. */ + + function _trackIssForTest() { + parts.tracking._trackSatellite(ISS_NORAD); + return layerState._trackedEntity; + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingSatelliteTrackingRestoreForTest() { + return layerState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated catalog refresh. */ + + function _applyPendingSatelliteTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Remove a cached catalog row so tests can model a target arriving later. */ + + function _removeSatelliteTrackingCandidateForTest(noradId) { + const id = Number(noradId); + layerState._catalog.delete(id); + layerState._points.delete(id); + } + + /** Exercise production untrack and restore the default host seam. */ + + function _clearSatelliteLabelLifecycleForTest() { + parts.tracking._clearTracking(); + layerState._enabled = false; + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + } + return { + _setTrackedSatelliteRefreshStateForTest, + _setSatelliteTrackingRefreshOutcomeForTest, + _trackedFrameCartesianForTest, + _runSatellitePreRenderForTest, + _setDenseCatalogStateForTest, + _clearDenseCatalogStateForTest, + _catalogGroupForTest, + _setSatelliteLabelLifecycleStateForTest, + _trackIssForTest, + _pendingSatelliteTrackingRestoreForTest, + _applyPendingSatelliteTrackingRestoreForTest, + _removeSatelliteTrackingCandidateForTest, + _clearSatelliteLabelLifecycleForTest, + }; +} diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js new file mode 100644 index 000000000..7bdb1d29f --- /dev/null +++ b/src/layers/satellites/tracking.js @@ -0,0 +1,479 @@ +import * as Cesium from 'cesium'; +import { satelliteClassLabel } from '../../data/satelliteClass.js'; +import { + ISS_NORAD, + CONTEXT_REFRESH_INTERVAL_MS, + HIGH_ORBIT_ALTITUDE_M, + TRACK_VIEW_FROM_HIGH_SCALE, + TRACK_VIEW_FROM_LEO, +} from './policy.js'; + +export function createTracking({ state: layerState, services, parts, source }) { + const { clearFocusTarget, publishFocusTargetFromCachedPosition } = + services.focus; + const { + clearTrackedSubjectContext, + getContextStore, + refreshTrackedSubjectContext, + selectTrackedSubjectContext, + } = services.context; + const { refreshTrackedReadout } = services.readout; + + function _normalizeTrackedNorad(candidate) { + const numeric = Number(candidate); + if (!Number.isFinite(numeric)) return null; + const rounded = Math.trunc(numeric); + return rounded > 0 ? rounded : null; + } + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _applyPendingTrackingRestore() { + const pending = layerState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== layerState._trackingIntentGeneration || + !layerState._enabled + ) + return false; + if ( + !layerState._viewer || + !layerState._catalog.has(pending.id) || + !layerState._points.has(pending.id) + ) + return false; + layerState._pendingTrackingRestore = null; + _trackSatellite(pending.id, { origin: pending.origin }); + return layerState._trackedNorad === pending.id; + } + + function _cancelPendingTrackingRestore() { + layerState._trackingIntentGeneration += 1; + layerState._pendingTrackingRestore = null; + } + + /** + * Stop tracking the currently followed satellite. + * @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed + * the follow-camera (viewer.trackedEntityChanged), tear down our own state + * but do NOT clear viewer.trackedEntity — the new owner controls it now, + * and clearing it would yank the camera off their target (mirror of flights). + */ + + function _clearTracking( + skipViewerUntrack = false, + { origin = 'programmatic' } = {}, + ) { + // Untracking dissolves the cluster: every companion returns to its own + // ambient label on the next collection. + layerState._dockedCompanions = new Set(); + layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY; + if (!layerState._trackedNorad) { + clearFocusTarget('satellites'); + parts.labels._syncIssOverlay(); + return; + } + const clearedNorad = layerState._trackedNorad; + clearFocusTarget('satellites', layerState._trackedNorad); + + const lastPos = layerState._points.get(layerState._trackedNorad); + + // Re-show the primitive (hidden while the tracked entity rendered the dot) + // and restore the original group palette from the shared style table (WS-D3) + if (lastPos) { + const style = parts.controls._pointStyleFor( + layerState._trackedNorad, + layerState._catalog.get(layerState._trackedNorad)?.group, + ); + lastPos.show = true; + lastPos.pixelSize = style.pixelSize; + lastPos.color = style.color; + lastPos.outlineColor = style.outlineColor; + lastPos.outlineWidth = style.outlineWidth; + lastPos.disableDepthTestDistance = 0; + } + + // Invalidate the per-frame tracked-position cache (WS-D2) + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + + // Remove tracked entity and orbit path (unless ISS — keep its path) + if (layerState._trackedNorad !== ISS_NORAD) { + parts.rendering._hideOrbitPath(layerState._trackedNorad); + } + if (layerState._viewer && !skipViewerUntrack) + layerState._viewer.trackedEntity = undefined; + if (layerState._trackedEntity) { + layerState._viewer.entities.remove(layerState._trackedEntity); + layerState._trackedEntity = null; + } + layerState._trackedNorad = null; + parts.labels._syncIssOverlay(); + clearTrackedSubjectContext('satellites'); + layerState._contextRefreshedAtMs = 0; + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'satellites', + id: clearedNorad, + origin, + }); + } + + /** + * Get the tracked satellite's geodetic position, propagated at most once per + * rendered frame (WS-D2). All tracked-satellite consumers (entity position + * callback → camera, host model, point primitive, getTrackedInfo) share this + * single `new Date()` epoch per frame, so they can never diverge by the old + * 200ms throttle. The matching ECEF position is left in + * `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap. + * @returns {{ longitude: number, latitude: number, altitude: number }|null} + */ + + function _getTrackedFramePosition() { + if (layerState._trackedNorad === null) return null; + const sat = layerState._catalog.get(layerState._trackedNorad); + if (!sat) return null; + + const frameNumber = + layerState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frameNumber === -1 || + frameNumber !== layerState._trackedFrameNumber || + layerState._trackedFrameGeo === null + ) { + const pos = parts.orbits.propagatePosition( + sat.satrec, + layerState._trackedFrameNowForTest + ? new Date(layerState._trackedFrameNowForTest()) + : new Date(), + ); + if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample + layerState._trackedFrameGeo = pos; + Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + undefined, + layerState._trackedFrameCartesian, + ); + layerState._trackedFrameNumber = frameNumber; + // Throttled inside; membership changes are rare, so resync the ISS ambient + // gate only when the cluster actually changed. + const clusterChanged = parts.labels._refreshDockedCompanions( + layerState._trackedFrameNowForTest + ? layerState._trackedFrameNowForTest() + : Date.now(), + ); + _updateTrackedSatelliteLabelModel(); + // The card and the context slot describe the same satellite — keep them + // together so voice never narrates a fix the card has already replaced. + _refreshTrackedSubjectContext(); + if (clusterChanged) parts.labels._syncIssOverlay(); + const name = sat.name?.trim() || `SAT-${layerState._trackedNorad}`; + const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${layerState._trackedNorad}`; + const labelWidthPx = + Math.max(name.length, altitudeText.length) * 7.8 + 20; + const labelHeightPx = 2 * 13 + 12; + const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides + // Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all + // share one epoch, avoiding a second propagation phase and its old jitter. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'satellites', + id: layerState._trackedNorad, + scene: layerState._viewer?.scene, + camera: layerState._viewer?.camera, + displayPosition: layerState._trackedFrameCartesian, + widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)), + // Union of the 14 px point and the two-line label shifted 18 px upward. + heightPx: trackedPointDiameterPx + 18 + labelHeightPx, + }); + } + return layerState._trackedFrameGeo; + } + + /** Cached ECEF display point only; never propagates a fresh SGP4 sample. */ + + function _trackedDisplayCached() { + return layerState._trackedFrameGeo + ? layerState._trackedFrameCartesian + : null; + } + + /** + * Describe the tracked satellite for the shared context slot the voice tools + * read. Values come from the live per-frame propagation, not a selection-time + * snapshot, so a long follow never narrates a position the satellite has left. + * @param {number} noradId Catalog identity. + * @param {{latitude: number, longitude: number, altitude: number}|null} [position] + * Explicit position; defaults to the current per-frame propagation. + * @returns {object|null} Context metadata, or null when the satellite is gone. + */ + + function _contextSubjectMetadata(noradId, position = null) { + const sat = layerState._catalog.get(noradId); + if (!sat) return null; + const pos = position || _getTrackedFramePosition(); + if (!pos) return null; + const name = sat.name?.trim() || `SAT-${noradId}`; + const altitudeKm = Number.isFinite(pos.altitude) + ? Math.round(pos.altitude / 1000) + : null; + return { + id: String(noradId), + layerId: 'satellites', + layerName: 'Satellites', + source: 'CelesTrak', + label: name, + latitude: pos.latitude, + longitude: pos.longitude, + // Flat text only: the voice payload compacts properties through a string + // cleaner that drops nested objects. + properties: { + name, + operator: '', + noradId: String(noradId), + class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }), + altitude: + altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`, + }, + }; + } + + /** + * Reconcile the published subject with a freshly rebuilt catalog. + * + * A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the + * catalog, so the tracked satellite's entry is a NEW object with a new satrec. + * A surviving subject is simply re-resolved against it. A subject that is GONE + * must release the slot: the per-frame refresh cannot do this itself, because + * `_getTrackedFramePosition` returns early once the satellite has no catalog + * entry — so without this the record would linger and voice would narrate a + * satellite the catalog no longer carries, frozen at its last position. + * + * Releasing is gated on PROOF. The subject is preserved unless it is absent + * from a catalog that is both complete (`accepted`, no failed CelesTrak group) + * and applicable (dense settled, when dense is the requested catalog). A + * partial refresh, a failed dense load, or an empty catalog is unproven + * absence, and unproven absence is not absence — the same honesty rule the + * tracking-restore path already applies. + * @returns {Promise} Resolves once the applicable catalog has settled. + */ + + async function _reconcileTrackedSubjectContext() { + const subjectAtStart = layerState._trackedNorad; + if (subjectAtStart === null) return; + // Dense extras land AFTER the core rebuild resolves. Deciding before they + // settle called a dense subject missing and deleted its record; the record + // then stayed gone, because a refresh can update an existing record but + // cannot recreate one. + const denseSettlement = + layerState._params.catalog === 'dense' + ? layerState._denseLoadPromise + : null; + if (denseSettlement) { + try { + await denseSettlement; + } catch { + // A failed dense load proves nothing about the subject; fall through and + // let the outcome check below preserve it. + } + } + // The operator may have moved on while we waited. + if (layerState._trackedNorad !== subjectAtStart) return; + + const metadata = _contextSubjectMetadata(subjectAtStart); + if (metadata) { + const store = getContextStore(); + const key = String(subjectAtStart); + if (store.entities.has(key)) { + refreshTrackedSubjectContext(metadata); + } else if (!store.selectedEntityId) { + // The record was dropped while the subject was briefly unresolvable. + // Restore it — but only into an EMPTY slot: a satellite reappearing must + // never yank the subject away from something the operator selected since. + selectTrackedSubjectContext(metadata); + } + layerState._contextRefreshedAtMs = Date.now(); + return; + } + + // Absence only counts when EVERY catalog that could carry the subject + // actually loaded. Unproven absence is not absence — the same honesty rule + // the tracking-restore path applies. + // + // Deliberately NOT scoped to the group the subject was last seen in: + // CelesTrak reclassifies satellites between groups, so a subject missing + // from its old group may simply have moved to one that failed this refresh. + // Believing the old group alone would drop it. Any failed or empty group is + // therefore a reason to wait — and `accepted` already means every group + // returned entries. + if (layerState._catalog.size === 0) return; + if (layerState._lastTrackingRefreshOutcome?.status !== 'accepted') return; + // Dense is a potential carrier too whenever it was REQUESTED — and the + // request is read from `denseSettlement`, captured before the await, not + // from `_params.catalog` now. A failed dense load reverts the mode to 'core' + // as part of settling (an ACTIVE chip over an empty sky would be a lie), so + // by the time we get here the intent that made dense a carrier has been + // erased. Re-reading it would skip this guard on exactly the runs that need + // it and delete a subject the dense catalog might have carried. + if (denseSettlement && layerState._denseStatus !== 'ready') return; + clearTrackedSubjectContext('satellites'); + layerState._contextRefreshedAtMs = 0; + } + + /** + * Keep the shared context slot current with the tracked satellite, on the + * propagation beat rather than the frame clock. + * @returns {void} + */ + + function _refreshTrackedSubjectContext() { + if (layerState._trackedNorad === null) return; + const now = Date.now(); + if (now - layerState._contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS) + return; + layerState._contextRefreshedAtMs = now; + refreshTrackedSubjectContext( + _contextSubjectMetadata(layerState._trackedNorad), + ); + } + + function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) { + if (!layerState._trackedEntity || layerState._trackedNorad === null) return; + const sat = layerState._catalog.get(layerState._trackedNorad); + const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`; + const altitudeM = + layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM; + const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`; + // Class leads the detail block: it is what tells the operator WHAT they are + // looking at, and it stays readable under the IR styles that flatten the + // dot colors to a single channel (the card is painted above post-FX). + const details = [ + satelliteClassLabel(sat?.group, { + isIss: layerState._trackedNorad === ISS_NORAD, + }), + detail, + ]; + // Docked companions are consolidated onto the tracked card as SECONDARY info + // instead of competing with it as separate ambient labels. Identities are + // preserved: the catalog is untouched and every companion returns to its own + // label the moment the cluster is no longer tracked. + const companions = parts.labels._dockedCompanionNames(); + if (companions.length > 0) { + const extra = companions.length - 1; + details.push( + `DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`, + ); + } + const current = layerState._trackedEntity.gevLabelModel; + // Compare the WHOLE detail array: comparing only `details[0]` swallowed any + // change confined to the companions line, so the card would never republish. + const unchanged = + current?.title === title && + current?.details?.length === details.length && + details.every((line, index) => current.details[index] === line); + if (unchanged) return; + layerState._trackedEntity.gevLabelModel = { + title, + details, + accent: '#ffd84d', + }; + refreshTrackedReadout(layerState._trackedEntity); + } + + function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); + + const point = layerState._points.get(noradId); + const sat = layerState._catalog.get(noradId); + if (!point || !sat) return; + + layerState._trackedNorad = noradId; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + parts.labels._syncIssOverlay(); + + // Hide the primitive — the tracked ENTITY renders the dot below. The + // entity must own a point graphic so the Viewer's tracking camera can + // resolve a bounding sphere and engage viewFrom (a label-only entity + // left the camera stranded; this mirrors the proven flights pattern). + point.show = false; + + // Show orbital path + parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW); + + // Tracked entity position propagates per evaluation through the per-frame + // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. + // Falls back to the point primitive's 1s-throttled position if SGP4 fails. + const positionProperty = new Cesium.CallbackProperty(() => { + const pos = _getTrackedFramePosition(); + return pos ? layerState._trackedFrameCartesian : point.position; + }, false); + + const name = sat.name.trim(); + + // Comfortable tracking landing: ~726 km back for LEO (user-validated + // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled + // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. + const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const viewScale = + initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M + ? TRACK_VIEW_FROM_HIGH_SCALE + : 1; + const viewFrom = Cesium.Cartesian3.multiplyByScalar( + TRACK_VIEW_FROM_LEO, + viewScale, + new Cesium.Cartesian3(), + ); + + layerState._trackedEntity = layerState._viewer.entities.add({ + position: positionProperty, + viewFrom, + point: { + pixelSize: 14, + color: Cesium.Color.YELLOW, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + layerState._trackedEntity.gevSelectionOrigin = origin; + layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`; + layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached; + _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); + + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'satellites', + id: noradId, + label: name, + position: Cesium.Cartesian3.clone(point.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); + layerState._contextRefreshedAtMs = Date.now(); + + layerState._viewer.trackedEntity = layerState._trackedEntity; + console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); + } + return { + _normalizeTrackedNorad, + _emitAwarenessEvent, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _clearTracking, + _getTrackedFramePosition, + _trackedDisplayCached, + _contextSubjectMetadata, + _reconcileTrackedSubjectContext, + _refreshTrackedSubjectContext, + _updateTrackedSatelliteLabelModel, + _trackSatellite, + }; +} diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js new file mode 100644 index 000000000..bda32e78a --- /dev/null +++ b/src/layers/traffic/animation.js @@ -0,0 +1,314 @@ +import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js'; +import { presetDotOutline } from '../../data/trafficPresetStyle.js'; +import * as Cesium from 'cesium'; +import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js'; +import { + SPEED_MPS, + MAX_DOTS, + JAM_DOT_FAR_SCALE, + JAM_DOT_DEPTH_PUNCH, + CREEP_MOVE_MS, + CREEP_STOP_MS, + CREEP_BURST, + HEAT_JAM_BASE_ALPHA, + HEAT_JAM_PULSE_ALPHA, +} from './policy.js'; + +export function createAnimation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Spawn animated dot primitives along a single road. + * + * Each dot is placed at a random position along the road, assigned a + * randomized speed (base +/-30%), and given a direction (alternating + * forward/backward to simulate two-way traffic). + * + * @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road + * Parsed road object with pre-computed waypoints. + * @param {number} altitude - Camera altitude (used if budgetCount is null). + * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back + * to `computeDotCount` when null. + */ + + function spawnDotsForRoad(road, altitude, budgetCount = null) { + // Live flow styling (`road.flow` only exists in live mode; keyless path is + // byte-identical): closures spawn nothing, congestion colors/slows dots. + const flow = layerState._liveMode ? road.flow : null; + if (flow?.closure) return; + if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide') + return; + + const count = Number.isFinite(budgetCount) + ? Math.max(0, Math.floor(budgetCount)) + : parts.model.computeDotCount(road, altitude); + const numSegments = road.waypoints.length - 1; + if (numSegments < 1 || count <= 0) return; + + const baseMps = SPEED_MPS[road.type] || 5; + const bucket = flow ? flowBucket(flow.level) : null; + // Jam dots get +1px: a red queue should read as a queue at a glance. + // Preset-aware styling adds its own size delta and floors the base (0 / + // no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT. + const pixelSize = + parts.style.baseDotSize(road.type, bucket) + + (bucket === 'jam' ? 1 : 0) + + parts.style.activeSizeDelta(bucket); + const flowColor = bucket ? layerState._activeBucketColors[bucket] : null; + // Dark-halo outline under styled presets (null = shipped no-outline). + const outlineSpec = + bucket && layerState._presetDots === 'on' + ? presetDotOutline(layerState._stylePreset, bucket) + : null; + const outlineColor = outlineSpec + ? new Cesium.Color( + outlineSpec.rgba[0] / 255, + outlineSpec.rgba[1] / 255, + outlineSpec.rgba[2] / 255, + outlineSpec.rgba[3], + ) + : null; + const flowSpeed = flow ? flowSpeedScale(flow.level) : 1; + const now = Date.now(); + + // Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper + // platoons (one shared direction per queue) instead of uniform scatter. + // Unreachable in sim mode — `bucket` requires flow. + let placements = null; + if (bucket === 'jam' && parts.style.jamDensityOn()) { + let totalLen = 0; + for (const d of road.segmentDist) totalLen += d; + const platoons = queuePlatoons(totalLen, count); + if (platoons.length) { + placements = []; + for (let p = 0; p < platoons.length; p++) { + const dir = road.oneway ? road.oneway : p % 2 === 0 ? 1 : -1; + for (const s of platoons[p]) { + const { segIdx, t } = locateAlongRoad(road.segmentDist, s); + placements.push({ segIdx, t, direction: dir }); + } + } + } + } + + for (let i = 0; i < count; i++) { + if (layerState._dots.length >= MAX_DOTS) return; + + // Random start position: pick a random segment and offset within it — + // unless this is a queued jam dot with a platoon placement. + const segIdx = placements + ? placements[i].segIdx + : Math.floor(Math.random() * numSegments); + const t = placements ? placements[i].t : Math.random(); + + // Speed noise: base speed +/-30% for organic variation. baseMps (noise + // included, flow excluded) is kept on the dot so a late-arriving flow + // match can rescale speed in place (recolorDotsInPlace). + const noisedMps = + baseMps * layerState._speedScale * (0.7 + Math.random() * 0.6); + const mps = noisedMps * flowSpeed; + + // One-way roads flow only their legal direction; two-way alternates + // (per platoon in queue mode — a queue moves as one). + const direction = placements + ? placements[i].direction + : road.oneway + ? road.oneway + : i % 2 === 0 + ? 1 + : -1; + + // Compute initial Cartesian3 position via linear interpolation + Cesium.Cartesian3.lerp( + road.waypoints[segIdx], + road.waypoints[segIdx + 1], + t, + layerState._scratchLerp, + ); + + // Jam-viz density prototype: jam dots stay visible at city scale — a + // longer depth-test punch-through (single-sample road heights sit under + // the mesh at oblique views) and a higher far-scale floor. Sim dots and + // other buckets keep the shipped values. + const jamProminent = bucket === 'jam' && parts.style.jamDensityOn(); + const point = layerState._pointCollection.add({ + position: Cesium.Cartesian3.clone(layerState._scratchLerp), + pixelSize, + // No flow data → today's exact simulated white. + color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), + scaleByDistance: new Cesium.NearFarScalar( + 100, + 1.5, + layerState._fadeScaleFar, + jamProminent ? JAM_DOT_FAR_SCALE : 0.3, + ), + translucencyByDistance: new Cesium.NearFarScalar( + 100, + 1.0, + layerState._fadeTransFar, + 0.0, + ), + // visible through tiles only when very close (jam: city-scale punch) + disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000, + // Preset dark halo (spread only when present — the keyless/normal + // path passes the exact shipped option set). + ...(outlineSpec + ? { outlineColor, outlineWidth: outlineSpec.width } + : {}), + }); + layerState._bucketCounts[bucket || 'sim'] += 1; + + layerState._dots.push({ + point, + road, + bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse + waypoints: road.waypoints, + segmentDist: road.segmentDist, + numSegments, + segIdx, + t, + mps, // meters per second (flow-scaled) + baseMps: noisedMps, // pre-flow speed, for in-place flow rescale + direction, + stoppedUntil: 0, + // Stop-and-go creep state (jam-viz density prototype): jam dots + // alternate move-bursts and stops. Null in sim mode and for non-jam. + creep: + bucket === 'jam' && parts.style.jamDensityOn() + ? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 } + : null, + }); + } + } + + /** + * Per-frame animation callback registered on `scene.preRender`. + * + * For every active dot: + * 1. Skip if currently paused by a simulated stop-light. + * 2. Convert speed (m/s) to a parametric t-delta relative to the current + * segment's Cartesian distance. + * 3. Advance t in the dot's travel direction, handling segment boundary + * crossings and end-of-road reversals. + * 4. Linearly interpolate between the two bounding waypoints and update the + * point primitive's position. + * + * Delta time is capped at 100 ms to prevent large jumps after background tabs. + */ + + function animate() { + const now = Date.now(); + // Delta time in seconds, capped to avoid jumps when returning from background tab + const dt = layerState._lastAnimTime + ? Math.min((now - layerState._lastAnimTime) / 1000, 0.1) + : 0.016; + layerState._lastAnimTime = now; + + for (let i = 0; i < layerState._dots.length; i++) { + const dot = layerState._dots[i]; + + // Simulated stop-light pause — skip movement while timer is active + if (now < dot.stoppedUntil) continue; + + // Stop-and-go creep (jam-viz density prototype, live jam dots only): + // alternate short forward bursts with stops. The burst multiplier keeps + // the long-run average near the honest TomTom crawl speed. + let burst = 1; + if (dot.creep) { + if (now >= dot.creep.until) { + dot.creep.moving = !dot.creep.moving; + const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS; + dot.creep.until = now + lo + Math.random() * (hi - lo); + } + if (!dot.creep.moving) continue; + burst = CREEP_BURST; + } + + // Convert m/s speed to parametric t-delta for the current segment length + const segLen = dot.segmentDist[dot.segIdx] || 1; + const tDelta = (dot.mps * burst * dt) / segLen; + + // Advance parametric position along the road in the current direction + dot.t += tDelta * dot.direction; + + // Handle forward segment boundary crossing (t >= 1.0) + if (dot.t >= 1.0) { + dot.t -= 1.0; + dot.segIdx++; + if (dot.segIdx >= dot.numSegments) { + // End of road: recycle to the road's entry with a small stagger — + // cars don't reverse at the end of a street (field-test round 1). + // Direction is preserved, so one-way flow stays legal. + dot.segIdx = 0; + dot.t = Math.random() * 0.3; + } + maybeStopLight(dot, now); + } else if (dot.t <= 0.0) { + // Handle backward segment boundary crossing (t <= 0.0) + dot.t += 1.0; + dot.segIdx--; + if (dot.segIdx < 0) { + // Start of road (traveling backward): recycle to the far end. + dot.segIdx = dot.numSegments - 1; + dot.t = 1.0 - Math.random() * 0.3; + } + maybeStopLight(dot, now); + } + + // Lerp between pre-computed Cartesian3 waypoints (no trig needed). + // Pass the scratch directly: PointPrimitive's position setter clones the + // value into its own storage (and skips the VBO dirty flag when equal), so + // the extra defensive clone here allocated 360–720k Cartesian3/s of pure + // garbage across 6000 dots. (perf item 5) + const a = dot.waypoints[dot.segIdx]; + const b = dot.waypoints[dot.segIdx + 1]; + Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp); + dot.point.position = layerState._scratchLerp; + } + + // Jam heat-lines throb (~1.6 s period) — one shared material uniform for + // the whole jam batch, no geometry rebuild. Null outside live heatline mode. + if (layerState._heatJamPrim?.appearance) { + layerState._heatJamPrim.appearance.material.uniforms.color.alpha = + HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); + } + + layerState._animFrame++; + } + + /** + * Randomly pause a dot near road endpoints to simulate stop-light behaviour. + * + * Only triggers within the first 2 or last 2 segments of the road, and only + * with a very low per-frame probability (0.8%) to keep traffic flowing. + * + * @param {Object} dot - The dot state object. + * @param {number} now - Current timestamp in milliseconds. + */ + + function maybeStopLight(dot, now) { + const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2; + if (nearEnd && Math.random() < 0.008) { + // Pause for 2–6 seconds + dot.stoppedUntil = now + 2000 + Math.random() * 4000; + } + } + + // ─── Cleanup ─────────────────────────────────────────────── + + /** Remove all point primitives and reset dot/road arrays and counters. */ + + function clearDots() { + if (layerState._pointCollection) layerState._pointCollection.removeAll(); + parts.rendering.removeHeatLines(); + layerState._dots = []; + layerState._roads = []; + layerState._count = 0; + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + layerState._closedRoads = 0; + } + return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; +} diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js new file mode 100644 index 000000000..95b3665a3 --- /dev/null +++ b/src/layers/traffic/controls.js @@ -0,0 +1,189 @@ +import { + trafficBucketTier, + trafficStyleProfile, +} from '../../data/trafficPresetStyle.js'; +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { getFlowSessionStats } = source; + + const methods = { + id: 'traffic', + + name: 'Street Traffic', + + icon: '🚗', + + source: 'OpenStreetMap', + + /** @type {number} Zero — layer is self-managed via camera listener + preRender */ + updateInterval: 0, + + /** + * Update user-adjustable parameters (density and speed scaling). + * + * @param {Object} [params] + * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). + * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). + */ + setParams(params = {}) { + if (typeof params.densityScale === 'number') { + layerState._densityScale = Math.max( + 0.2, + Math.min(2.5, params.densityScale), + ); + } + if (typeof params.speedScale === 'number') { + layerState._speedScale = Math.max( + 0.3, + Math.min(3.0, params.speedScale), + ); + } + // Live-mode treatment of roads TomTom has no flow data for: + // 'sim' (default) keeps them as today's white ambient dots — colored = + // real data, white = simulation; 'hide' spawns nothing on them (strict + // data-integrity view). Owner-explorable; re-render applies on the next + // camera-driven load. + if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') { + layerState._uncoveredMode = params.uncoveredRoads; + } + // Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior; + // live mode only, applies on the next camera-driven load like the + // uncoveredRoads param above. + if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) { + layerState._jamViz = params.jamViz; + } + // Preset-aware dot styling kill switch (owner A/B): 'off' forces the + // shipped palette under every post-FX preset. Applies immediately via + // in-place restyle — no refetch — so A/B legs share identical dots. + if (params.presetDots === 'on' || params.presetDots === 'off') { + if (params.presetDots !== layerState._presetDots) { + layerState._presetDots = params.presetDots; + parts.style.restyleDotsInPlace(); + } + } + }, + + /** + * Return the current user-adjustable parameters. + * @returns {{densityScale:number, speedScale:number}} + */ + getParams() { + return { + densityScale: layerState._densityScale, + speedScale: layerState._speedScale, + uncoveredRoads: layerState._uncoveredMode, + jamViz: layerState._jamViz, + presetDots: layerState._presetDots, + }; + }, + + /** + * Return a sub-sampled list of active dot positions for detection overlays + * (e.g. CCTV bounding-box rendering). + * + * Uses a deterministic stride-based sampling so different seeds yield + * non-overlapping subsets without sorting or shuffling. + * + * @param {Object} [options] + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). + * @param {number} [options.seed] - Integer seed to offset the sampling start. + * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || layerState._dots.length === 0) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : layerState._dots.length; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Stride-based sampling: step through dots evenly to get ~maxCount samples + const stride = Math.max(1, Math.ceil(layerState._dots.length / maxCount)); + const start = seed % stride; + + const result = []; + for (let i = start; i < layerState._dots.length; i += stride) { + const pos = layerState._dots[i].point.position; + if (!pos) continue; + const entry = { + position: pos, + id: `VEH-${String(i).padStart(4, '0')}`, + type: 'VEH', + }; + // Live mode: the detection bracket carries the congestion signal — + // its canvas sits ABOVE the post-FX chain, so tier colors survive + // every preset (owner round 2: "bounding boxes do the heavy + // lifting"). Keyless mode sets no tier: contacts keep the stock + // 'vehicle' bracket and the keyless experience stays untouched. + if (layerState._liveMode) { + const tier = trafficBucketTier(layerState._dots[i].bucket || 'sim'); + if (tier) entry.tier = tier; + } + result.push(entry); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Return current layer statistics for UI status chips. + * `mode` is the CONFIGURED source — 'live' (a TomTom key is present) or + * 'sim' (keyless simulation, which the manager renders as a FALLBACK chip); + * `error` carries this instant's health, so a live-configured layer whose + * flow feed went down reads DEGRADED with the reason instead of a stale + * LIVE coverage number. `flowCoveragePct` is matched roads / roads with any + * flow candidates (0–100 int); `tilesFetched` counts flow-tile requests + * issued to the proxy this session (decode-cache hits excluded). + * @returns {{count:number, lastUpdate:number|null, loading:boolean, + * mode:'live'|'sim', error:string|null, flowCoveragePct:number, + * tilesFetched:number}} + */ + getStats() { + // Outstanding flow work counts as loading: the paint race can leave a + // TomTom request in flight after the roads have settled, and the shared + // loading batch has to stay open long enough to announce its failure. + const loading = layerState._fetching || layerState._flowPending > 0; + const feed = parts.model.trafficFeedPresentation({ + liveMode: layerState._liveMode, + fetching: loading, + flowError: layerState._flowError, + coveragePct: layerState._flowCoveragePct, + statusUnavailable: layerState._flowStatusUnavailable, + }); + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + loading, + mode: feed.mode, + error: layerState._roadError || feed.error, + flowCoveragePct: layerState._flowCoveragePct, + tilesFetched: getFlowSessionStats().tilesFetched, + ...(TRAFFIC_TIMING_ENABLED + ? { trafficTiming: parts.timing.getTrafficTimingDiagnostics() } + : {}), + // Per-bucket rendered-dot counts (sim = white ambient). Drives the + // qa-traffic color assertions and the sync-chip mode label below. + flowBuckets: { ...layerState._bucketCounts }, + closedRoads: layerState._closedRoads, + // Jam-viz prototype diagnostics (additive — harness contract untouched). + heatLines: layerState._heatLineCount, + jamViz: layerState._jamViz, + // Preset-styling diagnostics (additive): active style + profile. + stylePreset: layerState._stylePreset, + styleProfile: + layerState._presetDots === 'on' + ? trafficStyleProfile(layerState._stylePreset) + : 'normal', + // Sync-chip text: shown while busy, and flashed on its own for 1.5 s + // after each completed load (ui.js _updateTrafficSyncChip semantics). + // The settled flash carries NO progress number beside it — this label's + // coverage figure is the chip's only percentage — so a label that ends + // in one had better be the honest one. This is also where LIVE vs + // SIMULATED mode is surfaced, and it must never imply a live feed the + // layer does not have. + loadingLabel: feed.loadingLabel, + }; + }, + }; + + return { methods }; +} diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js new file mode 100644 index 000000000..c12f05f53 --- /dev/null +++ b/src/layers/traffic/flow.js @@ -0,0 +1,215 @@ +import { matchFlowToRoads } from '../../data/flowMatch.js'; +import { TRAFFIC_TIMING_ENABLED, FLOW_RENDER_RACE_MS } from './policy.js'; + +export function createFlow({ state: layerState, services, parts, source }) { + const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits; + const { fetchFlowForBounds } = source; + + // ─── Live Flow (TomTom) ──────────────────────────────────── + + /** + * Map a failed flow fetch onto one short, honest user-facing reason. + * + * `fetchFlowForBounds` only rejects when EVERY covering tile failed, so a + * non-null result here always means "there is no live flow to show right + * now" — the dots fall back to simulated white. Mirrors the + * `deriveAisFeedError` honesty helper. + * + * @param {Error|{name?:string, message?:string}|null|undefined} error - Rejection from the flow fetch. + * @returns {string|null} Short reason, or null for an aborted (superseded) fetch. + */ + + function deriveTrafficFlowError(error) { + if (!error || error.name === 'AbortError') return null; + const message = String(error.message || error); + const status = Number(message.match(/HTTP (\d{3})/)?.[1]); + if (status === 503) return 'TomTom key unavailable'; + if (status === 429) return 'TomTom daily budget reached'; + if (status === 502 || status === 504) return 'TomTom upstream unreachable'; + if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`; + return 'TomTom flow unavailable'; + } + + /** + * Check `/api/tomtom/status` once per session and cache the result. + * Live mode iff the server holds a TomTom key; the TomTom attribution credit + * registers the first time live mode activates. Keyless or unreachable → + * simulation mode, exactly today's behavior. + * + * @returns {Promise} Resolves when `_liveMode` is settled. + */ + + function ensureFlowStatus() { + if (!layerState._flowStatusPromise) { + layerState._flowStatusPromise = source + .getStatus() + .then((status) => { + layerState._liveMode = Boolean(status?.hasKey); + layerState._flowStatusUnavailable = false; + if (layerState._liveMode) { + console.log('[Data:Traffic] TomTom key present — live flow mode'); + registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT); + } + }) + .catch((e) => { + // Simulating because we could not ask, which is NOT the same as + // "server says no key" — getStats() distinguishes the two. + layerState._liveMode = false; + layerState._flowStatusUnavailable = true; + console.warn( + '[Data:Traffic] TomTom status unreachable — simulated traffic:', + e?.message || e, + ); + }); + } + return layerState._flowStatusPromise; + } + + /** + * Live mode only: fetch TomTom flow for the clamped bounds, match it onto the + * parsed roads, and attach `road.flow` (`{level, closure}` or null). + * + * Reuses the load-generation guard: stale flow responses are discarded, and + * the shared AbortController lets `cancelActiveFetch()` (next load / disable) + * cancel an in-flight flow fetch. Any failure leaves roads unmatched — the + * dots then render in today's simulated white, never a phantom color — and is + * recorded in `_flowError` so `getStats()` degrades honestly instead of + * reporting a stale "LIVE · N% cov" over simulated dots. + * + * @param {Array} roads - Parsed road objects (mutated: `road.flow`). + * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. + * @param {number} generation - `_loadGeneration` at call time. + * @returns {Promise} + */ + + async function applyFlowToRoads(roads, clamped, generation) { + // Claim the work synchronously, before the first await, so `stats.loading` + // covers this request from the same tick the caller started it — the + // loading batch must not be able to close underneath an in-flight fetch. + layerState._flowPending += 1; + try { + if (!layerState._flowStatusPromise) return; // status check not started — sim mode + await layerState._flowStatusPromise; + if (!layerState._liveMode || !layerState._enabled) return; + if (generation !== layerState._loadGeneration) return; + if (!Array.isArray(roads) || roads.length === 0) return; + try { + // Cached paths reach here without a live controller; the fetch paths + // reuse theirs so one cancel covers both roads and flow. + if (!layerState._activeFetchAbort) + layerState._activeFetchAbort = new AbortController(); + const segments = await fetchFlowForBounds(clamped, { + signal: layerState._activeFetchAbort.signal, + }); + if (generation !== layerState._loadGeneration) return; + const { matches, matchedCount, candidateCount } = matchFlowToRoads( + roads, + segments, + ); + for (let i = 0; i < roads.length; i++) { + roads[i].flow = matches[i]; + } + layerState._flowCoveragePct = + candidateCount > 0 + ? Math.round((matchedCount / candidateCount) * 100) + : 0; + layerState._flowError = null; + } catch (e) { + if (e?.name === 'AbortError') return; + // Same guard the success path gets: a superseded request rejecting late + // (or after disable() cleared the state) must not restore a stale + // outage over newer good data. + if (generation !== layerState._loadGeneration || !layerState._enabled) + return; + // Every covering tile failed: there is no live flow on screen. Drop the + // now-false coverage number and surface the reason through getStats(). + layerState._flowError = deriveTrafficFlowError(e); + layerState._flowCoveragePct = 0; + console.warn( + '[Data:Traffic] Flow fetch failed (sim colors remain):', + e?.message || e, + ); + } + } finally { + if (generation === layerState._loadGeneration) + layerState._flowPending -= 1; + } + } + + /** + * Race flow application against the paint deadline, render, and schedule an + * in-place recolor if flow lost the race. + * @param {Array} roads - Parsed road objects. + * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. + * @param {number} generation - `_loadGeneration` at call time. + * @param {number} altitude - Camera altitude in meters. + * @param {string} label - Render log label. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} True if this generation rendered. + */ + + async function applyFlowThenRender( + roads, + clamped, + generation, + altitude, + label, + trace = null, + ) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingRenderState(trace, label) + : null; + const flowRaceStart = state + ? parts.timing.trafficTimingMark(state, 'flow-render-race-start', { + deadlineMs: FLOW_RENDER_RACE_MS, + }) + : null; + const flowJob = applyFlowToRoads(roads, clamped, generation); + const outcome = await Promise.race([ + flowJob.then(() => 'flow'), + new Promise((resolve) => + setTimeout(() => resolve('timeout'), FLOW_RENDER_RACE_MS), + ), + ]); + if (state) { + const flowRaceEnd = parts.timing.trafficTimingMark( + state, + 'flow-render-race-end', + { + deadlineMs: FLOW_RENDER_RACE_MS, + outcome, + }, + ); + parts.timing.trafficTimingMeasure( + 'flow-render-race', + state, + flowRaceStart, + flowRaceEnd, + { + deadlineMs: FLOW_RENDER_RACE_MS, + outcome, + }, + ); + } + if (generation !== layerState._loadGeneration) return false; + parts.rendering.renderRoadsForAltitude(roads, altitude, label, trace); + if (outcome === 'timeout') { + flowJob + .then(() => { + if (generation !== layerState._loadGeneration) return; + parts.model.recolorDotsInPlace(label); + }) + .catch(() => { + /* applyFlowToRoads settles its own failures */ + }); + } + return true; + } + return { + deriveTrafficFlowError, + ensureFlowStatus, + applyFlowToRoads, + applyFlowThenRender, + }; +} diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js new file mode 100644 index 000000000..016ce576b --- /dev/null +++ b/src/layers/traffic/flowDecode.js @@ -0,0 +1,70 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; + +/** + * @file TomTom traffic-flow vector-tile client: fetch + MVT decode. + * + * Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy + * (the TomTom key never reaches the browser) and decodes the Mapbox Vector + * Tile layer "Traffic flow" into plain lon/lat polylines with congestion + * attributes. Consumed by the traffic layer's live mode + * (`src/data/traffic.js` → `src/data/flowMatch.js`). + * + * Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string, + * closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed + * ratio (1 = free flow). Features with a missing/non-finite `traffic_level` + * are skipped unless `road_closure` is true (closures decode with level 0). + * + * Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and + * tree-shakeable; decoding happens client-side so the proxy stays a dumb + * binary cache. + * + * @module data/flowTiles + */ + +const FLOW_LAYER_NAME = 'Traffic flow'; +export function decodeFlowTile(data, z, x, y) { + let layer; + try { + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const tile = new VectorTile(new PbfReader(bytes)); + layer = tile.layers[FLOW_LAYER_NAME]; + } catch { + return []; + } + if (!layer) return []; + + const segments = []; + for (let i = 0; i < layer.length; i++) { + let feature; + let geometry; + try { + feature = layer.feature(i); + geometry = feature.toGeoJSON(x, y, z).geometry; + } catch { + continue; // one malformed feature must not drop the tile + } + const props = feature.properties || {}; + const closure = + props.road_closure === true || props.road_closure === 'true'; + const rawLevel = props.traffic_level; + const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel); + // Skip features we can't color — unless closed (closures render dot-free + // regardless of level, so they stay useful without one). + if (!hasLevel && !closure) continue; + const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0; + const roadType = typeof props.road_type === 'string' ? props.road_type : ''; + + const lines = + geometry.type === 'LineString' + ? [geometry.coordinates] + : geometry.type === 'MultiLineString' + ? geometry.coordinates + : []; + for (const coords of lines) { + if (!Array.isArray(coords) || coords.length < 2) continue; + segments.push({ coords, trafficLevel, roadType, closure }); + } + } + return segments; +} diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js new file mode 100644 index 000000000..dc1ed8b90 --- /dev/null +++ b/src/layers/traffic/flowSource.js @@ -0,0 +1,97 @@ +import { tilesForBounds } from '../../data/tomtomTiles.js'; +import { decodeFlowTile } from './flowDecode.js'; +/** Own one decoded flow cache and its session counters. */ +export function createFlowTileSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const DECODE_CACHE_TTL_MS = 120_000; + /** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */ + const DECODE_CACHE_MAX_ENTRIES = 64; + + /** + * Decoded-tile cache keyed by "z/x/y". + * @type {Map} + */ + const _decodeCache = new Map(); + /** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */ + let _tilesFetched = 0; + + /** Insert into the decode cache with oldest-entry eviction. */ + function cacheSet(key, entry) { + if ( + !_decodeCache.has(key) && + _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES + ) { + const oldest = _decodeCache.keys().next().value; + _decodeCache.delete(oldest); + } + _decodeCache.set(key, entry); + } + + /** + * Fetch + decode all flow tiles covering the given bounds. + * + * Tiles are fetched from the local proxy in parallel; each decoded tile is + * cached in memory for 120 s (keyed z/x/y), so repeat calls for the same + * viewport are free. Partial tile failures return the segments that DID + * decode (last-good philosophy); the promise rejects only when every tile + * failed (e.g. keyless 503, aborted signal, proxy down). + * + * @param {{south:number, west:number, north:number, east:number}} bounds - Degrees. + * @param {Object} [opts] + * @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled). + * @param {number} [opts.zoom=12] - Flow tile zoom level. + * @returns {Promise>} + * Flat array of flow segments across all covering tiles. + */ + async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) { + signal?.throwIfAborted(); + const tiles = tilesForBounds(bounds, zoom); + if (tiles.length === 0) return []; + const now = Date.now(); + + const results = await Promise.allSettled( + tiles.map(async ({ z, x, y }) => { + const key = `${z}/${x}/${y}`; + const cached = _decodeCache.get(key); + if (cached && now - cached.at < DECODE_CACHE_TTL_MS) + return cached.segments; + + _tilesFetched += 1; + const res = await fetchImpl(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { + signal, + }); + if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`); + const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y); + signal?.throwIfAborted(); + cacheSet(key, { at: Date.now(), segments }); + return segments; + }), + ); + + signal?.throwIfAborted(); + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + if (fulfilled.length === 0) { + throw results[0].reason instanceof Error + ? results[0].reason + : new Error('flow fetch failed'); + } + return fulfilled.flatMap((r) => r.value); + } + + /** + * Session diagnostics for `getStats()` surfaces. + * @returns {{tilesFetched:number}} Count of tile requests issued to the proxy + * this session (decode-cache hits excluded). + */ + function getFlowSessionStats() { + return { tilesFetched: _tilesFetched }; + } + + /** Clear the decode cache (tests + layer teardown). Session stats persist. */ + function resetFlowTileCache() { + _decodeCache.clear(); + } + + return { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache }; +} diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js new file mode 100644 index 000000000..4039ada17 --- /dev/null +++ b/src/layers/traffic/index.js @@ -0,0 +1,63 @@ +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { createStyle } from './style.js'; +import { createTiming } from './timing.js'; +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createAnimation } from './animation.js'; +import { createViewport } from './viewport.js'; +import { createFlow } from './flow.js'; +import { createRendering } from './rendering.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createTrafficLayer({ services, source }) { + if ( + ![ + 'requestRoads', + 'getStatus', + 'fetchFlowForBounds', + 'getFlowSessionStats', + 'resetFlowTileCache', + ].every((key) => typeof source?.[key] === 'function') + ) + throw new TypeError('A traffic source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.style = createStyle(context); + parts.timing = createTiming(context); + parts.model = createModel(context); + parts.ingestion = createIngestion(context); + parts.animation = createAnimation(context); + parts.viewport = createViewport(context); + parts.flow = createFlow(context); + parts.rendering = createRendering(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + state._parseRoads = TRAFFIC_TIMING_ENABLED + ? (data, trace) => + trace + ? parts.timing.parseRoadsTimed(data, trace) + : parts.model.parseRoads(data) + : parts.model.parseRoads; + + state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED + ? parts.timing.loadRoadsForBoundsTimed + : parts.ingestion.loadRoadsForBounds; + + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + getTrafficTimingDiagnostics: parts.timing.getTrafficTimingDiagnostics, + deriveTrafficFlowError: parts.flow.deriveTrafficFlowError, + trafficFeedPresentation: parts.model.trafficFeedPresentation, + }, + ); +} + +export { createTrafficSource } from './source.js'; diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js new file mode 100644 index 000000000..b7d48597f --- /dev/null +++ b/src/layers/traffic/ingestion.js @@ -0,0 +1,354 @@ +import { + TRAFFIC_TIMING_ENABLED, + OVERPASS_URL, + TILE_CACHE_MAX_ENTRIES, + FAST_FETCH_ALTITUDE, +} from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const { fetchFlowForBounds } = source; + + /** + * Fetch road geometries from the Overpass API via the local proxy. + * + * Sends a POST with the query as form-encoded `data`. Supports + * AbortController signals so in-flight requests can be cancelled + * when the camera moves before the response arrives. + * + * @param {number} south - Southern latitude bound (degrees). + * @param {number} west - Western longitude bound (degrees). + * @param {number} north - Northern latitude bound (degrees). + * @param {number} east - Eastern longitude bound (degrees). + * @param {Object} [opts] + * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes. + * @param {number} [opts.timeoutSec=25] - Server-side Overpass timeout. + * @param {AbortSignal} [opts.signal] - Abort signal for cancellation. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} Parsed JSON response from Overpass. + * @throws {Error} If the HTTP response status is not OK. + */ + + async function fetchRoads( + south, + west, + north, + east, + { majorOnly = false, timeoutSec = 25, signal } = {}, + trace = null, + ) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingPass( + trace, + majorOnly ? 'major' : 'full', + 'proxy', + ) + : null; + if (state) trace.currentPass = state.pass; + const fetchStart = state + ? parts.timing.trafficTimingMark(state, 'fetch-start') + : null; + if (state) { + parts.timing.trafficTimingMeasure( + 'last-camera-change-to-fetch-start', + state, + trace.cameraChangeMark, + fetchStart, + ); + } + const response = await source.requestRoads( + { south, west, north, east }, + { majorOnly, timeoutSec, signal }, + ); + + if (!response.ok) { + throw new Error(`Overpass API returned ${response.status}`); + } + + if (!state) { + const data = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(data?.elements)) + throw new Error('Malformed road snapshot'); + return data; + } + + if (state) { + state.proxyCache = response.headers.get('x-overpass-cache'); + state.proxyUpstream = response.headers.get('x-overpass-upstream'); + } + const responseStart = state + ? parts.timing.trafficTimingMark(state, 'response-json-start', { + responseStatus: response.status, + }) + : null; + if (state) { + parts.timing.trafficTimingMeasure( + 'fetch-to-response', + state, + fetchStart, + responseStart, + { + responseStatus: response.status, + }, + ); + } + const data = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(data?.elements)) + throw new Error('Malformed road snapshot'); + if (state) { + const responseEnd = parts.timing.trafficTimingMark( + state, + 'response-json-end', + { + responseStatus: response.status, + }, + ); + parts.timing.trafficTimingMeasure( + 'response-json', + state, + responseStart, + responseEnd, + { + responseStatus: response.status, + }, + ); + } + return data; + } + + /** Abort any in-flight Overpass fetch and clear the controller reference. */ + + function cancelActiveFetch() { + if (layerState._activeFetchAbort) { + layerState._activeFetchAbort.abort(); + layerState._activeFetchAbort = null; + } + } + + /** + * Load road data for the given viewport bounds and render traffic dots. + * + * Implements a two-pass fetch strategy with tile caching: + * + * 1. Check the tile cache (keyed by clamped bounding-box coordinates). + * - If a full road set is cached, render immediately and return. + * - If only major roads are cached, render those first. + * 2. Fetch major roads from Overpass (fast, small payload). Render. + * 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full + * road graph (includes tertiary/residential). Render again to upgrade. + * + * Each fetch is guarded by a monotonic `_loadGeneration` counter so that + * stale responses from superseded requests are silently discarded. + * + * @param {{south:number, west:number, north:number, east:number}} bounds + * Viewport bounds (will be clamped internally). + * @param {number} altitude - Camera altitude in meters. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} + */ + + async function loadRoadsForBounds(bounds, altitude, trace = null) { + // Increment generation to invalidate any in-flight responses from prior calls + const generation = ++layerState._loadGeneration; + cancelActiveFetch(); + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + layerState._flowPending = 0; + layerState._activeFetchAbort = new AbortController(); + const requestSignal = layerState._activeFetchAbort.signal; + const clamped = parts.viewport.clampBounds(bounds); + + // Cache key: fixed-precision bounding-box string for deterministic lookups + const cacheKey = `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`; + + if (cacheKey !== layerState._retryBoundsKey) { + layerState._retryBoundsKey = cacheKey; + layerState._retryDelayMs = 1500; + } + layerState._roadError = null; + + // Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road + // fetch — sequential fetches doubled first-paint latency (field-test + // round 1). Failures are irrelevant; applyFlowToRoads settles the truth. + parts.flow.ensureFlowStatus().then(() => { + if ( + layerState._liveMode && + layerState._enabled && + generation === layerState._loadGeneration + ) { + fetchFlowForBounds(clamped, { signal: requestSignal }).catch(() => { + /* warm-up only */ + }); + } + }); + + layerState._fetching = true; + // Only COMMIT these on success. Committing up-front means a failed Overpass + // fetch (rate-limited / feed down) still trips the overlap gate in + // onCameraChanged, so a stationary user never retries (H3/H5). Stage the + // prospective values and roll back if nothing rendered. + const prevBounds = layerState._lastBounds; + const prevViewCenter = layerState._lastViewCenter; + layerState._lastBounds = clamped; + layerState._lastViewCenter = parts.viewport.getBoundsCenter(clamped); + let renderedSomething = false; + + try { + let cache = layerState._tileCache.get(cacheKey); + if (!cache) { + // LRU eviction: drop the oldest entry when cache exceeds the cap + if (layerState._tileCache.size >= TILE_CACHE_MAX_ENTRIES) { + const oldest = layerState._tileCache.keys().next().value; + layerState._tileCache.delete(oldest); + } + cache = { major: null, full: null }; + layerState._tileCache.set(cacheKey, cache); + } + + // Fast path: full road set already cached — render and return. + // Flow is (re)applied even on cache hits: roads cache for the session, + // but congestion data has a 120s shelf life. The race renders within + // FLOW_RENDER_RACE_MS either way; late flow recolors in place. + if (cache.full) { + renderedSomething = await parts.flow.applyFlowThenRender( + cache.full, + clamped, + generation, + altitude, + 'Cache full', + trace, + ); + return; + } + + // Intermediate path: render cached major roads while fetching the rest + if (cache.major) { + if ( + !(await parts.flow.applyFlowThenRender( + cache.major, + clamped, + generation, + altitude, + 'Cache major', + trace, + )) + ) + return; + renderedSomething = true; + } else { + // Fetch major roads first (smaller payload, faster response) + console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`); + const majorData = await fetchRoads( + clamped.south, + clamped.west, + clamped.north, + clamped.east, + { + majorOnly: true, + timeoutSec: 12, + signal: requestSignal, + }, + trace, + ); + // Discard stale response if a newer load was triggered while waiting + if (generation !== layerState._loadGeneration) return; + cache.major = layerState._parseRoads(majorData, trace); + if ( + !(await parts.flow.applyFlowThenRender( + cache.major, + clamped, + generation, + altitude, + 'Loaded major', + trace, + )) + ) + return; + renderedSomething = true; + } + + // At higher altitude, major roads provide sufficient motion density + if (altitude > FAST_FETCH_ALTITUDE) return; + + // Detailed pass: fetch the full road graph (tertiary, residential, etc.) + console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`); + const fullData = await fetchRoads( + clamped.south, + clamped.west, + clamped.north, + clamped.east, + { + majorOnly: false, + timeoutSec: 20, + signal: requestSignal, + }, + trace, + ); + if (generation !== layerState._loadGeneration) return; + + cache.full = layerState._parseRoads(fullData, trace); + if ( + !(await parts.flow.applyFlowThenRender( + cache.full, + clamped, + generation, + altitude, + 'Loaded full', + trace, + )) + ) + return; + renderedSomething = true; + } catch (e) { + if (e?.name === 'AbortError') return; + if (generation === layerState._loadGeneration && !renderedSomething) + layerState._roadError = 'Road data temporarily unavailable'; + console.warn('[Data:Traffic] Fetch error:', e); + } finally { + if (generation === layerState._loadGeneration) { + layerState._fetching = false; + // Roll back the bounds commit if this load rendered nothing (e.g. the + // Overpass fetch failed). Leaving them committed would make the overlap + // gate skip the retry while the user sits still. Guarded on generation so + // a superseding load's commit is not clobbered. + if (!renderedSomething) { + layerState._lastBounds = prevBounds; + layerState._lastViewCenter = prevViewCenter; + if (layerState._enabled) { + layerState._retryTimer = setTimeout(() => { + layerState._retryTimer = null; + parts.viewport.onCameraChanged(); + }, layerState._retryDelayMs); + layerState._retryDelayMs = Math.min( + layerState._retryDelayMs * 2, + 30000, + ); + } + } else { + layerState._retryDelayMs = 1500; + } + } + // Keep this generation's controller until superseded or disabled: flow + // can still be running after its paint deadline. An older finally must + // never clear the controller belonging to a newer destination. + } + } + const methods = { + /** + * No-op — traffic updates are entirely camera-driven, not timer-driven. + * @returns {Promise} + */ + async update() { + // No-op — updates are camera-driven + }, + }; + + return { fetchRoads, cancelActiveFetch, loadRoadsForBounds, methods }; +} diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js new file mode 100644 index 000000000..bb452a984 --- /dev/null +++ b/src/layers/traffic/lifecycle.js @@ -0,0 +1,202 @@ +import * as Cesium from 'cesium'; +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { resetFlowTileCache } = source; + + const methods = { + /** + * One-time initialisation. Creates the PointPrimitiveCollection and adds it + * to the scene (hidden). The collection is never removed and re-added — only + * toggled via `.show` to avoid destroy-on-remove errors. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + init(viewer) { + layerState._viewer = viewer; + layerState._pointCollection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + // Add permanently — toggle with .show to avoid destroy-on-remove errors + viewer.scene.primitives.add(layerState._pointCollection); + layerState._pointCollection.show = false; + layerState._dots = []; + layerState._roads = []; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastBounds = null; + layerState._fetching = false; + layerState._loadGeneration = 0; + layerState._densityScale = 1.0; + layerState._speedScale = 1.0; + layerState._lastViewCenter = null; + layerState._flowCoveragePct = 0; + layerState._flowError = null; + if (TRAFFIC_TIMING_ENABLED) { + layerState._trafficTimingCurrentAnchor = null; + layerState._trafficTimingSequence = 0; + layerState._trafficTimingTracesCreated = 0; + layerState._trafficTimingDroppedTraces = 0; + } + + // Preset-aware dot styling: adopt the active post-FX style (persisted + // style restore may run before layer registration, so read the dataset) + // and follow StyleManager's gev:style-change event thereafter. Guarded + // for non-browser contexts; bound once per page (init survives layer + // destroy/re-register). + if (typeof window !== 'undefined') { + layerState._stylePreset = + document?.documentElement?.dataset?.gevStyle || 'normal'; + if (!layerState._styleListenerBound) { + window.addEventListener('gev:style-change', (e) => + parts.style.setStylePreset(e?.detail?.style), + ); + layerState._styleListenerBound = true; + } + } + parts.style.refreshBucketColors(); + console.log('[Data:Traffic] Initialized'); + }, + + /** + * Enable the traffic layer. Shows the point collection, subscribes to the + * preRender animation loop and camera-change events, and kicks off an + * initial viewport check. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + enable(viewer) { + layerState._enabled = true; + holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + layerState._lastAnimTime = 0; + layerState._pointCollection.show = true; + + // One status check per session decides sim vs live-TomTom mode. + parts.flow.ensureFlowStatus(); + + layerState._preRenderRemover = viewer.scene.preRender.addEventListener( + parts.animation.animate, + ); + if (TRAFFIC_TIMING_ENABLED) { + parts.timing.clearTrafficTimingEntries(); + layerState._trafficTimingCurrentAnchor = null; + layerState._trafficTimingPostRenderRemovers = new Set(); + layerState._trafficTimingMoveEndRemover = + viewer.camera.moveEnd.addEventListener( + parts.timing.markTrafficTimingMoveEnd, + ); + } + + // Subscribe to camera changes with a 5% movement threshold. Save the prior + // value so disable() can restore it — percentageChanged is a shared global + // on the camera and leaving it mutated affects every other camera.changed + // listener in the app. + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + // Always inspect the final view, even when the last flight step is below + // camera.changed's movement threshold. + layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener( + parts.viewport.onCameraChanged, + ); + layerState._prevPercentageChanged = viewer.camera.percentageChanged; + viewer.camera.percentageChanged = 0.05; + + // Kick off initial viewport check + parts.viewport.onCameraChanged(); + + // Boot-order guard (field-test round 1: layer sat empty until the user + // moved): when the persisted layer state re-enables traffic during the + // intro flyTo, the initial check bails at high altitude — and a camera + // that then parks never re-fires camera.changed. Retry cheaply until the + // first load commits, then self-clear. Also acts as a safety kick if a + // failed first fetch left the viewport unloaded while parked. + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = setInterval(() => { + if (!layerState._enabled || layerState._lastUpdate) { + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + return; + } + if (!layerState._fetching && !layerState._retryTimer) + parts.viewport.onCameraChanged(); + }, 1500); + }, + + /** + * Disable the traffic layer. Cancels pending fetches, clears all dots, + * unsubscribes from events, and hides the point collection. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + disable(viewer) { + layerState._enabled = false; + releaseContinuousRender('traffic'); + clearTimeout(layerState._fetchTimeout); + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + layerState._retryDelayMs = 1500; + parts.ingestion.cancelActiveFetch(); + layerState._loadGeneration++; + layerState._fetching = false; + layerState._flowPending = 0; + layerState._roadError = null; + parts.animation.clearDots(); + layerState._lastViewCenter = null; + // A stale outage from the last session would misreport a fresh enable — + // the next load re-derives feed health from real evidence. + layerState._flowError = null; + + if (layerState._preRenderRemover) { + layerState._preRenderRemover(); + layerState._preRenderRemover = null; + } + if (TRAFFIC_TIMING_ENABLED) { + layerState._trafficTimingMoveEndRemover?.(); + layerState._trafficTimingMoveEndRemover = null; + for (const remove of layerState._trafficTimingPostRenderRemovers || []) + remove(); + layerState._trafficTimingPostRenderRemovers = null; + layerState._trafficTimingCurrentAnchor = null; + } + + viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged); + layerState._arrivalRemover?.(); + layerState._arrivalRemover = null; + // Restore the camera's global percentageChanged so other layers/listeners + // keep their expected sensitivity. + if (layerState._prevPercentageChanged != null) { + viewer.camera.percentageChanged = layerState._prevPercentageChanged; + layerState._prevPercentageChanged = null; + } + if (layerState._pointCollection) layerState._pointCollection.show = false; + }, + + /** + * Permanently tear down the layer. Disables it, removes the point collection + * from the scene, and clears the tile cache. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + destroy(viewer) { + this.disable(viewer); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + parts.rendering.removeHeatLines(); + layerState._tileCache.clear(); + resetFlowTileCache(); + layerState._count = 0; + layerState._lastUpdate = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js new file mode 100644 index 000000000..db8f9536b --- /dev/null +++ b/src/layers/traffic/model.js @@ -0,0 +1,417 @@ +import * as Cesium from 'cesium'; +import { + flowDensityMult, + flowBucket, + flowSpeedScale, +} from '../../data/trafficFlowStyle.js'; +import { + MAX_WAYPOINTS_PER_ROAD, + DOT_HEIGHT_OFFSET, + DENSITY_MULT, + JAM_DOT_FAR_SCALE, + JAM_DOT_DEPTH_PUNCH, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + // ─── Overpass API ────────────────────────────────────────── + + /** + * Build an Overpass QL query string to fetch road ways within a bounding box. + * + * The query uses a highway tag regex filter. When `majorOnly` is true, only + * motorway/trunk/primary/secondary are included; otherwise all drivable road + * classes are fetched. The `out geom qt;` suffix returns inline geometry + * (lat/lon per node) sorted by quadtile for faster server response. + * + * @param {number} south - Southern latitude bound (degrees). + * @param {number} west - Western longitude bound (degrees). + * @param {number} north - Northern latitude bound (degrees). + * @param {number} east - Eastern longitude bound (degrees). + * @param {Object} [opts] + * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only. + * @param {number} [opts.timeoutSec=25] - Overpass server-side timeout. + * @returns {string} Overpass QL query body. + */ + + /** + * Parse an Overpass `out geom;` JSON response into internal road objects. + * + * Each OSM `way` element carries an inline `geometry` array of `{lat, lon}` + * objects, so no separate node look-up or osmtogeojson conversion is needed. + * + * Processing per way: + * 1. Extract [lon, lat] coordinate pairs. + * 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices. + * 3. Sample terrain height once at the first vertex (avoids per-vertex cost). + * 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances. + * + * @param {Object} overpassData - Raw JSON response from the Overpass API. + * @param {Array} overpassData.elements - Array of OSM elements. + * @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} + * Parsed road objects ready for dot spawning. + */ + + function parseRoads(overpassData) { + if (!overpassData || !overpassData.elements) return []; + + const roads = []; + for (const el of overpassData.elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + + const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + + // Sub-sample long polylines: keep every Nth vertex to stay within budget + const simplifyStep = + rawCoords.length > MAX_WAYPOINTS_PER_ROAD + ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) + : 1; + const coords = []; + for (let i = 0; i < rawCoords.length; i += simplifyStep) { + coords.push(rawCoords[i]); + } + + // Ensure the original endpoint is always preserved + const last = rawCoords[rawCoords.length - 1]; + const tail = coords[coords.length - 1]; + if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { + coords.push(last); + } + + if (coords.length < 2) continue; + + const type = el.tags?.highway || 'unclassified'; + // One-way capture (field-test round 1: "a car would never go in + // reverse"): dots on one-way roads all travel the legal direction. + // OSM: oneway=yes/1/true → digitization order; '-1' → reversed; + // roundabouts are one-way by definition. 0 = two-way (alternate). + const onewayTag = el.tags?.oneway; + const oneway = + onewayTag === 'yes' || + onewayTag === '1' || + onewayTag === 'true' || + el.tags?.junction === 'roundabout' + ? 1 + : onewayTag === '-1' + ? -1 + : 0; + + // Sample terrain height once at the road start to avoid per-vertex cost + let baseHeight = 0; + const firstCoord = coords[0]; + if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) { + const carto = Cesium.Cartographic.fromDegrees( + firstCoord[0], + firstCoord[1], + ); + const sampled = layerState._viewer.scene.sampleHeight(carto); + if (Number.isFinite(sampled)) baseHeight = sampled; + } + + // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation + const waypoints = coords.map(([lng, lat]) => { + const h = baseHeight + DOT_HEIGHT_OFFSET; + return Cesium.Cartesian3.fromDegrees(lng, lat, h); + }); + + // Pre-compute segment distances in meters for speed-to-t conversion + const segmentDist = []; + for (let i = 0; i < waypoints.length - 1; i++) { + segmentDist.push( + Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + ); + } + + roads.push({ coords, type, oneway, waypoints, segmentDist }); + } + + return roads; + } + + // ─── Road Length Estimation ──────────────────────────────── + + /** + * Estimate the total length of a road in meters from its degree-based coordinates. + * + * Uses Euclidean distance in degree-space then multiplies by the equatorial + * approximation of 111 km per degree. Accurate enough for dot density spacing + * but not for navigation. + * + * @param {number[][]} coords - Array of [lon, lat] pairs. + * @returns {number} Approximate road length in meters. + */ + + function estimateRoadLengthDeg(coords) { + let len = 0; + for (let i = 0; i < coords.length - 1; i++) { + const dx = coords[i + 1][0] - coords[i][0]; + const dy = coords[i + 1][1] - coords[i][1]; + len += Math.sqrt(dx * dx + dy * dy); + } + // Rough conversion: 1 degree ~ 111,000 meters at equator + return len * 111000; + } + + // ─── Dot Spawning ────────────────────────────────────────── + + /** + * Compute the ideal number of dots for a single road at a given camera altitude. + * + * Spacing increases with altitude so fewer dots are rendered when zoomed out. + * The result is further scaled by the road-type density multiplier and the + * user-adjustable `_densityScale`. + * + * @param {{coords:number[][], type:string}} road - Parsed road object. + * @param {number} altitude - Current camera altitude in meters. + * @returns {number} Ideal dot count (minimum 1). + */ + + function computeDotCount(road, altitude) { + // Live flow: closed roads carry zero traffic; congestion packs more dots. + // `road.flow` is only ever set in live mode, so the keyless path is + // untouched (flow stays undefined → multiplier 1, identical output). + const flow = layerState._liveMode ? road.flow : null; + if (flow?.closure) return 0; + // Strict data-integrity view: uncovered roads spawn nothing when hidden. + if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide') + return 0; + + const lengthM = estimateRoadLengthDeg(road.coords); + + // Altitude-adaptive spacing: closer camera = denser dots + let spacing; + if (altitude < 1000) spacing = 30; + else if (altitude < 3000) spacing = 80; + else if (altitude < 5000) spacing = 150; + else spacing = 250; + + const mult = + (DENSITY_MULT[road.type] || 1) * + layerState._densityScale * + (flow + ? flowDensityMult(flow.level, { jamBoost: parts.style.jamDensityOn() }) + : 1); + return Math.max(1, Math.floor((lengthM / spacing) * mult)); + } + + /** + * Distribute a fixed dot budget fairly across all visible roads. + * + * Algorithm: + * 1. Compute ideal dot count per road via `computeDotCount`. + * 2. Seed one dot to every road that wants at least one (fairness pass). + * 3. Distribute remaining budget proportionally to each road's ideal count. + * 4. Assign leftover dots (from floor rounding) to roads with the highest + * fractional residuals (largest-remainder method). + * + * This prevents high-density motorways from starving smaller residential roads + * when the global MAX_DOTS cap is reached. + * + * @param {Array} roads - Parsed road objects. + * @param {number} altitude - Camera altitude in meters (affects spacing). + * @param {number} dotCap - Maximum total dots to allocate. + * @returns {number[]} Per-road dot budgets, same length as `roads`. + */ + + function allocateRoadDotBudgets(roads, altitude, dotCap) { + const planned = roads.map((road) => computeDotCount(road, altitude)); + const budgets = new Array(roads.length).fill(0); + let remaining = Math.max(0, dotCap); + + // Pass 1 — fairness seed: give one dot to every road (highest-demand first) + const firstPassOrder = planned + .map((count, index) => ({ count, index })) + .sort((a, b) => b.count - a.count); + + for (const entry of firstPassOrder) { + if (remaining <= 0) break; + if (entry.count <= 0) continue; + budgets[entry.index] = 1; + remaining -= 1; + } + + if (remaining <= 0) return budgets; + + // Pass 2 — proportional distribution of the remaining budget + let totalRemainder = 0; + for (let i = 0; i < planned.length; i++) { + totalRemainder += Math.max(0, planned[i] - budgets[i]); + } + if (totalRemainder <= 0) return budgets; + + const residuals = []; + let assigned = 0; + for (let i = 0; i < planned.length; i++) { + const cap = Math.max(0, planned[i] - budgets[i]); + if (cap <= 0) continue; + const ideal = (cap / totalRemainder) * remaining; + const add = Math.min(cap, Math.floor(ideal)); + budgets[i] += add; + assigned += add; + residuals.push({ index: i, residual: ideal - add }); + } + + // Pass 3 — largest-remainder: hand out leftover dots from floor rounding + let leftover = remaining - assigned; + if (leftover > 0 && residuals.length > 0) { + residuals.sort((a, b) => b.residual - a.residual); + let cursor = 0; + while (leftover > 0 && residuals.length > 0) { + const idx = residuals[cursor % residuals.length].index; + if (budgets[idx] < planned[idx]) { + budgets[idx] += 1; + leftover -= 1; + } + cursor += 1; + // Safety valve: avoid infinite loop if all roads are already at their ideal + if (cursor > residuals.length * 3 && leftover > 0) break; + } + } + + return budgets; + } + + /** + * Derive the layer's honest feed presentation from its live-flow state. + * + * The three states a user can be in, and what each must read as: + * - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a + * label that never claims live data; + * - live and healthy → LIVE with real coverage; + * - live but flow-down → an `error` string, so the chip degrades and says + * the colors on screen are simulated. Never a stale "LIVE · N% cov". + * + * @param {Object} [input] + * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. + * @param {boolean} [input.fetching] - A viewport load is in flight. + * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {number} [input.coveragePct] - Matched-road coverage, 0–100. + * @param {boolean} [input.statusUnavailable] - The status probe itself failed. + * @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}} + */ + + function trafficFeedPresentation({ + liveMode = false, + fetching = false, + flowError = null, + coveragePct = 0, + statusUnavailable = false, + } = {}) { + // `mode` is the CONFIGURED source (live key present vs keyless), not this + // instant's health — health rides on `error`. The qa-traffic harness pins + // that meaning. + const mode = liveMode ? 'live' : 'sim'; + if (liveMode && flowError) { + // One string for both fields. The manager's meta line renders `error` and + // drops `loadingLabel` in its error branch, so the owner's SIMULATED copy + // has to BE the error text or the steady state reverts to a bare + // "TomTom daily budget reached" that never says what is on screen. + const degraded = `SIMULATED — ${flowError}`; + return { mode, error: degraded, loadingLabel: degraded }; + } + if (liveMode) { + return { + mode, + error: null, + loadingLabel: fetching + ? 'syncing LIVE traffic flow' + : `LIVE · TomTom flow · ${coveragePct}% cov`, + }; + } + // Keyless simulation — one terse line that names the mode and the remedy + // (owner's copy shape). The chip's own progress text carries "working"; + // this line must never imply a live feed. + return { + mode, + error: null, + loadingLabel: statusUnavailable + ? 'SIMULATED — traffic service unreachable' + : 'SIMULATED — add TomTom key for live', + }; + } + + /** + * Apply late-arriving flow data to already-rendered dots without a respawn: + * color, jam size, and speed update in place; closed roads' dots hide. + * Density bunching intentionally waits for the next natural re-render — + * color and speed are the live signal, dot count is a refinement. + * @param {string} label - Render log label (for the console trace). + */ + + function recolorDotsInPlace(label) { + if (!layerState._liveMode || !layerState._dots.length) return; + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + let closedDots = 0; + const now = Date.now(); + for (const dot of layerState._dots) { + const flow = dot.road ? dot.road.flow : null; + if (flow?.closure) { + dot.point.show = false; + closedDots += 1; + continue; + } + const bucket = flow ? flowBucket(flow.level) : null; + dot.bucket = bucket; + dot.point.color = bucket + ? layerState._activeBucketColors[bucket] + : Cesium.Color.WHITE.withAlpha(0.85); + if (bucket === 'jam') { + dot.point.pixelSize = + parts.style.baseDotSize(dot.road?.type, bucket) + + 1 + + parts.style.activeSizeDelta('jam'); + } else if (bucket && parts.style.presetProfileActive()) { + // Preset profiles size-floor every bucket; the shipped normal path + // keeps its jam-only size touch (byte-identical behavior). + dot.point.pixelSize = + parts.style.baseDotSize(dot.road?.type, bucket) + + parts.style.activeSizeDelta(bucket); + } + // Late flow can move a dot between buckets — keep the preset halo in + // step (no-op writes under the normal profile, whose dots have none). + if (parts.style.presetProfileActive()) + parts.style.applyOutline(dot.point, bucket); + dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1); + // Late flow tags/untags stop-and-go creep + city-scale prominence the + // same way it rescales speed. Queue *positions* wait for the next + // natural re-render, like density bunching. + if (bucket === 'jam' && parts.style.jamDensityOn()) { + if (!dot.creep) + dot.creep = { + moving: Math.random() < 0.4, + until: now + Math.random() * 2000, + }; + dot.point.scaleByDistance = new Cesium.NearFarScalar( + 100, + 1.5, + layerState._fadeScaleFar, + JAM_DOT_FAR_SCALE, + ); + dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH; + } else { + dot.creep = null; + } + layerState._bucketCounts[bucket || 'sim'] += 1; + } + layerState._closedRoads = layerState._roads.reduce( + (n, r) => n + (r.flow?.closure ? 1 : 0), + 0, + ); + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + console.log( + `[Data:Traffic] Flow recolor (${label}): ${layerState._dots.length} dots, closedDots=${closedDots}`, + ); + } + return { + parseRoads, + estimateRoadLengthDeg, + computeDotCount, + allocateRoadDotBudgets, + trafficFeedPresentation, + recolorDotsInPlace, + }; +} diff --git a/src/layers/traffic/navigation.test.mjs b/src/layers/traffic/navigation.test.mjs new file mode 100644 index 000000000..3d594c4e5 --- /dev/null +++ b/src/layers/traffic/navigation.test.mjs @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createTrafficLayer } from './index.js'; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function setup(t, requestRoads) { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const camera = { + positionCartographic: Cesium.Cartographic.fromDegrees( + -97.744, + 30.267, + 3200, + ), + get positionWC() { + return Cesium.Cartesian3.fromRadians( + this.positionCartographic.longitude, + this.positionCartographic.latitude, + this.positionCartographic.height, + ); + }, + changed: new Cesium.Event(), + moveEnd: new Cesium.Event(), + percentageChanged: 0.5, + computeViewRectangle() { + const { longitude, latitude } = this.positionCartographic; + return new Cesium.Rectangle( + longitude - 0.0002, + latitude - 0.0002, + longitude + 0.0002, + latitude + 0.0002, + ); + }, + pickEllipsoid: () => null, + }; + const viewer = { + camera, + scene: { + canvas: { width: 100, height: 100 }, + preRender: new Cesium.Event(), + primitives: { add: (value) => value, remove: () => true }, + }, + }; + const layer = createTrafficLayer({ + services: { + credits: {}, + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + source: { + requestRoads, + getStatus: async () => ({ hasKey: false }), + fetchFlowForBounds: async () => [], + getFlowSessionStats: () => ({ tilesFetched: 0 }), + resetFlowTileCache() {}, + }, + }); + layer.init(viewer); + t.after(() => layer.destroy(viewer)); + const move = (lon, lat, height = 3200) => { + camera.positionCartographic = Cesium.Cartographic.fromDegrees( + lon, + lat, + height, + ); + camera.changed.raiseEvent(); + camera.moveEnd.raiseEvent(); + }; + const tick = async (ms) => { + t.mock.timers.tick(ms); + for (let i = 0; i < 30; i++) await Promise.resolve(); + }; + return { layer, viewer, move, tick }; +} +function roads(bounds) { + return { + ok: true, + json: async () => ({ + elements: [ + { + type: 'way', + tags: { highway: 'primary' }, + geometry: [ + { lat: bounds.south, lon: bounds.west }, + { lat: bounds.south + 0.005, lon: bounds.west + 0.005 }, + ], + }, + ], + }), + }; +} + +test('traffic recovers a failed destination request after another city has loaded', async (t) => { + let londonCalls = 0; + const { layer, viewer, move, tick } = setup(t, async (bounds) => { + if (bounds.south > 50 && ++londonCalls === 1) + throw new Error('temporary Overpass outage'); + return roads(bounds); + }); + layer.enable(viewer); + await tick(400); + await tick(2000); + assert.ok(layer.getStats().count > 0); + move(-40, 40, 1000000); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(londonCalls, 1); + for (let i = 0; i < 20; i++) await tick(1500); + assert.ok( + londonCalls >= 2, + 'the stationary destination must retry without a toggle', + ); + assert.ok(layer.getStats().count > 0); + assert.equal(layer.getStats().loading, false); +}); + +test('a superseded road response cannot release the current request controller', async (t) => { + const pending = []; + const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const result = deferred(); + pending.push({ ...result, bounds, signal }); + return result.promise; + }); + layer.enable(viewer); + await tick(400); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(pending.length, 2); + assert.equal(pending[0].signal.aborted, true); + pending[0].resolve(roads(pending[0].bounds)); + await tick(0); + layer.disable(viewer); + assert.equal( + pending[1].signal.aborted, + true, + 'disable must still cancel the destination request', + ); + pending[1].resolve(roads(pending[1].bounds)); + await tick(0); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().loading, false); +}); + +test('leaving traffic altitude cancels queued and in-flight work', async (t) => { + const pending = []; + const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const result = deferred(); + pending.push({ ...result, bounds, signal }); + return result.promise; + }); + layer.enable(viewer); + move(-40, 40, 1000000); + await tick(400); + assert.equal( + pending.length, + 0, + 'a departing city debounce must not fetch above traffic altitude', + ); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(pending.length, 1); + move(-40, 40, 1000000); + assert.equal(pending[0].signal.aborted, true); + pending[0].resolve(roads(pending[0].bounds)); + await tick(0); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().loading, false); +}); + +test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => { + const seen = []; + const { layer, viewer, tick } = setup(t, async (bounds) => { + seen.push(bounds); + return roads(bounds); + }); + layer.enable(viewer); + await tick(400); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1276, + 51.5072, + 3200, + ); + viewer.camera.moveEnd.raiseEvent(); + await tick(400); + assert.ok(seen.some((bounds) => bounds.south > 50)); + layer.disable(viewer); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal(viewer.camera.changed.numberOfListeners, 0); +}); + +test('parked failures back off and disabling cancels the scheduled retry', async (t) => { + let calls = 0; + const { layer, viewer, tick } = setup(t, async () => { + calls++; + throw new Error('temporary Overpass outage'); + }); + layer.enable(viewer); + await tick(400); + assert.equal(calls, 1); + assert.equal(layer.getStats().loading, false); + assert.equal(layer.getStats().error, 'Road data temporarily unavailable'); + await tick(1500); + await tick(400); + assert.equal(calls, 2); + await tick(1500); + await tick(400); + assert.equal(calls, 2, 'second failure waits longer than the first'); + layer.disable(viewer); + await tick(60000); + assert.equal(calls, 2); + assert.equal(layer.getStats().error, null); +}); diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js new file mode 100644 index 000000000..003fbb5c3 --- /dev/null +++ b/src/layers/traffic/policy.js @@ -0,0 +1,215 @@ +import * as Cesium from 'cesium'; + +/** + * @file Street Traffic — animated dots along OSM road polylines, colored by + * live TomTom congestion when a key is configured. + * + * Road geometry: OSM Overpass API (free, no auth). Fetches road polylines for + * the camera viewport, spawns PointPrimitives that lerp along pre-computed + * Cartesian3 waypoints. Camera-gated: only active below ~8 km altitude. + * + * Two modes (decided once per session via `/api/tomtom/status`): + * - `sim` (keyless default): white dots at hardcoded per-road-class speeds — + * the original simulation, byte-identical behavior. + * - `live`: TomTom flow tiles (`flowTiles.js`) are matched onto the same + * Overpass roads (`flowMatch.js`); matched roads color/slow/densify their + * dots by real congestion (`trafficFlowStyle.js`), closed roads spawn no + * dots, and unmatched roads keep the simulated white. + * + * Architecture overview: + * - Camera-change listener triggers debounced road fetching per viewport tile. + * - Fetch bounds center on the camera's look-at point (`trafficBounds.js`, C4). + * - Roads are fetched in two passes: major-only (fast) then full graph (detailed). + * - Fetched tiles are cached by clamped bounding-box key to avoid re-fetching. + * - Dot budget allocation distributes a hard cap fairly across visible roads. + * - Each dot lerps along pre-computed Cartesian3 waypoints every preRender frame. + * + * @module data/traffic + */ + +/** @const {string} Proxy endpoint for Overpass API queries */ + +export const OVERPASS_URL = '/api/overpass'; + +/** @const {number} Meters — hide all traffic dots above this camera altitude */ + +export const ACTIVATION_ALTITUDE = 8000; + +/** @const {number} Meters — above this altitude, only major roads are fetched */ + +export const FAST_FETCH_ALTITUDE = 4500; + +/** @const {number} Milliseconds — debounce delay before fetching after camera settles */ + +export const FETCH_DEBOUNCE = 320; + +/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */ + +export const DOT_HEIGHT_OFFSET = 3.0; + +/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */ + +export const OVERLAP_THRESHOLD = 0.6; + +/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */ + +export const MAX_DOTS = 6000; + +/** @const {number} Polylines longer than this are simplified by sub-sampling */ + +export const MAX_WAYPOINTS_PER_ROAD = 80; + +/** @const {number} Km — minimum viewport center shift before allowing refresh */ + +export const MIN_CENTER_SHIFT_KM = 0.35; + +/** + * @const {number} Km — max great-circle distance the fetch center may sit from + * the camera nadir. Traffic only activates below ACTIVATION_ALTITUDE (8 km), + * so a look-at ground point farther than this is horizon-gazing and gets + * pulled back toward nadir (C4 oblique-bounds fix). + */ + +export const MAX_LOOKAT_PULL_KM = 12; + +/** + * Development-only causal timing. Vite folds `import.meta.env.DEV` to false + * in production, so the query-string read, nullable trace branches, and every + * debug helper are removed from production builds. The remaining load-path + * selectors point directly at the original functions: no marks, listeners, + * observers, timers, counters, or per-road timing checks are installed. + */ + +export const TRAFFIC_TIMING_ENABLED = + import.meta.env?.DEV && + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('trafficDebug') === '1'; + +/** @const {Object} Speed in meters per second by highway tag (approximate real-world values) */ + +export const SPEED_MPS = { + motorway: 25, // ~90 km/h + trunk: 20, // ~72 km/h + primary: 14, // ~50 km/h + secondary: 11, // ~40 km/h + tertiary: 8, // ~30 km/h + residential: 5, // ~18 km/h + unclassified: 5, +}; + +/** @const {Object} Density multiplier — higher values spawn more dots on important roads */ + +export const DENSITY_MULT = { + motorway: 3.0, + trunk: 2.5, + primary: 2.0, + secondary: 1.5, + tertiary: 1.0, + residential: 0.5, + unclassified: 0.4, +}; + +/** @const {Object} Pixel size per road type (scaled up 25% for screen-recording visibility) */ + +export const SIZE_BY_TYPE = { + motorway: 6, + trunk: 6, + primary: 5, + secondary: 5, + tertiary: 4, + residential: 4, + unclassified: 4, +}; + +/** + * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): + * green free flow / amber slow / red jam, all at 0.9 alpha. + * Roads without flow data (`road.flow == null`) keep the sim's white. + * @const {Object} + */ + +export const FLOW_BUCKET_COLORS = { + free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9), + slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9), + jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), +}; + +// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── +/** @const {number} Max congestion heat-line polylines per render (jam first). */ + +export const HEAT_LINE_CAP = 400; + +/** @const {number} Px — glowing jam corridor line width. */ + +export const HEAT_LINE_JAM_WIDTH = 9; + +/** @const {number} Px — flat slow corridor line width. */ + +export const HEAT_LINE_SLOW_WIDTH = 4; + +/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */ + +export const HEAT_JAM_BASE_ALPHA = 0.55; + +/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */ + +export const HEAT_JAM_PULSE_ALPHA = 0.2; + +/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ + +export const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); + +/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ + +export const HEAT_SLOW_COLOR = + Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2); + +/** + * @const {number} Meters — jam dots depth-test-punch through the 3D tiles out + * to this camera distance so queues stay visible at city scale. The single + * start-of-road terrain sample puts much of a road below the rendered mesh + * at oblique city views (first A/B capture: 396 jam dots, zero visible), so + * the shipped 2 km window hides exactly the congestion this prototype is + * meant to surface. Live jam dots only; sim dots keep the shipped 2 km. + */ + +export const JAM_DOT_DEPTH_PUNCH = 15000; + +/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ + +export const JAM_DOT_FAR_SCALE = 0.55; + +/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */ + +export const CREEP_BURST = 2.2; + +/** @const {number[]} Ms range a jam dot creeps forward before stopping. */ + +export const CREEP_MOVE_MS = [1200, 3000]; + +/** @const {number[]} Ms range a jam dot sits stopped between creeps. */ + +export const CREEP_STOP_MS = [1500, 5000]; + +/** + * @const {number} Minimum base pixel size for COLORED dots while a styled + * preset is active — residential-road dots spawn at 4 px and vanish into + * post-FX pixelation; presence is the dots' whole job there (owner round + * 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched. + */ + +export const STYLED_MIN_BASE_PX = 5; + +/** @const {number} Maximum tile cache entries before LRU eviction */ + +export const TILE_CACHE_MAX_ENTRIES = 64; + +/** + * Milliseconds the first dot paint will wait for flow data. Cached flow + * settles within this window (decode cache, 120 s TTL) and renders fully + * colored; a cold tile fetch loses the race, dots paint immediately in + * white, and recolorDotsInPlace applies the colors when flow arrives — + * field-test round 1's "takes forever to load" was the sequential wait. + */ + +export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js new file mode 100644 index 000000000..3a6a122b9 --- /dev/null +++ b/src/layers/traffic/rendering.js @@ -0,0 +1,318 @@ +import * as Cesium from 'cesium'; +import { flowBucket } from '../../data/trafficFlowStyle.js'; +import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; +import { + HEAT_LINE_CAP, + HEAT_JAM_COLOR, + HEAT_SLOW_COLOR, + HEAT_LINE_JAM_WIDTH, + HEAT_JAM_BASE_ALPHA, + HEAT_LINE_SLOW_WIDTH, + TRAFFIC_TIMING_ENABLED, + MAX_DOTS, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + /** + * At high altitude only major roads render — shared by the render pass and + * the late-flow heat-line rebuild so lines never mark roads without dots. + * @param {Array} roads - Parsed road objects. + * @param {number} altitude - Camera altitude in meters. + * @returns {Array} The roads visible at this altitude. + */ + + function visibleRoadsForAltitude(roads, altitude) { + return altitude > 5000 + ? roads.filter( + (r) => + r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary', + ) + : roads; + } + + /** Remove both heat-line ground primitives from the scene. */ + + function removeHeatLines() { + if (layerState._heatJamPrim) { + layerState._viewer?.scene.groundPrimitives.remove( + layerState._heatJamPrim, + ); + layerState._heatJamPrim = null; + } + if (layerState._heatSlowPrim) { + layerState._viewer?.scene.groundPrimitives.remove( + layerState._heatSlowPrim, + ); + layerState._heatSlowPrim = null; + } + layerState._heatLineCount = 0; + } + + /** + * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): + * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing + * pulsing red for jam, faint flat amber for slow — with the dots animating + * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam + * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam + * first, longest first); overflow is logged. No-op in sim mode, when the + * heatline mode is off, or without ground-primitive support. + * + * @param {Array} roads - Road objects visible in the current render. + */ + + function rebuildHeatLines(roads) { + removeHeatLines(); + if ( + !layerState._viewer || + !layerState._liveMode || + !parts.style.heatlineOn() + ) + return; + if (layerState._heatSupported === null) { + layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( + layerState._viewer.scene, + ); + if (!layerState._heatSupported) + console.warn( + '[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled', + ); + } + if (!layerState._heatSupported) return; + + const candidates = []; + for (const road of roads) { + const flow = road.flow; + if (!flow || flow.closure) continue; + const bucket = flowBucket(flow.level); + if (bucket === 'free') continue; + let len = 0; + for (const d of road.segmentDist) len += d; + candidates.push({ road, bucket, len }); + } + candidates.sort((a, b) => + a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1, + ); + const kept = candidates.slice(0, HEAT_LINE_CAP); + + const instancesFor = (bucket, width) => + kept + .filter((c) => c.bucket === bucket) + .map( + (c) => + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions: c.road.waypoints, + width, + }), + }), + ); + + // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in + // luminance like the dots: jam = white glow, slow = faint gray. + const monoHeat = + layerState._presetDots === 'on' && + trafficStyleProfile(layerState._stylePreset) === 'mono'; + const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; + const slowLineColor = monoHeat + ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) + : HEAT_SLOW_COLOR; + + const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); + if (jamInstances.length) { + layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: jamInstances, + classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, + appearance: new Cesium.PolylineMaterialAppearance({ + material: Cesium.Material.fromType('PolylineGlow', { + color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), + glowPower: 0.25, + }), + }), + }), + ); + } + const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); + if (slowInstances.length) { + layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: slowInstances, + classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, + appearance: new Cesium.PolylineMaterialAppearance({ + material: Cesium.Material.fromType('Color', { + color: slowLineColor, + }), + }), + }), + ); + } + + layerState._heatLineCount = kept.length; + if (candidates.length > kept.length) { + console.log( + `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, + ); + } + } + + /** + * Clear existing dots and re-spawn them for the given road set and altitude. + * + * When zoomed out (>5 km), only major road types are rendered to reduce clutter. + * Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`. + * + * @param {Array} roads - Parsed road objects to render. + * @param {number} altitude - Camera altitude in meters. + * @param {string} label - Logging label (e.g. "Cache full", "Loaded major"). + * @param {Object|null} [trace=null] - Development-only correlated load trace. + */ + + function renderRoadsForAltitude(roads, altitude, label, trace = null) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingRenderState(trace, label) + : null; + const renderId = state ? ++trace.renderSequence : null; + parts.animation.clearDots(); + layerState._roads = roads; + layerState._lastRenderAltitude = altitude; + + // At high altitude, drop minor roads to reduce visual noise + const filteredRoads = visibleRoadsForAltitude(roads, altitude); + + // Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count + // them here so the closure signal is visible in stats even at zero dots. + layerState._closedRoads = layerState._liveMode + ? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0) + : 0; + + // Fade distances must track the camera-to-AREA distance, not assume a + // nadir view: oblique pitches put the loaded roads many km away even at + // low altitude. Probe three roads and stretch the curves accordingly. + let areaDist = altitude; + if (layerState._viewer && filteredRoads.length) { + const probes = [ + filteredRoads[0], + filteredRoads[Math.floor(filteredRoads.length / 2)], + filteredRoads[filteredRoads.length - 1], + ]; + for (const probe of probes) { + const wp = probe?.waypoints?.[0]; + if (wp) + areaDist = Math.max( + areaDist, + Cesium.Cartesian3.distance( + layerState._viewer.camera.positionWC, + wp, + ), + ); + } + } + layerState._fadeScaleFar = Math.max(8000, areaDist * 1.5); + layerState._fadeTransFar = Math.max(10000, areaDist * 1.8); + + const dotStart = state + ? parts.timing.trafficTimingMark(state, 'dot-construction-start', { + renderId, + renderLabel: label, + roadCount: roads.length, + visibleRoadCount: filteredRoads.length, + }) + : null; + const roadBudgets = parts.model.allocateRoadDotBudgets( + filteredRoads, + altitude, + MAX_DOTS, + ); + for (let i = 0; i < filteredRoads.length; i++) { + const road = filteredRoads[i]; + const budget = roadBudgets[i] || 0; + if (budget <= 0) continue; + parts.animation.spawnDotsForRoad(road, altitude, budget); + if (layerState._dots.length >= MAX_DOTS) break; + } + + const renderMetrics = state + ? { + renderId, + renderLabel: label, + roadCount: roads.length, + visibleRoadCount: filteredRoads.length, + dotCount: layerState._dots.length, + } + : null; + if (state) { + const dotEnd = parts.timing.trafficTimingMark( + state, + 'dot-construction-end', + renderMetrics, + ); + parts.timing.trafficTimingMeasure( + 'dot-construction', + state, + dotStart, + dotEnd, + renderMetrics, + ); + } + + const heatStart = state + ? parts.timing.trafficTimingMark( + state, + 'rebuild-heat-lines-start', + renderMetrics, + ) + : null; + rebuildHeatLines(filteredRoads); + if (state) { + const heatEnd = parts.timing.trafficTimingMark( + state, + 'rebuild-heat-lines-end', + { + ...renderMetrics, + heatLineCount: layerState._heatLineCount, + }, + ); + parts.timing.trafficTimingMeasure( + 'rebuild-heat-lines', + state, + heatStart, + heatEnd, + { + ...renderMetrics, + heatLineCount: layerState._heatLineCount, + }, + ); + } + + layerState._count = layerState._dots.length; + layerState._lastUpdate = Date.now(); + console.log( + `[Data:Traffic] ${label}: ${layerState._count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`, + ); + if (state) { + const renderEnd = parts.timing.trafficTimingMark( + state, + 'render-return', + renderMetrics, + ); + parts.timing.scheduleTrafficTimingPostRender( + state, + renderEnd, + renderId, + renderMetrics, + ); + } + } + return { + visibleRoadsForAltitude, + removeHeatLines, + rebuildHeatLines, + renderRoadsForAltitude, + }; +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js new file mode 100644 index 000000000..616f4d0c7 --- /dev/null +++ b/src/layers/traffic/source.js @@ -0,0 +1,76 @@ +import { createFlowTileSource } from './flowSource.js'; +function buildOverpassQuery( + south, + west, + north, + east, + { majorOnly = false, timeoutSec = 25 } = {}, +) { + // Regex matches the OSM `highway` tag value against allowed road types + const regex = majorOnly + ? '^(motorway|trunk|primary|secondary)$' + : '^(motorway|trunk|primary|secondary|tertiary|residential|unclassified)$'; + return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`; +} + +/** Supply road responses, flow availability and one decoded flow cache. */ +export function createTrafficSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const flow = createFlowTileSource({ fetchImpl }); + return { + ...flow, + async requestRoads( + { south, west, north, east }, + { majorOnly = false, timeoutSec = 25, signal } = {}, + ) { + if ( + ![south, west, north, east].every(Number.isFinite) || + south < -90 || + north > 90 || + west < -180 || + east > 180 || + north <= south || + east <= west || + north - south > 10 || + east - west > 10 || + !Number.isInteger(timeoutSec) || + timeoutSec < 1 || + timeoutSec > 30 + ) + throw new TypeError('A bounded road viewport and timeout are required'); + signal?.throwIfAborted(); + const query = buildOverpassQuery(south, west, north, east, { + majorOnly, + timeoutSec, + }); + const response = await fetchImpl('/api/overpass', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'data=' + encodeURIComponent(query), + signal, + }); + signal?.throwIfAborted(); + return { + ok: response.ok, + status: response.status, + headers: response.headers, + async json() { + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + }; + }, + async getStatus({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/tomtom/status', { signal }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const status = await response.json(); + signal?.throwIfAborted(); + if (typeof status?.hasKey !== 'boolean') + throw new Error('Malformed traffic status'); + return status; + }, + }; +} diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs new file mode 100644 index 000000000..9be3315ba --- /dev/null +++ b/src/layers/traffic/source.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createTrafficSource } from './source.js'; +const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 }; +const fixture = readFileSync( + new URL( + '../../data/fixtures/tomtom-flow-austin-12-935-1686.pbf', + import.meta.url, + ), +); +test('flow caches and diagnostics belong to their constructed source', async () => { + let requestsA = 0, + requestsB = 0; + const a = createTrafficSource({ + fetchImpl: async () => { + requestsA++; + return new Response(fixture); + }, + }); + const b = createTrafficSource({ + fetchImpl: async () => { + requestsB++; + return new Response(fixture); + }, + }); + const first = await a.fetchFlowForBounds(bounds); + assert.ok(first.length > 0); + await a.fetchFlowForBounds(bounds); + assert.equal(requestsA, 1); + assert.equal(b.getFlowSessionStats().tilesFetched, 0); + b.resetFlowTileCache(); + await a.fetchFlowForBounds(bounds); + assert.equal(requestsA, 1); + await b.fetchFlowForBounds(bounds); + assert.equal(requestsB, 1); +}); +test('a cancelled flow body cannot refill its source cache', async () => { + const controller = new AbortController(); + let calls = 0; + const source = createTrafficSource({ + fetchImpl: async () => ({ + ok: true, + arrayBuffer: async () => { + calls++; + if (calls === 1) controller.abort(); + return fixture; + }, + }), + }); + await assert.rejects( + source.fetchFlowForBounds(bounds, { signal: controller.signal }), + { name: 'AbortError' }, + ); + await source.fetchFlowForBounds(bounds); + assert.equal( + calls, + 2, + 'cancelled bytes were not admitted to the decode cache', + ); +}); +test('road requests have finite bounds and retain the two-pass query', async () => { + const calls = []; + const source = createTrafficSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"elements":[]}'); + }, + }); + await assert.rejects( + source.requestRoads({ ...bounds, north: Infinity }), + /bounded road viewport/, + ); + await assert.rejects( + source.requestRoads(bounds, { timeoutSec: '25];out;' }), + /bounded road viewport/, + ); + assert.equal(calls.length, 0); + await source.requestRoads(bounds, { majorOnly: true, timeoutSec: 8 }); + assert.equal(calls[0][0], '/api/overpass'); + const query = new URLSearchParams(calls[0][1].body).get('data'); + assert.match(query, /\[timeout:8\]/); + assert.doesNotMatch(query, /residential/); + await source.requestRoads(bounds); + assert.match( + new URLSearchParams(calls[1][1].body).get('data'), + /residential/, + ); +}); +test('malformed availability is an unavailable source rather than a keyless response', async () => { + const source = createTrafficSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects(source.getStatus(), /Malformed traffic status/); +}); + +test('traffic construction is inert and parameters belong to each layer', async () => { + const { createTrafficLayer } = await import('./index.js'); + const source = createTrafficSource({ + fetchImpl: () => assert.fail('construction fetched data'), + }); + const services = { credits: {}, render: {} }; + const a = createTrafficLayer({ services, source }); + const b = createTrafficLayer({ services, source }); + a.setParams({ densityScale: 2, speedScale: 3, uncoveredRoads: 'hide' }); + assert.equal(a.getParams().densityScale, 2); + assert.equal(b.getParams().densityScale, 1); + assert.equal(b.getParams().speedScale, 1); + assert.equal(b.getParams().uncoveredRoads, 'sim'); +}); + +test('road body parsing retains the source request cancellation signal', async () => { + const controller = new AbortController(); + const source = createTrafficSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => { + controller.abort(); + return { elements: [] }; + }, + }), + }); + const response = await source.requestRoads(bounds, { + signal: controller.signal, + }); + await assert.rejects(response.json(), { name: 'AbortError' }); +}); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js new file mode 100644 index 000000000..ad240b4bf --- /dev/null +++ b/src/layers/traffic/state.js @@ -0,0 +1,280 @@ +import * as Cesium from 'cesium'; +import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createState({ services }) { + const layerState = {}; + + // ─── Module State ────────────────────────────────────────── + /** @type {Cesium.Viewer|null} */ + + layerState._viewer = null; + + /** @type {Cesium.PointPrimitiveCollection|null} */ + + layerState._pointCollection = null; + + /** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ + + layerState._dots = []; + + /** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ + + layerState._roads = []; + + /** @type {boolean} Whether the layer is currently enabled */ + + layerState._enabled = false; + + /** @type {Function|null} Disposer returned by preRender event subscription */ + + layerState._preRenderRemover = null; + + /** @type {Function|null} Disposer returned by camera.changed event subscription */ + + layerState._cameraRemover = null; + layerState._arrivalRemover = null; + + /** @type {ReturnType|null} Debounce timer for camera-change fetch */ + + layerState._fetchTimeout = null; + + /** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */ + + layerState._lastBounds = null; + + /** @type {boolean} True while an Overpass fetch is in flight */ + + layerState._fetching = false; + + /** @type {number} Current count of rendered dots */ + + layerState._count = 0; + + /** @type {number|null} Timestamp of last successful render */ + + layerState._lastUpdate = null; + + /** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */ + + layerState._loadGeneration = 0; + + /** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */ + + layerState._activeFetchAbort = null; + + /** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */ + + layerState._densityScale = 1.0; + + /** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */ + + layerState._speedScale = 1.0; + + /** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */ + + layerState._lastViewCenter = null; + + /** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */ + + layerState._prevPercentageChanged = null; + + /** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */ + + layerState._liveMode = false; + + /** + * Short user-facing reason live flow is currently unavailable, or null while + * healthy. Only ever set in live mode: keyless simulation is a designed + * fallback, not a fault, and must never read as an error. + * @type {string|null} + */ + + layerState._flowError = null; + + /** + * True when `/api/tomtom/status` itself could not be reached, so the layer is + * simulating because it could not ask — not because the server said "no key". + * @type {boolean} + */ + + layerState._flowStatusUnavailable = false; + + /** + * Flow requests this layer still owns. The 250 ms paint race lets a flow + * fetch outlive the road load that started it (cached roads settle + * instantly), so `_fetching` alone under-reports the work in flight: the + * loading batch would close with LOAD COMPLETE and a failure landing after + * it could never be announced. Counted, not boolean — recolor-after-timeout + * means two loads can overlap. + * @type {number} + */ + + layerState._flowPending = 0; + + /** + * Rendered-dot counts per flow bucket (sim = white ambient, no flow data). + * Reset with the dots in clearDots; drives the data-panel diagnostics and + * the qa-traffic harness color assertions. + * @type {{free:number, slow:number, jam:number, sim:number}} + */ + + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + + /** @type {number} Roads in the current render skipped entirely as closed. */ + + layerState._closedRoads = 0; + + /** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */ + + layerState._uncoveredMode = 'sim'; + + /** + * Jam-viz prototype mode: 'density' = deep-jam density boost + platoon queues + * + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both'; + * 'none' = shipped main behavior. Live mode only — the keyless simulation + * never has `road.flow`, so every jamViz path is unreachable there. + * Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available + * via setParams). + * @type {'none'|'density'|'heatline'|'both'} + */ + + layerState._jamViz = 'density'; + + /** + * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the + * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE) — polylines at the + * dots' single-sample heights depth-fail under the mesh across an oblique + * city view (first A/B capture: 198 lines rendered, zero visible). One + * primitive per bucket so the jam batch can pulse via a single shared + * material uniform. + * @type {Cesium.GroundPolylinePrimitive|null} + */ + + layerState._heatJamPrim = null; + + /** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ + + layerState._heatSlowPrim = null; + + /** @type {number} Heat-lines in the current render (stats). */ + + layerState._heatLineCount = 0; + + /** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ + + layerState._heatSupported = null; + + /** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ + + layerState._lastRenderAltitude = 0; + + /** + * Active post-FX style (StyleManager preset name), synced from + * `document.documentElement.dataset.gevStyle` at init and the + * `gev:style-change` window event thereafter. Drives the preset-aware dot + * styling (`trafficPresetStyle.js`): NVG/FLIR/noir re-encode congestion in + * luminance + size (their shaders discard hue), retro/CRT gets saturated + * hues + a size boost to survive pixelation. 'normal' → shipped palette. + * @type {string} + */ + + layerState._stylePreset = 'normal'; + + /** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */ + + layerState._presetDots = 'on'; + + /** @type {boolean} gev:style-change listener bound (bind once per page). */ + + layerState._styleListenerBound = false; + + /** + * Effective per-bucket dot colors: preset override when one applies, else + * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — + * spawn/recolor/restyle all read from here, no per-dot allocation. + * @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} + */ + + layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS }; + + /** + * Dot fade-out distances, recomputed per render from the camera-to-area + * distance. The original constants (8 km scale / 10 km translucency) + * assumed a nadir view; at oblique pitch the loaded area legitimately sits + * 7–12+ km from the CAMERA and every dot faded to invisible (field-test + * round 1: "the screen itself was empty"). + */ + + layerState._fadeScaleFar = 8000; + + layerState._fadeTransFar = 10000; + + /** @type {Promise|null} Session-cached status check (one fetch per session) */ + + layerState._flowStatusPromise = null; + + /** @type {ReturnType|null} Enable-time retry until the first load commits. */ + + layerState._enableKickTimer = null; + // Failed destinations retry while parked, with a bounded backoff. + layerState._retryTimer = null; + layerState._retryDelayMs = 1500; + layerState._retryBoundsKey = null; + layerState._roadError = null; + + /** @type {number} 0–100 int — matched roads / roads with any flow candidates */ + + layerState._flowCoveragePct = 0; + + /** @type {Function|null} Development-only camera moveEnd timing disposer. */ + + layerState._trafficTimingMoveEndRemover = null; + + /** @type {Set|null} Development-only one-shot postRender disposers. */ + + layerState._trafficTimingPostRenderRemovers = null; + + /** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */ + + layerState._trafficTimingCurrentAnchor = null; + + /** @type {number} Development-only unique mark/trace sequence. */ + + layerState._trafficTimingSequence = 0; + + /** @type {number} Development-only count of correlated trace objects created. */ + + layerState._trafficTimingTracesCreated = 0; + + /** @type {number} Development-only count of loads dropped for missing/stale anchors. */ + + layerState._trafficTimingDroppedTraces = 0; + + /** + * Tile cache keyed by "s,w,n,e" string. + * Each entry stores separately fetched major-only and full road sets + * so the major pass can be served from cache while a full fetch continues. + * @type {Map} + */ + + layerState._tileCache = new Map(); + + /** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ + + layerState._scratchLerp = new Cesium.Cartesian3(); + + // ─── Animation ───────────────────────────────────────────── + + /** @type {number} Timestamp of the last animation tick (ms) */ + + layerState._lastAnimTime = 0; + + /** @type {number} Running frame counter (for diagnostics) */ + + layerState._animFrame = 0; + + // Disabled-path contract: these references resolve directly to the original + // functions. Instrumentation adds no load-path callbacks or per-item checks. + + return layerState; +} diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js new file mode 100644 index 000000000..cb2f08349 --- /dev/null +++ b/src/layers/traffic/style.js @@ -0,0 +1,156 @@ +import { + trafficStyleProfile, + presetSizeDelta, + presetDotRgba, + presetDotOutline, +} from '../../data/trafficPresetStyle.js'; +import * as Cesium from 'cesium'; +import { + SIZE_BY_TYPE, + STYLED_MIN_BASE_PX, + FLOW_BUCKET_COLORS, +} from './policy.js'; + +export function createStyle({ state: layerState, services, parts, source }) { + /** @returns {boolean} A non-normal preset profile is active and enabled. */ + + function presetProfileActive() { + return ( + layerState._presetDots === 'on' && + trafficStyleProfile(layerState._stylePreset) !== 'normal' + ); + } + + /** + * Pixel-size delta the active preset adds for a bucket (0 when the kill + * switch is off or the profile is normal). + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket. + * @returns {number} Pixels to add on top of the shipped sizing. + */ + + function activeSizeDelta(bucket) { + return layerState._presetDots === 'on' + ? presetSizeDelta(layerState._stylePreset, bucket) + : 0; + } + + /** + * Base pixel size for a dot: shipped SIZE_BY_TYPE, floored at + * STYLED_MIN_BASE_PX for colored dots while a styled preset is active. + * @param {string} roadType - OSM highway class. + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). + * @returns {number} Base pixel size before jam/preset deltas. + */ + + function baseDotSize(roadType, bucket) { + const base = SIZE_BY_TYPE[roadType] || 4; + return bucket && presetProfileActive() + ? Math.max(base, STYLED_MIN_BASE_PX) + : base; + } + + /** Recompute `_activeBucketColors` from the active style + kill switch. */ + + function refreshBucketColors() { + for (const bucket of ['free', 'slow', 'jam']) { + const rgba = + layerState._presetDots === 'on' + ? presetDotRgba(layerState._stylePreset, bucket) + : null; + layerState._activeBucketColors[bucket] = rgba + ? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) + : FLOW_BUCKET_COLORS[bucket]; + } + } + + /** + * Apply the active preset's dark-halo outline to a colored dot (or clear + * it back to the shipped no-outline state). NVG's auto-gain saturates the + * scene, so brightness alone cannot separate a dot from a bright road — + * the dark ring restores local contrast through every luma-mapping shader. + * @param {Cesium.PointPrimitive} point - The dot primitive. + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). + */ + + function applyOutline(point, bucket) { + const spec = + layerState._presetDots === 'on' + ? presetDotOutline(layerState._stylePreset, bucket) + : null; + if (spec) { + point.outlineColor = new Cesium.Color( + spec.rgba[0] / 255, + spec.rgba[1] / 255, + spec.rgba[2] / 255, + spec.rgba[3], + ); + point.outlineWidth = spec.width; + } else { + point.outlineWidth = 0; + } + } + + /** + * Re-apply dot styling in place after a style/param change: colored dots + * get the (new) effective bucket color and size; sim (white) dots are + * never touched. No refetch, no respawn — heat-lines rebuild for their + * per-preset colors. + */ + + function restyleDotsInPlace() { + refreshBucketColors(); + if (!layerState._dots.length && !layerState._heatLineCount) return; + for (const dot of layerState._dots) { + const bucket = dot.bucket; + if (!bucket) continue; // sim/uncovered dots stay byte-identical + dot.point.color = layerState._activeBucketColors[bucket]; + dot.point.pixelSize = + baseDotSize(dot.road?.type, bucket) + + (bucket === 'jam' ? 1 : 0) + + activeSizeDelta(bucket); + applyOutline(dot.point, bucket); + } + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + } + + /** + * Adopt a new active style preset (from the gev:style-change event or the + * dataset read at init) and restyle live dots immediately. + * @param {string|null|undefined} name - StyleManager preset name. + */ + + function setStylePreset(name) { + const next = typeof name === 'string' && name ? name : 'normal'; + if (next === layerState._stylePreset) return; + layerState._stylePreset = next; + restyleDotsInPlace(); + } + + /** @returns {boolean} Density/queue/creep prototype active. */ + + function jamDensityOn() { + return layerState._jamViz === 'density' || layerState._jamViz === 'both'; + } + + /** @returns {boolean} Heat-line prototype active. */ + + function heatlineOn() { + return layerState._jamViz === 'heatline' || layerState._jamViz === 'both'; + } + return { + presetProfileActive, + activeSizeDelta, + baseDotSize, + refreshBucketColors, + applyOutline, + restyleDotsInPlace, + setStylePreset, + jamDensityOn, + heatlineOn, + }; +} diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js new file mode 100644 index 000000000..59cb2d2c0 --- /dev/null +++ b/src/layers/traffic/timing.js @@ -0,0 +1,479 @@ +import * as Cesium from 'cesium'; +import { + TRAFFIC_TIMING_ENABLED, + MAX_WAYPOINTS_PER_ROAD, + DOT_HEIGHT_OFFSET, +} from './policy.js'; + +export function createTiming({ state: layerState, services, parts, source }) { + /** + * Return development timing counters for the capture harness and inertness test. + * This named export is unused by the application and removed from production. + * @returns {{enabled:boolean, marksInstalled:number, traceObjectsCreated:number, + * uncorrelatedTracesDropped:number}} + */ + + function getTrafficTimingDiagnostics() { + return { + enabled: Boolean(TRAFFIC_TIMING_ENABLED), + marksInstalled: layerState._trafficTimingMoveEndRemover ? 1 : 0, + traceObjectsCreated: layerState._trafficTimingTracesCreated, + uncorrelatedTracesDropped: layerState._trafficTimingDroppedTraces, + }; + } + + // ─── Development-only causal timing ─────────────────────── + + /** + * Return (and optionally update) the current trace's state for a render pass. + * This function is only reachable when `TRAFFIC_TIMING_ENABLED` is true. + * + * @param {Object|null} trace - Correlated load trace. + * @param {'major'|'full'} pass - Road-fetch/render pass. + * @param {string} [source] - Client cache or proxy/network source. + * @returns {Object|null} Mutable pass timing state. + */ + + function trafficTimingPass(trace, pass, source) { + if (!trace) return null; + let state = trace.passes.get(pass); + if (!state) { + state = { + trace, + pass, + source: source || 'unknown', + proxyCache: null, + proxyUpstream: null, + }; + trace.passes.set(pass, state); + } else if (source) { + state.source = source; + } + return state; + } + + /** Build a structured-clone-safe detail object for User Timing entries. */ + + function trafficTimingDetail(state, segment, extra = {}) { + return { + trafficTiming: true, + segment, + traceId: state?.trace?.id ?? null, + interactionId: state?.trace?.interactionId ?? null, + cameraChangeTimestamp: state?.trace?.cameraChangeTimestamp ?? null, + generation: state?.trace?.generation ?? null, + cacheKey: state?.trace?.cacheKey ?? null, + pass: state?.pass || 'load', + source: state?.source || 'unknown', + proxyCache: state?.proxyCache || null, + proxyUpstream: state?.proxyUpstream || null, + ...extra, + }; + } + + /** Add a uniquely named User Timing mark and return its name. */ + + function trafficTimingMark(state, phase, extra = {}, startTime) { + const traceId = state?.trace?.id ?? 'interaction'; + const pass = state?.pass || 'load'; + const name = `traffic:${traceId}:${pass}:${phase}:${++layerState._trafficTimingSequence}`; + const options = { detail: trafficTimingDetail(state, phase, extra) }; + if (Number.isFinite(startTime)) options.startTime = startTime; + performance.mark(name, options); + return name; + } + + /** Emit a named User Timing measure between two marks. */ + + function trafficTimingMeasure(segment, state, start, end, extra = {}) { + performance.measure(`traffic:${segment}:${state?.pass || 'load'}`, { + start, + end, + detail: trafficTimingDetail(state, segment, extra), + }); + } + + /** Emit an aggregate-duration measure without pretending its work was contiguous. */ + + function trafficTimingAggregate( + segment, + state, + anchorTime, + duration, + extra = {}, + ) { + const start = trafficTimingMark( + state, + `${segment}-aggregate-start`, + extra, + anchorTime, + ); + const end = trafficTimingMark( + state, + `${segment}-aggregate-end`, + extra, + anchorTime + duration, + ); + trafficTimingMeasure(segment, state, start, end, { + aggregate: true, + ...extra, + }); + } + + /** Clear only this module's stale User Timing entries before a new debug run. */ + + function clearTrafficTimingEntries() { + const markNames = new Set( + performance + .getEntriesByType('mark') + .filter((entry) => entry.name.startsWith('traffic:')) + .map((entry) => entry.name), + ); + const measureNames = new Set( + performance + .getEntriesByType('measure') + .filter((entry) => entry.name.startsWith('traffic:')) + .map((entry) => entry.name), + ); + for (const name of markNames) performance.clearMarks(name); + for (const name of measureNames) performance.clearMeasures(name); + } + + /** Return the stable User Timing mark name for a scheduling interaction. */ + + function trafficTimingCameraChangeMarkName(interactionId) { + return `traffic:interaction:${interactionId}:last-camera-change`; + } + + /** Mint and mark the exact camera-change interaction that armed a debounced load. */ + + function markTrafficTimingCameraChange() { + const interactionId = ++layerState._trafficTimingSequence; + const timestamp = performance.now(); + const anchor = { interactionId, timestamp }; + layerState._trafficTimingCurrentAnchor = anchor; + performance.mark(trafficTimingCameraChangeMarkName(interactionId), { + startTime: timestamp, + detail: { + trafficTiming: true, + segment: 'last-camera-change', + interactionId, + timestamp, + }, + }); + return anchor; + } + + /** + * Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this + * about 500 ms after stillness, so fetch has typically already started and + * never waits for this boundary. + */ + + function markTrafficTimingMoveEnd() { + const diagnosticId = ++layerState._trafficTimingSequence; + const timestamp = performance.now(); + const name = `traffic:diagnostic:${diagnosticId}:camera-move-end`; + performance.mark(name, { + startTime: timestamp, + detail: { + trafficTiming: true, + segment: 'camera-move-end', + diagnosticOnly: true, + cameraEventWaitTimeMs: 500, + fetchWaitsForMoveEnd: false, + timestamp, + }, + }); + } + + /** + * Instrumented twin of `parseRoads`. Operation ordering and road output match + * the normal function; debug-only clocks accumulate synchronous height and + * waypoint-materialization time independently. + */ + + function parseRoadsTimed(overpassData, trace) { + /* TRACE_ONLY_BEGIN */ + const _trafficTimingState = trafficTimingPass( + trace, + trace?.currentPass || 'full', + ); + const _trafficTimingParseStartTime = performance.now(); + const _trafficTimingParseStart = trafficTimingMark( + _trafficTimingState, + 'road-parse-start', + {}, + _trafficTimingParseStartTime, + ); + /* TRACE_ONLY_END */ + if (!overpassData || !overpassData.elements) { + /* TRACE_ONLY_BEGIN */ + const _trafficTimingParseEnd = trafficTimingMark( + _trafficTimingState, + 'road-parse-end', + { roadCount: 0 }, + ); + trafficTimingMeasure( + 'road-parse-total', + _trafficTimingState, + _trafficTimingParseStart, + _trafficTimingParseEnd, + { roadCount: 0 }, + ); + trafficTimingAggregate( + 'sample-height-total', + _trafficTimingState, + _trafficTimingParseStartTime, + 0, + { + sampleHeightCalls: 0, + sampleHeightMeanMs: 0, + distinctCells: 0, + roadCount: 0, + }, + ); + trafficTimingAggregate( + 'waypoint-materialization', + _trafficTimingState, + _trafficTimingParseStartTime, + 0, + { roadCount: 0 }, + ); + /* TRACE_ONLY_END */ + return []; + } + + const roads = []; + /* TRACE_ONLY_BEGIN */ + const _trafficTimingSampledCells = new Set(); + let _trafficTimingSampleHeightCalls = 0; + let _trafficTimingSampleHeightMs = 0; + let _trafficTimingWaypointMaterializationMs = 0; + /* TRACE_ONLY_END */ + for (const el of overpassData.elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + + const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + const simplifyStep = + rawCoords.length > MAX_WAYPOINTS_PER_ROAD + ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) + : 1; + const coords = []; + for (let i = 0; i < rawCoords.length; i += simplifyStep) { + coords.push(rawCoords[i]); + } + + const last = rawCoords[rawCoords.length - 1]; + const tail = coords[coords.length - 1]; + if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { + coords.push(last); + } + if (coords.length < 2) continue; + + const type = el.tags?.highway || 'unclassified'; + const onewayTag = el.tags?.oneway; + const oneway = + onewayTag === 'yes' || + onewayTag === '1' || + onewayTag === 'true' || + el.tags?.junction === 'roundabout' + ? 1 + : onewayTag === '-1' + ? -1 + : 0; + + let baseHeight = 0; + const firstCoord = coords[0]; + if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) { + /* TRACE_ONLY_BEGIN */ + _trafficTimingSampleHeightCalls += 1; + _trafficTimingSampledCells.add( + `${firstCoord[1].toFixed(3)},${firstCoord[0].toFixed(3)}`, + ); + /* TRACE_ONLY_END */ + const carto = Cesium.Cartographic.fromDegrees( + firstCoord[0], + firstCoord[1], + ); + /* TRACE_ONLY_BEGIN */ + const _trafficTimingSampleStart = performance.now(); + /* TRACE_ONLY_END */ + const sampled = layerState._viewer.scene.sampleHeight(carto); + /* TRACE_ONLY_BEGIN */ + _trafficTimingSampleHeightMs += + performance.now() - _trafficTimingSampleStart; + /* TRACE_ONLY_END */ + if (Number.isFinite(sampled)) baseHeight = sampled; + } + + /* TRACE_ONLY_BEGIN */ + const _trafficTimingMaterializeStart = performance.now(); + /* TRACE_ONLY_END */ + const waypoints = coords.map(([lng, lat]) => { + const h = baseHeight + DOT_HEIGHT_OFFSET; + return Cesium.Cartesian3.fromDegrees(lng, lat, h); + }); + const segmentDist = []; + for (let i = 0; i < waypoints.length - 1; i++) { + segmentDist.push( + Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + ); + } + /* TRACE_ONLY_BEGIN */ + _trafficTimingWaypointMaterializationMs += + performance.now() - _trafficTimingMaterializeStart; + /* TRACE_ONLY_END */ + + roads.push({ coords, type, oneway, waypoints, segmentDist }); + } + + /* TRACE_ONLY_BEGIN */ + const _trafficTimingMetrics = { + roadCount: roads.length, + sampleHeightCalls: _trafficTimingSampleHeightCalls, + sampleHeightMeanMs: _trafficTimingSampleHeightCalls + ? _trafficTimingSampleHeightMs / _trafficTimingSampleHeightCalls + : 0, + distinctCells: _trafficTimingSampledCells.size, + }; + trafficTimingAggregate( + 'sample-height-total', + _trafficTimingState, + _trafficTimingParseStartTime, + _trafficTimingSampleHeightMs, + _trafficTimingMetrics, + ); + trafficTimingAggregate( + 'waypoint-materialization', + _trafficTimingState, + _trafficTimingParseStartTime, + _trafficTimingWaypointMaterializationMs, + _trafficTimingMetrics, + ); + const _trafficTimingParseEnd = trafficTimingMark( + _trafficTimingState, + 'road-parse-end', + _trafficTimingMetrics, + ); + trafficTimingMeasure( + 'road-parse-total', + _trafficTimingState, + _trafficTimingParseStart, + _trafficTimingParseEnd, + _trafficTimingMetrics, + ); + /* TRACE_ONLY_END */ + return roads; + } + + /** Resolve a render label into its correlated pass and data source. */ + + function trafficTimingRenderState(trace, label) { + const pass = label.toLowerCase().includes('major') ? 'major' : 'full'; + const source = label.startsWith('Cache') ? 'client-cache' : 'proxy'; + return trafficTimingPass(trace, pass, source); + } + + /** Record the first Cesium postRender following a completed dot render. */ + + function scheduleTrafficTimingPostRender( + state, + renderEnd, + renderId, + renderMetrics, + ) { + if (!layerState._viewer?.scene) return; + let remove = null; + remove = layerState._viewer.scene.postRender.addEventListener(() => { + remove?.(); + layerState._trafficTimingPostRenderRemovers?.delete(remove); + const visibleTime = performance.now(); + const postRender = trafficTimingMark( + state, + 'next-post-render', + { + renderId, + ...renderMetrics, + }, + visibleTime, + ); + const firstVisible = trafficTimingMark( + state, + 'first-visible-pixel', + { + renderId, + visibleBoundary: 'next-postRender', + ...renderMetrics, + }, + visibleTime, + ); + trafficTimingMeasure( + 'render-to-post-render', + state, + renderEnd, + postRender, + { + renderId, + visibleBoundary: 'next-postRender', + ...renderMetrics, + }, + ); + trafficTimingMeasure( + 'last-camera-change-to-first-visible', + state, + state.trace.cameraChangeMark, + firstVisible, + { renderId, visibleBoundary: 'next-postRender', ...renderMetrics }, + ); + }); + layerState._trafficTimingPostRenderRemovers?.add(remove); + } + + /** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */ + + async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) { + const generation = layerState._loadGeneration + 1; + if ( + !expectedAnchor || + expectedAnchor !== layerState._trafficTimingCurrentAnchor + ) { + layerState._trafficTimingDroppedTraces += 1; + await parts.ingestion.loadRoadsForBounds(bounds, altitude); + return; + } + layerState._trafficTimingCurrentAnchor = null; + const clamped = parts.viewport.clampBounds(bounds); + const trace = { + id: ++layerState._trafficTimingSequence, + interactionId: expectedAnchor.interactionId, + cameraChangeTimestamp: expectedAnchor.timestamp, + cameraChangeMark: trafficTimingCameraChangeMarkName( + expectedAnchor.interactionId, + ), + generation, + cacheKey: `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`, + currentPass: null, + renderSequence: 0, + passes: new Map(), + }; + layerState._trafficTimingTracesCreated += 1; + await parts.ingestion.loadRoadsForBounds(bounds, altitude, trace); + } + return { + getTrafficTimingDiagnostics, + trafficTimingPass, + trafficTimingDetail, + trafficTimingMark, + trafficTimingMeasure, + trafficTimingAggregate, + clearTrafficTimingEntries, + trafficTimingCameraChangeMarkName, + markTrafficTimingCameraChange, + markTrafficTimingMoveEnd, + parseRoadsTimed, + trafficTimingRenderState, + scheduleTrafficTimingPostRender, + loadRoadsForBoundsTimed, + }; +} diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js new file mode 100644 index 000000000..e84a1c045 --- /dev/null +++ b/src/layers/traffic/viewport.js @@ -0,0 +1,249 @@ +import * as Cesium from 'cesium'; +import { + deriveFetchCenter, + clampBoundsAroundCenter, +} from '../../data/trafficBounds.js'; +import { + MAX_LOOKAT_PULL_KM, + ACTIVATION_ALTITUDE, + OVERLAP_THRESHOLD, + MIN_CENTER_SHIFT_KM, + TRAFFIC_TIMING_ENABLED, + FETCH_DEBOUNCE, +} from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + // ─── Camera Monitoring ───────────────────────────────────── + + /** + * Get the current camera altitude in meters above the ellipsoid. + * @returns {number} Camera height in meters, or Infinity if unavailable. + */ + + function getCameraAltitude() { + const carto = layerState._viewer.camera.positionCartographic; + return carto ? carto.height : Infinity; + } + + /** + * Compute the current camera view rectangle in degrees. + * @returns {{south:number, west:number, north:number, east:number}|null} + * Bounding box in degrees, or null if the rectangle cannot be computed. + */ + + function getViewBounds() { + const rect = layerState._viewer.camera.computeViewRectangle(); + if (!rect) return null; + return { + south: Cesium.Math.toDegrees(rect.south), + west: Cesium.Math.toDegrees(rect.west), + north: Cesium.Math.toDegrees(rect.north), + east: Cesium.Math.toDegrees(rect.east), + }; + } + + /** + * Derive the road-fetch center from the camera's look-at ground point. + * + * C4 fix: at oblique pitch `computeViewRectangle()` spans toward the horizon, + * so its midpoint can sit tens of km from what the user is looking at. Instead + * we pick the ellipsoid under the canvas center (`camera.pickEllipsoid` — this + * works with the globe hidden under Google 3D tiles, where `scene.globe.pick` + * is NOT reliable), fall back to the camera nadir on a sky/horizon look, and + * pull horizon-gaze hits back to MAX_LOOKAT_PULL_KM from nadir. + * + * @returns {{lat:number, lon:number, source:string}|null} Fetch center in + * degrees, or null when the camera position is unavailable. + */ + + function getFetchCenter() { + const carto = layerState._viewer.camera.positionCartographic; + if (!carto) return null; + const nadirLat = Cesium.Math.toDegrees(carto.latitude); + const nadirLon = Cesium.Math.toDegrees(carto.longitude); + + let hitLat; + let hitLon; + const canvas = layerState._viewer.scene.canvas; + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + if (width > 0 && height > 0) { + const hit = layerState._viewer.camera.pickEllipsoid( + new Cesium.Cartesian2(width / 2, height / 2), + Cesium.Ellipsoid.WGS84, + ); + if (hit) { + const hitCarto = Cesium.Cartographic.fromCartesian(hit); + hitLat = Cesium.Math.toDegrees(hitCarto.latitude); + hitLon = Cesium.Math.toDegrees(hitCarto.longitude); + } + } + + return deriveFetchCenter({ + nadirLat, + nadirLon, + hitLat, + hitLon, + maxPullKm: MAX_LOOKAT_PULL_KM, + }); + } + + /** + * Compute the geographic center of a bounding box. + * @param {{south:number, west:number, north:number, east:number}} bounds + * @returns {{lat:number, lon:number}} Center point in degrees. + */ + + function getBoundsCenter(bounds) { + return { + lat: (bounds.south + bounds.north) / 2, + lon: (bounds.west + bounds.east) / 2, + }; + } + + /** + * Approximate great-circle distance between two points in kilometres. + * + * Uses an equirectangular projection (cosine correction on longitude) + * with the 111 km/degree approximation. Sufficient for the small + * viewport-center shifts being compared. + * + * @param {{lat:number, lon:number}} a - First point. + * @param {{lat:number, lon:number}} b - Second point. + * @returns {number} Distance in kilometres. + */ + + function distanceKm(a, b) { + const dLat = (a.lat - b.lat) * 111; + const avgLat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dLon = (a.lon - b.lon) * 111 * Math.cos(avgLat); + return Math.sqrt(dLat * dLat + dLon * dLon); + } + + /** + * Check whether two bounding boxes overlap by at least a given fraction of + * the first box's area. Used to decide if a camera move is large enough to + * warrant a new road fetch. + * + * @param {{south:number, west:number, north:number, east:number}} a - Reference bounds. + * @param {{south:number, west:number, north:number, east:number}} b - Bounds to compare. + * @param {number} threshold - Minimum overlap fraction (0-1) relative to `a`. + * @returns {boolean} True if overlap area / a area >= threshold. + */ + + function boundsOverlap(a, b, threshold) { + // Compute the intersection rectangle + const overlapS = Math.max(a.south, b.south); + const overlapN = Math.min(a.north, b.north); + const overlapW = Math.max(a.west, b.west); + const overlapE = Math.min(a.east, b.east); + + // No intersection if the rectangle is degenerate + if (overlapN <= overlapS || overlapE <= overlapW) return false; + + const overlapArea = (overlapN - overlapS) * (overlapE - overlapW); + const aArea = (a.north - a.south) * (a.east - a.west); + + return aArea > 0 && overlapArea / aArea >= threshold; + } + + /** + * Clamp a bounding box to a maximum span to avoid overloading the Overpass API. + * + * The box is centered on the input's midpoint with each axis capped at 0.05 + * degrees (~5.5 km at the equator). This keeps query area and response size + * manageable while still covering the visible neighbourhood. + * + * NOTE (C4): the camera-driven path in `onCameraChanged` centers on the + * derived look-at point via `clampBoundsAroundCenter` instead; this + * midpoint-centered variant remains as the internal re-clamp guard in + * `loadRoadsForBounds` (idempotent on already-clamped bounds). + * + * @param {{south:number, west:number, north:number, east:number}} bounds + * @returns {{south:number, west:number, north:number, east:number}} Clamped bounds. + */ + + function clampBounds(bounds) { + return clampBoundsAroundCenter(bounds, getBoundsCenter(bounds)); + } + + /** + * Camera-change handler — the main entry point for viewport-driven road loading. + * + * Gating logic: + * 1. If the camera is above ACTIVATION_ALTITUDE, clear all dots and bail. + * 2. Clamp the view bounds and compute the viewport center. + * 3. Skip the fetch if the new viewport significantly overlaps the last-fetched + * bounds AND the center has shifted less than MIN_CENTER_SHIFT_KM. This + * prevents redundant fetches during small pans. + * 4. Otherwise, debounce and schedule `loadRoadsForBounds`. + */ + + function onCameraChanged() { + if (!layerState._enabled) return; + + const alt = getCameraAltitude(); + + // Above activation altitude — remove all traffic and stop. + // Also null the last-fetch gate: otherwise zooming back down to the SAME + // viewport hits the overlap/center-shift skip in step 3 and the dots + // (cleared here) never reload (H5). Clearing the gate forces a fresh fetch. + if (alt > ACTIVATION_ALTITUDE) { + clearTimeout(layerState._fetchTimeout); + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + parts.ingestion.cancelActiveFetch(); + layerState._loadGeneration++; + layerState._fetching = false; + layerState._flowPending = 0; + layerState._roadError = null; + parts.animation.clearDots(); + layerState._lastBounds = null; + layerState._lastViewCenter = null; + return; + } + + const bounds = getViewBounds(); + if (!bounds) return; + // C4 fix: center the fetch box on the camera's look-at ground point (with + // nadir fallback + 12 km horizon-gaze pull-back), NOT the view rectangle's + // midpoint — at oblique pitch that midpoint drifts toward the horizon. + const fetchCenter = getFetchCenter(); + const clamped = fetchCenter + ? clampBoundsAroundCenter(bounds, fetchCenter) + : clampBounds(bounds); + const center = getBoundsCenter(clamped); + + // Skip re-fetch when viewport overlap is high and center shift is negligible + if ( + layerState._lastBounds && + layerState._lastViewCenter && + boundsOverlap(clamped, layerState._lastBounds, OVERLAP_THRESHOLD) && + distanceKm(center, layerState._lastViewCenter) < MIN_CENTER_SHIFT_KM + ) { + return; + } + + // Debounce: wait for camera to settle before triggering a fetch. In debug + // captures the final changed event that arms this exact timeout is its + // causal anchor; Cesium's later moveEnd notification is diagnostic only. + const interactionAnchor = TRAFFIC_TIMING_ENABLED + ? parts.timing.markTrafficTimingCameraChange() + : null; + clearTimeout(layerState._fetchTimeout); + layerState._fetchTimeout = setTimeout( + () => layerState._loadRoadsForBounds(clamped, alt, interactionAnchor), + FETCH_DEBOUNCE, + ); + } + return { + getCameraAltitude, + getViewBounds, + getFetchCenter, + getBoundsCenter, + distanceKm, + boundsOverlap, + clampBounds, + onCameraChanged, + }; +} diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js new file mode 100644 index 000000000..d4341d363 --- /dev/null +++ b/src/layers/vessels/cards.js @@ -0,0 +1,181 @@ +import { + accentForVesselType, + normalizeVesselType, +} from '../../data/vesselLabels.js'; + +export function createCards({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + 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. + */ + + 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: components.rendering.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. + */ + + 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} + */ + + 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`; + } + return { + updateSelectedVesselHud, + resetSelectedVesselHud, + trimHudValue, + buildVesselCard, + buildSelectedVesselCard, + vesselOverlayEntryId, + vesselTypeShort, + cardScreenSeparated, + displayVesselName, + formatSpeed, + formatHeading, + formatPositionTime, + }; +} diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js new file mode 100644 index 000000000..abab8821c --- /dev/null +++ b/src/layers/vessels/evidence.js @@ -0,0 +1,58 @@ +import * as Cesium from 'cesium'; +import { FOCUS_EVIDENCE_DEV } from './policy.js'; + +export function createEvidence({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + /** 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 }; + } + components.selection.clearVesselInspection(); + components.store.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, + }; + }); + } + return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot }; +} diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js new file mode 100644 index 000000000..d459aa873 --- /dev/null +++ b/src/layers/vessels/index.js @@ -0,0 +1,56 @@ +import { createVesselState } from './state.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createStore } from './store.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createTracking } from './tracking.js'; +import { createCards } from './cards.js'; +import { createTesting } from './testing.js'; +import { createEvidence } from './evidence.js'; +import { createQueries } from './queries.js'; +/** Compose one vessel layer with application-owned scene services. */ +export function createVesselLayer({ source, services, options = {} } = {}) { + const vesselState = createVesselState({ source, services }); + const parts = {}; + const layer = {}; + const context = { vesselState, services, parts, layer, options }; + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + parts.store = createStore(context); + parts.rendering = createRendering(context); + parts.selection = createSelection(context); + parts.tracking = createTracking(context); + parts.cards = createCards(context); + parts.testing = createTesting(context); + parts.evidence = createEvidence(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + layer.source = source?.label || 'Vessels'; + Object.assign(layer, { + applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis, + buildVesselCard: parts.cards.buildVesselCard, + buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, + cardScreenSeparated: parts.cards.cardScreenSeparated, + }); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js'; +export { + VESSEL_OVERLAY_SOURCE_ID, + VESSEL_LABEL_GRID_PX, + VESSEL_DEFAULT_LABEL_LIMIT, + VESSEL_OVERLAY_MAX_COHORT, + VESSEL_CARD_FADE_DISTANCE_M, + normalizeVesselType, + vesselTypeCss, + accentForVesselType, + vesselOverlayCohortLimit, + applyVesselOverlayPolicy, +} from '../../data/vesselLabels.js'; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js new file mode 100644 index 000000000..ba0d6d538 --- /dev/null +++ b/src/layers/vessels/ingestion.js @@ -0,0 +1,184 @@ +import { AIS_FIRST_CONNECT_LABEL } from './policy.js'; + +export function createIngestion({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const aisLiveVesselsLayer = layer; + + 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 { + // 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 snapshot = await vesselState._source.getSnapshot( + { maxRows: components.rendering.renderRowLimit() }, + { signal }, + ); + if (!ownsAisRequest(requestController, requestSessionId)) return; + aisLiveVesselsLayer.source = snapshot.source; + // Map observations into the existing display store; source fields stop here. + applyAisFeedSnapshot(viewer, { + rows: snapshot.records.map(vesselDisplayRow), + observedAtMs: snapshot.observedAtMs, + freshness: snapshot.freshness, + complete: snapshot.complete, + rawRowCount: snapshot.rawRowCount, + reason: snapshot.reason, + status: snapshot.transportStatus, + lastMessageAt: snapshot.lastMessageAt, + nextAttemptAt: snapshot.nextAttemptAt, + refreshing: snapshot.stale, + newestPositionAt: + snapshot.observedAtMs == null + ? null + : new Date(snapshot.observedAtMs).toISOString(), + silentForMs: snapshot.silentForMs, + reconnectAttempt: snapshot.reconnectAttempt, + }); + } catch (error) { + if ( + ownsAisRequest(requestController, requestSessionId) && + error?.name !== 'AbortError' + ) { + components.lifecycle.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 = components.queries.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 ( + components.lifecycle.isDefinitiveTransportFailure( + snapshot.transportStatus, + ) + ) { + components.lifecycle.markAisUnavailable(snapshot.error); + return { reconciled: false, ...snapshot }; + } + if ( + state.firstConnectPhase === 'loading' && + components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus) + ) { + state.error = null; + state.loadingLabel = AIS_FIRST_CONNECT_LABEL; + return { reconciled: false, ...snapshot }; + } + if (state.firstConnectPhase === 'loading') { + components.lifecycle.markAisUnavailable(snapshot.error); + return { reconciled: false, ...snapshot }; + } + state.error = snapshot.error; + return { reconciled: false, ...snapshot }; + } + + components.lifecycle.settleFirstConnectPhase('ready'); + components.store.reconcileVessels(viewer, snapshot.acceptedRows, { + complete: payload?.complete !== false, + }); + state.count = state.vesselRecords.length; + state.stale = + Boolean(payload?.refreshing) || + payload?.complete === false || + payload?.freshness === 'unknown'; + 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 || payload?.reason || null; + state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') + ? payload.observedAtMs + : vesselState._aisRuntime.now(); + return { reconciled: true, ...snapshot }; + } + + function vesselDisplayRow(record) { + return { + mmsi: record.id, + reference: record.reference, + lat: record.latitude, + lon: record.longitude, + name: record.name, + imo: record.imo, + type: record.type, + destination: record.destination, + speed: record.speedMps == null ? null : record.speedMps / 0.514444, + course: record.courseDeg, + heading: record.headingDeg, + last_position_epoch: + record.observedAtMs == null ? null : record.observedAtMs / 1000, + last_position_UTC: + record.observedAtMs == null + ? '' + : new Date(record.observedAtMs).toISOString(), + }; + } + const methods = { + update(viewer) { + if (!state.enabled) return Promise.resolve(); + return loadLivePositions(viewer || state.viewer); + }, + }; + + return { + loadLivePositions, + ownsAisRequest, + applyAisFeedSnapshot, + vesselDisplayRow, + methods, + }; +} diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js new file mode 100644 index 000000000..c878b398c --- /dev/null +++ b/src/layers/vessels/lifecycle.js @@ -0,0 +1,253 @@ +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; +import { + AIS_FIRST_CONNECT_GRACE_MS, + AIS_FIRST_CONNECT_LABEL, + AIS_HEALTHY_STATUSES, +} from './policy.js'; + +export function createLifecycle({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + function clearFirstConnectTimer() { + if (state.firstConnectTimer === null) return; + vesselState._aisRuntime.clearTimeout(state.firstConnectTimer); + state.firstConnectTimer = null; + } + + function invalidateAisSession() { + clearFirstConnectTimer(); + state.sessionId = ++vesselState._aisSessionSequence; + state.firstConnectPhase = 'idle'; + state.firstConnectStartedAt = null; + state.firstConnectDeadline = null; + } + + function beginAisSession() { + clearFirstConnectTimer(); + const sessionId = ++vesselState._aisSessionSequence; + const startedAt = vesselState._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 = vesselState._aisRuntime.setTimeout(() => { + if ( + !state.enabled || + state.sessionId !== sessionId || + state.firstConnectPhase !== 'loading' + ) + return; + const remainingMs = + state.firstConnectDeadline - vesselState._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; + } + + function resetState() { + state.abort?.abort(); + state.trailAbort?.abort(); + state.trailAbort = null; + 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 = ++vesselState._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; + } + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (state.viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + vesselState._source = source; + this.source = source.label || this.source; + }, + + init(viewer) { + if (typeof vesselState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + state.viewer = viewer; + components.rendering.ensureCollections(viewer); + vesselState._vesselOverlayHost.setVisible( + VESSEL_OVERLAY_SOURCE_ID, + false, + ); + components.selection.installInteraction(viewer); + components.rendering.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; + components.rendering.ensureCollections(activeViewer); + components.selection.installInteraction(activeViewer); + components.rendering.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 (!vesselState._geoidReady) { + const sessionId = state.sessionId; + ensureGeoidReady() + .then(() => { + if (!state.enabled || state.sessionId !== sessionId) return; + vesselState._geoidReady = true; + components.tracking.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 components.ingestion.loadLivePositions(activeViewer); + }, + + disable() { + state.enabled = false; + invalidateAisSession(); + releaseContinuousRender('ais-vessels'); + unregisterPickOwner('ais-live-vessels'); + components.rendering.setVisible(false); + vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); + components.selection.clearVesselInspection(); + components.tracking.destroySelectedVesselTrail(); + components.selection.removeVesselInteraction(); + if (state.abort) { + state.abort.abort(); + state.abort = null; + } + state.loading = false; + state.loadingLabel = ''; + }, + + destroy(viewer) { + const activeViewer = viewer || state.viewer; + invalidateAisSession(); + releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + if (state.abort) state.abort.abort(); + unregisterPickOwner('ais-live-vessels'); + components.selection.clearVesselInspection(); + components.tracking.destroySelectedVesselTrail(); + if (state.billboardCollection && activeViewer?.scene?.primitives) { + activeViewer.scene.primitives.remove(state.billboardCollection); + } + vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); + vesselState._vesselOverlayHost.setVisible( + VESSEL_OVERLAY_SOURCE_ID, + false, + ); + components.selection.removeVesselInteraction(); + if (state.preRenderRemover) { + state.preRenderRemover(); + } + resetState(); + }, + }; + + return { + clearFirstConnectTimer, + invalidateAisSession, + beginAisSession, + scheduleFirstConnectExpiry, + settleFirstConnectPhase, + isGraceEligibleTransport, + isDefinitiveTransportFailure, + markAisUnavailable, + resetState, + methods, + }; +} diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs new file mode 100644 index 000000000..1be09728a --- /dev/null +++ b/src/layers/vessels/ownership.test.mjs @@ -0,0 +1,267 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { createVesselState } from './state.js'; + +const noop = () => {}; +function services() { + return { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { releaseContinuousRender: noop }, + }; +} +function setup(source, options = {}) { + const layer = createVesselLayer({ source, services: services(), options }); + layer.testing._setVesselStateForTest({ + viewer: {}, + billboardCollection: { add: (options) => ({ ...options }), remove: noop }, + }); + let now = 1000; + layer.testing._setAisRuntimeForTest({ + now: () => now, + setTimeout, + clearTimeout, + }); + return { + layer, + advance: (ms) => { + now += ms; + }, + }; +} +const observation = (id, reference = id) => ({ + id, + reference, + latitude: 51.93, + longitude: 4.05, + name: id, + observedAtMs: 500, + speedMps: 4, + headingDeg: 180, + courseDeg: 170, +}); +const snapshot = (records, extra = {}) => ({ + records, + source: 'Fixture vessels', + complete: true, + stale: false, + freshness: 'current', + observedAtMs: 500, + transportStatus: 'live', + rawRowCount: records.length, + ...extra, +}); + +test('vessel construction is inert and each instance owns its records, icon cache and clock', () => { + let requested = 0; + const source = { + getSnapshot() { + requested++; + }, + }; + const one = createVesselState({ source, services: services() }); + const two = createVesselState({ source, services: services() }); + for (const key of ['state', 'shipIconCache', '_scratchFocusScreen']) + assert.notEqual(one[key], two[key]); + assert.notEqual(one.state.vesselMap, two.state.vesselMap); + assert.notEqual(one.state.trailPositions, two.state.trailPositions); + const { layer } = setup(source); + const other = createVesselLayer({ source, services: services() }); + layer.testing._beginAisSessionForTest(); + assert.equal(other.getStats().loading, false); + assert.equal(requested, 0); + layer.testing._setVesselStateForTest({ enabled: false }); +}); + +test('missing vessel source fails before mutating the viewer', () => { + const layer = createVesselLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); + layer.setSource({ getSnapshot: noop }); +}); + +test('partial observations retain missing contacts only until their receipt deadline', async () => { + let current = snapshot([observation('111'), observation('222')]); + const { layer, advance } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([observation('111')], { complete: false, rawRowCount: 2 }); + await layer.update(); + assert.equal(layer.hasContact('222'), true); + assert.equal(layer.getStats().stale, true); + assert.equal(layer.getStats().rawRowCount, 2); + assert.equal(layer.getStats().acceptedRowCount, 1); + assert.equal(layer.getStats().lastUpdate, 500); + advance(5 * 60 * 1000); + await layer.update(); + assert.equal(layer.hasContact('222'), false); +}); + +test('a complete feed still removes an absent unselected vessel immediately', async () => { + let current = snapshot([observation('111'), observation('222')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([observation('111')]); + await layer.update(); + assert.equal(layer.hasContact('222'), false); +}); + +test('an incomplete feed bounds its retained union to the configured row budget', async () => { + let current = snapshot( + Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))), + ); + const { layer } = setup( + { + async getSnapshot() { + return current; + }, + }, + { maxRows: 500 }, + ); + await layer.update(); + current = snapshot( + Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))), + { complete: false }, + ); + await layer.update(); + assert.equal(layer.getStats().count, 500); + assert.equal(layer.hasContact('1000'), false); + assert.equal(layer.hasContact('2000'), true); +}); + +test('unknown source time does not become fresh at the time of receipt', async () => { + const { layer } = setup({ + async getSnapshot() { + return snapshot([observation('111')], { + observedAtMs: null, + freshness: 'unknown', + }); + }, + }); + await layer.update(); + assert.equal(layer.getStats().lastUpdate, null); + assert.equal(layer.getStats().stale, true); +}); + +test('refresh replaces the history reference and clearing selection cancels late history', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let current = snapshot([observation('111', 'old-reference')]); + let request; + let resolveTrack; + const { layer } = setup({ + async getSnapshot() { + return current; + }, + getTrack(reference, { signal }) { + request = { reference, signal }; + return new Promise((resolve) => { + resolveTrack = resolve; + }); + }, + }); + await layer.update(); + current = snapshot([observation('111', 'new-reference')]); + await layer.update(); + assert.equal(layer.selectById('111'), true); + assert.equal(request.reference, 'new-reference'); + layer.clearSelection(); + assert.equal(request.signal.aborted, true); + resolveTrack({ records: [{ latitude: 51.94, longitude: 4.04 }] }); + await new Promise(setImmediate); + assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); +}); + +test('destroy cancels pending observations and ignores their later completion', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let requestSignal; + let resolve; + const { layer } = setup({ + getSnapshot(_query, { signal }) { + requestSignal = signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = layer.update(); + layer.destroy(); + assert.equal(requestSignal.aborted, true); + resolve(snapshot([observation('111')])); + await pending; + assert.equal(layer.getStats().count, 0); + assert.equal(layer.hasContact('111'), null); +}); + +test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let current = snapshot([observation('111'), observation('222')]); + let signal; + let finish; + const { layer, advance } = setup({ + async getSnapshot() { + return current; + }, + getTrack(_reference, options) { + signal = options.signal; + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + await layer.update(); + layer.selectById('222'); + current = snapshot([observation('111')], { complete: false }); + await layer.update(); + assert.equal(layer.getSelectedInfo().mmsi, '222'); + assert.equal(signal.aborted, false); + advance(5 * 60 * 1000); + await layer.update(); + assert.equal(layer.getSelectedInfo(), null); + assert.equal(signal.aborted, true); + finish({ records: [] }); + await new Promise(setImmediate); + assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); +}); diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js new file mode 100644 index 000000000..b13dcb947 --- /dev/null +++ b/src/layers/vessels/policy.js @@ -0,0 +1,105 @@ +import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; + +export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; + +export const DEFAULT_RENDER_ROWS = 12000; + +export const DEFAULT_ACTIVE_LABELS = 900; + +export const REFRESH_MS = 60000; + +/** Bounded wait for the first accepted vessel position in one enabled session. */ + +export const AIS_FIRST_CONNECT_GRACE_MS = 30000; + +export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…'; + +export const VISIBILITY_UPDATE_MS = 800; + +/** Focus alpha alone samples faster inside the existing preRender pass. */ + +export const FOCUS_UPDATE_MS = 80; + +export 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. + */ + +export const CARD_MIN_SEP_PX = 150; + +/** Number of consecutive refreshes a selected-but-vanished vessel is retained. */ + +export const SELECTED_PIN_REFRESHES = 3; + +/** Trail hue for the selected vessel (PRD F4, pinned to the AIS teal-green family). */ + +export const TRAIL_COLOR = '#39ffd5'; + +/** Slight lift (m) for trail vertices to avoid sea-surface z-fighting. */ + +export 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). + */ + +export const VESSEL_LIFT_M = 3; + +/** Combined cap on trail vertices (server backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +/** Minimum movement (m) before a reconcile refresh appends a new trail point. */ + +export const TRAIL_MIN_MOVE_M = 25; + +export const DEFAULT_AIS_RUNTIME = Object.freeze({ + now: () => Date.now(), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer), +}); + +/** + * 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". + */ + +export 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. + */ + +export 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. + */ + +export const AIS_DEGRADED_STATUSES = new Set([ + 'stale', + 'reconnecting', + 'down', + 'auth-failed', +]); + +/** Missing records in an incomplete observation are retained for at most five minutes. */ +export const PARTIAL_RETENTION_MS = 5 * 60 * 1000; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js new file mode 100644 index 000000000..05109e2b2 --- /dev/null +++ b/src/layers/vessels/queries.js @@ -0,0 +1,527 @@ +import * as Cesium from 'cesium'; +import { + AIS_DEGRADED_STATUSES, + AIS_HEALTHY_STATUSES, + AIS_STATUS_REASON, + REFRESH_MS, + FOCUS_EVIDENCE_DEV, + AIS_FIRST_CONNECT_LABEL, +} from './policy.js'; + +export function createQueries({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { formatKnots } = services.labels; + + /** + * 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 - vesselState._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. + */ + + 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}} + */ + + 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: + Number.isInteger(payload?.rawRowCount) && + payload.rawRowCount >= rawRows.length + ? payload.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}} + */ + + 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, + }; + } + + /** + * 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). + */ + + 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'}} + */ + + 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; + } + const methods = { + mapAnalystRecord, + deriveAisFeedError, + classifyAisFeedSnapshot, + vesselDatumHeightM, + reduceVesselSelection, + + id: 'ais-live-vessels', + + name: 'Live AIS Vessels', + + icon: '◭', + + source: 'AISStream', + + updateInterval: REFRESH_MS, + + statsRefreshInterval: 1000, + + /** + * 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; + components.selection.selectVessel(record); + return true; + }, + + /** + * Clear the current vessel selection and reset the HUD readout. + * @returns {boolean} Always true. + */ + clearSelection() { + components.selection.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: components.evidence._setFocusEvidenceVessels, + snapshot: components.evidence._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(), + }; + }, + }; + + return { + aisRetryInSec, + describeDegradedAisFeed, + deriveAisFeedError, + hasUsableVesselCoordinates, + classifyAisFeedSnapshot, + mapAnalystRecord, + vesselDatumHeightM, + reduceVesselSelection, + normalizeSelectionMmsi, + methods, + }; +} diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js new file mode 100644 index 000000000..87f09537d --- /dev/null +++ b/src/layers/vessels/rendering.js @@ -0,0 +1,472 @@ +import * as Cesium from 'cesium'; +import { + vesselTypeCss, + vesselOverlayCohortLimit, + VESSEL_OVERLAY_SOURCE_ID, + applyVesselOverlayPolicy, + VESSEL_CARD_FADE_DISTANCE_M, +} from '../../data/vesselLabels.js'; +import { + cameraPoseSignature, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { + DEFAULT_RENDER_ROWS, + DEFAULT_ACTIVE_LABELS, + VISIBILITY_UPDATE_MS, + FOCUS_UPDATE_MS, + LABEL_GRID_PX, + CARD_MIN_SEP_PX, +} from './policy.js'; + +export function createRendering({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { registerSpriteCollection } = services.sprites; + const { + forgetSpriteFocus, + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + function renderRowLimit() { + const configured = Number(options.maxRows); + 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(options.maxLabels); + 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); + } + + /** + * 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, + }); + } + + /** + * 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 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 (vesselState.shipIconCache.has(key)) + return vesselState.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); + vesselState.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 !== vesselState._lastCamPoseSig; + if (doRotations) vesselState._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, + vesselState._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}} + */ + + 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 + ? [components.cards.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 ( + !components.cards.cardScreenSeparated( + accepted, + candidate, + CARD_MIN_SEP_PX, + ) + ) + continue; + accepted.push({ x: candidate.x, y: candidate.y }); + entries.push(components.cards.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(), + ); + vesselState._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; + components.selection.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 setVisible(show) { + if (state.billboardCollection) { + state.billboardCollection.show = show; + } + vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); + } + return { + renderRowLimit, + labelRowLimit, + ensureCollections, + addRecordPrimitives, + removeRecordPrimitives, + shipScale, + vesselCourseDeg, + shipIcon, + installRuntime, + updateVisibility, + applyVesselFocusDeemphasis, + makeOccluder, + isVisible, + updateClusteredLabels, + publishVesselOverlayEntries, + labelPriority, + hasUsefulName, + setVisible, + }; +} diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js new file mode 100644 index 000000000..1ef7cbd9c --- /dev/null +++ b/src/layers/vessels/selection.js @@ -0,0 +1,247 @@ +import * as Cesium from 'cesium'; +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; + +export function createSelection({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { requestWorldFocus } = services.worldFocus; + const { + selectEntityContext, + registerEntityContext, + clearSelectedEntityContextForLayer, + } = services.context; + const aisLiveVesselsLayer = layer; + + 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 + ? vesselState._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 = components.queries.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 = components.queries.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 = components.queries.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 = components.rendering.shipIcon(record, true); + record.billboard.scale = components.rendering.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. + components.rendering.updateVisibility(true); + components.cards.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) { + components.tracking.appendSelectedVesselTrailFix(record); + } else { + components.tracking.startSelectedVesselTrail(record); + } + } + + /** + * 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: aisLiveVesselsLayer.source, + label: components.cards.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 = components.rendering.shipIcon(record, false); + record.billboard.scale = components.rendering.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) components.rendering.updateVisibility(true); + if (!preserveTrail) components.tracking.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 }); + components.cards.resetSelectedVesselHud(); + } + return { + installInteraction, + bindVesselInteraction, + selectAndFocusVessel, + removeVesselInteraction, + onVesselKeyDown, + selectVessel, + registerSelectedContext, + clearSelection, + clearVesselInspection, + }; +} diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js new file mode 100644 index 000000000..5f17de65f --- /dev/null +++ b/src/layers/vessels/state.js @@ -0,0 +1,108 @@ +import * as Cesium from 'cesium'; +import { DEFAULT_AIS_RUNTIME } from './policy.js'; + +export function createVesselState({ source, services }) { + const { + setOverlayEntries, + setOverlaySourceVisible, + clearOverlaySource, + hitTestWorldOverlay, + } = services.overlay; + const vesselState = {}; + + vesselState._source = source; + + /** Camera pose signature at the last vessel rotation pass. */ + + vesselState._lastCamPoseSig = ''; + + vesselState._scratchFocusScreen = new Cesium.Cartesian2(); + + vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, + }); + + vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST; + + vesselState._aisRuntime = DEFAULT_AIS_RUNTIME; + + vesselState._aisSessionSequence = 0; + + /** + * 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} + */ + + vesselState._geoidReady = false; + + /** @type {Map} `${cssColor}:${variant}` -> chevron SVG data URL */ + + vesselState.shipIconCache = new Map(); + + vesselState.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, + trailAbort: null, + }; + return vesselState; +} diff --git a/src/layers/vessels/store.js b/src/layers/vessels/store.js new file mode 100644 index 000000000..80890452c --- /dev/null +++ b/src/layers/vessels/store.js @@ -0,0 +1,212 @@ +import * as Cesium from 'cesium'; +import { PARTIAL_RETENTION_MS } from './policy.js'; +import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js'; + +export function createStore({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + /** + * 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, { complete = true } = {}) { + const receivedAtMs = vesselState._aisRuntime.now(); + components.rendering.ensureCollections(viewer); + + // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them. + for (const record of state.unkeyedRecords) { + components.rendering.removeRecordPrimitives(record); + } + state.unkeyedRecords = []; + + const occluder = components.rendering.makeOccluder(); + const seen = new Set(); + for (let index = 0; index < rows.length; index += 1) { + const next = normalizeVessel(rows[index]); + if (!next) continue; + next.receivedAtMs = receivedAtMs; + + if (!next.mmsi) { + components.rendering.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 { + components.rendering.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 ( + !complete && + Number.isFinite(record.receivedAtMs) && + receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS + ) { + if (record === state.selectedRecord) { + record.missedRefreshes = Math.max(1, record.missedRefreshes || 0); + components.cards.updateSelectedVesselHud(record); + } + continue; + } + if (record === state.selectedRecord) { + record.missedRefreshes = (record.missedRefreshes || 0) + 1; + if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) { + components.cards.updateSelectedVesselHud(record); // re-render with STALE marker + continue; + } + // Aged out of the feed after exhausting its pin — not a deselect. + components.selection.clearVesselInspection({ evicted: true }); + } + components.rendering.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) + components.tracking.clearSelectedVesselTrail(); + } + + const cap = components.rendering.renderRowLimit(); + if (state.vesselMap.size + state.unkeyedRecords.length > cap) { + for (const [mmsi, record] of state.vesselMap) { + if (state.vesselMap.size + state.unkeyedRecords.length <= cap) break; + if (seen.has(mmsi) || record === state.selectedRecord) continue; + components.rendering.removeRecordPrimitives(record); + state.vesselMap.delete(mmsi); + if (state.trailMmsi === mmsi) + components.tracking.clearSelectedVesselTrail(); + } + } + state.vesselRecords = [ + ...state.vesselMap.values(), + ...state.unkeyedRecords, + ]; + state.lastVisibilityUpdate = 0; + components.rendering.updateVisibility(true); + } + + /** + * 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 = components.rendering.shipIcon(record, selected); + + record.reference = next.reference; + record.receivedAtMs = next.receivedAtMs; + 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 = + components.rendering.shipScale(record) * (selected ? 1.2 : 1); + const nextIcon = components.rendering.shipIcon(record, selected); + if (nextIcon !== prevIcon) { + record.billboard.image = nextIcon; + } + } + if (record.mmsi === state.trailMmsi) { + components.tracking.appendSelectedVesselTrailFix(record); + } + if (selected) { + components.cards.updateSelectedVesselHud(record); + components.selection.registerSelectedContext(record); + } + } + + 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 = components.queries.vesselDatumHeightM( + components.tracking.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.mmsi || 'VESSEL'), + mmsi: String(row.mmsi || '').trim(), + reference: row.reference ?? String(row.mmsi || '').trim(), + imo: String(row.imo || ''), + type: String(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; + } + return { + reconcileVessels, + updateRecordInPlace, + normalizeVessel, + finiteNumber, + }; +} diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js new file mode 100644 index 000000000..e0c8f37fe --- /dev/null +++ b/src/layers/vessels/testing.js @@ -0,0 +1,179 @@ +import { DEFAULT_AIS_RUNTIME } from './policy.js'; + +export function createTesting({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const aisLiveVesselsLayer = layer; + + /** + * 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} + */ + + function _bindVesselInteractionForTest(viewer, handler, keyTarget) { + components.selection.bindVesselInteraction(viewer, handler, keyTarget); + } + + /** + * Prime the minimum live state needed by interaction/lifecycle wire tests. + * @param {Object} [options={}] - Test state values. + * @returns {void} + */ + + function _setVesselStateForTest(options = {}) { + components.lifecycle.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. */ + + function _setVesselOverlayHostForTest(host = null) { + vesselState._vesselOverlayHost = + host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST; + } + + /** Exercise the production selector/publisher through a test-owned state. */ + + function _updateVesselCardsForTest(records = []) { + components.rendering.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} + */ + + function _reconcileVesselsForTest(viewer, rows) { + components.store.reconcileVessels(viewer, rows); + } + + /** Apply one server snapshot through the production pre-reconcile health gate. */ + + function _applyAisFeedSnapshotForTest(viewer, payload) { + return components.ingestion.applyAisFeedSnapshot(viewer, payload); + } + + /** Exercise the request-owned live loader with a test-controlled fetch. */ + + function _loadLivePositionsForTest(viewer) { + return components.ingestion.loadLivePositions(viewer); + } + + /** Start the production first-connect grace state without installing UI. */ + + function _beginAisSessionForTest() { + components.lifecycle.beginAisSession(); + } + + /** Inject a deterministic clock/scheduler; null restores production runtime. */ + + function _setAisRuntimeForTest(runtime = null) { + components.lifecycle.clearFirstConnectTimer(); + vesselState._aisRuntime = runtime + ? { + now: runtime.now, + setTimeout: runtime.setTimeout, + clearTimeout: runtime.clearTimeout, + } + : DEFAULT_AIS_RUNTIME; + } + + /** Read feed-health fields without exposing mutable production state. */ + + 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}} + */ + + function _getVesselStateForTest() { + return { + trailMmsi: state.trailMmsi, + trailPositionCount: state.trailPositions.length, + vesselCount: state.vesselMap.size, + }; + } + return { + _bindVesselInteractionForTest, + _setVesselStateForTest, + _setVesselOverlayHostForTest, + _updateVesselCardsForTest, + _reconcileVesselsForTest, + _applyAisFeedSnapshotForTest, + _loadLivePositionsForTest, + _beginAisSessionForTest, + _setAisRuntimeForTest, + _getVesselFeedStateForTest, + _getVesselStateForTest, + }; +} diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js new file mode 100644 index 000000000..52de1e0de --- /dev/null +++ b/src/layers/vessels/tracking.js @@ -0,0 +1,214 @@ +import * as Cesium from 'cesium'; +import { + TRAIL_HEIGHT_M, + VESSEL_LIFT_M, + TRAIL_COLOR, + TRAIL_MAX_POINTS, + TRAIL_MIN_MOVE_M, +} from './policy.js'; + +export function createTracking({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { geoidHeight } = services.geoid; + const { createTrail } = services.trails; + + /** + * 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 vesselState._geoidReady ? geoidHeight(lat, lon) : null; + } + + /** + * 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 = components.queries.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 = components.queries.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.trailAbort?.abort(); + state.trailAbort = new AbortController(); + 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, + record.reference, + ); + } + + /** + * 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, reference = mmsi) { + const owner = state.trailAbort; + const signal = owner + ? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)]) + : AbortSignal.timeout(8000); + let samples = null; + try { + const track = await vesselState._source.getTrack?.(reference, { signal }); + samples = track?.records ?? null; + } catch { + return; // silent — keep the live-accumulated trail + } + if ( + signal.aborted || + owner !== state.trailAbort || + !samples || + token !== state.trailBackfillToken + ) + return; + if (state.trailMmsi !== mmsi) return; + + const older = []; + for (const sample of samples) { + const lat = Number(sample?.latitude); + const lon = Number(sample?.longitude); + 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 = components.queries.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.trailAbort?.abort(); + state.trailAbort = null; + 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; + } + } + return { + currentGeoidN, + vesselTrailPosition, + refloorVesselRecords, + startSelectedVesselTrail, + backfillVesselTrail, + appendSelectedVesselTrailFix, + clearSelectedVesselTrail, + destroySelectedVesselTrail, + }; +} diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index a5540e1ce..85eb38606 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -16,21 +16,34 @@ function finiteCount(value) { /** Normalize one manager layer into a small loading-feedback record. */ export function normalizeLayerLoading(layer = {}) { const stats = layer.stats || {}; - const lifecycleState = String(layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled')); + const lifecycleState = String( + layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'), + ); const status = String(stats.status || '').toLowerCase(); const disabling = lifecycleState === 'disabling'; - const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true; + const loading = + lifecycleState === 'enabling' || + disabling || + stats.loading === true || + stats.refreshing === true; const count = finiteCount(stats.count); - const stoppingInstallations = layer.id === 'military-installations' && disabling; + const stoppingInstallations = + layer.id === 'military-installations' && disabling; // Guidance statuses ask the user to act (zoom in, run a search). They are // normal operation, never a batch failure — mirrors layerFeedState's carve-out // so a prompt stored alongside the status cannot turn the chip red. const guidance = GUIDANCE_STATUSES.includes(status); - const error = stoppingInstallations ? null - : (!guidance && stats.error) || stats.lastError || stats.managerRefreshError || null; - const unavailable = !stoppingInstallations && (stats.unavailable === true - || stats.available === false - || ['unavailable', 'offline', 'down', 'error'].includes(status)); + const error = stoppingInstallations + ? null + : (!guidance && stats.error) || + stats.lastError || + stats.managerRefreshError || + null; + const unavailable = + !stoppingInstallations && + (stats.unavailable === true || + stats.available === false || + ['unavailable', 'offline', 'down', 'error'].includes(status)); const keyRequired = stats.keyRequired === true || stats.missingKey === true; const degraded = stats.degraded === true || Boolean(error); const accepted = Boolean(stats.lastUpdate) || count > 0; @@ -39,7 +52,8 @@ export function normalizeLayerLoading(layer = {}) { label: String(layer.name || layer.id || 'Layer'), loading, disabling, - refresh: loading && layer.enabled && (stats.refreshing === true || accepted), + refresh: + loading && layer.enabled && (stats.refreshing === true || accepted), lifecycleState, count, accepted, @@ -47,18 +61,27 @@ export function normalizeLayerLoading(layer = {}) { unavailable, keyRequired, degraded, - installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling - ? { retryAt: Number(stats.retryAt) || 0, retrying: stats.retrying === true, - failureReason: stats.failureReason, loading, status: stats.status } - : null, + installationRetry: + layer.id === 'military-installations' && layer.enabled && !disabling + ? { + retryAt: Number(stats.retryAt) || 0, + retrying: stats.retrying === true, + failureReason: stats.failureReason, + loading, + status: stats.status, + } + : null, }; } function terminalFromParticipantStats(summary, participantIds) { if (!participantIds?.length) return null; const participants = new Set(participantIds); - return summary.records.some((record) => participants.has(record.id) - && (record.error || record.unavailable || record.keyRequired)) + return summary.records.some( + (record) => + participants.has(record.id) && + (record.error || record.unavailable || record.keyRequired), + ) ? 'error' : null; } @@ -67,13 +90,17 @@ function terminalFromParticipantStats(summary, participantIds) { export function aggregateLayerLoading(layers = []) { const records = layers.map(normalizeLayerLoading); const active = records.filter((record) => record.loading); - const disabling = active.length > 0 && active.every((record) => record.disabling); + const disabling = + active.length > 0 && active.every((record) => record.disabling); return { records, active, activeIds: active.map((record) => record.id), disabling, - refresh: !disabling && active.length > 0 && active.every((record) => record.refresh), + refresh: + !disabling && + active.length > 0 && + active.every((record) => record.refresh), }; } @@ -92,11 +119,11 @@ export function createLoadingFeedbackState() { } /** Create a top-center status notice, optionally persistent until explicitly cleared. */ -export function createGlobalStatusNotice(message, nowMs = 0, { - state = 'error', - detail = '', - persistent = false, -} = {}) { +export function createGlobalStatusNotice( + message, + nowMs = 0, + { state = 'error', detail = '', persistent = false } = {}, +) { const label = String(message || '').trim(); if (!label) return null; return { @@ -117,9 +144,11 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) { const now = Number.isFinite(nowMs) ? nowMs : 0; if (!notice?.label) return null; if (!notice.persistent && !Number.isFinite(notice.hideAt)) { - notice.hideAt = now + (Number.isFinite(notice.dwellMs) - ? notice.dwellMs - : LOADING_FAILURE_DWELL_MS); + notice.hideAt = + now + + (Number.isFinite(notice.dwellMs) + ? notice.dwellMs + : LOADING_FAILURE_DWELL_MS); } if (Number.isFinite(notice.hideAt) && now >= notice.hideAt) return null; return { @@ -130,10 +159,16 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) { } /** Whether deferred notice work still owns the current presentation epoch. */ -export function canPresentDeferredStatusNotice(expectedGeneration, currentGeneration, disposed = false) { - return !disposed - && Number.isSafeInteger(expectedGeneration) - && expectedGeneration === currentGeneration; +export function canPresentDeferredStatusNotice( + expectedGeneration, + currentGeneration, + disposed = false, +) { + return ( + !disposed && + Number.isSafeInteger(expectedGeneration) && + expectedGeneration === currentGeneration + ); } /** @@ -141,9 +176,19 @@ export function canPresentDeferredStatusNotice(expectedGeneration, currentGenera * hide a terminal manager failure. Failure dwell starts when the manager * reports it, so it must remain the highest-priority presentation while live. */ -export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) { - const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs); - if (['error', 'retry'].includes(loadingPresentation?.state)) return loadingPresentation; +export function presentGlobalLoadingStatus( + notice, + loadingState, + summary, + nowMs = 0, +) { + const loadingPresentation = presentLoadingFeedback( + loadingState, + summary, + nowMs, + ); + if (['error', 'retry'].includes(loadingPresentation?.state)) + return loadingPresentation; return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation; } @@ -162,11 +207,11 @@ export function createTrafficSyncFeedbackState() { * Reduce one sampled Street Traffic status without extending completion on * every animation-loop poll. Coverage describes accepted data, not work. */ -export function reduceTrafficSyncFeedback(previous, { - enabled = false, - stats = {}, - forceShow = false, -} = {}, nowMs = 0) { +export function reduceTrafficSyncFeedback( + previous, + { enabled = false, stats = {}, forceShow = false } = {}, + nowMs = 0, +) { const state = previous || createTrafficSyncFeedbackState(); const now = Number.isFinite(nowMs) ? nowMs : 0; if (!enabled) return createTrafficSyncFeedbackState(); @@ -174,10 +219,13 @@ export function reduceTrafficSyncFeedback(previous, { const hasProgress = Number.isFinite(stats.phaseProgressPct); const progressPct = hasProgress ? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct))) - : (stats.loading ? 1 : 100); - const busy = stats.loading === true - || stats.worldJumping === true - || (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0)); + : stats.loading + ? 1 + : 100; + const busy = + stats.loading === true || + stats.worldJumping === true || + (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0)); const label = String(stats.phaseLabel || stats.loadingLabel || '').trim(); if (busy) { @@ -193,13 +241,13 @@ export function reduceTrafficSyncFeedback(previous, { }; } - const existingConfirmation = state.confirmationUntil > now - ? state.confirmationUntil - : 0; - const confirmationUntil = existingConfirmation || (state.busy || forceShow - ? now + TRAFFIC_SYNC_CONFIRM_MS - : 0); - const visible = confirmationUntil > now && progressPct >= 100 && Boolean(label); + const existingConfirmation = + state.confirmationUntil > now ? state.confirmationUntil : 0; + const confirmationUntil = + existingConfirmation || + (state.busy || forceShow ? now + TRAFFIC_SYNC_CONFIRM_MS : 0); + const visible = + confirmationUntil > now && progressPct >= 100 && Boolean(label); return { busy: false, visible, @@ -217,7 +265,8 @@ export function reduceTrafficSyncFeedback(previous, { function terminalFromEvent(event) { const type = String(event?.type || ''); - if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error) return 'error'; + if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error) + return 'error'; if (type === 'visibility-cancelled' || event?.cancelled) return 'cancelled'; if (type === 'visibility' || type === 'refresh') return 'complete'; return null; @@ -238,7 +287,9 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { const beginning = state.phase !== 'loading'; const startedAt = beginning ? now : state.startedAt; const priorParticipants = beginning ? [] : state.activeIds; - const activeIds = [...new Set([...priorParticipants, ...summary.activeIds])]; + const activeIds = [ + ...new Set([...priorParticipants, ...summary.activeIds]), + ]; const eventLayerId = String(event?.layerId || ''); const eventParticipates = eventLayerId && activeIds.includes(eventLayerId); const batchOutcome = mergeTerminalOutcome( @@ -257,27 +308,41 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { activeIds, batchOutcome, terminal: null, - operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', - failedEventIds: [...new Set([ - ...(beginning ? [] : state.failedEventIds || []), - ...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), - ])], + operation: summary.disabling + ? 'disabling' + : summary.refresh + ? 'refresh' + : 'loading', + failedEventIds: [ + ...new Set([ + ...(beginning ? [] : state.failedEventIds || []), + ...(eventParticipates && terminalFromEvent(event) === 'error' + ? [eventLayerId] + : []), + ]), + ], }; } if (state.phase === 'loading') { const eventLayerId = String(event?.layerId || ''); - const eventParticipates = eventLayerId && state.activeIds.includes(eventLayerId); - const terminal = mergeTerminalOutcome( + const eventParticipates = + eventLayerId && state.activeIds.includes(eventLayerId); + const terminal = mergeTerminalOutcome( - state.batchOutcome, - terminalFromParticipantStats(summary, state.activeIds), - ), - eventParticipates ? terminalFromEvent(event) : null, - ) || 'complete'; + mergeTerminalOutcome( + state.batchOutcome, + terminalFromParticipantStats(summary, state.activeIds), + ), + eventParticipates ? terminalFromEvent(event) : null, + ) || 'complete'; const wasVisible = state.visible || now >= state.showAt; - if (!wasVisible && terminal === 'complete') return createLoadingFeedbackState(); - const dwell = terminal === 'error' ? LOADING_FAILURE_DWELL_MS : LOADING_TERMINAL_DWELL_MS; + if (!wasVisible && terminal === 'complete') + return createLoadingFeedbackState(); + const dwell = + terminal === 'error' + ? LOADING_FAILURE_DWELL_MS + : LOADING_TERMINAL_DWELL_MS; return { ...state, phase: 'terminal', @@ -285,10 +350,14 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { hideAt: now + dwell, batchOutcome: terminal, terminal, - failedEventIds: [...new Set([ - ...(state.failedEventIds || []), - ...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), - ])], + failedEventIds: [ + ...new Set([ + ...(state.failedEventIds || []), + ...(eventParticipates && terminalFromEvent(event) === 'error' + ? [eventLayerId] + : []), + ]), + ], }; } @@ -298,11 +367,17 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { /** Build the user-facing status copy for the current loading state. */ export function presentLoadingFeedback(state, summary, nowMs) { - const site = summary.records.find(record => record.installationRetry?.retryAt > 0); - const otherFailure = summary.records.some(record => record.id !== 'military-installations' - && (state?.activeIds || []).includes(record.id) - && (record.error || record.unavailable || record.keyRequired)) - || (state?.failedEventIds || []).some(id => id !== 'military-installations'); + const site = summary.records.find( + (record) => record.installationRetry?.retryAt > 0, + ); + const otherFailure = + summary.records.some( + (record) => + record.id !== 'military-installations' && + (state?.activeIds || []).includes(record.id) && + (record.error || record.unavailable || record.keyRequired), + ) || + (state?.failedEventIds || []).some((id) => id !== 'military-installations'); // Keep the actual retry visible between attempts, without hiding another // participant's failure or pretending that a scheduled retry is fetching. if (site && !summary.active.length && !otherFailure) { @@ -312,28 +387,55 @@ export function presentLoadingFeedback(state, summary, nowMs) { } if (!state?.visible) return null; if (state.phase === 'terminal') { - const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' }; - const label = state.operation === 'disabling' && state.terminal === 'complete' - ? 'LIVE DATA OFF' - : state.terminal === 'complete' && state.activeIds?.length === 1 && state.activeIds[0] === 'military-installations' - ? 'MAPPED SITES LOADED' : labels[state.terminal] || 'LOAD COMPLETE'; + const labels = { + complete: 'LOAD COMPLETE', + cancelled: 'LOAD CANCELLED', + error: 'LOAD FAILED', + }; + const label = + state.operation === 'disabling' && state.terminal === 'complete' + ? 'LIVE DATA OFF' + : state.terminal === 'complete' && + state.activeIds?.length === 1 && + state.activeIds[0] === 'military-installations' + ? 'MAPPED SITES LOADED' + : labels[state.terminal] || 'LOAD COMPLETE'; return { state: state.terminal, label, detail: '' }; } const active = summary.active; - if (active.length === 1 && active[0].installationRetry && !summary.disabling) { - return { state: 'loading', label: active[0].installationRetry.retrying - ? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', detail: 'OpenStreetMap · Overpass' }; + if ( + active.length === 1 && + active[0].installationRetry && + !summary.disabling + ) { + return { + state: 'loading', + label: active[0].installationRetry.retrying + ? 'RETRYING MAPPED SITES' + : 'FETCHING MAPPED SITES', + detail: 'OpenStreetMap · Overpass', + }; } const elapsed = Math.max(0, nowMs - state.startedAt); const label = summary.disabling ? 'TURNING OFF LIVE DATA' - : summary.refresh ? 'REFRESHING LIVE DATA' : 'LOADING LIVE DATA'; - const names = active.slice(0, 2).map((record) => record.label).join(' · '); + : summary.refresh + ? 'REFRESHING LIVE DATA' + : 'LOADING LIVE DATA'; + const names = active + .slice(0, 2) + .map((record) => record.label) + .join(' · '); const suffix = active.length > 2 ? ` +${active.length - 2}` : ''; return { - state: elapsed >= LOADING_LONG_THRESHOLD_MS - ? 'long' - : summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', + state: + elapsed >= LOADING_LONG_THRESHOLD_MS + ? 'long' + : summary.disabling + ? 'disabling' + : summary.refresh + ? 'refresh' + : 'loading', label, detail: `${names}${suffix}`, }; diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 7537efb06..78e6b6bbd 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -89,15 +90,15 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {'); const end = ui.indexOf('\n _initGlobalContextPanel() {', start); const handler = ui.slice(start, end); assert.match(handler, /this\._showGlobalStatusNotice\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /requestAnimationFrame\(\(\) => \{/); - assert.match(handler, /startupCover\.addEventListener\('transitionend', showOnce, \{ once: true \}\)/); - assert.match(handler, /fallbackTimer = setTimeout\(showOnce, 1000\)/); + assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); assert.doesNotMatch(handler, /this\._showToast\(message\)/); assert.doesNotMatch(handler, /pushCockpitSignal/); assert.match(handler, /result\.classification === 'pending'/); @@ -106,7 +107,7 @@ test('share-follow failures use the universal top-center status instead of the b assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed' \|\| result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); }); test('universal notice masks active loading only for its own fixed dwell', () => { @@ -195,13 +196,13 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); - assert.match(dispose, /this\._globalStatusNotice = null;/); + assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/); assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/); assert.match(html, //); }); @@ -235,14 +236,14 @@ test('reveals sustained loading and then a bounded completion state', () => { }); test('terminal loading feedback centers its label without an empty detail slot', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, - /#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, ); assert.match( css, - /#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\) #global-loading-detail\s*\{\s*display:\s*none;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/, ); }); @@ -531,8 +532,8 @@ test('the settled traffic chip shows exactly one percentage — the coverage it }); test('the chip renderer clears the progress slot instead of stranding the last value', () => { - 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/shellFeedback.js', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const start = ui.indexOf(' _updateTrafficSyncChip('); assert.ok(start > 0, '_updateTrafficSyncChip is missing'); const body = ui.slice(start, ui.indexOf('\n }', start)); @@ -620,7 +621,7 @@ test('aggregates Mapped Installations refresh beside CCTV without changing eithe // stack layout contract — the ticker's lifecycle is pinned against ui.js // source. (perf rebase 2026-08-17) test('the loading ticker never runs hidden and stops after loading and notices settle', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'); // Scope every assertion to _armLoadingFeedbackTicker's own body. The // neighbouring _startTrafficChipTicker is a deliberately PERMANENT 500ms // safety-net poll, so its `if (document.hidden) return;` is correct there @@ -655,7 +656,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // Persistent ACQUIRING notices remain visible without a 60ms timer. assert.match( arm, - /const noticeNeedsTicker = Number\.isFinite\(this\._globalStatusNotice\?\.hideAt\);[\s\S]*?if \(this\._loadingFeedbackState\?\.phase === 'idle' && !noticeNeedsTicker\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/, + /const noticeNeedsTicker = Number\.isFinite\(\s*this\._globalStatusNotice\?\.hideAt,?\s*\);[\s\S]*?if \(\s*this\._loadingFeedbackState\?\.phase === 'idle'\s*&&\s*!noticeNeedsTicker\s*\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/, 'an idle phase with no expiring notice must stop the ticker', ); assert.match( @@ -667,21 +668,21 @@ test('the loading ticker never runs hidden and stops after loading and notices s // 4. Returning to visible resamples, so a batch that finished while hidden // is reconciled and a still-running one re-arms its ticker. The handler // must live on the class that OWNS _updateGlobalLoadingFeedback - // (StyleManager) — wiring it into a neighbouring controller instead + // (ShellFeedback) — wiring it into a neighbouring controller instead // throws on every visibilitychange — and must be torn down with the rest. assert.match( ui, - /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/, + /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*else this\._stopLoadingFeedbackTicker\(\);\s*\};\s*document\.addEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/, 'visibilitychange must resample the chip on return', ); - const styleManager = ui.slice(ui.indexOf('export class StyleManager')); + const styleManager = ui.slice(ui.indexOf('export class ShellFeedback')); assert.ok( styleManager.includes('this._loadingVisibilityHandler'), - 'the resample handler must be owned by StyleManager, which defines _updateGlobalLoadingFeedback', + 'the resample handler must be owned by ShellFeedback, which defines _updateGlobalLoadingFeedback', ); assert.match( ui, - /document\.removeEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/, + /document\.removeEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/, 'the resample handler must be removed on teardown', ); }); diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 8bcbf6955..7711de20b 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,3 +1,4 @@ +import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,12 +6,12 @@ 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 ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); /** Source of the free-text LOCATION search handler (Enter on #location-search). */ function locationSearchHandler() { - const start = ui.indexOf('searchAndFlyTo(this.viewer, query, {'); + const start = ui.indexOf(' _handleLocationSearchState(state, change) {'); assert.ok(start > 0, 'free-text location search handler is missing'); const end = ui.indexOf('_beginWorldJumpTransition() {', start); assert.ok(end > start, 'could not bound the location search handler'); @@ -24,7 +25,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else assert.equal(writes.length, 1, 'the style indicator must have exactly one writer'); assert.match( ui.slice(writes[0].index, writes[0].index + 160), - /this\._styleIndicator\.textContent = displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/, + /this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/, ); const handler = locationSearchHandler(); @@ -38,17 +39,19 @@ test('a free-text search records its destination for the LOCATION mini-status', // own refresh would otherwise repaint the readout as "Location: --". assert.match( handler, - /this\._searchedLocationLabel = destination\.label[\s\S]{0,120}?this\._setActiveLocation\(null\);/, + /this\._searchedLocationLabel = state\.destination\.label[\s\S]{0,120}?this\._setActiveLocation\(null\);/, ); assert.match(handler, /this\._updateLocationMiniStatus\(\);/); }); test('the mini-status reads its copy from the shared formatter', () => { - assert.match(ui, /import \{ locationMiniStatus \} from '\.\/locationStatus\.js';/); + const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); + assert.match(controls, /import \{ locationMiniStatus \} from '\.\.\/locationStatus\.js';/); + assert.match(controls, /const lines = locationMiniStatus\(state\)/); const start = ui.indexOf(' _updateLocationMiniStatus() {'); assert.ok(start > 0, '_updateLocationMiniStatus is missing'); const body = ui.slice(start, ui.indexOf('\n }', start)); - assert.match(body, /locationMiniStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/); + assert.match(body, /_locationControls\?\.renderStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/); // No second copy of the placeholder strings to drift out of sync. assert.doesNotMatch(body, /Location: --/); }); @@ -64,15 +67,15 @@ test('any other camera destination clears the search label too', () => { // Voice navigation, the globe reset, camera takeover and entity selection // all funnel through _stampNavigation; without a clear there, a searched // label outlives the place it named. - const start = ui.indexOf(' _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {'); + const start = ui.indexOf(' _stampNavigation('); assert.ok(start > 0, '_stampNavigation is missing'); - assert.match(ui.slice(start, start + 700), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/); + assert.match(StyleManager.prototype._stampNavigation.toString(), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/); // The shared funnel is what the reset and voice seams actually reach. - for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(noun, navigate']) { + for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) { const at = ui.indexOf(seam); assert.ok(at > 0, `missing navigation seam "${seam}"`); - assert.match(ui.slice(at, at + 900), /_stampNavigation\(/, `"${seam}" must stamp navigation`); + assert.match(StyleManager.prototype[seam.match(/^(\w+)/)[1]].toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`); } // Public seam, so a camera owner that flies on its own can invalidate it. @@ -84,11 +87,11 @@ test('a deferred lookup that never flies leaves the readout standing', () => { // the stamp blanked a still-true readout whenever the lookup failed, was // superseded, or was refused — no camera ever moved. The deferred begin opts // out; the reassert seam, reached only once the flight is granted, clears. - const begin = ui.indexOf(' _beginDeferredNavigation(noun = '); + const begin = ui.indexOf(' _beginDeferredNavigation('); assert.ok(begin > 0, '_beginDeferredNavigation is missing'); assert.match( - ui.slice(begin, begin + 700), - /stamp: \(\) => this\._stampNavigation\(\{ cancelPendingSelection, clearSearchedLocation: false \}\)/, + StyleManager.prototype._beginDeferredNavigation.toString(), + /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); diff --git a/src/locationStatus.js b/src/locationStatus.js index 2dcc80fcc..1d90582c9 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -55,7 +55,10 @@ export function locationMiniStatus({ // The remaining address is the place's context ("Japan", "Minato City, // Tokyo, Japan"); the readout is ellipsised in CSS, so a long tail is // safe. A one-segment geocode ("Japan") has no context to show. - poi: segments.length > 1 ? segments.slice(1).join(', ') : 'Searched location', + poi: + segments.length > 1 + ? segments.slice(1).join(', ') + : 'Searched location', }; } diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 985bd5658..f4a033442 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,27 +1,17 @@ +import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; // Exercise the installed event routes and central close method, without WebGL. -const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const locationMarkup = markup.slice(markup.indexOf(']*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); const locationToggleAttributes = Object.fromEntries( [...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]), ); -const initStart = source.indexOf(' _initAutoHoverPanel('); -const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart); -const escapeStart = source.indexOf(' _collapsePanelOnEscape('); -const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart); -const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {'); -const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart); -const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {'); -const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart); -assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart - && closeStart >= 0 && closeEnd > closeStart); -assert.ok(syncStart >= 0 && syncEnd > syncStart); function harness({ hidden = false, selected = true, noChips = false } = {}) { let now = 0; @@ -116,13 +106,15 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) { window.performance = { now: () => now }; document.defaultView = window; const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', - `return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)( + `return ({${StyleManager.prototype._initAutoHoverPanel.toString()},\n${StyleManager.prototype._collapsePanelOnEscape.toString()},\n${StyleManager.prototype.setPanelCollapsed.toString()},\n${StyleManager.prototype._syncPanelCollapseButton.toString()}});`)( createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, ); const saves = []; const claims = []; let shareSyncs = 0; const manager = { + _lifetime: { frame() {} }, + _panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null }, ...methods, _savePanelCollapsedState(...args) { saves.push(args); }, _scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {}, diff --git a/src/mapStackChips.js b/src/mapStackChips.js index c87e319b3..ac523700d 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -1,8 +1,8 @@ // MAP STACK source chips — the always-visible replacement for the `` // that used to sit in the Map Stack panel. One button per stack, rendered from -// `MapStackController.getStacks()`. The four owner-approved sources below are +// `MapStackController.getStacks()`. The approved sources below are // the whole shipped set; keeping the allowlist explicit means a stack added to -// `MAP_STACKS` for internal use cannot reach the tray until someone names it +// `MAP_STACKS` for another purpose cannot reach the tray until someone names it // here. // // The chips are a control SURFACE only: selecting one calls back into the same @@ -40,7 +40,9 @@ export function mapStackChipModel(stack, activeId) { const fallbackReason = requiresIon ? keySetupRequirement('cesium-ion') : `${label || 'This map stack'} is unavailable`; - const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason); + const unavailableHint = available + ? '' + : String(stack?.unavailableReason || fallbackReason); return { id: String(stack?.id ?? ''), label, @@ -60,13 +62,13 @@ export function mapStackChipModel(stack, activeId) { * @param {Array} stacks - `MapStackController.getStacks()` output. * @param {string|null} activeId - Currently active stack id. * @returns {Array} One chip model per approved presentation id, in - * `PRESENTED_MAP_STACK_IDS` order; internal and future stacks stay hidden. + * `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation. */ export function mapStackChipModels(stacks, activeId) { - const stacksById = new Map((Array.isArray(stacks) ? stacks : []) - .map((stack) => [stack?.id, stack])); - return PRESENTED_MAP_STACK_IDS - .map((id) => stacksById.get(id)) + const stacksById = new Map( + (Array.isArray(stacks) ? stacks : []).map((stack) => [stack?.id, stack]), + ); + return PRESENTED_MAP_STACK_IDS.map((id) => stacksById.get(id)) .filter(Boolean) .map((stack) => mapStackChipModel(stack, activeId)); } @@ -79,9 +81,20 @@ export function mapStackChipModels(stacks, activeId) { * @param {string|null} [options.activeId] - Currently active stack id. * @param {(stackId: string) => void} [options.onSelect] - Selection callback. * @param {Document} [options.doc] - Document override (tests). + * @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override. * @returns {Array} The rendered chip models. */ -export function renderMapStackChips(container, stacks, { activeId = null, onSelect = null, doc } = {}) { +export function renderMapStackChips( + container, + stacks, + { + activeId = null, + onSelect = null, + doc, + bind = (element, type, listener) => + element.addEventListener(type, listener), + } = {}, +) { if (!container) return []; const ownerDoc = doc || container.ownerDocument || globalThis.document; if (!ownerDoc?.createElement) return []; @@ -96,13 +109,18 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele MAP_STACK_CHIP_CLASS, model.active ? 'active' : '', model.available ? '' : 'unavailable', - ].filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); chip.dataset.stackId = model.id; chip.title = model.title; chip.setAttribute('aria-pressed', String(model.active)); chip.setAttribute('aria-disabled', String(!model.available)); if (!model.available) { - chip.setAttribute('aria-label', `${model.label} unavailable: ${model.unavailableHint}`); + chip.setAttribute( + 'aria-label', + `${model.label} unavailable: ${model.unavailableHint}`, + ); } const label = ownerDoc.createElement('span'); @@ -117,7 +135,7 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele chip.appendChild(requirement); } - chip.addEventListener('click', () => { + bind(chip, 'click', () => { if (!model.available) return; onSelect?.(model.id); }); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 9cf2f6966..7106cd2e7 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; // MAP STACK chip row — the dropdown's replacement control surface. // // The owner's complaint was two clicks (open panel → open dropdown) to change @@ -262,7 +263,7 @@ test('a missing row or document is inert rather than throwing during boot', () = }); test('the active cyan survives hover', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const hover = css.indexOf('.map-stack-chip:hover'); const active = css.indexOf('.map-stack-chip.active {'); const unavailable = css.indexOf('.map-stack-chip.unavailable'); @@ -282,7 +283,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // `outline: none` — so a focus state built only from those properties is // INVISIBLE on the active chip. The ring must live on a property no other // chip-state rule sets. - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ''); const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)] .map(([, selector, body], order) => ({ @@ -329,7 +330,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row'); assert.match( @@ -345,28 +346,24 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse ); const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8'); assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/); - assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/); + assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/); + + const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8'); + assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/, + 'the application action uses the component selection path'); + assert.match(controls, /onSelect: [\s\S]*?select\(id\)/, + 'chip clicks use the same component selection path'); + assert.match(controls, /await controller\.setStack\(stackId\)/, + 'selection still delegates source loading to the map controller'); + assert.match(controls, /render\(controller\.getState\(\)\)/, + 'the active chip is re-synced from actual controller state'); + assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/, + 'provider-driven fallback reaches the component without a user click'); + assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/, + 'the provider-driven subscription has an explicit remover'); + assert.match(controls, /unsubscribe\?\.\(\)/, + 'component destruction releases its subscription'); - assert.match( - ui, - /renderMapStackChips\(this\._mapStackChips, this\.mapStackController\.getStacks\(\), \{[\s\S]*?onSelect: \(stackId\) => \{ this\._setMapStack\(stackId\); \}/, - 'chips must dispatch through the same _setMapStack path the dropdown used', - ); - assert.match( - ui, - /_renderMapStackState\(state\) \{[\s\S]*?syncMapStackChips\(this\._mapStackChips, state\.activeId\)/, - 'the active chip must be re-synced from controller state', - ); - assert.match( - ui, - /window\.addEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/, - 'provider-driven fallback must re-sync the UI without a user click', - ); - assert.match( - ui, - /window\.removeEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/, - 'the provider-driven state listener must be released with StyleManager', - ); }); test('Esri fallbacks report and attribute the imagery source actually rendered', () => { diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index e24f4bda3..dac9abdcb 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -467,7 +468,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM 'the surface is runtime host-owned, not static markup', ); assert.match( - readFileSync(new URL('../../style.css', import.meta.url), 'utf8'), + readStylesheet(new URL('../../style.css', import.meta.url)), /#world-overlay-detection-surface,\n#world-overlay-canvas \{\n position: absolute;\n inset: 0;[\s\S]*?pointer-events: none;/, 'both host surfaces share absolute positioning and pointer passthrough', ); @@ -484,7 +485,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM }); /** The shipped stylesheet, read once — fixtures must stack as production does. */ -const SHIPPED_CSS = readFileSync(new URL('../../style.css', import.meta.url), 'utf8'); +const SHIPPED_CSS = readStylesheet(new URL('../../style.css', import.meta.url)); /** * Position + z-index the SHIPPED stylesheet gives a selector. Fixtures are built @@ -551,7 +552,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' // builds and evaluates each node against the shipped stylesheet. const env = installMockEnvironment(); initWorldOverlay(env.viewer); - const css = readFileSync(new URL('../../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../../style.css', import.meta.url)); const surface = env.document.getElementById('world-overlay-detection-surface'); assert.ok(surface, 'the host owns a detection surface'); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 3c7245688..c63e084f7 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -1,9 +1,14 @@ +import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.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 { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); function method(name, nextName) { const start = source.indexOf(` ${name}(`); @@ -135,7 +140,7 @@ test('panel chrome wires Escape for every declared collapse target', () => { }); test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => { - const onKeyDown = method('onKeyDown', 'enter'); + const onKeyDown = cockpitKeyDown.toString(); assert.match( onKeyDown, /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/, @@ -155,16 +160,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => { assert.match( - source, - /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, + radioBindings, + /const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, ); assert.match( - source, + radioBindings, /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/, 'Cockpit utility disclosures clear their own focus when Escape closes them', ); assert.match( - source, + radioBindings, /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, 'compact Radio disclosure clears its own focus when Escape closes it', ); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index f6e2b2644..465f3cf4a 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -135,11 +136,11 @@ test('minimum panel corridor expands upward without crossing the lower obstacle }); test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => { - 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)); assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/); assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/); - assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); + assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); assert.match(ui, /reconsiderAutoCollapse/); assert.match( ui, @@ -150,13 +151,13 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/, ); assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/); - assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); + assert.match(ui, /this\._panelLayout\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); assert.match( leftRail, /preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened left panel must receive primary allocation', ); - assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); + assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( rightRail, /panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, @@ -177,8 +178,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c /panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/, 'right intrinsic measurement must retain Display allocation while clearing other panel allocations', ); - assert.match(css, /var\(--left-panel-allocated-height/); - assert.match(css, /var\(--right-panel-allocated-height/); + assert.match(css, /var\(\s*--left-panel-allocated-height/); + assert.match(css, /var\(\s*--right-panel-allocated-height/); assert.match( css, /#left-panel-stack\.layout-focus > \[data-panel-id\]\.collapsed\s*\{\s*display:\s*none;/, @@ -189,7 +190,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); assert.match( @@ -212,11 +213,11 @@ test('share-panel state excludes responsive collapse and preserves recipient pre }); test('parameterized Display presets keep one stable scroll owner', () => { - 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)); assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); - assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); + assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); assert.match( ui, @@ -234,7 +235,7 @@ test('parameterized Display presets keep one stable scroll owner', () => { }); test('expanded Display uses its container shell instead of a nested header card', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, @@ -242,13 +243,13 @@ test('expanded Display uses its container shell instead of a nested header card' ); assert.match( css, - /#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(--right-collapsed-width, 132px\);/, + /#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(\s*--right-collapsed-width, 132px\);/, 'collapsed Display must retain its standalone launcher sizing', ); }); test('expanded left panels integrate their headers with the container shell', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, @@ -256,13 +257,13 @@ test('expanded left panels integrate their headers with the container shell', () ); assert.match( css, - /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-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, 0\.22\);/, + /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-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\);/, ); }); test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.doesNotMatch(html, /id="stack-panel"/); assert.match(html, /id="control-panel"[\s\S]*?class="map-source-section"[\s\S]*?id="map-stack-chips"/); @@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', () test('expanded right panels highlight the title divider without changing collapsed launchers', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( html, @@ -283,10 +284,10 @@ test('expanded right panels highlight the title divider without changing collaps ); assert.match( css, - /#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-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, 0\.22\);/, + /#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-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( css, - /#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\);/, + /#param-slider-panel:not\(\.collapsed\) \.param-panel-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*\);/, ); }); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index d360ec2f1..eb21a0a5d 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,15 +1,23 @@ +import { readLayerSource } from './testSupport/readLayerSource.mjs'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; +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 assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import test from 'node:test'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); -const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); -const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8'); -const rocketLaunches = readFileSync(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); +const radio = ['playback', 'interaction'].map(name => + readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8') +).join('\n').replace(/layerState\.|parts\.\w+\./g, ''); +const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8'); const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n'); -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); /** Parse the Realtime tool array out of the Vite config as real data. */ function realtimeTools() { @@ -202,7 +210,7 @@ test('Radio volume and mission speed share the Sharpen slider visual language', assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/); assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/); assert.match(css, /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/); - assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: \.42;[\s\S]*?cursor: not-allowed;/); + assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/); assert.match(css, /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/); assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/); }); @@ -240,28 +248,28 @@ test('Radio is nested inside Context with separate disclosure and power controls assert.match(css, /\.radio-tuner\.is-static/); assert.match(css, /\.radio-tuner-tick\s*\{/); assert.doesNotMatch(css, /radio-tuner-scale-(?:left|right)/); - assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left \.18s ease-out;/); + assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left 0\.18s ease-out;/); assert.match(css, /\.radio-tuner\.is-dragging \.radio-tuner-needle,[\s\S]*?\.radio-tuner\.is-dragging \.radio-tuner-tick\s*\{\s*transition: none;/); assert.match(css, /\.radio-tuner\s*\{[\s\S]*?max-width: 100%;[\s\S]*?overflow: hidden;/); assert.match(css, /#radio-tuner-slider\s*\{[\s\S]*?max-width: 100%;[\s\S]*?touch-action: none;/); assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::before/); assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::after/); - assert.match(css, /--radio-broadcast-opacity: \.17/); + assert.match(css, /--radio-broadcast-opacity: 0\.17/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/); assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/); - assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); - const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); - const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart)); + assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); + const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); + const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart)); assert.doesNotMatch(cycleMethod, /refreshTunerBand/); - assert.match(ui, /_radioTunerBandPinnedForNavigation = true/); - assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/); - assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); - assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); - assert.match(ui, /radioLayer\.cancelTuning\(\)/); - assert.match(ui, /classList\.remove\('radio-broadcasting'\)/); - assert.match(ui, /radioLayer\.getTunerStations\(750\)/); - assert.match(ui, /radioTunerPointerPosition\(/); + assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/); + assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/); + assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); + assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/); + assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/); + assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); + assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); + assert.match(radioBindings, /radioTunerPointerPosition\(/); assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/); assert.match(css, /#global-context-panel #radio-panel\.collapsed/); assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/); @@ -277,16 +285,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb assert.match(voice, /'radio'/); assert.match(voice, /name:\s*'control_radio'/); assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']); - const enableStart = ui.lastIndexOf('\n _initRadioPanel()'); - const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart)); + const enableMethod = radioBindings; assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i); assert.match(enableMethod, /contextRadioToggleBtn/); assert.match(enableMethod, /contextRadioMiniEnableBtn/); assert.match(enableMethod, /contextRadioDetailsBtn/); assert.match(enableMethod, /contextRadioMiniCloseBtn/); assert.match(enableMethod, /contextRadioMiniPlayBtn/); - const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener'); - const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart); + const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,'); + const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart); const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd); assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing'); assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/); @@ -299,37 +306,34 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb ); assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/); assert.match(ui, /setAttribute\('aria-expanded', String\(/); - assert.match(ui, /\.hidden = !/); + assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/); assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/); }); test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => { - const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext'); - const revealEnd = ui.indexOf('\n /**', revealStart + 10); - const revealMethod = ui.slice(revealStart, revealEnd); + const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext'); + const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10); + const revealMethod = radioControlsSource.slice(revealStart, revealEnd); assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing'); assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/); - assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/); + assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/); assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/); assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/); - const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()'); - const syncEnd = ui.indexOf('\n /**', syncStart + 10); - const syncMethod = ui.slice(syncStart, syncEnd); + const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()'); + const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10); + const syncMethod = radioControlsSource.slice(syncStart, syncEnd); assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing'); - assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/); - assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/); - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); + assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/); + assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/); + const renderMethod = radioPresentation; assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/); assert.doesNotMatch(renderMethod, /compact Radio controls/); assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/); }); test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => { - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); - assert.ok(renderStart >= 0, 'Radio render method is missing'); + const renderMethod = radioPresentation; assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s); assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s); assert.match(ui, /contextRadioDetailsBtn/); @@ -339,15 +343,15 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb }); test('successful explicit user playback hands the speaker from voice to Radio', () => { - const playStart = radio.indexOf('export async function playSelectedRadio'); - const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10)); + const playStart = radio.indexOf('async function playSelectedRadio'); + const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' '); const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'"); const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)"); assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying); - assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/); - assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/); - assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); - assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); + assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/); + assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/); + assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); + assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/); }); diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index b007128c8..fa745c1ca 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -41,7 +41,9 @@ import { } from './scopeMask.js'; import { ShareLinkManager } from './sharelink.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'); const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); @@ -210,7 +212,7 @@ test('first run opens with detection on, in every style, using the one tactical // Normal used to start OFF while only CRT/NVG/FLIR auto-applied the preset. // It is now the baseline for all of them, reusing the SAME frozen object, so // "the tactical look" cannot fork into two definitions. - assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical look is still Dense @ 75%'); const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};'); assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/, diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 17d07f04c..2254ea24e 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -33,7 +34,7 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => { test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match( ui, diff --git a/src/scenes/director.js b/src/scenes/director.js index 4109c307f..d56a82e41 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -12,6 +12,8 @@ * State is persisted to localStorage and can be exported/imported as JSON. */ +import { createStateChannel } from '../app/stateChannel.js'; +import { SceneControls } from '../ui/scenes.js'; import * as Cesium from 'cesium'; import { SCENE_RECIPES } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; @@ -21,8 +23,6 @@ import { decodeBloomIntensity, } from '../bloom.js'; -/** @constant {string} Key code used to abort a running scene */ -const ESCAPE_KEY = 'Escape'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; /** @constant {number} Current schema version for project migration */ @@ -32,15 +32,6 @@ const DEFAULT_SHOT_DURATION_SEC = 4; /** @constant {number} Default hold/pause after a shot completes (seconds) */ const DEFAULT_HOLD_SEC = 0.9; -/** - * Clamp a numeric value to the [0, 1] range. - * @param {number} value - * @returns {number} - */ -function clamp01(value) { - return Math.max(0, Math.min(1, value)); -} - /** * Generate a short random identifier with the given prefix. * @param {string} prefix - e.g. 'shot', 'scene' @@ -298,7 +289,6 @@ export class SceneDirector { constructor(viewer, styleManager, dataManager) { this._destroyed = false; this._pendingWork = new Set(); - this._uiRemovers = []; this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; @@ -321,39 +311,16 @@ export class SceneDirector { this._lastRun = null; /** @type {string} JSON string of _lastRun for download */ this._lastRunJson = ''; - this._onKeyDown = this._onKeyDown.bind(this); this._project = this._loadProject(); this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; - // Cache DOM element references for the scene panel UI - this._scenePanel = document.getElementById('scene-panel'); - this._sceneSelect = document.getElementById('scene-select'); - this._sceneNewBtn = document.getElementById('scene-new-btn'); - this._sceneDeleteBtn = document.getElementById('scene-delete-btn'); - this._sceneCaptureBtn = document.getElementById('scene-capture-btn'); - this._sceneUpdateShotBtn = document.getElementById('scene-update-shot-btn'); - this._sceneShotList = document.getElementById('scene-shot-list'); - this._sceneStartBtn = document.getElementById('scene-start-btn'); - this._sceneStopBtn = document.getElementById('scene-stop-btn'); - this._sceneNextBtn = document.getElementById('scene-next-btn'); - this._sceneExportBtn = document.getElementById('scene-export-btn'); - this._sceneImportBtn = document.getElementById('scene-import-btn'); - this._sceneImportFile = document.getElementById('scene-import-file'); - this._sceneDownloadBtn = document.getElementById('scene-download-btn'); - this._sceneStatus = document.getElementById('scene-status'); - this._sceneProgressFill = document.getElementById('scene-progress-fill'); - this._sceneRuntime = document.getElementById('scene-runtime'); - + this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false }; + this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson })); this._initUI(); } - _listen(target, type, listener) { - if (!target) return; - target.addEventListener(type, listener); - this._uiRemovers.push(() => target.removeEventListener(type, listener)); - } _trackWork(promise) { this._pendingWork ||= new Set(); @@ -367,18 +334,16 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; + this._controls?.destroy(); + this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { this.stopScene('Stopped'); this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); - for (const remove of this._uiRemovers || []) remove(); - this._uiRemovers = []; - if (this._sceneShotList) this._sceneShotList.textContent = ''; clearTimeout(this._storageToastTimer); await Promise.allSettled(this._pendingWork || []); clearInterval(this._progressTimer); - document.removeEventListener('keydown', this._onKeyDown); }); return this._destroyPromise; } @@ -429,84 +394,66 @@ export class SceneDirector { } catch { /* toast is best-effort */ } } + /** Immutable playback snapshots and completed editing actions. */ + subscribe(listener, options) { return this._state.subscribe(listener, options); } + + _publish(change) { this._state?.publish(change); } + + _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) { + if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`; + this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index }); + } + /** * Wire up all scene-panel DOM event listeners and render the initial UI state. * Exits silently if the scene-select element is missing (headless/test mode). */ _initUI() { - if (!this._sceneSelect) return; - - this._renderSceneSelect(); - this._renderShotList(); - - this._listen(this._sceneSelect, 'change', () => { - this._selectedSceneId = this._sceneSelect.value; - const scene = this._getSelectedScene(); - this._selectedShotId = scene?.shots[0]?.id || null; - this._renderShotList(); + this._controls = new SceneControls({ + subscribe: (listener) => this.subscribe(listener), + read: () => ({ + scenes: this._project.scenes, + selectedSceneId: this._selectedSceneId, + selectedShotId: this._selectedShotId, + running: this._running, + hasRun: !!this._lastRunJson, + }), + actions: { + selectScene: (id) => { + this._selectedSceneId = id; + this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null; + this._renderShotList(); + }, + selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); }, + renameShot: (sceneId, shotId, title) => { + const { scene, shot } = this._getShot(sceneId, shotId); + if (!shot) return; + shot.title = title.trim() || shot.title; + this._saveProject(); + this._shotOutcome('shot-renamed', scene, shot); + }, + create: (name) => this._createScene(name), + deleteScene: () => this._deleteSelectedScene(), + capture: () => this.captureShot(), + update: () => this.updateSelectedShot(), + start: (id) => this.startScene(id), + stop: (reason) => this.stopScene(reason), + next: () => this.runNextScene(), + export: () => this.exportProject(), + import: (file) => this.importProjectFile(file), + download: () => this.downloadLastRunMetadata(), + load: (sceneId, shotId) => this.loadShot(sceneId, shotId), + deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId), + }, }); - - this._listen(this._sceneNewBtn, 'click', () => this._createScene()); - this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene()); - this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot()); - this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot()); - - this._listen(this._sceneStartBtn, 'click', () => { - this.startScene(this._selectedSceneId); - }); - - this._listen(this._sceneStopBtn, 'click', () => { - this.stopScene('Stopped'); - }); - - this._listen(this._sceneNextBtn, 'click', () => { - this.runNextScene(); - }); - - this._listen(this._sceneExportBtn, 'click', () => { - this.exportProject(); - }); - - this._listen(this._sceneImportBtn, 'click', () => { - this._sceneImportFile?.click(); - }); - - this._listen(this._sceneImportFile, 'change', async () => { - const file = this._sceneImportFile?.files?.[0]; - if (!file) return; - await this.importProjectFile(file); - this._sceneImportFile.value = ''; - }); - - this._listen(this._sceneDownloadBtn, 'click', () => { - this.downloadLastRunMetadata(); - }); - - this._updateStatus('Ready'); - this._setProgress(0); - this._setButtons(false); } /** Rebuild the scene dropdown options and sync the selected value. */ _renderSceneSelect() { - if (!this._sceneSelect) return; - - this._sceneSelect.innerHTML = ''; - for (const scene of this._project.scenes) { - const option = document.createElement('option'); - option.value = scene.id; - option.textContent = scene.title; - this._sceneSelect.appendChild(option); - } - - // Reset selection if the previously selected scene no longer exists if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) { this._selectedSceneId = this._project.scenes[0]?.id || null; } - - if (this._selectedSceneId) { - this._sceneSelect.value = this._selectedSceneId; - } + this._publish({ type: 'scene-options-changed' }); } /** @@ -515,79 +462,11 @@ export class SceneDirector { * LOAD/DEL action buttons. Supports click-to-select and double-click rename. */ _renderShotList() { - if (!this._sceneShotList) return; - const scene = this._getSelectedScene(); - this._sceneShotList.innerHTML = ''; - - if (!scene || scene.shots.length === 0) { - const empty = document.createElement('div'); - empty.className = 'scene-shot-empty'; - empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.'; - this._sceneShotList.appendChild(empty); - return; - } - - // Auto-select first shot if current selection is stale - if (!scene.shots.some((shot) => shot.id === this._selectedShotId)) { + if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) { this._selectedShotId = scene.shots[0].id; } - - for (const shot of scene.shots) { - const row = document.createElement('div'); - row.className = 'scene-shot-row'; - row.classList.toggle('active', shot.id === this._selectedShotId); - - const top = document.createElement('div'); - top.className = 'scene-shot-top'; - - const label = document.createElement('div'); - label.className = 'scene-shot-label'; - label.textContent = shot.title; - label.addEventListener('click', () => { - this._selectedShotId = shot.id; - this._renderShotList(); - }); - label.addEventListener('dblclick', () => { - const nextTitle = window.prompt('Shot title', shot.title); - if (!nextTitle) return; - shot.title = nextTitle.trim() || shot.title; - this._saveProject(); - this._renderShotList(); - }); - - const actions = document.createElement('div'); - actions.className = 'scene-shot-actions'; - - const loadBtn = document.createElement('button'); - loadBtn.className = 'scene-shot-btn'; - loadBtn.textContent = 'LOAD'; - loadBtn.addEventListener('click', () => { - this.loadShot(scene.id, shot.id); - }); - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'scene-shot-btn scene-shot-danger'; - deleteBtn.textContent = 'DEL'; - deleteBtn.addEventListener('click', () => { - this.deleteShot(scene.id, shot.id); - }); - - actions.appendChild(loadBtn); - actions.appendChild(deleteBtn); - top.appendChild(label); - top.appendChild(actions); - - const meta = document.createElement('div'); - meta.className = 'scene-shot-meta'; - const mode = shot.visual?.detection?.mode || 'OFF'; - const style = shot.visual?.style || 'normal'; - meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`; - - row.appendChild(top); - row.appendChild(meta); - this._sceneShotList.appendChild(row); - } + this._publish({ type: 'shots-changed' }); } /** @@ -610,9 +489,8 @@ export class SceneDirector { return { scene, shot }; } - /** Prompt the user for a name and append a new empty scene to the project. */ - _createScene() { - const sceneName = window.prompt('New scene name', `Scene ${this._project.scenes.length + 1}`); + /** Append a named empty scene after the controls accept the creation prompt. */ + _createScene(sceneName) { if (!sceneName) return; const scene = { @@ -625,8 +503,7 @@ export class SceneDirector { this._selectedSceneId = scene.id; this._selectedShotId = null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'scene-created', scene }); } /** Delete the currently selected scene after user confirmation. Resets to defaults if empty. */ @@ -634,9 +511,6 @@ export class SceneDirector { const scene = this._getSelectedScene(); if (!scene) return; - const ok = window.confirm(`Delete scene "${scene.title}" and all shots?`); - if (!ok) return; - this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id); // Restore default recipes if the user deleted all scenes if (!this._project.scenes.length) { @@ -646,8 +520,7 @@ export class SceneDirector { this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'scene-deleted', scene }); } /** @@ -693,7 +566,7 @@ export class SceneDirector { scene.shots.push(shot); this._selectedShotId = shot.id; this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-captured', scene, shot); this._updateStatus(`Captured: ${scene.title} / ${shot.title}`); } @@ -719,7 +592,7 @@ export class SceneDirector { shot.layers = this._captureLayerStates(); this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-updated', scene, shot); this._updateStatus(`Updated: ${scene.title} / ${shot.title}`); } @@ -732,13 +605,11 @@ export class SceneDirector { const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; - const ok = window.confirm(`Delete shot "${shot.title}"?`); - if (!ok) return; - + const index = scene.shots.indexOf(shot); scene.shots = scene.shots.filter((item) => item.id !== shot.id); this._selectedShotId = scene.shots[0]?.id || null; this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-deleted', scene, shot, index); } /** @@ -789,7 +660,7 @@ export class SceneDirector { if (token.cancelled) return; if (this._loadAbort === controller) this._loadAbort = null; - this._updateStatus(`Loaded: ${scene.title} / ${shot.title}`); + this._shotOutcome('shot-loaded', scene, shot); this._updateRuntime(''); } @@ -893,6 +764,9 @@ export class SceneDirector { return { running: this._running, selectedSceneId: this._selectedSceneId, + selectedShotId: this._selectedShotId, + elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null, + estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null, sceneCount: this._project.scenes.length, }; } @@ -949,7 +823,7 @@ export class SceneDirector { // Transition to running state this._running = true; - document.body.classList.add('scene-playback-mode'); + this._setPlaybackActive(true); this._setButtons(true); this._setProgress(0); @@ -984,7 +858,7 @@ export class SceneDirector { this._startProgressTicker(estimatedDurationSec || 1); this._logEvent('scene_run_start', { count: queue.length }); - document.addEventListener('keydown', this._onKeyDown); + this._setPlaybackKeyboardEnabled(true); try { // Main shot sequencing loop @@ -1106,6 +980,8 @@ export class SceneDirector { link.click(); link.remove(); URL.revokeObjectURL(url); + this._presentation.status = 'Project exported'; + this._publish({ type: 'project-exported', project: this._project }); } /** @@ -1122,8 +998,7 @@ export class SceneDirector { this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'project-imported', project: this._project }); this._updateStatus(`Imported ${file.name}`); } catch { this._updateStatus('Import failed (invalid JSON)'); @@ -1340,14 +1215,14 @@ export class SceneDirector { _finishRun() { clearInterval(this._progressTimer); this._progressTimer = null; - document.removeEventListener('keydown', this._onKeyDown); + this._setPlaybackKeyboardEnabled(false); // Covers the error path too: a run that threw mid-shot must not leave a // layer transition running against a director that has stopped watching. this._runAbort?.abort(); this._runAbort = null; this.styleManager.setRecordingMode(false); - document.body.classList.remove('scene-playback-mode'); + this._setPlaybackActive(false); this._updateRuntime(''); this._running = false; @@ -1369,21 +1244,16 @@ export class SceneDirector { * Editing controls are disabled during a run; stop is disabled when idle. * @param {boolean} isRunning */ - _setButtons(isRunning) { - if (this._sceneStartBtn) this._sceneStartBtn.disabled = isRunning; - if (this._sceneNextBtn) this._sceneNextBtn.disabled = isRunning; - if (this._sceneSelect) this._sceneSelect.disabled = isRunning; + _setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); } - if (this._sceneNewBtn) this._sceneNewBtn.disabled = isRunning; - if (this._sceneDeleteBtn) this._sceneDeleteBtn.disabled = isRunning; - if (this._sceneCaptureBtn) this._sceneCaptureBtn.disabled = isRunning; - if (this._sceneUpdateShotBtn) this._sceneUpdateShotBtn.disabled = isRunning; - if (this._sceneExportBtn) this._sceneExportBtn.disabled = isRunning; - if (this._sceneImportBtn) this._sceneImportBtn.disabled = isRunning; + _setPlaybackActive(active) { + this._presentation.playbackActive = active; + this._publish({ type: 'playback-presentation' }); + } - if (this._sceneStopBtn) this._sceneStopBtn.disabled = !isRunning; - if (this._sceneDownloadBtn) this._sceneDownloadBtn.disabled = !this._lastRunJson; - if (this._scenePanel) this._scenePanel.classList.toggle('running', isRunning); + _setPlaybackKeyboardEnabled(enabled) { + this._presentation.keyboardEnabled = enabled; + this._publish({ type: 'playback-keyboard' }); } /** @@ -1391,10 +1261,8 @@ export class SceneDirector { * @param {number} progress - Value in [0, 1] */ _setProgress(progress) { - if (!this._sceneProgressFill) return; - const pct = Math.round(clamp01(progress) * 100); - this._sceneProgressFill.style.width = `${pct}%`; - this._sceneProgressFill.textContent = `${pct}%`; + this._presentation.progress = progress; + this._publish({ type: 'progress-changed' }); } /** @@ -1402,7 +1270,8 @@ export class SceneDirector { * @param {string} text */ _updateStatus(text) { - if (this._sceneStatus) this._sceneStatus.textContent = text; + this._presentation.status = text; + this._publish({ type: 'status-changed' }); } /** @@ -1410,9 +1279,8 @@ export class SceneDirector { * @param {string} text - Empty string hides the label */ _updateRuntime(text) { - if (!this._sceneRuntime) return; - this._sceneRuntime.textContent = text; - this._sceneRuntime.classList.toggle('active', !!text); + this._presentation.runtime = text; + this._publish({ type: 'runtime-changed' }); } /** @@ -1422,6 +1290,7 @@ export class SceneDirector { */ _logEvent(type, payload) { if (!this._activeRun) return; + this._publish({ type: 'run-event', event: type, detail: payload || null }); this._activeRun.events.push({ t: new Date().toISOString(), type, @@ -1429,13 +1298,4 @@ export class SceneDirector { }); } - /** - * Global keydown handler registered during a run. Escape cancels the run. - * @param {KeyboardEvent} event - */ - _onKeyDown(event) { - if (event.key === ESCAPE_KEY && this._running) { - this.stopScene('Stopped (Esc)'); - } - } } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index e7e35ff38..5dd5be213 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -483,7 +483,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // another setStack() arrives, and a winning state that omits `mapStack` // never issues one — every normalized scene shot omits it — so a stale // switch would otherwise stand on the globe. - const source = fs.readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); + const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8'); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); assert.ok(method, 'applyVisualState is missing from ui.js'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); @@ -645,3 +645,55 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as assert.equal((await director.startScene('scene-1')).reason, 'destroyed'); await director.destroy(); }); + +test('Scene snapshots are immutable and editing outcomes retain the affected shot and index', async () => { + const { director, restore } = makeDirector(); + try { + director._activeRun = null; + const seen = []; + const unsubscribe = director.subscribe((notification) => seen.push(notification)); + assert.equal(seen[0].initial, true); + assert.equal(seen[0].state.selectedShotId, 'shot-a'); + assert.ok(Object.isFrozen(seen[0].state)); + director.captureShot(); + const captured = seen.find(({ change }) => change?.type === 'shot-captured'); + assert.equal(captured.change.index, 2); + assert.ok(Object.isFrozen(captured.change.shot.camera)); + const id = captured.change.shot.id; + director.deleteShot('scene-1', id); + const deleted = seen.find(({ change }) => change?.type === 'shot-deleted'); + assert.equal(deleted.change.index, 2); + assert.equal(deleted.change.shot.id, id); + assert.equal(captured.state.selectedShotId, id); + unsubscribe(); + const count = seen.length; + director.captureShot(); + assert.equal(seen.length, count); + await director.destroy(); + } finally { restore(); } +}); + +test('Scene load outcomes exclude superseded and disposed completions', async () => { + const { director, styleManager, restore } = makeDirector(); + try { + director._activeRun = null; + const seen = []; + director.subscribe(({ change }) => { if (change) seen.push(change); }); + const pending = []; + styleManager.applyVisualState = () => new Promise((resolve) => pending.push(resolve)); + const old = director.loadShot('scene-1', 'shot-a'); + const current = director.loadShot('scene-1', 'shot-b'); + pending[1](); + await current; + pending[0](); + await old; + assert.deepEqual(seen.filter((change) => change.type === 'shot-loaded').map((change) => change.shot.id), ['shot-b']); + const late = director.loadShot('scene-1', 'shot-a'); + const disposal = director.destroy(); + const count = seen.length; + pending[2](); + await Promise.all([late, disposal]); + director.subscribe(() => assert.fail('disposed director must not notify')); + assert.equal(seen.length, count); + } finally { restore(); } +}); diff --git a/src/scenes/scenePolicy.test.mjs b/src/scenes/scenePolicy.test.mjs index 172ca0b75..fbe240cac 100644 --- a/src/scenes/scenePolicy.test.mjs +++ b/src/scenes/scenePolicy.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; @@ -24,10 +25,10 @@ function sweepLayerParamKeys() { const dataDir = new URL('../data/', import.meta.url); for (const entry of fs.readdirSync(dataDir)) { if (!entry.endsWith('.js')) continue; - const source = fs.readFileSync(new URL(entry, dataDir), 'utf8'); + const source = readLayerSource(new URL(entry, dataDir)); // Every getParams() in this codebase is a plain object return; take the // body up to its closing brace and read the keys it publishes. - const body = source.match(/\n {2}getParams\(\)\s*\{[\s\S]*?\n {2}\},/); + const body = source.match(/\n\s+getParams\(\)\s*\{[\s\S]*?\n\s+\},/); if (!body) continue; // A key always follows `{` or `,` — which matches both the multi-line // returns and the single-line `return { passive: … }` form, while a diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index c655401cf..5defb11a6 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -1,12 +1,17 @@ +import { StyleManager } from './ui/applicationShell.js'; +import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); function sourceBlock(start, end) { + const name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1]; + if (typeof StyleManager.prototype[name] === 'function') return StyleManager.prototype[name].toString(); const startIndex = uiSource.indexOf(start); const endIndex = uiSource.indexOf(end, startIndex + start.length); assert.ok(startIndex >= 0, `missing source block start: ${start}`); @@ -192,7 +197,7 @@ test('a shared view reserves its own camera without cancelling its saved Follow' ); // The shared view's own `cancelPendingSelection` must reach the stamp; other // stamp options may ride alongside it. - assert.match(deferred, /_stampNavigation\(\{ cancelPendingSelection[^)]*\}\)/); + assert.match(deferred, /_stampNavigation\(\{\s*cancelPendingSelection[^)]*\}\)/); }); test('copy timestamp parsing is strict and rejects malformed or future values', () => { @@ -464,19 +469,17 @@ test('explicit Context transitions claim the visual restore lane before transiti assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`); }; - const helper = sourceBlock( - ' _claimContextVisualAuthority() {', - ' async _selectContextMode(mode, {', - ); + const helper = _claimContextVisualAuthority.toString(); assert.ok( - helper.includes("claimRestoreLane?.('visual')"), + helper.includes('this.actions.claimVisualAuthority()'), 'the Context authority helper must claim the visual lane', ); - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); + assert.match(uiSource, /claimVisualAuthority: \(\) =>\s*this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/); + const contextPanel = _initGlobalContextPanel.toString(); for (const [start, end, label] of [ - ["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'], - ["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], + ["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'], + ["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], ]) { const startIndex = contextPanel.indexOf(start); const endIndex = end === 'CONTEXT_PANEL_END' @@ -487,7 +490,7 @@ test('explicit Context transitions claim the visual restore lane before transiti } // The voice/tool facade validates the mode first, then transitions. - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const facade = setContextMode.toString(); assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade'); // Authority is taken per validated branch, never ahead of validation. The // OFF branch is validated by its own guard; the named-mode branch must claim @@ -517,8 +520,8 @@ test('explicit Context transitions claim the visual restore lane before transiti // detection: `_detectionUserOverridden` is what suppresses the military-style // auto-enable for the rest of the session. Contacts entry is not that. test('Context lane claims never set the session detection-override flag', () => { - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const contextPanel = _initGlobalContextPanel.toString(); + const facade = setContextMode.toString(); for (const [block, label] of [ [contextPanel, 'Context panel'], [facade, 'setContextMode facade'], @@ -690,6 +693,11 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.ok(firstAwait > 0); const synchronous = disposal.slice(0, firstAwait); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); + assert.match(synchronous, /this\._visualEffects\.stop\(\)/); + assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/); + assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/); + assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/); + assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/); assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); }); diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js new file mode 100644 index 000000000..9534a5c3c --- /dev/null +++ b/src/sources/live/aircraft.js @@ -0,0 +1,180 @@ +import { + admitRecords, + cleanText, + coordinates, + epoch, + finite, +} from './contract.js'; + +/** Normalize the OpenSky state-vector route, including its regional fallback. */ +export function normalizeOpenSkyAircraft(row) { + if (!Array.isArray(row)) return null; + const id = cleanText(row[0]).toLowerCase(); + if (!id || !coordinates(row[6], row[5])) return null; + return { + id, + reference: id, + latitude: row[6], + longitude: row[5], + callsign: cleanText(row[1]), + originCountry: cleanText(row[2]), + positionTimeMs: epoch(row[3], 1000), + contactTimeMs: epoch(row[4], 1000), + baroAltitudeM: finite(row[7]), + ellipsoidAltitudeM: finite(row[13]), + onGround: row[8] === true, + speedMps: finite(row[9]), + courseDeg: finite(row[10]), + verticalRateMps: finite(row[11]), + category: finite(row[17]), + typeCode: null, + registration: null, + operator: null, + }; +} + +/** Normalize readsb observations. The caller supplies the source snapshot epoch. */ +export function normalizeReadsbAircraft(row, snapshotTimeMs) { + const id = cleanText(row?.hex).toLowerCase(); + const latitude = finite(row?.lat), + longitude = finite(row?.lon); + if (!id || !coordinates(latitude, longitude)) return null; + const baroFt = finite(row.alt_baro), + geoFt = finite(row.alt_geom); + const speedKts = finite(row.gs), + rateFtMin = finite(row.baro_rate); + const seen = finite(row.seen), + seenPos = finite(row.seen_pos); + return { + id, + reference: id, + latitude, + longitude, + callsign: cleanText(row.flight), + originCountry: null, + positionTimeMs: + snapshotTimeMs == null ? null : snapshotTimeMs - (seenPos ?? 0) * 1000, + contactTimeMs: + snapshotTimeMs == null || seen == null + ? null + : snapshotTimeMs - seen * 1000, + baroAltitudeM: baroFt == null ? null : baroFt * 0.3048, + ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048, + onGround: cleanText(row.alt_baro).toLowerCase() === 'ground', + speedMps: speedKts == null ? null : speedKts * 0.514444, + courseDeg: finite(row.track), + verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508, + category: row.category ?? null, + typeCode: cleanText(row.t), + registration: cleanText(row.r), + operator: cleanText(row.ownOp), + }; +} + +export function openSkySnapshot( + payload, + { + source = 'OpenSky Network', + coverage = 'worldwide upstream snapshot', + now = Date.now(), + stale = false, + } = {}, +) { + const admitted = admitRecords( + payload?.states, + normalizeOpenSkyAircraft, + 'OpenSky', + ); + const observedAtMs = epoch(payload?.time, 1000); + const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs); + return { + ...admitted, + source, + coverage, + observedAtMs, + ageMs, + stale: stale || (ageMs != null && ageMs > 120000), + freshness: + observedAtMs == null + ? 'unknown' + : stale || ageMs > 120000 + ? 'stale' + : 'current', + }; +} + +export function readsbSnapshot( + payload, + { + observedAtMs, + source = 'adsb.lol', + coverage = 'military upstream snapshot', + now = Date.now(), + stale = false, + } = {}, +) { + const admitted = admitRecords( + payload?.ac, + (row) => normalizeReadsbAircraft(row, observedAtMs), + 'adsb.lol', + ); + const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs); + return { + ...admitted, + source, + coverage, + observedAtMs, + ageMs, + stale, + freshness: observedAtMs == null ? 'unknown' : stale ? 'stale' : 'current', + }; +} + +/** Track altitude remains barometric; renderers choose a visual ground fallback. */ +export function normalizeAircraftTrack( + rows, + { baseTimeMs = 0, readsb = false } = {}, +) { + if (!Array.isArray(rows)) return []; + return rows.flatMap((row) => { + if (!Array.isArray(row)) return []; + const latitude = finite(row[1]), + longitude = finite(row[2]); + const offset = finite(row[0]); + const observedAtMs = + offset == null ? null : epoch(baseTimeMs + offset * 1000); + if ( + !coordinates(latitude, longitude) || + observedAtMs == null || + observedAtMs <= 0 + ) + return []; + const altitude = finite(row[3]); + return [ + { + latitude, + longitude, + observedAtMs, + baroAltitudeM: + altitude == null ? null : altitude * (readsb ? 0.3048 : 1), + ellipsoidAltitudeM: null, + onGround: readsb + ? cleanText(row[3]).toLowerCase() === 'ground' + : row[5] === true, + }, + ]; + }); +} + +/** Known source identities are useful for classification even without positions. */ +export function readsbIdentities(payload) { + const admitted = admitRecords( + payload?.ac, + (row) => { + const id = cleanText(row?.hex).toLowerCase(); + return id ? { id } : null; + }, + 'aircraft identities', + ); + return admitted.records.map((record) => record.id); +} diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js new file mode 100644 index 000000000..2415ff43c --- /dev/null +++ b/src/sources/live/contract.js @@ -0,0 +1,125 @@ +/** + * Browser live-source contract. Coordinates are WGS84 degrees; lengths and + * velocities are metres and metres/second; times are Unix milliseconds. + * Barometric altitude and ellipsoid altitude are separate, nullable observations. + * A missing timestamp is unknown, never the time the response was received. + * + * Sources expose getSnapshot(query, { signal }) and optionally + * getTrack(reference, { signal }). References are opaque to consumers. + * Snapshots describe coverage and completeness independently of freshness. + * Records contain observation data only, with no scene objects or transport data. + */ +export class LiveSourceError extends Error { + constructor( + code, + message, + { status = null, retryAfterMs = 20000, source = null } = {}, + ) { + super(message); + this.name = 'LiveSourceError'; + this.code = code; + this.status = status; + this.retryAfterMs = retryAfterMs; + this.source = source; + } +} + +export function finite(value) { + if (value == null || value === '' || typeof value === 'boolean') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +export function epoch(value, scale = 1) { + const number = finite(value); + const milliseconds = number == null ? null : number * scale; + return milliseconds != null && + milliseconds > 0 && + milliseconds <= 8640000000000000 + ? milliseconds + : null; +} + +export function cleanText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function coordinates(latitude, longitude) { + return ( + Number.isFinite(latitude) && + Math.abs(latitude) <= 90 && + Number.isFinite(longitude) && + Math.abs(longitude) <= 180 + ); +} + +/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */ +export function admitRecords(rows, normalize, label) { + if (!Array.isArray(rows)) + throw new LiveSourceError('malformed', `Malformed ${label} response`); + const records = []; + const ids = new Set(); + for (const row of rows) { + const record = normalize(row); + if (record && !ids.has(record.id)) { + records.push(record); + ids.add(record.id); + } + } + if (rows.length && !records.length) { + throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`); + } + return { + records, + complete: records.length === rows.length, + rejectedCount: rows.length - records.length, + }; +} + +/** Cancellation is checked after body parsing even when a transport ignores it. */ +export async function readResponse( + fetchImpl, + url, + { signal, ...init } = {}, + source = 'Live source', +) { + signal?.throwIfAborted(); + let response; + try { + response = await fetchImpl(url, { ...init, signal }); + signal?.throwIfAborted(); + } catch (error) { + signal?.throwIfAborted(); + if (error?.name === 'AbortError') throw error; + throw new LiveSourceError('unavailable', `${source} network error`, { + source, + }); + } + let payload = null; + try { + payload = await response.json(); + } catch { + /* classified below */ + } + signal?.throwIfAborted(); + return { response, payload }; +} + +export function httpError(response, source) { + const status = response.status; + const code = + status === 429 + ? 'limited' + : status === 401 || status === 403 + ? 'denied' + : 'unavailable'; + return new LiveSourceError( + code, + status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`, + { + status, + source, + retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000, + }, + ); +} diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs new file mode 100644 index 000000000..671a2e5e4 --- /dev/null +++ b/src/sources/live/contract.test.mjs @@ -0,0 +1,315 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createOpenSkySource, + createAdsbLolSource, + createAisStreamSource, + normalizeReadsbAircraft, + normalizeVesselObservation, + openSkySnapshot, + readsbSnapshot, +} from './index.js'; + +const now = 1800000000000; +const aircraft = [ + 'AbC123', + 'TEST', + 'Example', + now / 1000 - 15, + now / 1000 - 2, + -97, + 30, + 1000, + false, + 100, + 250, + -2, + null, + 1040, +]; +const response = (payload, headers = {}, status = 200) => + Response.json(payload, { headers, status }); + +test('civil observations retain identity, separate altitude datums and source epochs', () => { + const snapshot = openSkySnapshot( + { time: now / 1000 - 130, states: [aircraft] }, + { now, source: 'Test source', coverage: 'regional' }, + ); + const row = snapshot.records[0]; + assert.equal(row.id, 'abc123'); + assert.equal(row.baroAltitudeM, 1000); + assert.equal(row.ellipsoidAltitudeM, 1040); + assert.equal(row.positionTimeMs, now - 15000); + assert.equal(row.contactTimeMs, now - 2000); + assert.equal(snapshot.ageMs, 130000); + assert.equal(snapshot.freshness, 'stale'); + assert.equal(snapshot.coverage, 'regional'); + const missing = openSkySnapshot( + { states: [[...aircraft.slice(0, 3), null, null, ...aircraft.slice(5)]] }, + { now }, + ); + assert.equal(missing.observedAtMs, null); + assert.equal(missing.freshness, 'unknown'); + assert.equal(missing.records[0].positionTimeMs, null); +}); + +test('mixed-invalid and duplicate admissions are explicitly incomplete; all-invalid rejects', () => { + const invalid = [...aircraft]; + invalid[5] = 181; + const partial = openSkySnapshot({ states: [aircraft, invalid, aircraft] }); + assert.equal(partial.records.length, 1); + assert.equal(partial.complete, false); + assert.equal(partial.rejectedCount, 2); + assert.throws(() => openSkySnapshot({ states: [invalid] }), { + code: 'malformed', + }); + assert.throws(() => openSkySnapshot({}), { code: 'malformed' }); + assert.equal(openSkySnapshot({ states: [] }).complete, true); +}); + +test('readsb units and position/contact ages are normalized once against the source epoch', () => { + const row = normalizeReadsbAircraft( + { + hex: 'abc123', + lat: '30', + lon: '-97', + alt_baro: 10000, + alt_geom: 10100, + gs: 120, + baro_rate: -600, + seen: 2, + seen_pos: 12, + }, + now, + ); + assert.equal(row.baroAltitudeM, 3048); + assert.equal(row.ellipsoidAltitudeM, 3078.48); + assert.equal(row.speedMps, 120 * 0.514444); + assert.equal(row.verticalRateMps, -600 * 0.00508); + assert.equal(row.positionTimeMs, now - 12000); + assert.equal(row.contactTimeMs, now - 2000); + const ground = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97, alt_baro: 'ground' }, + null, + ); + assert.equal(ground.onGround, true); + assert.equal(ground.baroAltitudeM, null); + assert.equal(ground.positionTimeMs, null); + assert.equal( + readsbSnapshot({ ac: [] }, { observedAtMs: null }).freshness, + 'unknown', + ); +}); + +test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => { + const row = normalizeVesselObservation( + { + mmsi: '123456789', + lat: 30, + lon: -97, + speed: 10, + heading: 45, + course: 60, + last_position_epoch: now / 1000, + extra: 'discard', + }, + 'opaque-reference', + ); + assert.equal(row.id, '123456789'); + assert.equal(row.reference, 'opaque-reference'); + assert.equal(row.headingDeg, 45); + assert.equal(row.courseDeg, 60); + assert.equal(row.speedMps, 10 * 0.514444); + assert.equal(row.altitudeDatum, 'sea-surface'); + assert.equal(row.observedAtMs, now); + assert.equal('extra' in row, false); + assert.equal( + normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), + null, + ); +}); + +test('construction is inert; adapters preserve routes, viewport query, cache epoch and history datum', async () => { + const requests = []; + const fetchImpl = async (url, init) => { + requests.push({ url, init }); + if (url.startsWith('/api/opensky?')) + return response( + { time: now / 1000, states: [aircraft] }, + { + 'x-flight-source': 'adsb.lol', + 'x-flight-coverage': '250 nm regional', + }, + ); + if (url.startsWith('/api/opensky-track')) + return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] }); + if (url === '/api/adsblol/mil') + return response( + { ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, + { 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' }, + ); + return response({ + timestamp: now / 1000 - 60, + trace: [[10, 30, -97, 1000]], + }); + }; + const civil = createOpenSkySource({ fetchImpl, now: () => now }); + const military = createAdsbLolSource({ fetchImpl, now: () => now }); + assert.equal(requests.length, 0); + const signal = new AbortController().signal; + const snapshot = await civil.getSnapshot( + { latitude: 30.123456, longitude: -97 }, + { signal }, + ); + assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000'); + assert.equal(requests[0].init.signal, signal); + assert.equal(snapshot.source, 'adsb.lol'); + assert.equal(snapshot.coverage, '250 nm regional'); + assert.equal((await civil.getTrack('abc123')).records[0].baroAltitudeM, 1000); + const mil = await military.getSnapshot(); + assert.equal(mil.observedAtMs, now - 60000); + assert.equal(mil.records[0].positionTimeMs, now - 75000); + assert.equal(mil.stale, true); + const track = await military.getTrack('abc123'); + assert.equal(track.complete, false); + assert.equal(track.records[0].observedAtMs, now - 50000); + assert.equal(track.records[0].baroAltitudeM, 304.8); + assert.equal(track.records[0].ellipsoidAltitudeM, null); +}); + +test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { + let release; + const controller = new AbortController(); + const source = createOpenSkySource({ + fetchImpl: async () => ({ + ok: true, + json: () => + new Promise((resolve) => { + release = resolve; + }), + }), + }); + const pending = source.getSnapshot({}, { signal: controller.signal }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + release({ states: [aircraft] }); + await assert.rejects(pending, { name: 'AbortError' }); +}); + +test('denials and outages never start another source and do not echo arbitrary response bodies', async () => { + for (const status of [401, 403, 429, 502]) { + let calls = 0; + const source = createOpenSkySource({ + fetchImpl: async () => { + calls++; + return response({ error: 'sensitive upstream detail' }, {}, status); + }, + }); + await assert.rejects(source.getSnapshot(), (error) => { + assert.equal(error.status, status); + assert.equal(error.message.includes('sensitive'), false); + assert.ok(error.retryAfterMs >= 20000); + return true; + }); + assert.equal(calls, 1); + } +}); + +test('AIS reports limited received coverage and keeps connection state separate from positions', async () => { + const source = createAisStreamSource({ + origin: () => 'http://example.test', + fetchImpl: async (url) => { + if (url.includes('/track?')) + return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] }); + assert.equal(url, 'http://example.test/api/ais-live?maxRows=500'); + return response({ + status: 'reconnecting', + refreshing: true, + rows: [ + { + mmsi: '123456789', + lat: 30, + lon: -97, + last_position_UTC: new Date(now).toISOString(), + }, + ], + newestPositionAt: new Date(now).toISOString(), + }); + }, + }); + const snapshot = await source.getSnapshot({ maxRows: 500 }); + assert.equal(snapshot.complete, false); + assert.equal(snapshot.stale, true); + assert.equal(snapshot.observedAtMs, now); + assert.equal(snapshot.transportStatus, 'reconnecting'); + assert.equal( + (await source.getTrack('123456789')).records[0].observedAtMs, + now, + ); +}); + +test('a malformed vessel row cannot prevent admission of valid positions', async () => { + const source = createAisStreamSource({ + fetchImpl: async () => + response({ + status: 'live', + rows: [null, { mmsi: '123456789', lat: 30, lon: -97 }], + }), + }); + const snapshot = await source.getSnapshot(); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.rejectedCount, 1); + assert.equal(snapshot.complete, false); +}); + +test('out-of-range source epochs do not reach Date or globe time constructors', () => { + const row = [...aircraft]; + row[3] = 1e100; + row[4] = 1e100; + const snapshot = openSkySnapshot({ time: 1e100, states: [row] }); + assert.equal(snapshot.observedAtMs, null); + assert.equal(snapshot.records[0].positionTimeMs, null); + assert.equal(snapshot.records[0].contactTimeMs, null); +}); + +test('classification identities include positionless aircraft without admitting them to rendering', async () => { + const source = createAdsbLolSource({ + fetchImpl: async () => + response({ + ac: [ + { hex: ' ABC123 ' }, + { hex: 'abc123' }, + { hex: 'def456', lat: 30, lon: -97 }, + ], + }), + }); + assert.deepEqual(await source.getIdentities(), ['abc123', 'def456']); + const snapshot = await source.getSnapshot(); + assert.deepEqual( + snapshot.records.map((record) => record.id), + ['def456'], + ); + assert.equal(snapshot.complete, false); +}); + +test('identity lookup validates its response and honors body-parse cancellation', async () => { + const malformed = createAdsbLolSource({ + fetchImpl: async () => response({ ac: [{}] }), + }); + await assert.rejects(malformed.getIdentities(), /Malformed/); + const abort = new AbortController(); + const source = createAdsbLolSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Headers(), + async json() { + abort.abort(); + return { ac: [{ hex: 'abc123' }] }; + }, + }), + }); + await assert.rejects(source.getIdentities({}, { signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/sources/live/index.js b/src/sources/live/index.js new file mode 100644 index 000000000..73f492500 --- /dev/null +++ b/src/sources/live/index.js @@ -0,0 +1,19 @@ +export { LiveSourceError } from './contract.js'; +export { + normalizeOpenSkyAircraft, + normalizeReadsbAircraft, + normalizeAircraftTrack, + openSkySnapshot, + readsbSnapshot, + readsbIdentities, +} from './aircraft.js'; +export { + normalizeVesselObservation, + normalizeVesselTrack, + vesselSnapshot, +} from './vessels.js'; +export { + createOpenSkySource, + createAdsbLolSource, + createAisStreamSource, +} from './standalone.js'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js new file mode 100644 index 000000000..ecee2a405 --- /dev/null +++ b/src/sources/live/standalone.js @@ -0,0 +1,212 @@ +import { + epoch, + finite, + httpError, + LiveSourceError, + readResponse, +} from './contract.js'; +import { + normalizeAircraftTrack, + openSkySnapshot, + readsbSnapshot, + readsbIdentities, +} from './aircraft.js'; +import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; + +const defaultFetch = (...args) => globalThis.fetch(...args); +const header = (response, name) => response.headers?.get?.(name); + +function openSkyError(response) { + const error = httpError(response, 'OpenSky'); + const mode = String( + header(response, 'x-opensky-auth-mode-used') || + header(response, 'x-opensky-auth') || + '', + ).toLowerCase(); + const reason = String( + header(response, 'x-opensky-auth-reason') || '', + ).toLowerCase(); + if (response.status === 429 && (!mode || mode === 'anon')) + error.message = 'OpenSky rate limited (anonymous)'; + if (response.status === 401 || response.status === 403) { + const reasons = { + oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', + oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', + basic_invalid_credentials: 'OpenSky username/password rejected', + missing_basic_creds: 'OpenSky auth missing', + missing_oauth_and_basic_creds: 'OpenSky auth missing', + auth_required: 'OpenSky auth required', + forced_anonymous: 'OpenSky auth required', + }; + error.message = + reasons[reason] || + (/^(oauth_|basic_)/.test(reason) + ? 'OpenSky auth invalid' + : mode === 'anon' + ? 'OpenSky auth required' + : 'OpenSky auth failed'); + } + return error; +} + +/** Existing same-origin aircraft routes; no request starts during construction. */ +export function createOpenSkySource({ + fetchImpl = defaultFetch, + now = () => Date.now(), +} = {}) { + return { + label: 'OpenSky Network', + async getSnapshot(query = {}, { signal } = {}) { + const params = new URLSearchParams(); + if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { + params.set('lat', query.latitude.toFixed(4)); + params.set('lon', query.longitude.toFixed(4)); + } + const { response, payload } = await readResponse( + fetchImpl, + `/api/opensky${params.size ? '?' + params : ''}`, + { signal }, + 'OpenSky', + ); + if (!response.ok) throw openSkyError(response); + return { + ...openSkySnapshot(payload, { + source: header(response, 'x-flight-source') || 'OpenSky Network', + coverage: + header(response, 'x-flight-coverage') || + 'worldwide upstream snapshot', + now: now(), + }), + status: response.status, + }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/opensky-track?icao24=' + encodeURIComponent(reference), + { signal }, + 'OpenSky', + ); + if (!response.ok) throw httpError(response, 'OpenSky'); + return { + records: normalizeAircraftTrack(payload?.path), + complete: false, + }; + }, + async getEnrichment(query, { signal } = {}) { + if (!['type', 'route'].includes(query.kind)) + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + const { response, payload } = await readResponse( + fetchImpl, + `/api/adsbdb/${query.kind}/${encodeURIComponent(query.id)}`, + { signal }, + 'adsbdb', + ); + if (!response.ok) throw httpError(response, 'adsbdb'); + return payload; + }, + }; +} + +export function createAdsbLolSource({ + fetchImpl = defaultFetch, + now = () => Date.now(), +} = {}) { + return { + label: 'adsb.lol', + async getIdentities(_query = {}, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/mil', + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + return readsbIdentities(payload); + }, + async getSnapshot(_query = {}, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/mil', + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + const age = finite(header(response, 'x-ads-b-cache-age-ms')); + return { + ...readsbSnapshot(payload, { + observedAtMs: now() - (age != null && age > 0 ? age : 0), + now: now(), + stale: header(response, 'x-ads-b-cache') === 'STALE', + }), + status: response.status, + }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/trace?hex=' + encodeURIComponent(reference), + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + const baseTimeMs = epoch(payload?.timestamp, 1000); + return { + records: + baseTimeMs == null + ? [] + : normalizeAircraftTrack(payload?.trace, { + baseTimeMs, + readsb: true, + }), + complete: false, + }; + }, + }; +} + +export function createAisStreamSource({ + fetchImpl = defaultFetch, + apiUrl = '/api/ais-live', + origin = () => globalThis.location?.origin || 'http://localhost', +} = {}) { + return { + label: 'AISStream', + async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { + const url = new URL(apiUrl, origin()); + url.searchParams.set('maxRows', String(maxRows)); + const { response, payload } = await readResponse( + fetchImpl, + url.toString(), + { signal, cache: 'no-store' }, + 'AIS live', + ); + if (!response.ok) { + const error = httpError(response, 'AIS live'); + const reasons = { + 'missing-key': 'AISSTREAM_API_KEY not set', + 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', + unsupported: 'live feed unsupported', + error: 'feed down', + closed: 'feed disconnected', + }; + error.message = reasons[payload?.status] || error.message; + throw error; + } + return { ...vesselSnapshot(payload), status: response.status }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/ais-live/track?mmsi=' + encodeURIComponent(reference), + { signal }, + 'AIS live', + ); + if (!response.ok) throw httpError(response, 'AIS live'); + return { + records: normalizeVesselTrack(payload?.samples), + complete: false, + }; + }, + }; +} diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js new file mode 100644 index 000000000..affdcce7a --- /dev/null +++ b/src/sources/live/vessels.js @@ -0,0 +1,96 @@ +import { coordinates, epoch, finite, LiveSourceError } from './contract.js'; + +/** AIS positions are sea-surface observations; heading and course are distinct. */ +export function normalizeVesselObservation(row, reference = null) { + const id = String(row?.mmsi || row?.input_identifier || '').trim(); + const latitude = finite(row?.lat), + longitude = finite(row?.lon); + if (!id || !coordinates(latitude, longitude)) return null; + const speedKts = finite(row.speed); + return { + id, + reference: reference ?? id, + latitude, + longitude, + name: String(row.name || row.input_name || id), + imo: String(row.imo || ''), + type: String(row.type_specific || row.type || ''), + destination: String(row.destination || ''), + speedMps: speedKts == null ? null : speedKts * 0.514444, + courseDeg: finite(row.course), + headingDeg: finite(row.heading), + observedAtMs: + epoch(row.last_position_epoch, 1000) ?? + epoch(Date.parse(row.last_position_UTC)), + altitudeDatum: 'sea-surface', + }; +} + +export function vesselSnapshot( + payload, + { + source = 'AISStream', + coverage = 'received AIS positions', + referenceFor = (row) => String(row.mmsi || row.input_identifier || ''), + } = {}, +) { + if (!Array.isArray(payload?.rows)) + throw new LiveSourceError('malformed', 'Malformed vessel response'); + const rows = payload.rows; + const records = [], + ids = new Set(); + for (const row of rows) { + const record = normalizeVesselObservation(row); + if (record) record.reference = referenceFor(row); + if (record && !ids.has(record.id)) { + records.push(record); + ids.add(record.id); + } + } + const observedAtMs = + epoch(payload?.newestPositionAt) ?? + epoch(Date.parse(payload?.newestPositionAt)) ?? + records.reduce( + (newest, row) => Math.max(newest || 0, row.observedAtMs || 0) || null, + null, + ); + return { + records, + source, + coverage, + complete: records.length === rows.length && !payload?.refreshing, + rejectedCount: rows.length - records.length, + observedAtMs, + freshness: payload?.refreshing + ? 'stale' + : observedAtMs == null + ? 'unknown' + : 'current', + stale: Boolean(payload?.refreshing), + // Connection state is distinct from receipt of a usable position. + transportStatus: + typeof payload?.status === 'string' ? payload.status : null, + lastMessageAt: payload?.lastMessageAt ?? null, + nextAttemptAt: finite(payload?.nextAttemptAt), + silentForMs: finite(payload?.silentForMs), + reconnectAttempt: finite(payload?.reconnectAttempt), + rawRowCount: rows.length, + }; +} + +export function normalizeVesselTrack(samples) { + if (!Array.isArray(samples)) return []; + return samples.flatMap((sample) => { + const latitude = finite(sample?.lat), + longitude = finite(sample?.lon); + if (!coordinates(latitude, longitude)) return []; + return [ + { + latitude, + longitude, + observedAtMs: epoch(sample?.t, 1000), + altitudeDatum: 'sea-surface', + }, + ]; + }); +} diff --git a/src/splitFlap.js b/src/splitFlap.js index c57da5f23..17d677999 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -107,7 +107,8 @@ function positiveNumber(value, fallback) { } function nowMs() { - return typeof performance !== 'undefined' && typeof performance.now === 'function' + return typeof performance !== 'undefined' && + typeof performance.now === 'function' ? performance.now() : Date.now(); } @@ -235,7 +236,9 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) { const turnRatio = Number.isFinite(Number(options.turnRatio)) ? Number(options.turnRatio) : FLAP_TURN_RATIO; - const elapsed = Number.isFinite(Number(elapsedMs)) ? Math.max(0, Number(elapsedMs)) : 0; + const elapsed = Number.isFinite(Number(elapsedMs)) + ? Math.max(0, Number(elapsedMs)) + : 0; const turn = charMs * turnRatio; let out = ''; for (const cell of plan?.cells || []) { @@ -246,9 +249,11 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) { /** Whether the viewer asked for reduced motion. */ function prefersReducedMotion() { - return typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); } /** @@ -274,7 +279,11 @@ function isVisible(element) { if (element.getClientRects().length === 0) return false; const view = element.ownerDocument?.defaultView; if (typeof view?.getComputedStyle !== 'function') return true; - for (let node = element; node && node.nodeType === 1; node = node.parentElement) { + for ( + let node = element; + node && node.nodeType === 1; + node = node.parentElement + ) { const style = view.getComputedStyle(node); if (style.visibility === 'hidden' || style.display === 'none') return false; if (Number(style.opacity) === 0) return false; @@ -301,10 +310,12 @@ function ensureHost(element) { // nothing changed. const text = element.firstElementChild; const cells = text?.nextElementSibling; - if (text?.classList?.contains(TEXT_CLASS) - && text.firstChild?.nodeType === 3 - && cells?.classList?.contains(CELLS_CLASS) - && !cells.nextElementSibling) { + if ( + text?.classList?.contains(TEXT_CLASS) && + text.firstChild?.nodeType === 3 && + cells?.classList?.contains(CELLS_CLASS) && + !cells.nextElementSibling + ) { return { text, cells }; } @@ -363,7 +374,8 @@ function easeWidth(element, fromWidth, toWidth, durationMs) { element.style.width = `${toWidth}px`; const finish = (event) => { - if (event && (event.target !== element || event.propertyName !== 'width')) return; + if (event && (event.target !== element || event.propertyName !== 'width')) + return; cancelWidthEase(element); clearSizing(element); }; @@ -449,16 +461,19 @@ export function setSplitFlapText(element, text, options = {}) { // previous label's glyphs, and those are what must flap away. const state = flapStates.get(element); const displayed = state - ? visibleGlyphs(state.plan, nowMs() - state.startedAt, { charMs: state.charMs }) + ? visibleGlyphs(state.plan, nowMs() - state.startedAt, { + charMs: state.charMs, + }) : settled; clearFlapTimer(element); const beforeWidth = measureWidth(element); - const animate = SPLIT_FLAP_ENABLED - && options.immediate !== true - && !prefersReducedMotion() - && isVisible(element); + const animate = + SPLIT_FLAP_ENABLED && + options.immediate !== true && + !prefersReducedMotion() && + isVisible(element); const plan = animate ? planSplitFlap(displayed, next, options) : null; @@ -519,3 +534,13 @@ export function setSplitFlapText(element, text, options = {}) { }); return true; } + +/** Stop decoration without replacing the label's permanent accessible text. */ +export function disposeSplitFlap(element) { + if (!element) return; + clearFlapTimer(element); + cancelWidthEase(element); + clearSizing(element); + const cells = element.querySelector?.('.gev-flap-cells'); + if (cells) rest(element, { cells }); +} diff --git a/src/standalone/ui.js b/src/standalone/ui.js new file mode 100644 index 000000000..7f46543bb --- /dev/null +++ b/src/standalone/ui.js @@ -0,0 +1,153 @@ +/** Compose UI controls with the application's existing engines and layer instances. */ +import { StyleManager as ApplicationShell } from '../ui/applicationShell.js'; +import { LocationSearch } from '../ui/location.js'; +import { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, +} from '../locations.js'; +import { interruptCameraMotion } from '../cameraVerbs.js'; +import { IntelHUD } from '../hud.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { OrbitController } from '../orbit.js'; +import { + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, +} from '../celestialRing.js'; +import { + destroyTrackedReadout, + initTrackedReadout, +} from '../data/trackedReadout.js'; +import { + destroyWorldOverlay, + initWorldOverlay, +} from '../overlays/worldOverlay.js'; +import { + destroyDetection, + initDetection, + cycleMode as cycleDetectionMode, + getDetectionDiagnostics as readDetectionDiagnostics, + getDetectionTuning, + getMode as getDetectionMode, + setMode as setDetectionModeByLabel, + suspendDetection, + resumeDetection, + setDetectionStyle, + setDetectionTuning, +} from '../data/detection.js'; +import trafficLayer from '../data/traffic.js'; +import flightsLayer from '../data/flights.js'; +import militaryFlightsLayer from '../data/militaryFlights.js'; +import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js'; +import satellitesLayer from '../data/satellites.js'; +import cctvLayer from '../data/cctv.js'; +import radioLayer from '../data/radio.js'; +import bikeshareLayer from '../data/bikeshare.js'; +import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; +import militaryAwarenessLayer from '../data/militaryAwareness.js'; +import militaryInstallationsLayer from '../data/militaryInstallations.js'; +import rocketLaunchesLayer from '../data/rocketLaunches.js'; +import { + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, +} from '../data/groundFloor.js'; +import { sampleMeshFloorCells } from '../data/meshFloorSampler.js'; +import { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, +} from '../renderGovernor.js'; +import { + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, +} from '../scopeMask.js'; +import { + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, +} from '../data/regionalBrief.js'; + +export class StyleManager extends ApplicationShell { + constructor(viewer, options = {}) { + super(viewer, { + ...options, + services: { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, + interruptCameraMotion, + IntelHUD, + ShareLinkManager, + OrbitController, + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, + destroyTrackedReadout, + initTrackedReadout, + destroyWorldOverlay, + initWorldOverlay, + destroyDetection, + initDetection, + cycleDetectionMode, + readDetectionDiagnostics, + getDetectionTuning, + getDetectionMode, + setDetectionModeByLabel, + suspendDetection, + resumeDetection, + setDetectionStyle, + setDetectionTuning, + trafficLayer, + flightsLayer, + militaryFlightsLayer, + isTr3b, + toggleTr3b, + satellitesLayer, + cctvLayer, + radioLayer, + bikeshareLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + militaryInstallationsLayer, + rocketLaunchesLayer, + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, + sampleMeshFloorCells, + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, + LocationSearch, + }, + }); + } +} diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 21a3cd190..8ab7eb914 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -13,7 +13,7 @@ export const nightVisionShader = { name: 'surveillance', uniforms: { gain: { default: 0.55, min: 0, max: 1, label: 'Gain' }, - bloom: { default: 0.30, min: 0, max: 1, label: 'Bloom' }, + bloom: { default: 0.3, min: 0, max: 1, label: 'Bloom' }, scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' }, pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' }, }, diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs new file mode 100644 index 000000000..4d07e0407 --- /dev/null +++ b/src/testSupport/readLayerSource.mjs @@ -0,0 +1,53 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Read actual component owners for structural regression assertions. */ +export function readLayerSource(file) { + const path = file instanceof URL ? fileURLToPath(file) : file; + if ( + ![ + 'flights.js', + 'militaryFlights.js', + 'aisLiveVessels.js', + 'firmsHeatmap.js', + 'satellites.js', + 'rocketLaunches.js', + 'militaryInstallations.js', + 'militaryAwareness.js', + 'traffic.js', + 'bikeshare.js', + 'cctv.js', + ].includes(basename(path)) + ) + return readFileSync(path, 'utf8'); + const directory = join( + dirname(path), + basename(path) === 'flights.js' + ? '../layers/flights' + : basename(path) === 'militaryFlights.js' + ? '../layers/military' + : basename(path) === 'firmsHeatmap.js' + ? '../layers/firms' + : basename(path) === 'satellites.js' + ? '../layers/satellites' + : basename(path) === 'rocketLaunches.js' + ? '../layers/launches' + : basename(path) === 'militaryInstallations.js' + ? '../layers/installations' + : basename(path) === 'militaryAwareness.js' + ? '../layers/awareness' + : basename(path) === 'traffic.js' + ? '../layers/traffic' + : basename(path) === 'bikeshare.js' + ? '../layers/bikeshare' + : basename(path) === 'cctv.js' + ? '../layers/cctv' + : '../layers/vessels', + ); + return readdirSync(directory) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => readFileSync(join(directory, name), 'utf8')) + .join('\n'); +} diff --git a/src/testSupport/readStylesheet.mjs b/src/testSupport/readStylesheet.mjs new file mode 100644 index 000000000..6ce02400e --- /dev/null +++ b/src/testSupport/readStylesheet.mjs @@ -0,0 +1,16 @@ +/** Resolve the actual ordered local CSS imports used by the application entry. */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +export function readStylesheet(file, ancestors = new Set()) { + const resolved = path.resolve( + file instanceof URL ? fileURLToPath(file) : file, + ); + if (ancestors.has(resolved)) throw new Error('Circular stylesheet import'); + const next = new Set(ancestors).add(resolved); + return readFileSync(resolved, 'utf8').replace( + /@import ['"]([^'"]+)['"];\s*/g, + (_, imported) => + readStylesheet(path.resolve(path.dirname(resolved), imported), next), + ); +} diff --git a/src/ui.js b/src/ui.js index e614eae70..ada8689fe 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,9853 +1,2 @@ -import { bindDisplayControls } from './ui/displayControls.js'; -import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js'; -import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; -import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; -import * as Cesium from 'cesium'; -import { retroShader } from './styles/retro.js'; -import { animeShader } from './styles/anime.js'; -import { noirShader } from './styles/noir.js'; -import { snowShader } from './styles/snow.js'; -import { nightVisionShader } from './styles/surveillance.js'; -import { thermalShader } from './styles/thermal.js'; -import { - BLOOM_INTENSITY_DEFAULT, - BLOOM_SCALE_VERSION, - bloomStrengthFromIntensity, - clampBloomIntensity, - decodeBloomIntensity, -} from './bloom.js'; -import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; -import { locationMiniStatus } from './locationStatus.js'; -import { interruptCameraMotion } from './cameraVerbs.js'; -import { - aircraftTrackingTarget, - enterCockpitWithTracking, -} from './cockpitTracking.js'; -import { IntelHUD } from './hud.js'; -import { ShareLinkManager } from './sharelink.js'; -import { - isExplicitLayerStateOrigin, - LayerStateCoordinator, -} from './data/layerState.js'; -import { renderMapStackChips, syncMapStackChips } from './mapStackChips.js'; -import { OrbitController } from './orbit.js'; -import { - CelestialRing, - getKeyholeFadeTuning, - isCelestialRingStyleSupported, - setKeyholeFadeTuning, -} from './celestialRing.js'; -import { destroyTrackedReadout, initTrackedReadout } from './data/trackedReadout.js'; -import { destroyWorldOverlay, initWorldOverlay } from './overlays/worldOverlay.js'; -import { - destroyDetection, - initDetection, - cycleMode as cycleDetectionMode, - getDetectionDiagnostics as readDetectionDiagnostics, - getDetectionTuning, - getMode as getDetectionMode, - setMode as setDetectionModeByLabel, - suspendDetection, - resumeDetection, - setDetectionStyle, - setDetectionTuning, -} from './data/detection.js'; -import { - ALLOCATION_STRATEGIES, - canonicalizeDensity, - defaultDensityForProfile, - normalizeAllocationStrategy, - normalizeProfile, - profileForDensity, -} from './data/detectionPolicy.js'; -import trafficLayer from './data/traffic.js'; -import flightsLayer from './data/flights.js'; -import militaryFlightsLayer from './data/militaryFlights.js'; -import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js'; -import satellitesLayer from './data/satellites.js'; -import cctvLayer from './data/cctv.js'; -import radioLayer, { - buildRadioTunerTicks, - radioTunerCommitSlot, - radioTunerPointerPosition, - radioTunerSlot, -} from './data/radio.js'; -import bikeshareLayer from './data/bikeshare.js'; -import aisLiveVesselsLayer from './data/aisLiveVessels.js'; -import militaryAwarenessLayer from './data/militaryAwareness.js'; -import militaryInstallationsLayer from './data/militaryInstallations.js'; -import rocketLaunchesLayer from './data/rocketLaunches.js'; -import { - aggregateLayerLoading, - canPresentDeferredStatusNotice, - createGlobalStatusNotice, - createLoadingFeedbackState, - createTrafficSyncFeedbackState, - presentGlobalLoadingStatus, - presentGlobalStatusNotice, - presentLoadingFeedback, - reduceLoadingFeedback, - reduceTrafficSyncFeedback, -} from './loadingFeedback.js'; -import { setSplitFlapText } from './splitFlap.js'; -import { - cockpitEntryAllowed, - contextAllowedLayerIds, - contextLayerEnableBlockReason, - contextRestoreLayerIds, - contextSnapshotLayerIds, - isExplicitUserIntentOrigin, - mergeContextTransitionErrors, - recordContextRestoreExplicitChange, - recordContextSessionUserChange, - runWithContextModeChanging, - settleContextModeChange, - settleContextIntentReplay, - settleUserFacingContextAction, - shouldCaptureContextSession, - shouldDeferContextEntryDuringClear, - shouldExitContextForLayerChange, - spaceMissionEntryCancellationDisposition, - contextModeWord, -} from './contextModePolicy.js'; -import { - shouldExpandGlobalContextPanel, -} from './rightRailPolicy.js'; -import { - resolveCockpitUtilityAnchor, - resolveCockpitUtilityLayout, -} from './cockpitUtilityLayout.js'; -import { - applyCockpitVisionStageIntensities, - captureCockpitVisionBaseline, - COCKPIT_VISION_MODES, - normalizeCockpitVisionMode, -} from './cockpitVisionPolicy.js'; -import { - applyContactsDetection, - shareCacheNeedsHeal, - shareableDetectionState, -} from './contactsDetectionPolicy.js'; -import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; -import { runCctvLayerEnableTransition } from './cctvFocusPolicy.js'; -import { - registerCctvFocusRequestListener, - routeCctvFocusRequest, -} from './cctvFocusRequest.js'; -import { - flyToWorldTarget, - registerWorldFocusRequestListener, - routeWorldFocusRequest, -} from './worldFocus.js'; -import { - beginDeferredNavigation, - reassertNavigationHandoff, - registerNavigationAuthorityListener, - runExplicitNavigation, - stampInitialShareGesture, -} from './navigationPolicy.js'; -import { - cachedGroundFloor, - cachedMeshFloor, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, -} from './data/groundFloor.js'; -import { sampleMeshFloorCells } from './data/meshFloorSampler.js'; -import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from './renderGovernor.js'; -import { - setScopeMaskEnabled, - isScopeMaskEnabled, - setScopeMaskFeather, - getScopeMaskFeather, - setScopeTerminusOverride, - getScopeTerminusOverride, - clampScopeTerminusPct, -} from './scopeMask.js'; -import { - fetchRegionalBrief, - regionalDistanceM, - weatherCodeLabel, -} from './data/regionalBrief.js'; -import { - altitudeRulerCurveInset, - altitudeRulerTicks, - bearingBetweenCoordinates, - cockpitAnchorCorrectionStep, - cockpitAltitudeDisplayFt, - cockpitGroundSafeHeight, - cockpitSurfaceWaitExpired, - cockpitUiUpdateDue, - compassDivisions, - formatAltitudeRulerTick, - formatCockpitContextScope, - formatCompassDivision, - formatSpeedRulerTick, - normalizeHeading, - relativeBearing, - resolveCockpitContextReadout, - resolveHudRailLayout, - resolveTrackedAircraftInfo, - slewHeading, - speedRulerTicks, -} from './cockpitMath.js'; - -/** Duration (ms) for shader intensity crossfade between style presets. */ -const TRANSITION_DURATION_MS = 500; -/** Map of style name to its GLSL shader module for post-process stages. */ -const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; -/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ -const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; -const SHARE_PANEL_STATE_SPECS = Object.freeze([ - { id: 'control-panel', pinnable: true }, - { id: 'location-bar', pinnable: true }, - { id: 'data-panel' }, - { id: 'cctv-panel' }, - { id: 'radio-panel' }, - { id: 'scene-panel' }, - { id: 'global-context-panel' }, - { id: 'pp-toggles' }, - { id: 'param-slider-panel' }, -]); -/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */ -const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ - 'data-panel', - 'cctv-panel', - 'scene-panel', - 'pp-toggles', - 'global-context-panel', - 'radio-panel', -]); -/** - * Position keys are versioned separately from collapsed-state keys so layout - * default changes (e.g. right-rail origin) can reset positions without also - * resetting every panel's open/closed preference. - */ -const PANEL_POSITION_STORAGE_VERSION = 'v8'; -const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; -/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ -const PANEL_Z_BASE = 100; -const PANEL_Z_MAX = 139; -const COCKPIT_HEADING_SLEW_DPS = 28; -const COCKPIT_FORWARD_OFFSET_M = 7; -const COCKPIT_UP_OFFSET_M = 2.6; -const COCKPIT_MIN_GROUND_CLEARANCE_M = 12; -const COCKPIT_VIEW_PITCH_DEG = -4; -const COCKPIT_CAMERA_UPDATE_MS = 50; -const COCKPIT_HUD_UPDATE_MS = 100; -const COCKPIT_CONTEXT_UPDATE_MS = 250; -const COCKPIT_LAYOUT_SETTLE_MS = 240; -// Cockpit's Display/Radio strip hangs off the REC readout it shares the right -// margin with, and never touches the briefing card underneath it. -const COCKPIT_UTILITY_REC_GAP_PX = 12; -const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8; -const COCKPIT_UTILITY_MIN_TOP_PX = 96; -const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12; -const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50; -const COCKPIT_GROUND_PROBE_MS = 500; -const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; -const COCKPIT_BRIEF_ROTATE_MS = 9000; -const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; -const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; -const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; -const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; -const COCKPIT_BRIEF_PAGES = [ - { - id: 'signals', - kicker: 'LIVE SIGNALS', - subtitle: 'OBSERVED / MAPPED PINGS', - source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', - }, - { - id: 'news', - kicker: 'REGIONAL NEWS', - subtitle: 'LATEST LOCATION-MATCHED REPORTING', - source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', - }, - { - id: 'local', - kicker: 'LOCAL INFO', - subtitle: 'PLACE / CONDITIONS / POSITION', - source: 'OPENSTREETMAP · OPEN-METEO · UTC', - }, -]; -/** - * Fixed UI regions that can occupy the left accordion's vertical lane. - * Rectangles are filtered at runtime for visibility and horizontal overlap, - * so right-side/center controls do not reduce the lane unless they actually - * intersect it at the current viewport size. - */ -const LEFT_STACK_OBSTACLE_SELECTOR = [ - '#cockpit-hud .cockpit-topline', - '#cockpit-hud .cockpit-topline > div', - '#title-bar', - '#style-indicator', - '#top-center-actions', - '#traffic-sync-chip', - '#cctv-sync-chip', - '#intel-hud .hud-top-left', - '#intel-hud .hud-top-right', - '#intel-hud .hud-bottom-left', - '#intel-hud .hud-bottom-right', - '#intel-hud .hud-top-bar', - '#intel-hud .hud-bottom-bar', - '#intel-hud .hud-left-edge', - '#intel-hud .hud-right-edge', - '#cockpit-context', - '#cesium-credits .cesium-credit-logoContainer', - '#cesium-credits .cesium-credit-textContainer', - '#location-bar', - '#control-panel', - '#gev-voice-control', - '#pp-toggles', - '#param-slider-panel', -].join(', '); -/** - * Whether an element currently occupies screen space a layout must respect. - * - * A rect alone is not enough: `visibility: hidden` and `opacity: 0` (how the - * Intel HUD retires as a whole) leave the rect intact, so anything anchoring to - * a HUD readout must walk the ancestors as well. - * - * @param {Element|null|undefined} element - Candidate element. - * @returns {boolean} True when the element is painted and has area. - */ -function isRenderedOnScreen(element) { - if (!element) return false; - for (let node = element; node instanceof Element; node = node.parentElement) { - const style = getComputedStyle(node); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { - return false; - } - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} -/** - * Fixed UI regions that can occupy the right control lane. Runtime rectangle - * filtering keeps the rail clear of whichever HUD variant is currently - * visible without tying the layout to one screen height. - */ -const RIGHT_STACK_OBSTACLE_SELECTOR = [ - '#cockpit-hud .cockpit-topline', - '#cockpit-hud .cockpit-topline > div', - '#title-bar', - '#style-indicator', - '#top-center-actions', - '#traffic-sync-chip', - '#cctv-sync-chip', - '#intel-hud .hud-top-left', - '#intel-hud .hud-top-right', - '#intel-hud .hud-bottom-left', - '#intel-hud .hud-bottom-right', - '#intel-hud .hud-top-bar', - '#intel-hud .hud-bottom-bar', - '#intel-hud .hud-left-edge', - '#intel-hud .hud-right-edge', - '#cockpit-context', - '#cockpit-signal-stream', - '#cesium-credits .cesium-credit-logoContainer', - '#cesium-credits .cesium-credit-textContainer', - '#command-dock', - '#gev-voice-control', -].join(', '); -/** Display labels shown in the mini-status readout for each active style. */ -const STYLE_STATUS_LABELS = { - normal: 'NORMAL', - retro: 'CRT', - surveillance: 'NVG', - thermal: 'FLIR', - anime: 'ANIME', - noir: 'NOIR', - snow: 'SNOW', -}; -/** - * The tactical detection look: Dense at 75%. - * - * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade - * and 5% outside, whatever we had. I want that as the default. It should just - * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever - * we had" still needs nothing here — but they are 7% and 1% now, the outside - * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the - * baseline of the day, not the two numbers it happened to name. - * - * ONE object, shared by the first-load baseline below, by every military style, - * AND by the Contacts context mode (which OWNS detection while active and - * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit - * deliberately does NOT touch detection: entering it with SPARSE selected leaves - * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads - * from it. - */ -const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); - -/** Baseline post-processing settings applied on first load (before share-link restore). */ -const GLOBAL_POST_DEFAULTS = { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - hudVariant: 'tactical', - hudVisible: true, - // Detection is ON for EVERY style on a first run, Normal included (owner - // directive 2026-08-22: "detect should also be on by default"). It is the - // same preset object the military styles and Contacts already apply, so there - // is one tactical look, not several that can drift. - // - // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` - // runs before any share-link restore, so a link's `dm`/`dd` still lands on top - // of it. It also deliberately leaves `_detectionUserOverridden` alone — the - // flag means the OPERATOR hand-edited detection, and a factory default is not - // that. Turning detection off by hand therefore still sets the flag and still - // suppresses the military-style auto-enable for the rest of the session. - detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), - detectionDensity: MILITARY_DETECTION_PRESET.densityPct, - detectionAllocation: 'ELASTIC', - detectionFadePct: 7, - detectionOutsideOpacityPct: 1, - celestialRing: false, -}; - -// Tactical style defaults applied when users select military style presets. -const STYLE_PRESET_DEFAULTS = { - retro: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - retro: { - pixelation: 1.0, - distortion: 0, - instability: 0.42, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - surveillance: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - surveillance: { - gain: 0.18, - bloom: 0.22, - scanlineStr: 0.96, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - thermal: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - thermal: { - sensitivity: 0.85, - bloom: 0.2, - mode: 0.33, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, -}; - -/** - * GLSL fragment shader implementing an unsharp-mask sharpening filter. - * Samples a 3x3 neighborhood, computes box blur, then adds the - * difference (center - blur) scaled by `amount` for edge enhancement. - */ -const SHARPEN_SHADER = /* glsl */ ` - uniform sampler2D colorTexture; - uniform vec2 colorTextureDimensions; - uniform float amount; - in vec2 v_textureCoordinates; - - void main() { - vec2 uv = v_textureCoordinates; - vec2 texel = 1.0 / colorTextureDimensions; - vec4 center = texture(colorTexture, uv); - vec4 blur = ( - texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + - texture(colorTexture, uv + vec2( 0.0, -texel.y)) + - texture(colorTexture, uv + vec2( texel.x, -texel.y)) + - texture(colorTexture, uv + vec2(-texel.x, 0.0)) + - center + - texture(colorTexture, uv + vec2( texel.x, 0.0)) + - texture(colorTexture, uv + vec2(-texel.x, texel.y)) + - texture(colorTexture, uv + vec2( 0.0, texel.y)) + - texture(colorTexture, uv + vec2( texel.x, texel.y)) - ) / 9.0; - vec4 sharpened = center + (center - blur) * amount; - out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); - } -`; - -/** - * Central UI orchestrator for the God's Eye View application. - * - * Responsibilities: - * - CesiumJS PostProcessStage pipeline: registers per-style GLSL stages - * (NVG, FLIR, CRT, anime, noir, snow) and manages intensity crossfades. - * - Bloom and sharpen post-processing toggle/intensity control. - * - Draggable/collapsible panel system with localStorage persistence, - * z-order stacking, and viewport-clamped positioning. - * - CCTV panel: camera selection, coverage toggle, projection, calibration - * sliders, auto-hop, and summary typewriter effect. - * - Location bar with city/POI preset pills, QWERTY key navigation, - * geocoding search, and inter-city world-jump transitions. - * - Orbit controller integration for POI fly-around. - * - Recording mode with safe-frame overlay and HUD mode switching. - * - Share link encoding/decoding (delegates to ShareLinkManager). - * - Detection overlay mode cycling and density tuning. - * - Toast notification system. - * - Intel HUD lifecycle and variant switching. - */ - -/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ -const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; - -/** - * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the - * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old - * "HEADING 0°" nonsense); typed values convert back to calibration offsets - * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon - * typing is user-hostile). - */ -const CCTV_CAL_FIELDS = { - heading: { - label: 'HDG', unit: '°', decimals: 1, - get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), - }, - pitch: { - label: 'PITCH', unit: '°', decimals: 1, - get: (cam) => cam.pitchDeg, - toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), - }, - fov: { - label: 'FOV', unit: '°', decimals: 0, - get: (cam) => cam.fovDeg, - toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), - }, - range: { - label: 'RANGE', unit: 'm', decimals: 0, - get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), - }, - height: { - label: 'HGT', unit: 'm', decimals: 0, - get: (cam) => cam.mountHeightM, - toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), - }, - north: { - label: 'ΔN', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetNorthM || 0, - toPatch: (value) => ({ offsetNorthM: value }), - }, - east: { - label: 'ΔE', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetEastM || 0, - toPatch: (value) => ({ offsetEastM: value }), - }, -}; - -function formatCockpitBriefAge(value) { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; - const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); - if (minutes < 60) return `${minutes}M AGO`; - const hours = Math.round(minutes / 60); - return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`; -} - -function formatCockpitWindDirection(value) { - if (!Number.isFinite(value)) return 'DIR UNKNOWN'; - const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - const normalized = ((value % 360) + 360) % 360; - return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; -} - -function setCockpitRollingValue(element, text, numericValue, { - circularRange = null, - immediate = false, -} = {}) { - if (!element) return; - const nextText = String(text); - const previousText = element.dataset.rollingText; - const previousValue = Number(element.dataset.rollingValue); - const nowMs = performance.now(); - const lastRollMs = Number(element.dataset.rollingAt); - if (!immediate - && previousText !== undefined - && previousText !== nextText - && Number.isFinite(lastRollMs) - && nowMs - lastRollMs < 220) { - return; - } - element.dataset.rollingText = nextText; - element.dataset.rollingAt = String(nowMs); - if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); - else delete element.dataset.rollingValue; - element.setAttribute('aria-label', nextText); - - if (immediate || previousText === undefined || previousText === nextText) { - if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { - element.replaceChildren(...Array.from(nextText, (character) => { - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - token.textContent = character; - return token; - })); - } - return; - } - - let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) - ? numericValue - previousValue - : 0; - if (Number.isFinite(circularRange) && circularRange > 0) { - const halfRange = circularRange / 2; - if (delta > halfRange) delta -= circularRange; - else if (delta < -halfRange) delta += circularRange; - } - const direction = delta < 0 ? 'down' : 'up'; - const width = Math.max(previousText.length, nextText.length); - const from = previousText.padStart(width, ' '); - const to = nextText.padStart(width, ' '); - const fragment = document.createDocumentFragment(); - - for (let index = 0; index < width; index += 1) { - const previousCharacter = from[index]; - const nextCharacter = to[index]; - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - - if (previousCharacter === nextCharacter - || !/\d/.test(previousCharacter) - || !/\d/.test(nextCharacter)) { - token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; - fragment.append(token); - continue; - } - - token.classList.add('is-rolling', `roll-${direction}`); - const track = document.createElement('span'); - track.className = 'cockpit-roll-track'; - const first = document.createElement('span'); - const second = document.createElement('span'); - first.textContent = direction === 'up' ? previousCharacter : nextCharacter; - second.textContent = direction === 'up' ? nextCharacter : previousCharacter; - track.append(first, second); - token.append(track); - fragment.append(token); - } - - element.replaceChildren(fragment); -} - -class CockpitViewController { - constructor(viewer, { - onVisionChange = null, - onCameraTakeover = null, - isEntryAllowed = null, - onEntered = null, - onExited = null, - getInheritedVisionLabel = null, - restoreTrackingFrame = null, - } = {}) { - this.viewer = viewer; - this.active = false; - this.trackedEntity = null; - this.trackedEntityWasShown = true; - this.heading = null; - this.lastFrameMs = 0; - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastGroundProbeMs = 0; - this.contextNavigationDeadlineMs = 0; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.entry = document.getElementById('cockpit-entry'); - this.tr3bToggle = document.getElementById('tr3b-toggle'); - this._tr3bSignature = null; - this.mapViewButton = document.getElementById('map-view-switch'); - this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); - this.hud = document.getElementById('cockpit-hud'); - this.entryFocusOrigin = null; - this.callsign = document.getElementById('cockpit-callsign'); - this.speed = document.getElementById('cockpit-speed-value'); - this.speedRim = document.getElementById('cockpit-speed-rim'); - this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); - this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); - this.altitude = document.getElementById('cockpit-altitude-value'); - this.altitudeRim = document.getElementById('cockpit-altitude-rim'); - this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); - this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); - this.headingValue = document.getElementById('cockpit-heading-value'); - this.compassTape = document.getElementById('cockpit-compass-tape'); - this.clock = document.getElementById('cockpit-clock'); - this.position = document.getElementById('cockpit-position'); - this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); - this.route = document.getElementById('cockpit-route'); - this.routeFrom = document.getElementById('cockpit-route-from'); - this.routeTo = document.getElementById('cockpit-route-to'); - this.routeStatus = document.getElementById('cockpit-route-status'); - this.routeDirection = document.getElementById('cockpit-route-direction'); - this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); - this.visionPrevious = document.getElementById('cockpit-vision-previous'); - this.visionCurrent = document.getElementById('cockpit-vision-current'); - this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); - this.visionNext = document.getElementById('cockpit-vision-next'); - this.visionMode = 'optical'; - this.onVisionChange = onVisionChange; - this.onCameraTakeover = onCameraTakeover; - this.onEntered = onEntered; - this.onExited = onExited; - this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' - ? getInheritedVisionLabel - : () => 'NORMAL'; - this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' - ? restoreTrackingFrame - : () => false; - this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; - this.context = document.getElementById('cockpit-context'); - this.contextSubject = document.getElementById('cockpit-context-subject'); - this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); - this.contextBearing = document.getElementById('cockpit-context-bearing'); - this.contextDistance = document.getElementById('cockpit-context-distance'); - this.contextDirection = document.getElementById('cockpit-context-direction'); - this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); - this.contextUpdated = document.getElementById('cockpit-context-updated'); - this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) - .map((element) => [element.dataset.contextCohort, element])); - this.contextPrevious = document.getElementById('cockpit-context-previous'); - this.contextNext = document.getElementById('cockpit-context-next'); - this.contextToggle = document.getElementById('cockpit-context-toggle'); - this.weatherToggle = document.getElementById('cockpit-weather-toggle'); - this.weatherState = document.getElementById('cockpit-weather-state'); - this.contextCollapsed = false; - this.signalStream = document.getElementById('cockpit-signal-stream'); - this.signalList = document.getElementById('cockpit-signal-list'); - this.signalToggle = document.getElementById('cockpit-signal-toggle'); - this.briefKicker = document.getElementById('cockpit-brief-kicker'); - this.briefSubtitle = document.getElementById('cockpit-brief-subtitle'); - this.briefPrevious = document.getElementById('cockpit-brief-previous'); - this.briefNext = document.getElementById('cockpit-brief-next'); - this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); - this.briefPosition = document.getElementById('cockpit-brief-position'); - this.briefSource = document.getElementById('cockpit-brief-source'); - this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); - this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); - this.newsStatus = document.getElementById('cockpit-news-status'); - this.newsList = document.getElementById('cockpit-news-list'); - this.localPlace = document.getElementById('cockpit-local-place'); - this.localCoordinates = document.getElementById('cockpit-local-coordinates'); - this.localTemperature = document.getElementById('cockpit-local-temperature'); - this.localWind = document.getElementById('cockpit-local-wind'); - this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); - this.localCondition = document.getElementById('cockpit-local-condition'); - this.localCloud = document.getElementById('cockpit-local-cloud'); - this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); - this.signalCollapsed = false; - this.signalUserCollapsed = false; - this.signalItems = []; - this.signalSignatures = new Map(); - this.briefPageIndex = 0; - this.briefAutoRotateEnabled = false; - this.briefTimer = null; - this.lastAircraftInfo = null; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - this.regionalBriefAbort = null; - this.regionalBriefRequestToken = 0; - this.regionalBriefSubjectId = null; - this.contextLayoutFrame = null; - this.contextLayoutStamp = null; - this.scratchTarget = new Cesium.Cartesian3(); - this.cockpitAnchor = new Cesium.Cartesian3(); - this.cockpitAnchorValid = false; - this.scratchCamera = new Cesium.Cartesian3(); - this.scratchAdvance = new Cesium.Cartesian3(); - this.scratchCorrection = new Cesium.Cartesian3(); - this.scratchForward = new Cesium.Cartesian3(); - this.scratchHorizontal = new Cesium.Cartesian3(); - this.scratchUp = new Cesium.Cartesian3(); - this.scratchLocal = new Cesium.Cartesian3(); - this.scratchEnu = new Cesium.Matrix4(); - this.scratchAnchorCartographic = new Cesium.Cartographic(); - this.scratchCameraCartographic = new Cesium.Cartographic(); - this.scratchTargetCartographic = new Cesium.Cartographic(); - this._listenerRemovers = []; - - // Camera mutations belong before scene update/culling. Changing the camera - // from preRender makes 3D Tiles discover a new view after traversal and can - // create a self-sustaining refinement loop under a moving cockpit camera. - this._listenerRemovers.push( - viewer.scene.preUpdate.addEventListener(() => this.update()), - viewer.trackedEntityChanged.addEventListener(() => { - if (this.active) this._adoptTrackedEntity(performance.now()); - else this.syncEntry(); - }), - ); - this._listen(this.entry, 'click', () => this.enter()); - this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); - this._listen(this.mapViewButton, 'click', () => this.exit()); - this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); - this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); - this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); - this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); - this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); - this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); - this._listen(this.weatherToggle, 'click', () => { - const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; - this.syncWeatherToggle(enabled); - window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { - detail: { enabled }, - })); - }); - this._listen(window, 'gev:cockpit-weather-state', (event) => { - this.syncWeatherToggle(event?.detail?.enabled !== false); - }); - this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( - !this.signalCollapsed, - { user: true }, - )); - this._listen(this.signalList, 'click', (event) => { - const target = event.target.closest('button[data-signal-layer][data-signal-id]'); - if (!target) return; - event.preventDefault(); - militaryAwarenessLayer.focusTarget?.( - target.dataset.signalLayer, - target.dataset.signalId, - { origin: 'user' }, - ); - }); - this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); - this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); - this._listen(this.briefAutoToggle, 'click', () => { - this.setBriefAutoRotate(!this.briefAutoRotateEnabled); - }); - this.briefTabs.forEach((button) => this._listen(button, 'click', () => { - this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); - })); - this._listen(document, 'visibilitychange', () => { - if (document.hidden) this.stopBriefRotation(); - else if (this.briefAutoRotateEnabled) this.startBriefRotation(); - }); - this._listen(window, 'resize', () => this.scheduleContextLayout()); - this._listen(document, 'keydown', (event) => this.onKeyDown(event), true); - } - - _listen(target, type, handler, options) { - if (!target?.addEventListener) return; - target.addEventListener(type, handler, options); - this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); - } - - syncWeatherToggle(enabled) { - if (!this.weatherToggle) return; - const active = !!enabled; - this.weatherToggle.setAttribute('aria-pressed', String(active)); - this.weatherToggle.setAttribute( - 'aria-label', - `${active ? 'Disable' : 'Enable'} cockpit weather effects`, - ); - this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; - if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; - } - - readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; - return resolveTrackedAircraftInfo({ - civilian: flightsLayer.getTrackedInfo?.() || null, - military: militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: trackedEntity?.gevTrackedId || '', - }); - } - - dispatchCockpitModeChanged(active, info = null) { - const subjectId = active - ? String(info?.icao24 || '').trim().toLowerCase() || null - : null; - const layerId = active && ['flights', 'military'].includes(info?.layerId) - ? info.layerId - : null; - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { - detail: { active: active === true, subjectId, layerId }, - })); - } - - /** - * TR-3B Easter egg: flip the tracked contact between its real silhouette and - * the black triangle. The registry owns the session state; the owning layer - * re-derives what renders. - * @returns {boolean} True when a tracked contact was converted/restored. - */ - toggleTrackedTr3b() { - const info = this.readAircraftInfo(); - const icao24 = String(info?.icao24 || '').trim(); - if (!icao24) return false; - toggleTr3b(icao24); - const layer = info.layerId === 'military' ? militaryFlightsLayer : flightsLayer; - layer.refreshTr3b?.(icao24); - this._tr3bSignature = null; // force the chip to repaint on the next sync - this.syncTr3bToggle(info); - return true; - } - - /** - * Show the 🛸 chip whenever a contact is tracked (its own gate — converting - * does not depend on the cockpit entry policy) and mirror the conversion - * state. Change-only DOM writes: this runs on the preUpdate cadence. - * @param {object|null} info Tracked-aircraft descriptor, or null. - */ - syncTr3bToggle(info) { - if (!this.tr3bToggle) return; - const icao24 = String(info?.icao24 || '').trim(); - const converted = !!icao24 && isTr3b(icao24); - const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; - if (this._tr3bSignature === signature) return; - this._tr3bSignature = signature; - this.tr3bToggle.hidden = !icao24; - this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); - this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; - } - - syncEntry() { - if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); - // Change-only DOM writes: this runs on a preUpdate cadence, and - // unconditional `hidden` assignments invalidate style/layout every frame - // even when nothing changed. (perf item 9) - if (this._entryAvailable === available) return; - this._entryAvailable = available; - if (this.entry) this.entry.hidden = !available; - if (this.mapViewButton) this.mapViewButton.hidden = true; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; - } - - /** - * Navigate Contacts through one Cockpit-owned funnel. A short grace window - * covers feed-refresh handoffs where the old source releases before the new - * source publishes its entity; trackedEntityChanged adopts synchronously as - * soon as the replacement exists, so the normal path does not wait. - */ - navigateContext(direction, options = {}) { - const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; - const wasActive = this.active; - if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; - const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; - const changed = Boolean(militaryAwarenessLayer?.[method]?.(navigationOptions)); - if (!changed) { - this.contextNavigationDeadlineMs = 0; - return false; - } - if (wasActive) this._adoptTrackedEntity(performance.now()); - return true; - } - - /** Adopt a newly selected aircraft without ever leaving Cockpit. */ - _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; - if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; - const info = suppliedInfo || this.readAircraftInfo(); - if (!info) return false; - if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { - this.trackedEntity.show = this.trackedEntityWasShown; - } - this.trackedEntity = nextEntity; - this.trackedEntityWasShown = nextEntity.show; - nextEntity.show = false; - this.viewer.trackedEntity = undefined; - this.cockpitAnchorValid = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = nowMs; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastCameraUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.dispatchCockpitModeChanged(true, info); - return true; - } - - setVisionMode(mode, { revealParameters = false } = {}) { - const next = normalizeCockpitVisionMode(mode); - this.visionMode = next; - const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); - const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; - const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; - if (this.visionCurrent) { - this.visionCurrent.dataset.cockpitVision = next; - this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); - this.visionCurrent.title = `Current style: ${names[next]} — click for next`; - } - if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; - this.onVisionChange?.(next, this.active, { revealParameters }); - } - - cycleVisionMode(direction = 1) { - const modes = COCKPIT_VISION_MODES; - const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); - const step = direction < 0 ? -1 : 1; - const nextIndex = (currentIndex + step + modes.length) % modes.length; - this.setVisionMode(modes[nextIndex], { revealParameters: true }); - } - - clearPredictiveRoute() { - if (this.routeDirection) this.routeDirection.hidden = true; - } - - onKeyDown(event) { - if (event.repeat || event.isComposing) return; - if (event.key === 'Escape' && this.active) { - // The credit lightbox owns Escape while its Close control or links hold - // focus. Its target handler closes the overlay and restores attribution - // focus; Cockpit must stay active behind it. - if (event.target?.closest?.('.cesium-credit-lightbox')) return; - if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; - if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; - if (this.context?.contains(event.target) && !this.contextCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setContextCollapsed(true); - if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { - this.contextToggle?.blur?.(); - } else { - this.contextToggle?.focus({ preventScroll: true }); - } - return; - } - if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setSignalCollapsed(true, { user: true }); - if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { - this.signalToggle?.blur?.(); - } else { - this.signalToggle?.focus({ preventScroll: true }); - } - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - this.exit(); - return; - } - if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; - const key = event.key?.toLowerCase(); - if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { - if (!this.active) { - const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); - if (!cockpitAttempt) return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); - return; - } - } - - enter() { - if (this.active) return false; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; - this.entryFocusOrigin = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - // Retire deferred navigation before cancelFlight can run its callbacks. - this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); - this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = performance.now(); - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastGroundProbeMs = 0; - this.surfaceWaitStartedMs = performance.now(); - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.cockpitAnchorValid = false; - this.lastCameraUpdateMs = 0; - this.active = true; - // Cockpit animates the camera from preUpdate every frame — preUpdate only - // runs on rendered frames, so idle mode would freeze the cockpit solid. - // (perf wave 2) - holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; - document.body.classList.add('cockpit-mode'); - // Activation writes entry/quick/map visibility directly, bypassing - // syncEntry's change-only cache — invalidate it so the exit-path - // syncEntry re-applies every write (notably re-hiding mapViewButton). - this._entryAvailable = undefined; - if (this.entry) this.entry.hidden = true; - if (this.tr3bToggle) { - this.tr3bToggle.hidden = true; - this._tr3bSignature = null; - } - if (this.mapViewButton) this.mapViewButton.hidden = false; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; - if (this.hud) this.hud.hidden = false; - if (this.signalStream) this.signalStream.hidden = false; - this.hud?.classList.add('signals-active'); - this.signalItems = []; - this.signalSignatures.clear(); - this.showBriefPage(0); - this.startBriefRotation(); - const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); - this.pushCockpitSignal( - 'track', - 'track', - 'TRACK ACQUIRED', - `${trackLabel} · COURSE ${trackHeading}°`, - ); - this.updateHud(info, performance.now(), true); - this.setVisionMode(this.visionMode); - this.scheduleContextLayout(); - this.mapViewButton?.focus({ preventScroll: true }); - this.onEntered?.(); - this.dispatchCockpitModeChanged(true, info); - return true; - } - - exit({ restoreTracking = true } = {}) { - if (!this.active) return false; - const entity = this.trackedEntity; - this.active = false; - releaseContinuousRender('cockpit'); - this.trackedEntity = null; - this.heading = null; - this.cockpitAnchorValid = false; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastCompassSignature = ''; - this.stopBriefRotation(); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); - this.onExited?.(); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - if (this.hud) this.hud.hidden = true; - if (this.route) this.route.hidden = true; - this.clearPredictiveRoute(); - this.setVisionMode('optical'); - if (this.signalStream) this.signalStream.hidden = true; - this.hud?.classList.remove('signals-active'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; - if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; - this.trackedEntityWasShown = true; - this.dispatchCockpitModeChanged(false); - if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; - this.restoreTrackingFrame(entity); - } - this.syncEntry(); - const restoreTarget = this.entryFocusOrigin === this.entry - ? this.entry - : (this.entry || this.entryFocusOrigin); - this.entryFocusOrigin = null; - if (restoreTarget?.isConnected && !restoreTarget.hidden) { - restoreTarget.focus({ preventScroll: true }); - } - return true; - } - - update() { - if (!this.active) { - // Entry availability changes on a human timescale (tracking start/stop, - // info arriving after a poll) — polling it every rendered frame ran - // readAircraftInfo() + DOM pokes at display rate in plain map mode. - // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still - // fires syncEntry immediately on the events that matter. (perf item 9) - const nowMs = performance.now(); - if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { - this._lastEntrySyncMs = nowMs; - this.syncEntry(); - } - return; - } - - const nowMs = performance.now(); - const info = this.readAircraftInfo(); - - // Adopt a newly selected track before the camera cadence gate. A context - // NEXT/PREV selection can otherwise spend one frame driving the old - // aircraft with the new aircraft's metadata. - this._adoptTrackedEntity(nowMs, info); - if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { - if (nowMs < this.contextNavigationDeadlineMs) return; - this.exit({ restoreTracking: false }); - return; - } - if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; - this.lastCameraUpdateMs = nowMs; - - const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); - if (!target) return; - const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); - this.lastFrameMs = nowMs; - if (Number.isFinite(info.track)) { - this.heading = slewHeading( - this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, - ); - } - - if (!this.cockpitAnchorValid) { - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - this.cockpitAnchorValid = true; - } - - // First-person motion cannot use the delayed feed correction as a raw - // camera destination: a harmless icon re-anchor becomes a whole-world - // surge/reversal in cockpit view. Advance the camera anchor inertially - // from the reported course/speed and converge on the authoritative layer - // display position at a bounded rate. This preserves the layer's required - // 15/30-second interpolation and per-frame cache without exposing its - // sample-boundary corrections to the camera. - const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); - const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; - - if (info.stale) { - // A feed backoff has no authoritative velocity epoch to advance from. - // Hold the cockpit on the exact layer-rendered position so the camera - // cannot coast away while the icon correctly remains fixed. - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - } else { - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); - Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); - const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); - const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); - if (correctionStepM > 0 && correctionDistanceM > 0) { - Cesium.Cartesian3.multiplyByScalar( - this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); - } - } - - // The inertial anchor is independent of the layer's render-floor clamp and - // can otherwise coast into a photoreal mesh while a landing contact is - // between fixes. Clamp it against the same mesh-first shared floor used by - // aircraft rendering. For a slow contact whose floor cell is still cold, - // its already-clamped render position is a conservative temporary floor. - const anchorCartographic = Cesium.Cartographic.fromCartesian( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, - ); - const targetCartographic = Cesium.Cartographic.fromCartesian( - target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, - ); - let cockpitFloorM = cachedGroundFloor(info.latitude, info.longitude); - if (info.onGround === true) { - const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; - warmGroundFloor(groundPoint); - const meshFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (meshFloorPreferred() && !Number.isFinite(meshFloorM)) { - if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { - this.lastGroundProbeMs = nowMs; - const viewerCartographic = this.viewer.camera.positionCartographic; - sampleMeshFloorCells(this.viewer.scene, groundPoint, { - excludeObjects: [this.trackedEntity], - viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), - viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), - }); - } - cockpitFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (!Number.isFinite(cockpitFloorM)) { - if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; - if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { - // Keep the already-safe map camera in place while the photoreal - // surface under a parked aircraft is acquired. The bounded wait - // prevents a permanently cold mesh cell from freezing cockpit. - this.surfaceAcquiring = true; - this.surfaceFallback = false; - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - return; - } - this.surfaceAcquiring = false; - this.surfaceFallback = true; - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - } - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - if (!Number.isFinite(cockpitFloorM) - && speedMps < 90 - && Number.isFinite(targetCartographic?.height)) { - cockpitFloorM = targetCartographic.height - GROUND_FLOOR_LIFT_M; - } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { - const minimumAnchorHeightM = cockpitGroundSafeHeight( - anchorCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, - ); - if (minimumAnchorHeightM !== anchorCartographic.height) { - anchorCartographic.height = minimumAnchorHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); - } - } - - // Rebuild the local frame at the stabilized anchor after advancing it. - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); - this.scratchLocal.z = Math.sin(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); - Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); - this.scratchLocal.z = Math.cos(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); - Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); - - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); - Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); - Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); - Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); - Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); - - // Recheck at the final forward-offset camera coordinate because a taxiing - // aircraft can cross into an adjacent coarse floor cell between updates. - const cameraCartographic = Cesium.Cartographic.fromCartesian( - this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, - ); - if (cameraCartographic) { - const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); - const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); - const cameraFloorM = cachedGroundFloor(cameraLat, cameraLon); - if (Number.isFinite(cameraFloorM)) { - cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); - } - const safeHeightM = cockpitGroundSafeHeight( - cameraCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, - ); - if (safeHeightM !== cameraCartographic.height) { - cameraCartographic.height = safeHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); - } - } - - this.viewer.camera.setView({ - destination: this.scratchCamera, - orientation: { direction: this.scratchForward, up: this.scratchUp }, - }); - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - } - - updateHud(info, nowMs = performance.now(), forceContext = false) { - this.lastAircraftInfo = info; - const heading = normalizeHeading(this.heading ?? info.track ?? 0); - if (this.callsign) { - this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - } - const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; - setCockpitRollingValue( - this.speed, - formatSpeedRulerTick(speedKt), - speedKt, - { immediate: forceContext }, - ); - if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); - if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); - const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); - this.speedRimTicks.forEach((element, index) => { - const tick = speedTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); - }); - const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); - if (this.altitude) { - const displayedAltitudeFt = Number.isFinite(altitudeFt) - ? Math.round(altitudeFt) - : null; - setCockpitRollingValue( - this.altitude, - displayedAltitudeFt !== null - ? displayedAltitudeFt.toLocaleString('en-US') - : '-----', - displayedAltitudeFt, - { immediate: forceContext }, - ); - } - if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); - if (this.altitudeRimValue) { - this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); - } - const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); - this.altitudeRimTicks.forEach((element, index) => { - const tick = altitudeTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); - }); - setCockpitRollingValue( - this.headingValue, - String(Math.round(heading) % 360).padStart(3, '0'), - heading, - { circularRange: 360, immediate: forceContext }, - ); - if (this.compassTape) { - const divisions = compassDivisions(heading); - const signature = divisions.join(','); - if (signature !== this.lastCompassSignature) { - this.lastCompassSignature = signature; - this.compassTape.innerHTML = divisions - .map((division, index) => { - const slot = index - 3; - return `${formatCompassDivision(division)}`; - }) - .join(''); - } - } - if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; - if (this.position) { - const lat = Number.isFinite(info.latitude) - ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; - const lon = Number.isFinite(info.longitude) - ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; - this.position.textContent = `${lat} · ${lon}`; - } - if (this.aircraftMeta) { - const feedState = this.surfaceAcquiring - ? 'ACQUIRING SURFACE' - : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; - } - this.updateRoute(info); - if (forceContext - || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { - this.lastContextUpdateMs = nowMs; - this.updateLocalPosition(info); - this.maybeRefreshRegionalBrief(info); - this.updateContext(info, heading); - } - if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; - } - - updateRoute(info) { - const origin = info?.route?.origin; - const destination = info?.route?.destination; - const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); - const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; - if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); - if (this.routeTo) this.routeTo.textContent = routeLabel(destination); - if (this.routeStatus) { - this.routeStatus.textContent = validDestination - ? 'ARROW · ESTIMATED DIRECTION' - : 'ROUTE DATA UNAVAILABLE'; - } - if (this.route) this.route.hidden = !origin && !destination; - if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { - this.clearPredictiveRoute(); - return; - } - const destinationBearing = bearingBetweenCoordinates( - info.latitude, - info.longitude, - destination.lat, - destination.lon, - ); - const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); - if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { - this.clearPredictiveRoute(); - return; - } - if (this.routeDirection) { - const displayedRelative = Math.max(-120, Math.min(120, relative)); - this.routeDirection.hidden = false; - this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); - } - if (this.routeDirectionLabel) { - this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; - } - } - - updateContext(info, heading) { - if (!this.context) return; - const snapshot = militaryAwarenessLayer.getContextSnapshot?.() || null; - const trackedId = info.icao24 || info.id; - const readout = resolveCockpitContextReadout({ snapshot, info }); - if (!readout.visible) { - this.context.hidden = true; - this.hud?.classList.remove('context-active'); - this.contextLayoutStamp = null; - this.pushCockpitSignal( - 'context-status', - 'info', - 'CONTEXT STANDBY', - 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', - ); - return; - } - - this.context.hidden = false; - this.hud?.classList.add('context-active'); - if (this.contextSubject) { - const installationCoverage = snapshot.cohorts - .find((cohort) => cohort.id === 'military-installations')?.coverage; - this.contextSubject.textContent = formatCockpitContextScope( - snapshot.subject.label || trackedId, - snapshot.radiusM, - installationCoverage, - ); - } - // Navigation stays wired in every state — the operator must always be able - // to step off the current contact from the panel that hosts the controls. - if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; - if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; - - if (readout.contactLost) { - // The subject left its source. Every number below is measured against a - // position that stopped updating, so hold the last rendered readout and - // say so instead of re-deriving stale geometry as if it were live. - const enteringLost = this.context.dataset.state !== 'lost'; - this.context.dataset.state = 'lost'; - if (this.contextUncertainty) { - this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; - } - // The cue changes the footer's height; re-run layout once on the way in - // rather than every frame the contact stays lost. - if (enteringLost) this.scheduleContextLayout(); - this.pushCockpitSignal( - 'context-status', - 'warning', - `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, - 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', - ); - return; - } - - let unknownCount = 0; - const nearest = []; - for (const cohort of snapshot.cohorts) { - const element = this.contextCohorts.get(cohort.id); - const value = element?.querySelector('strong'); - if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); - element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); - if (cohort.count === null) unknownCount += 1; - for (const item of cohort.nearest) nearest.push({ ...item, cohort }); - } - nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); - const closest = nearest[0] || null; - const closestLabel = formatAwarenessLabel(closest); - if (this.contextNearestLabel) { - this.contextNearestLabel.textContent = closest - ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; - this.contextNearestLabel.setAttribute( - 'aria-label', - closest && closestLabel === '—' - ? `${closest.cohort.label}, Unavailable` - : this.contextNearestLabel.textContent, - ); - } - if (this.contextDistance) { - const distanceM = closest?.distanceM; - this.contextDistance.textContent = Number.isFinite(distanceM) - ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; - this.contextDistance.setAttribute( - 'aria-label', - Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', - ); - } - - // The arrow and BRG are nose-relative to the tracked aircraft. When the - // subject is a vessel, an installation, or another aircraft, the rest of - // this row is measured from that subject — so the aircraft-frame half is - // dashed rather than presented alongside subject-frame distances as if the - // two shared an origin. - let relative = null; - if (readout.aircraftRelative - && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { - const cartographic = Cesium.Cartographic.fromCartesian(closest.position); - const bearing = cartographic ? bearingBetweenCoordinates( - info.latitude, - info.longitude, - Cesium.Math.toDegrees(cartographic.latitude), - Cesium.Math.toDegrees(cartographic.longitude), - ) : null; - relative = relativeBearing(bearing, heading); - } - if (this.contextDirection) { - this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; - this.contextDirection.classList.toggle('unknown', relative === null); - } - if (this.contextBearing) { - if (relative === null) this.contextBearing.textContent = 'BRG —'; - else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; - else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; - } - if (this.contextUncertainty) { - this.contextUncertainty.textContent = unknownCount - ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` - : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; - } - if (this.contextUpdated) { - this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) - ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - } - this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; - this.updateCockpitSignals(snapshot, unknownCount); - if (this.contextLayoutStamp !== snapshot.evaluatedAt) { - this.contextLayoutStamp = snapshot.evaluatedAt; - this.scheduleContextLayout(); - } - } - - scheduleContextLayout() { - const contextVisible = this.context && !this.context.hidden; - const signalVisible = this.signalStream && !this.signalStream.hidden; - if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; - this.contextLayoutFrame = requestAnimationFrame(() => { - this.contextLayoutFrame = null; - this.syncContextLayout(); - this.syncSignalLayout(); - }); - } - - showBriefPage(index, { manual = false } = {}) { - const count = COCKPIT_BRIEF_PAGES.length; - this.briefPageIndex = ((Number(index) % count) + count) % count; - const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; - this.briefPages.forEach((element) => { - element.hidden = element.dataset.cockpitBriefPage !== page.id; - }); - this.briefTabs.forEach((button) => { - const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; - button.setAttribute('aria-current', current ? 'true' : 'false'); - }); - if (this.briefKicker) { - const indicator = this.briefKicker.querySelector('i'); - this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); - } - if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; - if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; - if (this.briefSource) this.briefSource.textContent = page.source; - if (this.signalStream) this.signalStream.dataset.briefPage = page.id; - if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - this.scheduleContextLayout(); - } - - setBriefAutoRotate(enabled) { - this.briefAutoRotateEnabled = Boolean(enabled); - if (this.briefAutoToggle) { - this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); - const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; - this.briefAutoToggle.textContent = label; - const help = this.briefAutoRotateEnabled - ? COCKPIT_BRIEF_CYCLE_ON_HELP - : COCKPIT_BRIEF_CYCLE_OFF_HELP; - this.briefAutoToggle.setAttribute('aria-label', label); - this.briefAutoToggle.title = help; - } - if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - else this.stopBriefRotation(); - } - - startBriefRotation({ reset = false } = {}) { - if (reset) this.stopBriefRotation(); - if (!this.briefAutoRotateEnabled - || this.briefTimer - || !this.active - || this.signalCollapsed - || document.hidden) return; - this.briefTimer = window.setTimeout(() => { - this.briefTimer = null; - const hasPointer = this.signalStream?.matches(':hover') === true; - const hasFocus = this.signalStream?.contains(document.activeElement) === true; - const isInteracting = hasPointer || hasFocus; - if (!isInteracting) { - this.showBriefPage(this.briefPageIndex + 1); - } - this.startBriefRotation(); - }, COCKPIT_BRIEF_ROTATE_MS); - } - - stopBriefRotation() { - if (this.briefTimer) window.clearTimeout(this.briefTimer); - this.briefTimer = null; - } - - updateLocalPosition(info) { - if (!this.localCoordinates) return; - if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { - this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; - return; - } - const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; - const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; - this.localCoordinates.textContent = `${lat} · ${lon}`; - } - - maybeRefreshRegionalBrief(info) { - if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; - if (subjectId !== this.regionalBriefSubjectId) { - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = subjectId; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - } - const point = { latitude: info.latitude, longitude: info.longitude }; - const ageMs = Date.now() - this.regionalBriefFetchedAt; - const distanceM = regionalDistanceM(this.regionalBriefAnchor, point); - if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS - && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; - - this.regionalBriefAnchor = point; - this.regionalBriefFetchedAt = Date.now(); - const controller = new AbortController(); - const requestToken = ++this.regionalBriefRequestToken; - this.regionalBriefAbort = controller; - if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); - fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) - .then((payload) => { - if (!this.active - || requestToken !== this.regionalBriefRequestToken - || subjectId !== this.regionalBriefSubjectId) return; - this.regionalBrief = payload; - this.renderRegionalBrief(payload, info); - }) - .catch((error) => { - if (error?.name !== 'AbortError' - && this.active - && requestToken === this.regionalBriefRequestToken - && subjectId === this.regionalBriefSubjectId) { - this.renderRegionalBriefStatus('unavailable', info); - } - }) - .finally(() => { - if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; - }); - } - - renderRegionalBriefStatus(status, info) { - if (this.newsStatus) { - this.newsStatus.hidden = false; - this.newsStatus.dataset.state = status; - this.newsStatus.textContent = status === 'loading' - ? 'ACQUIRING REGIONAL NEWS' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (status === 'unavailable') this.newsList?.replaceChildren(); - if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; - if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; - this.updateLocalPosition(info); - } - - renderRegionalBrief(payload, info) { - const articles = Array.isArray(payload?.articles) ? payload.articles : []; - if (this.newsStatus) { - this.newsStatus.hidden = articles.length > 0; - this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; - this.newsStatus.textContent = payload?.newsStatus === 'empty' - ? 'NO RECENT LOCATION MATCHES' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (this.newsList) { - this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { - const entry = document.createElement('li'); - const link = document.createElement('a'); - link.href = article.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - const title = document.createElement('strong'); - title.textContent = article.title; - const metadata = document.createElement('span'); - metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`; - link.append(title, metadata); - entry.append(link); - return entry; - })); - } - - const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; - if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); - this.updateLocalPosition(info); - const weather = payload?.weather; - if (this.localTemperature) { - this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) - ? `${Math.round(weather.temperatureC)}°C` : '—'; - } - if (this.localWind) { - this.localWind.textContent = Number.isFinite(weather?.windKph) - ? `${Math.round(weather.windKph)} KM/H` : '—'; - } - if (this.localWindDirection) { - this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); - } - if (this.localCondition) this.localCondition.textContent = weatherCodeLabel(weather?.weatherCode); - if (this.localCloud) { - this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) - ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; - } - if (this.localPrecipitation) { - this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) - ? weather.precipitationMm.toFixed(1) : '—'; - } - if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; - if (this.briefPageIndex === 1 && this.briefSource) { - this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; - } - this.scheduleContextLayout(); - } - - renderCockpitSignals() { - if (!this.signalList) return; - const existing = [...this.signalList.children]; - const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); - const entriesByKey = new Map(); - for (const entry of existing) { - const key = entry.dataset.signalKey; - if (!entriesByKey.has(key)) entriesByKey.set(key, []); - entriesByKey.get(key).push(entry); - } - const setText = (element, value) => { - if (element.textContent !== value) element.textContent = value; - }; - const entries = this.signalItems.map((item) => { - // Identity includes the action target: a reused status key must never - // silently turn a focused flight button into a different selection. - const key = JSON.stringify([ - item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), - ]); - let entry = entriesByKey.get(key)?.shift(); - if (!entry) { - entry = document.createElement('li'); - entry.dataset.signalKey = key; - const time = document.createElement('time'); - const body = document.createElement('div'); - const heading = document.createElement(item.target ? 'button' : 'strong'); - if (item.target) { - heading.type = 'button'; - heading.className = 'cockpit-signal-target'; - const label = document.createElement('span'); - label.className = 'cockpit-signal-target-label'; - const rule = document.createElement('span'); - rule.className = 'cockpit-signal-target-rule'; - rule.setAttribute('aria-hidden', 'true'); - const chevron = document.createElement('span'); - chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - chevron.textContent = 'chevron_right'; - heading.append(label, rule, chevron); - } - body.append(heading, document.createElement('span')); - entry.append(time, body); - } - const [time, body] = entry.children; - const [heading, copy] = body.children; - const className = item.target ? `${item.tone} actionable` : item.tone; - if (entry.className !== className) entry.className = className; - setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); - if (item.target) { - heading.dataset.signalLayer = item.target.layerId; - heading.dataset.signalId = item.target.id; - const label = `Select flight ${item.title}`; - if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); - setText(heading.children[0], item.title); - } else { - setText(heading, item.title); - } - setText(copy, item.detail); - return entry; - }); - const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; - if (focusedEntry && !retainedFocus) { - // A departed contact cannot remain selectable. Continue from the stable - // briefing footer instead of dropping keyboard traversal to the page top. - (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); - } - for (const entry of existing) if (!entries.includes(entry)) entry.remove(); - - // Ordinary insertBefore moves disconnect an element and lose its focus. - // Reorder the other rows around the focused row, which stays connected. - const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; - if (retainedFocus) { - let anchor = retainedFocus; - for (let index = focusIndex - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); - anchor = entry; - } - } - let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; - for (let index = focusIndex + 1; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry === anchor) anchor = anchor.nextElementSibling; - else this.signalList.insertBefore(entry, anchor); - } - this.scheduleContextLayout(); - } - - pushCockpitSignal(key, tone, title, detail, target = null) { - if (!this.signalList || !title || !detail) return; - const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; - if (this.signalSignatures.get(key) === signature) return; - this.signalSignatures.set(key, signature); - this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); - this.signalItems = this.signalItems.slice(0, 5); - this.renderCockpitSignals(); - } - - updateCockpitSignals(snapshot, unknownCount) { - const previous = new Map(this.signalItems.map((item) => [item.key, item])); - const contacts = []; - const subject = snapshot.subject; - if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { - contacts.push({ - key: `flight:${subject.layerId}:${subject.id}`, - tone: 'track', - title: subject.label || subject.id, - detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, - target: { layerId: subject.layerId, id: String(subject.id) }, - distanceM: -1, - }); - } - for (const cohort of snapshot.cohorts) { - if (!['flights', 'military'].includes(cohort.id)) continue; - for (const item of cohort.nearest) { - const id = item.icao24 || item.id; - if (!id) continue; - contacts.push({ - key: `flight:${cohort.id}:${id}`, - tone: cohort.id === 'military' ? 'nearby military' : 'nearby', - // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: - // the row's own `id` already carries the layer's label convention - // (callsign → registration → hex), so a callsign-less enriched - // contact reads as its registration here too. Same helper the - // Context panel's nearest list uses. - title: formatAwarenessLabel(item), - detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ - Number.isFinite(item.distanceM) - ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` - : 'DISTANCE UNKNOWN' - }`, - target: { layerId: cohort.id, id: String(id) }, - distanceM: item.distanceM ?? Infinity, - }); - } - } - contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ - ...item, - timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), - })); - if (unknownCount) { - const sources = snapshot.cohorts - .filter((cohort) => cohort.count === null) - .map((cohort) => cohort.source) - .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', - tone: 'warning', - title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, - detail: sources || 'SOURCE STATUS UNAVAILABLE', - target: null, - timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), - }); - } - this.signalItems = nextItems; - this.signalSignatures.clear(); - this.renderCockpitSignals(); - } - - setContextCollapsed(collapsed) { - const wasCollapsed = this.contextCollapsed; - this.contextCollapsed = Boolean(collapsed); - if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); - if (this.contextToggle) { - const expanded = !this.contextCollapsed; - this.contextToggle.setAttribute('aria-expanded', String(expanded)); - this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); - this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; - const icon = this.contextToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; - } - if (this.active && wasCollapsed && !this.contextCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); - } - this.scheduleContextLayout(); - } - - setSignalCollapsed(collapsed, { user = false } = {}) { - const wasCollapsed = this.signalCollapsed; - this.signalCollapsed = Boolean(collapsed); - if (user) this.signalUserCollapsed = this.signalCollapsed; - if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); - if (this.signalToggle) { - const expanded = !this.signalCollapsed; - this.signalToggle.setAttribute('aria-expanded', String(expanded)); - this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); - this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; - const icon = this.signalToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; - } - if (this.signalCollapsed) this.stopBriefRotation(); - else this.startBriefRotation({ reset: true }); - if (this.active && wasCollapsed && !this.signalCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); - } - this.scheduleContextLayout(); - } - - syncContextLayout() { - if (!this.context || this.context.hidden) return; - if (window.matchMedia('(max-width: 760px)').matches) { - this.context.dataset.layoutMode = 'compact-bottom'; - this.context.style.removeProperty('--cockpit-context-left'); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.context.dataset.layoutMode = 'bottom-left'; - this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - } - - syncSignalLayout() { - if (!this.signalStream || this.signalStream.hidden) return; - const utilityControls = document.getElementById('cockpit-utility-controls'); - if (window.matchMedia('(max-width: 760px)').matches) { - this.signalStream.dataset.layoutMode = 'compact-top'; - utilityControls?.classList.remove('layout-primary-only'); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = Boolean( - utilityControls.querySelector('.cockpit-utility-control.is-expanded') - && !control.classList.contains('is-expanded') - ); - control.setAttribute('aria-hidden', String(hiddenSibling)); - }); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); - this.signalStream.style.removeProperty('--cockpit-signal-right'); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.signalStream.dataset.layoutMode = 'bottom-right'; - this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - const signalBounds = this.signalStream.getBoundingClientRect(); - const utilityBounds = utilityControls?.getBoundingClientRect(); - if (utilityBounds) { - const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); - const collapsedControl = utilityControls?.querySelector( - '.cockpit-utility-control:not(.is-expanded)', - ); - const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); - const expandedHeight = expandedControl - ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) - : 0; - const collapsedHeight = collapsedLauncher - ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) - : 0; - // Cockpit owns this anchor outright. The strip used to inherit the left - // accordion's committed top, which is solved against left-lane obstacles - // and dropped the strip straight through the briefing card below it. - // The readout only anchors the strip while it is genuinely on screen: - // the Minimal variant drops it with `display:none`, but HUD Off hides the - // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. - const recReadout = document.querySelector('#intel-hud .hud-top-right'); - const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; - const utilityAnchor = resolveCockpitUtilityAnchor({ - recBottom: recBounds ? recBounds.bottom : 0, - signalTop: signalBounds.top, - stripHeight: utilityBounds.height, - viewportHeight: window.innerHeight, - collapsedHeight, - recGap: COCKPIT_UTILITY_REC_GAP_PX, - signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, - minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, - minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, - }); - const availableHeight = utilityAnchor.maxHeight; - this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); - this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); - const utilityLayout = expandedControl && collapsedControl - ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) - : { primaryOnly: false, expandedMaxHeight: availableHeight }; - utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, - ); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; - control.setAttribute('aria-hidden', String(hiddenSibling)); - if (hiddenSibling && control.contains(document.activeElement)) { - expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); - } - }); - } - } - - dispose() { - this.exit({ restoreTracking: false }); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.stopBriefRotation(); - if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); - this.contextLayoutFrame = null; - for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); - } -} - -export class StyleManager { - /** - * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. - * @param {object} [options] - */ - constructor(viewer, { mapStackController = null, placeSearch } = {}) { - this.viewer = viewer; - this.mapStackController = mapStackController; - this.placeSearch = placeSearch; - this.stages = {}; - this.activeStyle = 'normal'; - document.documentElement.dataset.gevStyle = this.activeStyle; - this.transitions = new Map(); - this.startTime = Date.now(); - - // True once the user manually changes detection (button/key/slider/voice). - // Gates per-style detection defaults so they never stomp an explicit choice. - this._detectionUserOverridden = false; - - // Bloom/sharpen state - this.bloomEnabled = false; - this.sharpenEnabled = false; - this._bloomStage = null; - this._sharpenStage = null; - this._recordingMode = false; - this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' }; - this._preRecordingHudState = null; - this._panelZCounter = PANEL_Z_BASE + 10; - this._animFrameId = null; - this._lastLoadingFeedbackUpdateAt = 0; - this._loadingFeedbackState = createLoadingFeedbackState(); - this._loadingFeedbackEvent = null; - this._loadingFeedbackTicker = null; - this._globalStatusNotice = null; - this._shareTrackingAcquiringKey = null; - this._shareTrackingNoticeGeneration = 0; - this._globeResetPromise = null; - this._globeResetHandler = null; - this._clearSelectedLayersPromise = null; - this._clearSelectedLayersManagerPromise = null; - this._clearSelectedLayersHandler = null; - this._dataManager = null; - this._cctvUnsubscribe = null; - this._radioUnsubscribe = null; - this._radioState = null; - this._radioCategorySignature = ''; - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerDragging = false; - this._radioTunerDragStartSlot = 0; - this._radioTunerDragSnapshot = null; - this._radioTunerLastSlot = 0; - this._radioTunerDragDirection = 0; - this._radioTunerCoordinate = 0; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - this._radioTunerAbort = null; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerCameraRemove = null; - this._refreshRadioTunerBand = null; - this._cctvState = null; - this._cctvSummaryTypingTimer = null; - this._lastCctvSummaryText = ''; - // Auto-expand guard: last active camera id seen while the layer was - // enabled; routine state notifications with the same id never re-expand. - this._lastSeenCctvActiveId = null; - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = false; - this._leftStackLayoutFrame = null; - this._leftStackReconsiderAutoCollapse = false; - this._leftStackResizeObserver = null; - this._leftStackMutationObserver = null; - this._leftStackHudTransitionHandler = null; - this._leftStackCollapsedHeights = new Map(); - this._leftStackPreferredPanelId = null; - this._rightPanelStack = document.getElementById('right-context-rail'); - this._rightStackLayoutFrame = null; - this._rightStackReconsiderAutoCollapse = false; - this._rightStackResizeObserver = null; - this._rightStackMutationObserver = null; - this._rightStackHudTransitionHandler = null; - this._rightStackPreferredPanelId = null; - this._adaptivePanelSettleTimer = null; - this._windowResizeHandler = null; - this._loadingVisibilityHandler = null; - this._cctvRequestFocusHandler = null; - this._removeCctvRequestFocusListener = null; - this._worldRequestFocusHandler = null; - this._removeWorldRequestFocusListener = null; - this._removeNavigationAuthorityListener = null; - this._navigationOwnerChangedRemover = null; - this._navigationGeneration = 0; - this._activeLocationSearchGeneration = null; - this._initialShareState = null; - this._initialShareNavigationGeneration = null; - this._initialShareRestoreTimeout = null; - this._layerStateCoordinator = null; - this._layerStateRestorePromise = null; - this._awarenessSelectedHandler = null; - this._awarenessClearedHandler = null; - this._disposed = false; - this._draggableResizeObserver = null; - - // DOM refs - this._styleIndicator = document.getElementById('active-style-name'); - this._sliderPanel = document.getElementById('param-slider-panel'); - this._sliderContainer = document.getElementById('param-sliders'); - this._ppToggles = document.getElementById('pp-toggles'); - this._bloomBtn = document.getElementById('bloom-toggle'); - this._bloomSliderRow = document.getElementById('bloom-slider-row'); - this._bloomSlider = document.getElementById('bloom-intensity-slider'); - this._bloomSliderValue = document.getElementById('bloom-intensity-value'); - this._sharpenBtn = document.getElementById('sharpen-toggle'); - this._sharpenSliderRow = document.getElementById('sharpen-slider-row'); - this._sharpenSlider = document.getElementById('sharpen-intensity-slider'); - this._sharpenSliderValue = document.getElementById('sharpen-intensity-value'); - this._hudBtn = document.getElementById('hud-toggle'); - this._hudLayoutRow = document.getElementById('hud-layout-row'); - this._hudLayoutSelect = document.getElementById('hud-layout-select'); - this._detectionSliderRow = document.getElementById('detection-slider-row'); - this._detectionDensitySlider = document.getElementById('detection-density-slider'); - this._detectionDensityValue = document.getElementById('detection-density-value'); - this._detectionAllocationRow = document.getElementById('detection-allocation-row'); - this._detectionAllocationBtns = [ - document.getElementById('detection-allocation-elastic'), - document.getElementById('detection-allocation-weighted'), - ].filter(Boolean); - this._detectionFadeRow = document.getElementById('detection-fade-row'); - this._detectionFadeSlider = document.getElementById('detection-fade-slider'); - this._detectionFadeValue = document.getElementById('detection-fade-value'); - this._detectionOpacityRow = document.getElementById('detection-opacity-row'); - this._detectionOpacitySlider = document.getElementById('detection-opacity-slider'); - this._detectionOpacityValue = document.getElementById('detection-opacity-value'); - let storedDetectionAllocation = 'ELASTIC'; - try { - storedDetectionAllocation = localStorage.getItem(DETECTION_ALLOCATION_STORAGE_KEY) || 'ELASTIC'; - } catch { /* storage can be unavailable in privacy/test contexts */ } - this._detectionAllocationPreference = normalizeAllocationStrategy(storedDetectionAllocation); - this._celestialBtn = document.getElementById('celestial-toggle'); - this._scopeBtn = document.getElementById('scope-toggle'); - this._scopeFeatherSlider = document.getElementById('scope-feather-slider'); - this._scopeFeatherValue = document.getElementById('scope-feather-value'); - this._mapStackChips = document.getElementById('map-stack-chips'); - this._mapStackStatus = document.getElementById('map-stack-status'); - this._mapStackChangeHandler = null; - this._cleanViewBtn = document.getElementById('clean-view-toggle'); - this._cleanViewExitBtn = document.getElementById('clean-view-exit'); - this._dataPanel = document.getElementById('data-panel'); - this._scenePanel = document.getElementById('scene-panel'); - this._cctvPanel = document.getElementById('cctv-panel'); - this._radioPanel = document.getElementById('radio-panel'); - this._contextRadioDock = document.getElementById('context-radio-dock'); - this._contextRadioToggleBtn = document.getElementById('context-radio-toggle-btn'); - this._contextRadioMini = document.getElementById('context-radio-mini'); - this._contextRadioMiniEnableBtn = document.getElementById('context-radio-mini-enable-btn'); - this._contextRadioDetailsBtn = document.getElementById('context-radio-details-btn'); - this._contextRadioMiniCloseBtn = document.getElementById('context-radio-mini-close-btn'); - this._contextRadioMiniStation = document.getElementById('context-radio-mini-station'); - this._contextRadioMiniPrevBtn = document.getElementById('context-radio-mini-prev-btn'); - this._contextRadioMiniPlayBtn = document.getElementById('context-radio-mini-play-btn'); - this._contextRadioMiniNextBtn = document.getElementById('context-radio-mini-next-btn'); - this._contextRadioMiniVolume = document.getElementById('context-radio-mini-volume'); - this._contextRadioMiniVolumeValue = document.getElementById('context-radio-mini-volume-value'); - this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls'); - this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn'); - this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel'); - this._cockpitDisplayPortalRecords = []; - this._cockpitDisplayPortalActive = false; - this._cockpitDisplayModeHandler = null; - this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn'); - this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel'); - this._cockpitRadioEnableBtn = document.getElementById('cockpit-radio-enable-btn'); - this._cockpitRadioStation = document.getElementById('cockpit-radio-station'); - this._cockpitRadioPrevBtn = document.getElementById('cockpit-radio-prev-btn'); - this._cockpitRadioPlayBtn = document.getElementById('cockpit-radio-play-btn'); - this._cockpitRadioNextBtn = document.getElementById('cockpit-radio-next-btn'); - this._cockpitRadioVolume = document.getElementById('cockpit-radio-volume'); - this._cockpitRadioVolumeValue = document.getElementById('cockpit-radio-volume-value'); - this._radioLayerState = document.getElementById('radio-layer-state'); - this._radioEnableBtn = document.getElementById('radio-enable-btn'); - this._radioFilter = document.getElementById('radio-filter'); - this._radioStationName = document.getElementById('radio-station-name'); - this._radioStationMeta = document.getElementById('radio-station-meta'); - this._radioStationTags = document.getElementById('radio-station-tags'); - this._radioTuner = document.getElementById('radio-tuner'); - this._radioTunerSlider = document.getElementById('radio-tuner-slider'); - this._radioTunerNeedle = document.getElementById('radio-tuner-needle'); - this._radioTunerBandLabel = document.getElementById('radio-tuner-band-label'); - this._radioTunerValue = document.getElementById('radio-tuner-value'); - this._radioTunerStation = document.getElementById('radio-tuner-station'); - this._radioPrevBtn = document.getElementById('radio-prev-btn'); - this._radioPlayBtn = document.getElementById('radio-play-btn'); - this._radioNextBtn = document.getElementById('radio-next-btn'); - this._radioStopBtn = document.getElementById('radio-stop-btn'); - this._radioVolume = document.getElementById('radio-volume'); - this._radioVolumeValue = document.getElementById('radio-volume-value'); - this._radioPlaybackState = document.getElementById('radio-playback-state'); - this._radioStationHomepage = document.getElementById('radio-station-homepage'); - this._globalContextFlightsBtn = document.getElementById('global-context-flights-btn'); - this._globalContextMissionsBtn = document.getElementById('global-context-missions-btn'); - this._contextModeStandby = document.getElementById('context-mode-standby'); - this._contextFlightsView = document.getElementById('context-flights-view'); - this._contextMissionsView = document.getElementById('context-missions-view'); - this._contextMode = null; - this._contextModeChanging = false; - this._contextModeGeneration = 0; - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeDeferredEntryIntent = null; - this._contextSessionSnapshot = null; - this._contextRestoreState = null; - this._contextLayerReactionPromises = new Set(); - this._preservePanelStateDuringLayerClear = false; - this._userFacingContextNotificationTokens = new Set(); - this._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe = null; - this._installationsSearchBtn = document.getElementById('installations-search-btn'); - this._leftPanelStack = document.getElementById('left-panel-stack'); - this._cctvEnableBtn = document.getElementById('cctv-enable-btn'); - this._cctvNearestBtn = document.getElementById('cctv-nearest-btn'); - this._cctvPrevBtn = document.getElementById('cctv-prev-btn'); - this._cctvNextBtn = document.getElementById('cctv-next-btn'); - this._cctvSelect = document.getElementById('cctv-camera-select'); - this._cctvFocusBtn = document.getElementById('cctv-focus-btn'); - this._cctvCoverageBtn = document.getElementById('cctv-coverage-btn'); - this._cctvAutoHopBtn = document.getElementById('cctv-auto-hop-btn'); - this._cctvProjectionBtn = document.getElementById('cctv-projection-btn'); - this._cctvQualityChip = document.getElementById('cctv-quality-chip'); - this._cctvAdjustBtn = document.getElementById('cctv-adjust-btn'); - this._cctvCalReadout = document.getElementById('cctv-cal-readout'); - this._cctvCalibSaveBtn = document.getElementById('cctv-calib-save-btn'); - this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn'); - this._cctvFrame = document.getElementById('cctv-frame'); - this._cctvFrameWrap = document.getElementById('cctv-frame-wrap'); - this._cctvFrameRequestToken = 0; - this._cctvFramePreloader = null; - this._cctvSourceBadge = document.getElementById('cctv-source-badge'); - this._cctvMeta = document.getElementById('cctv-meta'); - this._cctvSummary = document.getElementById('cctv-summary'); - this._shareBtn = document.getElementById('share-btn'); - this._clearSelectedLayersBtn = document.getElementById('clear-selected-layers'); - this._globalLoadingStatus = document.getElementById('global-loading-status'); - this._globalLoadingLabel = document.getElementById('global-loading-label'); - this._globalLoadingDetail = document.getElementById('global-loading-detail'); - this._resetGlobeBtn = document.getElementById('reset-globe-view'); - this._cockpitResetGlobeBtn = document.getElementById('cockpit-reset-globe'); - this._styleButtons = document.getElementById('style-buttons'); - this._trafficSyncChip = document.getElementById('traffic-sync-chip'); - this._trafficSyncLabel = document.getElementById('traffic-sync-label'); - this._trafficSyncProgress = document.getElementById('traffic-sync-progress'); - this._cctvSyncChip = document.getElementById('cctv-sync-chip'); - this._cctvSyncLabel = document.getElementById('cctv-sync-label'); - this._cctvSyncProgress = document.getElementById('cctv-sync-progress'); - this._toast = document.getElementById('toast'); - this._locationSearch = document.getElementById('location-search'); - this._searchToggle = document.getElementById('search-toggle'); - this._locationPills = document.getElementById('location-pills'); - this._poiRow = document.getElementById('poi-row'); - this._locationBarDivider = document.getElementById('location-bar-divider'); - this._styleMiniValue = document.getElementById('style-mini-value'); - this._locationMiniCity = document.getElementById('location-mini-city'); - this._locationMiniPoi = document.getElementById('location-mini-poi'); - this._safeFrameOverlay = document.getElementById('safe-frame-overlay'); - this._safeFrameBox = document.getElementById('safe-frame-box'); - this._activeLocationId = null; - this._expandedCityId = null; - this._activePoiIndex = null; - this._currentTarget = null; // Cesium.Cartesian3 of current POI target - this._currentPoi = null; // Current POI data object - // Formatted address of the last free-text geocode search. Preset pills set - // _activeLocationId instead; a search has no preset record, so this is the - // only thing the mini-status can report for it. - this._searchedLocationLabel = null; - this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); - this._trafficTransitionTimer = null; - this._lastTrafficChipUpdateAt = 0; - - // Orbit controller - this.orbitController = new OrbitController(viewer); - this._orbitIndicator = null; - - // Intel HUD - this.hud = new IntelHUD(viewer); - this._cockpitVisionMode = 'optical'; - this._cockpitVisionRestore = null; - this._cockpitPanelRestore = null; - // True only while the open Data Layers panel is the reason Cockpit's - // Contact panel is collapsed. A user-collapsed Contact panel must remain - // collapsed when Data Layers closes. - this._cockpitContextCollapsedForDataPanel = false; - /** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */ - this._contactsDetectionRestore = null; - this.cockpitView = new CockpitViewController(viewer, { - onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), - onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), - getInheritedVisionLabel: () => ( - STYLE_STATUS_LABELS[this.activeStyle] - || String(this.activeStyle || 'normal').toUpperCase() - ), - isEntryAllowed: () => cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }), - onEntered: () => { - // A new Cockpit session owns both side rails. Clear standard map-view - // panels once on entry; NEXT/PREVIOUS never reaches this callback, so - // panels the operator opens while already inside remain untouched. - this._cockpitPanelRestore = new Map(); - this._cockpitContextCollapsedForDataPanel = false; - for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) { - const panel = document.getElementById(panelId); - if (panel) { - this._cockpitPanelRestore.set(panelId, panel.classList.contains('collapsed')); - } - this.setPanelCollapsed(panelId, true, { - persist: false, - syncShare: false, - }); - } - this.cockpitView?.setContextCollapsed(false); - this.cockpitView?.setSignalCollapsed(false, { user: true }); - }, - onExited: () => { - const restore = this._cockpitPanelRestore; - this._cockpitPanelRestore = null; - this._cockpitContextCollapsedForDataPanel = false; - if (!restore) return; - for (const [panelId, wasCollapsed] of restore) { - this.setPanelCollapsed(panelId, wasCollapsed, { - persist: false, - syncShare: false, - }); - } - }, - restoreTrackingFrame: (entity) => { - const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(':'); - const trackedId = idParts.join(':'); - if (!trackedId) return false; - if (layerId === 'flights') return flightsLayer.refocusTrackedById?.(trackedId) === true; - if (layerId === 'military') return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true; - return false; - }, - }); - - // Full-globe sun/moon ring. It is a crisp screen-space overlay above the - // Cesium canvas but below the HUD/detection/readout z ladder. - this.celestialRing = new CelestialRing(viewer, { - enabled: false, - onAutoDisable: () => this.setCelestialRingEnabled(false, { - syncShare: !!this.shareLinkManager, - focus: false, - }), - }); - - // Share Link Manager - this.shareLinkManager = new ShareLinkManager(viewer, { - onRestore: async (state) => { - const { - style, - bloom, - sharpen, - bloomIntensity, - bloomVersion, - sharpenIntensity, - hudVariant, - hudVisible, - detectionMode, - detectionDensity, - detectionAllocation, - detectionFadePct, - detectionOutsideOpacityPct, - celestialRing, - scopeEnabled, - scopeFeatherPct, - scopeTerminusPct, - mapStack, - panelState, - styleParams, - } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { - this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true }); - } - if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { - for (const [uniformName, uniformValue] of Object.entries(styleParams)) { - if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue; - this.stages[style].uniforms[uniformName] = uniformValue; - } - this._updateSliderPanel(style, { reveal: false }); - } - if (typeof bloomIntensity === 'number' && this._bloomSlider) { - const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion); - this._setBloomIntensity(intensity, { syncShare: false }); - } - if (typeof sharpenIntensity === 'number' && this._sharpenSlider) { - const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity))); - this._sharpenSlider.value = String(pct); - this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - } - if (typeof bloom === 'boolean') this._setBloomEnabled(bloom); - if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen); - if (hudVariant) this._setHudVariant(hudVariant); - if (typeof hudVisible === 'boolean') { - this.hud.setMode(hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - if (typeof detectionDensity === 'number' && this._detectionDensitySlider) { - const pct = canonicalizeDensity(detectionDensity); - this._detectionDensitySlider.value = String(pct); - this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - } - if (detectionAllocation) { - this._setDetectionAllocation(detectionAllocation, { syncShare: false, persist: false }); - } - if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(detectionFadePct); - } - if (typeof detectionOutsideOpacityPct === 'number' && this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct); - } - this._applyDetectionFadeFromUi(); - if (detectionMode) this._setDetectionMode(detectionMode); - if (typeof celestialRing === 'boolean') { - this.setCelestialRingEnabled(celestialRing, { syncShare: false, focus: false }); - } - if (typeof scopeEnabled === 'boolean') { - setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); - } - if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); - this._scopeFeatherSlider.value = String(pct); - if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; - setScopeMaskFeather(pct / 100); - } - // null restores the altitude-adaptive ramp; a number pins the terminus - // (clamped to the supported 94..100 band, same as the `sce` hash key). - if (scopeTerminusPct === null) setScopeTerminusOverride(null); - else if (typeof scopeTerminusPct === 'number') { - const pinned = clampScopeTerminusPct(scopeTerminusPct); - setScopeTerminusOverride(pinned == null ? null : pinned / 100); - } - const mapStackRestore = mapStack - ? this._setMapStack(mapStack, { syncShare: false }) - : Promise.resolve(); - if (panelState) this._restorePanelState(panelState); - await mapStackRestore; - this._syncShareState(); - }, - isNavigationCurrent: (generation) => generation === this._navigationGeneration, - cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), - }); - this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState()); - this.shareLinkManager.setStyleParamStateProvider((styleName) => { - const shader = STYLES[styleName]; - const stage = this.stages[styleName]; - if (!shader?.uniforms || !stage) return null; - return Object.fromEntries( - Object.keys(shader.uniforms).map((uniformName) => [uniformName, stage.uniforms[uniformName]]), - ); - }); - // Parse before panel chrome initializes so every valid share URL starts - // from deterministic markup defaults instead of recipient-local panel - // preferences. Encoded panel fields are applied after all panels exist. - this._initialShareState = this.shareLinkManager.parseInitialHash(); - - this._detectionBtn = document.getElementById('detection-toggle'); - this._models3dBtn = document.getElementById('models3d-toggle'); - this._models3dModeRow = document.getElementById('models3d-mode-row'); - this._models3dModeBtns = [ - document.getElementById('models3d-mode-proximity'), - document.getElementById('models3d-mode-all'), - ]; - // DISPLAY-rail 3D-aircraft toggle (flights layer param). DEFAULT-ON in - // PROXIMITY (owner directive 2026-08-22) — mirrors the `models3d` default in - // layerState.js and `_models3dEnabled` in both flight layers, and the `active` - // class the button carries in index.html. A fresh boot skips layer-state - // restoration, so these initializers are the only thing keeping the lit - // button and the armed layer in agreement. - this._models3dEnabled = true; - this._models3dMode = 'proximity'; // 'proximity' (nearest in view) | 'all' (every in-view plane) - - // The shared world-overlay host must own its one postRender lane before - // detection and tracked-readout initialize. It stays transparent until a - // production source explicitly registers entries. - initWorldOverlay(viewer); - - // Initialize detection overlay BEFORE style stages so the composite - // stage is first in the post-process pipeline - initDetection(viewer, [trafficLayer, flightsLayer, militaryFlightsLayer, satellitesLayer, cctvLayer, bikeshareLayer, aisLiveVesselsLayer], (modeLabel) => { - this._updateDetectionButton(modeLabel); - }); - initTrackedReadout(viewer); - setDetectionStyle(this.activeStyle); - this._applyDetectionDensityFromUi(); - - this._initStages(); - this._initBloomSharpen(); - this._initUI(); - this._initMapStackControl(); - this._initPanelChrome(); - this._initLeftPanelAdaptiveLayout(); - this._initRightPanelAdaptiveLayout(); - this._initRadioPanel(); - this._initCctvPanel(); - this._initGlobalContextPanel(); - this._initLocationBar(); - this._initShareButton(); - this._initClearSelectedLayersButton(); - this._initResetGlobeButton(); - this._initHUDToggle(); - this._initModels3dToggle(); - this._applyGlobalPostDefaults(); - this._initOrbit(); - this._initRecordingOverlay(); - this._startAnimationLoop(); - this._startTrafficChipTicker(); - this._updateStyleMiniStatus(); - this._updateLocationMiniStatus(); - - // Restore from URL hash if present - const savedState = this._initialShareState; - this._initialShareRestorePromise = savedState - ? new Promise((resolve) => { this._resolveInitialShareRestore = resolve; }) - : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); - if (savedState) { - this._hasShareState = true; - // Reserve camera authority now; the delayed mesh-friendly flight may - // run only if no newer user, voice, or tracking navigation has won. - this._initialShareNavigationGeneration = this._beginDeferredNavigation( - 'shared view', - { cancelPendingSelection: false }, - ); - this._initialShareRestoreTimeout = setTimeout(() => { - this._initialShareRestoreTimeout = null; - if (this._disposed) return; - const generation = this._initialShareNavigationGeneration; - const applyCamera = Number.isInteger(generation) - && this._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || Promise.resolve([])); - const tracking = share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ status: 'settled', share, layers, tracking }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); - }, 1500); - } else { - this._syncShareState(); - } - // A recipient can orbit before or during the delayed share flight. That - // gesture keeps ordinary layer state but revokes the passive base camera - // and selected-subject Follow so delayed work cannot seize navigation. - this._initialShareGestureHandler = () => { - if ( - this._disposed - || !this._hasShareState - || !this._resolveInitialShareRestore - ) return; - stampInitialShareGesture((options) => this._stampNavigation(options)); - }; - this.viewer?.canvas?.addEventListener('pointerdown', this._initialShareGestureHandler, { - passive: true, - }); - this.viewer?.canvas?.addEventListener('wheel', this._initialShareGestureHandler, { - passive: true, - }); - - // Keep the parameter panel from overlapping toggle controls. - this._layoutRightPanels(); - this._syncCctvPanelViewport(); - this._windowResizeHandler = () => { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this._syncCctvPanelViewport(); - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - }; - window.addEventListener('resize', this._windowResizeHandler); - // The loading-chip ticker is stopped while the tab is hidden (it can do no - // useful work off-screen and must not hold a 60ms timer there). Resample on - // return so the time-driven reducer catches up on real elapsed time — and - // re-arms its own ticker if the batch is still running. - this._loadingVisibilityHandler = () => { - if (!document.hidden) this._updateGlobalLoadingFeedback(); - }; - document.addEventListener('visibilitychange', this._loadingVisibilityHandler); - this._cctvRequestFocusHandler = (event) => routeCctvFocusRequest( - event, - (activate, focus) => this._runExplicitCctvFocus(activate, focus), - (cameraId, durationSec) => cctvLayer.focusCamera(cameraId, durationSec), - ); - this._removeCctvRequestFocusListener = registerCctvFocusRequestListener( - window, - this._cctvRequestFocusHandler, - ); - this._worldRequestFocusHandler = (event) => routeWorldFocusRequest( - event, - (detail, fly) => this._runExplicitWorldFocus(detail, fly), - (detail) => flyToWorldTarget(this.viewer, detail), - ); - this._removeWorldRequestFocusListener = registerWorldFocusRequestListener( - window, - this._worldRequestFocusHandler, - ); - this._navigationOwnerChangedRemover = viewer.trackedEntityChanged.addEventListener((entity) => { - if (entity && !this._disposed) this._stampNavigation({ cancelPendingSelection: false }); - }); - // Vessel/installation focus flies without ever assigning a tracked entity, - // so it cannot reach the listener above. It announces instead. - this._removeNavigationAuthorityListener = registerNavigationAuthorityListener( - window, - (event) => { - if (this._disposed) return; - this._stampNavigation({ - cancelPendingSelection: event?.detail?.cancelPendingSelection !== false, - }); - }, - ); - } - - /** Advance camera authority and settle any older search UI immediately. */ - _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { - this._navigationGeneration += 1; - // A newer destination owns the camera, so the last free-text search is no - // longer where we are. DEFERRED navigation opts out here and clears at the - // reassert seam instead: a geocode that never resolves moves no camera, and - // a lookup that fails must not blank a readout that is still true. - if (clearSearchedLocation) this.clearSearchedLocation(); - if (cancelPendingSelection) { - if (this._hasShareState && this._resolveInitialShareRestore && !this._layerStateCoordinator) { - this._initialShareSelectionSuperseded = true; - } - const passivelyClearedShareSelection = this._layerStateCoordinator - ?.cancelPendingShareTracking?.( - 'superseded-by-explicit-navigation', - { clearSelection: true }, - ) === true; - try { flightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - try { militaryFlightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - try { satellitesLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - // A deliberate destination supersedes share-selected entities that have - // not arrived yet. Active owners publish their clear when released. - if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('flights', { - selectedFlightsTrackingId: null, - }, { origin: 'tool' }); - } - if (!passivelyClearedShareSelection && !militaryFlightsLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('military', { - selectedMilitaryTrackingId: null, - }, { origin: 'tool' }); - } - if (!passivelyClearedShareSelection && !satellitesLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('satellites', { - selectedSatTrackingId: null, - }, { origin: 'tool' }); - } - } - if (this._activeLocationSearchGeneration !== null) { - this._settleLocationSearchUi(this._activeLocationSearchGeneration); - } - return this._navigationGeneration; - } - - /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { - if (this._activeLocationSearchGeneration !== generation) return; - this._activeLocationSearchGeneration = null; - this._locationSearch?.classList.remove('searching', 'expanded'); - if (this._locationSearch) this._locationSearch.value = ''; - this._locationSearch?.blur(); - } - - /** Release every follow owner while preserving Contact and vessel selection. */ - _releaseFollowCamera({ - preserveVesselSelection = true, - preserveCameraFlight = false, - trackingOrigin = 'tool', - } = {}) { - let contactSelected = false; - try { - contactSelected = Boolean( - militaryAwarenessLayer.releaseCameraOwnership?.({ - preserveVesselSelection, - origin: trackingOrigin, - }), - ); - } catch { - try { flightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - try { militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - if (!preserveVesselSelection) { - try { aisLiveVesselsLayer.clearSelection?.(); } catch { /* best-effort release */ } - } - } - try { satellitesLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - try { rocketLaunchesLayer.releaseCameraOwnership?.(); } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; - interruptCameraMotion('explicit-navigation'); - this._stopOrbit(); - if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); - try { - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - } catch { /* teardown race */ } - return contactSelected; - } - - /** Run one immediate destination through the shared ownership policy. */ - _runExplicitNavigation(noun, navigate, releaseOptions = undefined) { - return runExplicitNavigation({ - disposed: this._disposed, - cockpitActive: !!this.cockpitView?.active, - noun, - showToast: (text) => this._showToast(text), - stamp: () => this._stampNavigation(), - release: () => this._releaseFollowCamera(releaseOptions), - navigate, - }); - } - - /** Accept a delayed lookup without releasing its current camera owner. */ - _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) { - return beginDeferredNavigation({ - disposed: this._disposed, - cockpitActive: !!this.cockpitView?.active, - noun, - showToast: (text) => this._showToast(text), - // The searched-location readout survives the STAMP; only a flight that - // actually starts invalidates it (see the release hook below). - stamp: () => this._stampNavigation({ cancelPendingSelection, clearSearchedLocation: false }), - }); - } - - /** Final authority check and release immediately before a delayed flight. */ - _reassertNavigationHandoff(generation) { - return reassertNavigationHandoff({ - generation, - currentGeneration: this._navigationGeneration, - cockpitActive: !!this.cockpitView?.active, - disposed: this._disposed, - showToast: (text) => this._showToast(text), - // Reached only once the handoff is granted, immediately before the - // deferred flight starts — so a lookup that failed, was superseded, or - // was refused by the cockpit leaves the old readout standing. - release: () => { - this.clearSearchedLocation(); - return this._releaseFollowCamera(); - }, - }); - } - - /** Public lifecycle seam used by voice location navigation. */ - beginDeferredLocationNavigation() { - return this._beginDeferredNavigation('location'); - } - - /** Public final-authority seam used by voice geocoding. */ - reassertDeferredLocationNavigation(generation) { - return this._reassertNavigationHandoff(generation); - } - - /** Public immediate route used by voice destinations. */ - runImmediateLocationNavigation(navigate) { - return this.runImmediateNavigation('location', navigate); - } - - /** Public authority facade used by validated voice camera destinations. */ - runImmediateNavigation(noun, navigate, releaseOptions = undefined) { - return this._runExplicitNavigation(noun, navigate, releaseOptions); - } - - /** Supersede deferred work when an owner-specific route handles release. */ - supersedeDeferredNavigation() { - return this._stampNavigation(); - } - - /** Route a valid vessel/fire request through the shared navigation policy. */ - _runExplicitWorldFocus(detail, fly) { - return this._runExplicitNavigation(detail?.kind || 'target', fly); - } - - /** Return the aircraft tracker owned before a multi-step Cockpit transaction. */ - getAircraftTrackingTarget() { - return aircraftTrackingTarget(this.cockpitView?.readAircraftInfo?.()); - } - - /** - * On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall - * outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and - * clamp its top. No-op until the panel has been positioned (explicit inline top). - * @returns {void} - */ - _reclampDraggablePanels() { - const el = this._ppToggles; - if (!el || !el.style.top || el.style.top === 'auto') return; - const top = parseInt(el.style.top, 10); - if (!Number.isFinite(top)) return; - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); - } - - /** - * Creates one CesiumJS PostProcessStage per visual style and registers - * it with the scene. Each stage starts with intensity 0 (invisible) - * so crossfade transitions can animate it in later. - * @returns {void} - */ - _initStages() { - for (const [name, shader] of Object.entries(STYLES)) { - const uniforms = { intensity: 0.0 }; - - // Auto-detect time uniform — animated shaders (CRT scanlines, snow, etc.) - // declare `uniform float time` and receive elapsed seconds each frame. - if (shader.fragmentShader.includes('uniform float time')) { - uniforms.time = 0.0; - } - - // Initialize custom uniforms from shader metadata (e.g. gain, pixelation) - if (shader.uniforms) { - for (const [uName, uMeta] of Object.entries(shader.uniforms)) { - uniforms[uName] = uMeta.default; - } - } - - const stage = new Cesium.PostProcessStage({ - name: `godsEyeView_${name}`, - fragmentShader: shader.fragmentShader, - uniforms, - }); - - // Zero-intensity stages are DISABLED (perf wave 1). History: the - // first attempt at this deleted the product's signature scope — the - // circular starfield mask was an EMERGENT artifact of these six - // stacked "identity" passes, not an implemented feature. The owner - // ruled to reimplement the scope explicitly (src/scopeMask.js, a - // featherable zero-per-frame canvas), which frees these passes for - // real. If the scope ever looks wrong, look there — not here. - stage.enabled = false; - this.viewer.scene.postProcessStages.add(stage); - this.stages[name] = stage; - } - // Frozen after init — cached so the per-frame animation loop doesn't - // rebuild Object.entries arrays every frame. - this._stageEntries = Object.entries(this.stages); - } - - /** - * Single write path for style-stage intensity: keeps `enabled` in - * lockstep so zero-intensity stages cost nothing (safe now that the - * scope is explicit — see _initStages). The stage enables on the same - * frame the first non-zero intensity lands, so crossfades never pop. - * @param {Cesium.PostProcessStage} stage - Style post-process stage. - * @param {number} value - Intensity in [0, 1]. - * @returns {void} - */ - _setStageIntensity(stage, value) { - if (!stage) return; - stage.uniforms.intensity = value; - stage.enabled = value > 0.001; - // An animated shader becoming visible needs the style loop (its clock) - // running again; the loop self-stops when nothing visible animates. - if (stage.enabled && stage.uniforms.time !== undefined) this._startAnimationLoop(); - governorRequestRender('style-stage'); - } - - /** - * Re-sync every stage's `enabled` flag from its CURRENT intensity. - * - * The cockpit-vision policy helpers (src/cockpitVisionPolicy.js) are pure - * intensity math — they write `uniforms.intensity` directly and know - * nothing about the enabled/intensity lockstep _setStageIntensity owns. - * Without this sweep a stage the policy raised to 1 would stay DISABLED - * and cockpit NVG/FLIR/CRT would render nothing at all. (Inert while the - * chain is permanently enabled; load-bearing again once the explicit - * scope frees the zero-intensity stages — see _initStages.) - * @returns {void} - */ - _syncStagesEnabledFromIntensity() { - if (!this.stages) return; - for (const stage of Object.values(this.stages)) { - this._setStageIntensity(stage, stage.uniforms.intensity); - } - } - - /** - * 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"). - * - * The scope is the CONTACTS SESSION, not Cockpit. Cockpit enter/exit and - * third-person tracking are moves WITHIN that session and deliberately do not - * touch detection — an earlier build hooked this to cockpit enter/exit, which - * is exactly what turned detections off when the owner left the cockpit. - * - * Called from `_syncContextModeButtons`, the single funnel every - * `_contextMode` mutation routes through, and gated on the transaction having - * SETTLED (`!_contextModeChanging`) so a failed activation can never strand - * detection on. - * @returns {void} - */ - _syncContactsDetection() { - if (this._contextModeChanging) return; - const result = applyContactsDetection({ - active: this._contextMode === 'flights', - restore: this._contactsDetectionRestore, - // A map style picked DURING the session owns detection on the way out — - // its auto-enable preset is younger than the entry snapshot. - styleOwnsDetection: !this._detectionUserOverridden - && Boolean(STYLE_PRESET_DEFAULTS[this.activeStyle]?.detection), - // The snapshot must cover everything activation mutates — the preset - // writes DENSITY as well as mode, so a mode-only snapshot returned - // OFF @ 25% as OFF @ 75% and the next manual enable came back Dense. - getState: () => { - const state = this.getDetectionState(); - return { mode: state.detectionMode, densityPct: state.densityPct }; - }, - // Owner playtest: the force-on lands on the tactical look the military - // styles apply — the SAME preset object — not on whatever profile the - // operator last happened to leave detection at. - applyPreset: () => this._applyDetectionPreset(MILITARY_DETECTION_PRESET), - // The preset applier IS the state replayer: same density-then-mode order, - // same slider writes, so a restore round-trips exactly. - restoreState: (state) => this._applyDetectionPreset(state), - }); - const hadOwnership = Boolean(this._contactsDetectionRestore); - this._contactsDetectionRestore = result.restore; - // Serialization reads that ownership: while Contacts holds it the link - // carries the SAVED snapshot, and once released it carries live state. The - // share cache therefore goes stale on any ownership transition, whether or - // not the detection engine itself moved — and it does not always move. - // Exiting while a military style owns detection returns changed:false (the - // style's preset already matches), and returning early there left a copied - // link claiming the operator's pre-Contacts values while the map showed - // Dense @ 75%. - if (!shareCacheNeedsHeal({ - changed: result.changed, - hadOwnership, - hasOwnership: Boolean(result.restore), - })) return; - if (result.changed) this._syncDetectionUiFromEngine(); - this._syncShareState(); - } - - /** Apply a temporary cockpit-only CRT/NVG/FLIR/NOIR post-process override. */ - _setCockpitVision(mode, active, { revealParameters = false } = {}) { - const next = active ? normalizeCockpitVisionMode(mode) : 'optical'; - if (!this.stages) return; - if (!active) { - if (this._cockpitVisionRestore) { - for (const [name, intensity] of Object.entries(this._cockpitVisionRestore)) { - if (this.stages[name]) this._setStageIntensity(this.stages[name], intensity); - } - } - this._cockpitVisionRestore = null; - this._cockpitVisionMode = 'optical'; - this._syncIrBoost(); // Cockpit exit: fall back to the map preset's IR state - this._updateSliderPanel(this.activeStyle, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - return; - } - if (!this._cockpitVisionRestore) { - this._cockpitVisionRestore = captureCockpitVisionBaseline(this.stages, this.transitions); - } - if (next === 'optical') { - applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); - this._syncStagesEnabledFromIntensity(); - this._cockpitVisionMode = next; - this._syncIrBoost(); - this._updateSliderPanel(this.activeStyle, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - return; - } - const target = applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); - this._syncStagesEnabledFromIntensity(); - this._cockpitVisionMode = next; - this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear) - this._updateSliderPanel(target || null, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - } - - /** IR hot-target boost (owner playtest 2026-08-16): under the luminance- - * mapped NVG/FLIR looks the 3D fleets flip to flat white so contacts read - * HOT instead of vanishing mid-gray; restored when the look exits. The - * EFFECTIVE look is Cockpit's vision override while Cockpit is active - * ('nvg'/'thermal', which can differ from the map preset in BOTH - * directions), otherwise the map preset ('surveillance'/'thermal'). */ - _syncIrBoost() { - const cockpitMode = this.cockpitView?.active ? this._cockpitVisionMode : null; - const effective = cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle; - const irBoost = effective === 'surveillance' || effective === 'thermal' || effective === 'nvg'; - this._dataManager?.setLayerParams('flights', { irBoost }); - this._dataManager?.setLayerParams('military', { irBoost }); - // Fog blends distant geometry toward an effectively-BLACK color in this - // app (the Cesium globe is hidden), so beyond ~100 km every 3D aircraft - // fogs to a black silhouette — lighting and shaders can't reach past it - // (owner cockpit-FLIR field rounds, 2026-08-16). IR sensors see through - // haze, so the boost styles simply turn fog off; the prior state restores - // on exit. Transition-guarded so repeated syncs don't clobber the saved value. - const scene = this.viewer?.scene; - if (scene?.fog && irBoost !== this._irBoostActive) { - this._irBoostActive = irBoost; - if (irBoost) { - this._irFogWasEnabled = scene.fog.enabled; - scene.fog.enabled = false; - } else if (this._irFogWasEnabled != null) { - scene.fog.enabled = this._irFogWasEnabled; - this._irFogWasEnabled = null; - } - scene.requestRender?.(); - } - } - - /** Keep Cockpit's inherited label and restore target aligned with the active map preset. */ - _syncCockpitInheritedStyle() { - if (!this.cockpitView?.active || !this.stages) return; - this._cockpitVisionRestore = Object.fromEntries( - Object.keys(this.stages).map((name) => [name, name === this.activeStyle ? 1 : 0]), - ); - for (const name of Object.keys(this.stages)) this.transitions.delete(name); - this.cockpitView.setVisionMode(this.cockpitView.visionMode); - } - - /** Reveal shared style parameters, optionally opening Cockpit Display first. */ - _revealCockpitStyleParameters({ openDisplay = false } = {}) { - if (!this.cockpitView?.active || !this._sliderPanel?.classList.contains('active')) return; - if (openDisplay && this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') { - this._setCockpitDisclosure?.('display', true); - return; - } - if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') return; - this._sliderPanel.classList.remove('collapsed'); - this._syncPanelCollapseButton(this._sliderPanel); - requestAnimationFrame(() => requestAnimationFrame(() => { - this._sliderPanel?.scrollIntoView?.({ block: 'nearest' }); - })); - } - - /** - * Configures Cesium's built-in bloom stage and adds a custom unsharp-mask - * sharpen stage to the post-process pipeline. Both start disabled. - * @returns {void} - */ - _initBloomSharpen() { - // Bloom — use Cesium's built-in bloom - this._bloomStage = this.viewer.scene.postProcessStages.bloom; - this._bloomStage.enabled = false; - this._bloomStage.uniforms.glowOnly = false; - this._bloomStage.uniforms.contrast = 256.0; - this._bloomStage.uniforms.brightness = -0.35; - this._bloomStage.uniforms.delta = 0.25; - this._bloomStage.uniforms.sigma = 0.35; - this._bloomStage.uniforms.stepSize = 1.0; - - // Sharpen — custom unsharp mask PostProcessStage - this._sharpenStage = new Cesium.PostProcessStage({ - name: 'godsEyeView_sharpen', - fragmentShader: SHARPEN_SHADER, - uniforms: { - amount: 1.3, - }, - }); - this._sharpenStage.enabled = false; - this.viewer.scene.postProcessStages.add(this._sharpenStage); - if (this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } - } - - /** - * Reads the current bloom intensity percentage from the UI slider. - * @returns {number} Clamped bloom intensity (0-200). - */ - _getBloomIntensity() { - return clampBloomIntensity(parseInt(this._bloomSlider?.value || `${BLOOM_INTENSITY_DEFAULT}`, 10)); - } - - /** - * Enables or disables the Cesium bloom stage based on both the user toggle - * and whether the computed strength exceeds the perceptual threshold (0.06). - * @returns {void} - */ - _syncBloomStageEnabled() { - if (!this._bloomStage) return; - const strength = bloomStrengthFromIntensity(this._getBloomIntensity()); - this._bloomStage.enabled = this.bloomEnabled && strength > 0.06; - } - - /** - * Sets the bloom intensity, updates the slider UI, and applies the value. - * @param {number} intensity - Raw intensity percentage. - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - Whether to push state to the share link. - * @returns {void} - */ - _setBloomIntensity(intensity, { syncShare = true } = {}) { - governorRequestRender('bloom'); - const clamped = clampBloomIntensity(intensity); - if (this._bloomSlider) this._bloomSlider.value = String(clamped); - if (this._bloomSliderValue) this._bloomSliderValue.textContent = `${clamped}%`; - this._applyBloomIntensity(clamped); - if (syncShare) this._syncShareState(); - } - - /** - * Maps a bloom intensity percentage to Cesium bloom stage uniforms. - * Uses smoothstep easing (Hermite interpolation: 3t^2 - 2t^3) to - * produce a perceptually linear glow ramp from zero to full strength. - * @param {number} intensity - Bloom intensity percentage (0-200). - * @returns {void} - */ - _applyBloomIntensity(intensity) { - if (!this._bloomStage) return; - const rawStrength = bloomStrengthFromIntensity(intensity); - // Dead-zone: strengths below 0.06 are imperceptible, clamp to zero. - const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); - // Smoothstep easing for perceptually linear bloom ramp - const eased = strength * strength * (3.0 - 2.0 * strength); - - // Mapping tuned for intuitive UX: - // 0 => effectively no glow, 200 => strong glow. - // Keep threshold strict at low values so only very bright highlights bloom. - this._bloomStage.uniforms.contrast = 255.0 - (eased * 168.0); - this._bloomStage.uniforms.brightness = -0.5 + (eased * 0.36); - this._bloomStage.uniforms.sigma = 0.28 + (eased * 6.3); - this._bloomStage.uniforms.delta = 0.2 + (eased * 2.25); - this._bloomStage.uniforms.stepSize = 1.0 + (eased * 1.25); - this._syncBloomStageEnabled(); - } - - /** - * Toggles bloom on/off, syncs button state, and reveals/hides the intensity slider row. - * @param {boolean} enabled - Whether bloom should be active. - * @returns {void} - */ - _setBloomEnabled(enabled) { - governorRequestRender('bloom'); - this.bloomEnabled = !!enabled; - this._syncBloomStageEnabled(); - this._bloomBtn.classList.toggle('active', this.bloomEnabled); - this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled); - if (this.bloomEnabled) { - this._applyBloomIntensity(this._getBloomIntensity()); - } - this._syncShareState(); - this._layoutRightPanels(); - } - - /** - * Maps a normalized sharpen value (0-1) to the unsharp-mask `amount` uniform. - * Range: 0.1 (subtle) to 2.1 (aggressive edge enhancement). - * @param {number} val - Normalized sharpen intensity (0.0 to 1.0). - * @returns {void} - */ - _applySharpenIntensity(val) { - governorRequestRender('sharpen'); - if (!this._sharpenStage || !this._sharpenStage.uniforms) return; - this._sharpenStage.uniforms.amount = 0.1 + val * 2.0; - } - - /** - * Toggles sharpening on/off, syncs button state, and reveals/hides the intensity slider row. - * @param {boolean} enabled - Whether sharpening should be active. - * @returns {void} - */ - _setSharpenEnabled(enabled) { - governorRequestRender('sharpen'); - this.sharpenEnabled = !!enabled; - this._sharpenStage.enabled = this.sharpenEnabled; - this._sharpenBtn.classList.toggle('active', this.sharpenEnabled); - if (this._sharpenSliderRow) { - this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled); - } - if (this.sharpenEnabled && this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } - this._syncShareState(); - this._layoutRightPanels(); - } - - /** - * Wires up all primary UI event listeners: style buttons, keyboard shortcuts - * (1-8 style keys, H/O/V/F/D/C hotkeys, Escape), AI prompt input with - * debounce, bloom/sharpen/HUD toggles, detection density slider, and - * clean-view toggle. - * @returns {void} - */ - _initUI() { - this._applicationShortcuts?.destroy(); - this._applicationShortcuts = bindApplicationShortcuts({ - documentRef: document, - searchInput: this._locationSearch, - actions: { - setStyle: (style) => this.setStyle(style), - dismissSearch: () => { - if (this._locationSearch.classList.contains('expanded')) { - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); - } - }, - toggleHud: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.toggle(); - this._updateHudButtonState(); - this._syncShareState(); - }, - toggleOrbit: () => this._toggleOrbit(), - toggleCleanView: () => this.toggleCleanView(), - toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'), - cycleDetection: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - cycleDetectionMode(); - this._syncShareState(); - }, - toggleCctv: () => this._toggleCctvEnabled(), - }, - }); - - this._displayControls?.destroy(); - this._displayControls = bindDisplayControls({ - elements: { - styleButtons: document.querySelectorAll('.style-btn'), - bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider, - sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, - scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, - hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, - cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn, - densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn, - allocationButtons: this._detectionAllocationBtns, - fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider], - celestialButton: this._celestialBtn, - modelsButton: this._models3dBtn, - modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [], - }, - actions: { - setStyle: (style) => this.setStyle(style), - toggleBloom: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomEnabled(!this.bloomEnabled); - }, - setBloomIntensity: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomIntensity(value); - }, - toggleSharpen: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setSharpenEnabled(!this.sharpenEnabled); - }, - toggleScope: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const next = !isScopeMaskEnabled(); - setScopeMaskEnabled(next); - this._scopeBtn.classList.toggle('active', next); - this._scopeBtn.setAttribute('aria-pressed', String(next)); - this._syncShareState(); - }, - setScopeFeather: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = Math.max(0, Math.min(100, value || 0)); - if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; - setScopeMaskFeather(pct / 100); - this._syncShareState(); - }, - setSharpenIntensity: (pct) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - this._syncShareState(); - }, - setHudLayout: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(value); - }, - toggleCleanView: () => this.toggleCleanView(), - exitCleanView: () => this.toggleCleanView(false), - setDensity: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - const pct = canonicalizeDensity(value); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - this._syncShareState(); - }, - setAllocation: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - this._setDetectionAllocation(value); - }, - setFade: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._applyDetectionFadeFromUi(); - this._syncShareState(); - }, - toggleCelestial: () => { - const ringIsVisible = !!this.celestialRing?.visible; - if (!this.celestialRingEnabled || !ringIsVisible) { - this.setCelestialRingEnabled(true, { focus: true }); - } else { - this.setCelestialRingEnabled(false); - } - }, - toggleHud: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.toggle(); - this._updateHudButtonState(); - this._syncShareState(); - }, - cycleDetection: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - cycleDetectionMode(); - this._syncShareState(); - }, - toggleModels: () => { - this._setModels3dEnabled(!this._models3dEnabled); - this._syncModels3dModeRow(); - }, - setModelsMode: (mode) => this._setModels3dMode(mode), - }, - }); - } - - /** - * Renders the owner-approved map stack chip row from the matching controller - * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, - * with an accessible explanation, until a CESIUM_ION_TOKEN is configured. - * @returns {void} - */ - _initMapStackControl() { - if (!this._mapStackChips || !this.mapStackController) return; - - if (!this._mapStackChangeHandler) { - // Provider-driven transitions (notably Esri tile-error fallback) do not - // pass through `_setMapStack()`. Follow the controller's existing public - // event so the lit tile, the status line, AND the durable share state all - // describe the rendered source — without the share sync, a silent - // fallback leaves copyLink() encoding a stack that is no longer shown. - this._mapStackChangeHandler = (event) => { - this._renderMapStackState(event.detail); - this._syncShareState(); - }; - window.addEventListener('gev:map-stack-changed', this._mapStackChangeHandler); - } - - renderMapStackChips(this._mapStackChips, this.mapStackController.getStacks(), { - activeId: this.mapStackController.getActiveId(), - onSelect: (stackId) => { this._setMapStack(stackId); }, - }); - - this._renderMapStackState(this.mapStackController.getState()); - } - - /** - * Switches the active map/globe source stack. - * @param {string} stackId - Map stack id. - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - Whether to update the share link. - * @returns {Promise} - */ - async _setMapStack(stackId, { syncShare = true } = {}) { - if (!this.mapStackController) return; - if (syncShare) this.shareLinkManager?.claimRestoreLane?.('map'); - const before = this.mapStackController.getActiveId(); - this._renderMapStackState(this.mapStackController.getState('switching')); - const state = await this.mapStackController.setStack(stackId); - this._renderMapStackState(state); - - if (state?.activeId === before && stackId !== before && state?.lastError) { - this._showToast(state.lastError); - } - if (syncShare) this._syncShareState(); - } - - /** - * Syncs the map stack chip row and status chip with controller state. The - * lit chip always follows `state.activeId`, never the click — a rejected or - * superseded switch therefore leaves the genuinely active stack lit. - * @param {object} state - Map stack controller state. - * @returns {void} - */ - _renderMapStackState(state) { - if (!state) return; - syncMapStackChips(this._mapStackChips, state.activeId); - if (this._mapStackStatus) { - const stack = state.activeStack; - const label = state.status === 'switching' - ? '...' - : (stack?.shortLabel || stack?.label || 'MAP'); - this._mapStackStatus.textContent = label; - this._mapStackStatus.classList.toggle('warn', !!state.lastError); - } - } - - /** - * Reads and canonicalizes the five-stop density control. The engine derives - * Sparse/Balanced/Dense from the same stop. - * @returns {void} - */ - _applyDetectionDensityFromUi() { - if (!this._detectionDensitySlider) return; - const pct = canonicalizeDensity(this._detectionDensitySlider.value); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - setDetectionTuning({ densityPct: pct }); - this._updateDetectionButton(getDetectionMode()); - } - - /** Apply responsive keyhole fade controls from normalized UI percentages. */ - _applyDetectionFadeFromUi() { - const fadePct = Math.max(0, Math.min(40, Math.round(Number(this._detectionFadeSlider?.value) || 0))); - const outsideOpacityValue = this._detectionOpacitySlider?.value; - const outsideOpacityPct = Math.max( - 0, - Math.min(100, Math.round(outsideOpacityValue == null ? 3 : Number(outsideOpacityValue) || 0)), - ); - if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(fadePct); - if (this._detectionFadeValue) this._detectionFadeValue.textContent = `${fadePct}%`; - if (this._detectionOpacitySlider) this._detectionOpacitySlider.value = String(outsideOpacityPct); - if (this._detectionOpacityValue) this._detectionOpacityValue.textContent = `${outsideOpacityPct}%`; - setKeyholeFadeTuning({ - fadeRatio: fadePct / 100, - outsideOpacity: outsideOpacityPct / 100, - }); - this.viewer.scene.requestRender?.(); - } - - _setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) { - const raw = String(strategy || '').trim().toUpperCase(); - if (!ALLOCATION_STRATEGIES.includes(raw)) return false; - const normalized = normalizeAllocationStrategy(raw); - this._detectionAllocationPreference = normalized; - setDetectionTuning({ allocationStrategy: normalized }); - for (const button of this._detectionAllocationBtns) { - const active = button.dataset.allocation === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } - if (persist) { - try { localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized); } catch { /* best effort */ } - } - if (syncShare) this._syncShareState(); - return true; - } - - _syncDetectionUiFromEngine() { - const tuning = getDetectionTuning(); - if (this._detectionDensitySlider) this._detectionDensitySlider.value = String(tuning.densityPct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${tuning.densityPct}%`; - this._setDetectionAllocation(tuning.allocationStrategy, { syncShare: false, persist: false }); - const fadeTuning = getKeyholeFadeTuning(); - if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(Math.round(fadeTuning.fadeRatio * 100)); - if (this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(Math.round(fadeTuning.outsideOpacity * 100)); - } - this._applyDetectionFadeFromUi(); - this._updateDetectionButton(getDetectionMode()); - } - - /** - * Activates a detection overlay mode by label (e.g. 'OFF', 'SPARSE', 'PANOPTIC'). - * @param {string} modeLabel - Detection mode label to set. - * @returns {void} - */ - _setDetectionMode(modeLabel) { - if (!modeLabel) return; - setDetectionModeByLabel(modeLabel); - this._syncDetectionUiFromEngine(); - this._syncShareState(); - } - - /** - * Switches the HUD layout variant (e.g. 'tactical', 'minimal') and syncs - * the layout dropdown if present. - * @param {string} variantName - HUD variant identifier. - * @returns {void} - */ - _setHudVariant(variantName) { - if (!variantName) return; - this.hud.setVariant(variantName); - if (this._hudLayoutSelect && this._hudLayoutSelect.value !== this.hud.getVariant()) { - this._hudLayoutSelect.value = this.hud.getVariant(); - } - this._syncShareState(); - this._scheduleAdaptivePanelLayout({ settle: true }); - } - - /** - * Keeps both responsive panel lanes and Cockpit's utility strip on the same - * measured layout commit. HUD visibility transitions can outlive the first - * animation frame, so variant changes receive one bounded settling pass. - * @param {{settle?: boolean}} [options] Whether to remeasure after transitions. - * @returns {void} - */ - _scheduleAdaptivePanelLayout({ settle = false } = {}) { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this.cockpitView?.scheduleContextLayout(); - if (!settle) return; - clearTimeout(this._adaptivePanelSettleTimer); - this._adaptivePanelSettleTimer = setTimeout(() => { - this._adaptivePanelSettleTimer = null; - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this.cockpitView?.scheduleContextLayout(); - }, COCKPIT_LAYOUT_SETTLE_MS); - } - - /** - * Applies preset defaults (bloom, sharpen, shader uniforms, HUD variant) - * when a military-class style (CRT, NVG, FLIR) is selected. Does nothing - * for styles without entries in STYLE_PRESET_DEFAULTS. - * @param {string} styleName - The style whose defaults to apply. - * @returns {void} - */ - _applyStylePresetDefaults(styleName) { - const preset = STYLE_PRESET_DEFAULTS[styleName]; - if (!preset) return; - - if (preset.styleParams && typeof preset.styleParams === 'object') { - for (const [targetStyle, params] of Object.entries(preset.styleParams)) { - const stage = this.stages[targetStyle]; - if (!stage || !params || typeof params !== 'object') continue; - for (const [uniformName, uniformValue] of Object.entries(params)) { - if (stage.uniforms[uniformName] === undefined) continue; - stage.uniforms[uniformName] = uniformValue; - governorRequestRender('style-param'); - } - } - } - - const bloomInput = preset.bloom || {}; - if (typeof bloomInput.intensity === 'number' && this._bloomSlider) { - this._setBloomIntensity(clampBloomIntensity(bloomInput.intensity), { syncShare: false }); - } - if (typeof bloomInput.enabled === 'boolean') { - this._setBloomEnabled(bloomInput.enabled); - } - - const sharpenInput = preset.sharpen || {}; - if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { - const sharpenPct = Math.max(0, Math.min(100, Math.round(sharpenInput.intensity))); - this._sharpenSlider.value = String(sharpenPct); - this._sharpenSliderValue.textContent = `${sharpenPct}%`; - this._applySharpenIntensity(sharpenPct / 100); - } - if (typeof sharpenInput.enabled === 'boolean') { - this._setSharpenEnabled(sharpenInput.enabled); - } - - if (preset.hudVariant) { - this._setHudVariant(preset.hudVariant); - } - if (typeof preset.hudVisible === 'boolean') { - this.hud.setMode(preset.hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - - // A style may set a detection default (e.g. military styles -> Dense for - // the "epic" default view), but ONLY if the user hasn't manually changed - // detection this session. Detection is user-controlled and persists across - // style switches, so an explicit Sparse/Off choice is never stomped. - if (preset.detection && !this._detectionUserOverridden) { - this._applyDetectionPreset(preset.detection); - } - } - - /** - * Apply a detection preset's density and mode through the real UI path. - * - * Deliberately does NOT consult `_detectionUserOverridden` — the CALLER owns - * that decision. The style path checks it (an explicit Sparse/Off must - * survive a style switch); Cockpit entry does not (owner: detection is on in - * the cockpit "regardless"). - * @param {{mode?: string, densityPct?: number}} det Preset detection config. - * @returns {void} - */ - _applyDetectionPreset(det) { - if (!det) return; - if (typeof det.densityPct === 'number' && this._detectionDensitySlider) { - const pct = canonicalizeDensity(det.densityPct); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - } - if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase()); - } - - /** - * Applies the global post-processing baseline (GLOBAL_POST_DEFAULTS) at - * startup before any share-link restore runs. Sets bloom, sharpen, HUD, - * and detection to their factory defaults. - * @returns {void} - */ - _applyGlobalPostDefaults() { - const defaults = GLOBAL_POST_DEFAULTS; - if (typeof defaults.bloom?.intensity === 'number' && this._bloomSlider) { - this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), { syncShare: false }); - } - if (typeof defaults.bloom?.enabled === 'boolean') { - this._setBloomEnabled(defaults.bloom.enabled); - } - - if (typeof defaults.sharpen?.intensity === 'number' && this._sharpenSlider) { - const sharpenPct = Math.max(0, Math.min(100, Math.round(defaults.sharpen.intensity))); - this._sharpenSlider.value = String(sharpenPct); - this._sharpenSliderValue.textContent = `${sharpenPct}%`; - this._applySharpenIntensity(sharpenPct / 100); - } - if (typeof defaults.sharpen?.enabled === 'boolean') { - this._setSharpenEnabled(defaults.sharpen.enabled); - } - - if (defaults.hudVariant) { - this._setHudVariant(defaults.hudVariant); - } - if (typeof defaults.hudVisible === 'boolean') { - this.hud.setMode(defaults.hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - - if (defaults.detectionMode) { - this._setDetectionMode(defaults.detectionMode); - } - if (typeof defaults.detectionDensity === 'number' && this._detectionDensitySlider) { - const density = canonicalizeDensity(defaults.detectionDensity); - this._detectionDensitySlider.value = String(density); - this._detectionDensityValue.textContent = `${density}%`; - this._applyDetectionDensityFromUi(); - } - this._setDetectionAllocation( - this._detectionAllocationPreference || defaults.detectionAllocation || 'ELASTIC', - { syncShare: false, persist: false }, - ); - if (this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(defaults.detectionFadePct ?? 7); - } - if (this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(defaults.detectionOutsideOpacityPct ?? 1); - } - this._applyDetectionFadeFromUi(); - if (typeof defaults.celestialRing === 'boolean') { - this.setCelestialRingEnabled(defaults.celestialRing, { syncShare: false, focus: false }); - } - } - - /** - * Pushes the current visual state (bloom, sharpen, HUD, detection) to - * the ShareLinkManager so the URL hash stays in sync. - * @returns {void} - */ - /** - * Detection as a DURABLE preference, for serialization into a share link. - * - * While Contacts is active it OWNS detection and forces Dense @ 75%. That is - * a session-scoped override, not something the operator chose: it is undone - * verbatim on deactivation. Serializing the forced values shipped a link that - * pinned Dense @ 75% on the recipient — as a durable preference, with no - * Contacts mode present to explain or undo it — even though the author's own - * setting was (say) OFF @ 50%. Publish what deactivation would restore. - * - * `_contactsDetectionRestore` is exactly that snapshot and is null whenever - * Contacts does not own detection, so the live values are used normally. - */ - _shareableDetectionState() { - return shareableDetectionState({ - owned: this._contactsDetectionRestore, - liveMode: getDetectionMode(), - liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10), - }); - } - - _syncShareState() { - const detection = this._shareableDetectionState(); - this.shareLinkManager.onToggleChange(this.bloomEnabled, this.sharpenEnabled, { - bloomIntensity: this._getBloomIntensity(), - bloomVersion: BLOOM_SCALE_VERSION, - sharpenIntensity: parseInt(this._sharpenSlider?.value || '49', 10), - hudVariant: this.hud.getVariant(), - hudVisible: this.hud.visible, - detectionMode: detection.mode, - detectionDensity: detection.densityPct, - detectionAllocation: getDetectionTuning().allocationStrategy, - detectionFadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - detectionOutsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '1', 10), - celestialRingEnabled: this.celestialRingEnabled, - scopeEnabled: isScopeMaskEnabled(), - scopeFeatherPct: Math.round(getScopeMaskFeather() * 100), - // null when adaptive — the share layer omits `sce` entirely in that case. - scopeTerminusPct: getScopeTerminusOverride() == null - ? null - : Math.round(getScopeTerminusOverride() * 100), - mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', - }); - } - - /** - * Updates the traffic sync status chip with loading phase label and progress. - * Auto-hides after 1.5s when loading completes; stays visible while busy. - * @param {boolean} [forceShow=false] - Force the chip visible regardless of busy state. - * @returns {void} - */ - _updateTrafficSyncChip(forceShow = false, now = performance.now()) { - if (!this._trafficSyncChip || !this._trafficSyncLabel || !this._trafficSyncProgress) return; - const layers = this._dataManager?.getAll?.(); - const traffic = Array.isArray(layers) ? layers.find((layer) => layer.id === 'traffic') : null; - this._trafficSyncFeedbackState = reduceTrafficSyncFeedback( - this._trafficSyncFeedbackState, - { enabled: traffic?.enabled === true, stats: traffic?.stats || {}, forceShow }, - now, - ); - const presentation = this._trafficSyncFeedbackState; - // setSplitFlapText carries the same unchanged-text guard internally, and - // the flap keeps textContent equal to the settled label throughout, so - // this stays a no-op on the repeat ticks exactly as it did before. - if (presentation.label) setSplitFlapText(this._trafficSyncLabel, presentation.label); - // Written on every change INCLUDING the empty settled value — the reducer - // clears the progress number once the sync lands, and a truthiness guard - // here would strand the last "..." beside the settled label. - if (this._trafficSyncProgress.textContent !== presentation.progressText) { - this._trafficSyncProgress.textContent = presentation.progressText; - } - this._trafficSyncChip.classList.toggle('visible', presentation.visible); - } - - /** - * Updates the CCTV loading chip (same pattern as the traffic sync chip) - * with staggered initial-load progress, e.g. "LOADING FRAMES 12/36". - * Shows a brief "camera grid ready" confirmation, then hides. - * @param {{active: boolean, loaded: number, total: number}|null|undefined} loading - * Loading progress from the CCTV layer UI state. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @returns {void} - */ - _updateCctvSyncChip(loading, enabled) { - if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; - const total = Number(loading?.total) || 0; - const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); - const busy = !!enabled && !!loading?.active && total > 0; - - if (busy) { - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = true; - setSplitFlapText(this._cctvSyncLabel, 'loading frames'); - // The counter is left plain on purpose: it ticks every few frames - // during a grid load, and flapping it would read as a slot machine. - this._cctvSyncProgress.textContent = `${loaded}/${total}`; - this._cctvSyncChip.classList.add('visible'); - return; - } - - if (this._cctvChipWasBusy && enabled && total > 0) { - // Load just completed — flash the final count, then auto-hide. - this._cctvChipWasBusy = false; - setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); - this._cctvSyncProgress.textContent = `${total}/${total}`; - this._cctvSyncChip.classList.add('visible'); - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = window.setTimeout(() => { - this._cctvChipHideTimer = null; - this._cctvSyncChip.classList.remove('visible'); - }, 1500); - return; - } - - if (!this._cctvChipHideTimer) { - this._cctvChipWasBusy = false; - this._cctvSyncChip.classList.remove('visible'); - } - } - - /** - * Initializes panel collapse buttons and restores persisted collapsed state. - * Also sets up hover-expand behavior for the style presets and location bar panels. - * @returns {void} - */ - _initPanelChrome() { - for (const control of this._panelDisclosureControls || []) control.destroy(); - this._panelDisclosureControls = []; - const targets = new Map(); - document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((button) => { - const targetId = button.dataset.collapseTarget; - if (!targetId) return; - if (!targets.has(targetId)) targets.set(targetId, []); - targets.get(targetId).push(button); - }); - for (const [targetId, buttons] of targets) { - const panel = document.getElementById(targetId); - if (!panel) continue; - this._panelDisclosureControls.push(bindPanelDisclosure({ - panel, - buttons, - onChange: (collapsed, options) => this.setPanelCollapsed(targetId, collapsed, options), - onEscape: (event) => this._collapsePanelOnEscape(event, targetId), - })); - this._restorePanelCollapsedState(targetId, { - allowStored: !this._initialShareState, - }); - } - // The command dock always starts compact; either wing reveals on hover, - // focus, or click and collapses again after the interaction moves away. - this.setPanelCollapsed('control-panel', true, { syncShare: false, persist: false }); - this.setPanelCollapsed('location-bar', true, { syncShare: false, persist: false }); - this._initAutoHoverPanel('control-panel', { openDelayMs: 140, closeDelayMs: 420 }); - this._initAutoHoverPanel('location-bar', { openDelayMs: 140, closeDelayMs: 420 }); - this._initCommandDockPins(); - this._initCommandDockTrayMetrics(); - this._maybeNotifyLayoutReset(); - } - - /** - * Collapses the nearest expanded panel that owns keyboard focus on Escape. - * Nested panels consume the event first, so one key closes one level and - * returns focus to that level's disclosure. If Escape was pressed on the - * disclosure itself, remove focus after closing so the collapsed button does - * not keep a stale keyboard ring. - * @param {KeyboardEvent} event - Candidate Escape key event. - * @param {string} panelId - Collapsible panel containing the listener. - * @returns {boolean} Whether this panel handled the key. - */ - _collapsePanelOnEscape(event, panelId) { - return collapsePanelOnEscape(event, { - panel: document.getElementById(panelId), - onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), - beforeCollapse: () => { - if (panelId !== 'location-bar' || !this._locationSearch) return; - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); - }, - }); - } - - /** - * Allows either command-dock tray to remain open until explicitly unpinned. - * Both trays may be pinned; transient and error trays stack above them. - * @returns {void} - */ - _initCommandDockPins() { - document.querySelectorAll('.dock-pin-btn[data-pin-target]').forEach((button) => { - button.addEventListener('click', (event) => { - event.stopPropagation(); - const panelId = button.dataset.pinTarget; - this._setCommandDockPanelPinState(panelId); - }); - }); - } - - _setCommandDockPanelPinState(panelId, pin, { - restore = false, - persist = true, - syncShare = true, - } = {}) { - const panelEl = document.getElementById(panelId); - const button = document.querySelector(`.dock-pin-btn[data-pin-target="${panelId}"]`); - if (!panelEl || !button) return undefined; - const shouldPin = typeof pin === 'boolean' - ? pin - : !panelEl.classList.contains('dock-pinned'); - panelEl.classList.toggle('dock-pinned', shouldPin); - button.setAttribute('aria-pressed', String(shouldPin)); - document.querySelectorAll('#command-dock .dock-pinned-top').forEach((pinnedPanel) => { - pinnedPanel.classList.remove('dock-pinned-top'); - }); - if (shouldPin) { - panelEl.classList.add('dock-pinned-top'); - this.setPanelCollapsed(panelId, false, { - explicit: !restore, - restore, - persist, - syncShare: false, - }); - } else { - const remainingPinnedPanel = document.querySelector('#command-dock .dock-pinned'); - remainingPinnedPanel?.classList.add('dock-pinned-top'); - if (!restore && !panelEl.matches(':hover')) { - this.setPanelCollapsed(panelId, true, { - explicit: true, - persist, - syncShare: false, - }); - } - } - this._updateCommandDockTrayStack(); - if (syncShare) { - if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); - this.shareLinkManager?.onPanelStateChange?.(); - } - return shouldPin; - } - - /** - * Tracks the live pinned-tray height so a hovered sibling can stack above it - * without hardcoded content dimensions. - * @returns {void} - */ - _initCommandDockTrayMetrics() { - const dock = document.getElementById('command-dock'); - if (!dock) return; - this._commandDockTrayObserver?.disconnect?.(); - if (typeof ResizeObserver === 'function') { - this._commandDockTrayObserver = new ResizeObserver(() => this._updateCommandDockTrayStack()); - dock.querySelectorAll('.dock-popover-content').forEach((tray) => { - this._commandDockTrayObserver.observe(tray); - }); - } - this._updateCommandDockTrayStack(); - } - - /** - * Writes each pinned tray height and their combined stack height as CSS - * variables. The most recently pinned tray forms the upper level. - * @returns {void} - */ - _updateCommandDockTrayStack() { - const dock = document.getElementById('command-dock'); - if (!dock) return; - const locationPanel = dock.querySelector('#location-bar.dock-pinned:not(.collapsed)'); - const presetsPanel = dock.querySelector('#control-panel.dock-pinned:not(.collapsed)'); - const locationHeight = locationPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; - const presetsHeight = presetsPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; - const pinnedCount = Number(locationHeight > 0) + Number(presetsHeight > 0); - const locationHeightPx = Math.ceil(locationHeight); - const presetsHeightPx = Math.ceil(presetsHeight); - const topPinnedPanel = dock.querySelector('.dock-pinned-top.dock-pinned:not(.collapsed)'); - const lowerPinnedPanel = topPinnedPanel?.id === 'location-bar' ? presetsPanel : locationPanel; - const lowerPinnedHeight = lowerPinnedPanel - ?.querySelector('.dock-popover-content') - ?.getBoundingClientRect().height || 0; - const stackHeight = pinnedCount > 1 - ? `calc(${locationHeightPx}px + ${presetsHeightPx}px + 1.2rem)` - : `${locationHeightPx + presetsHeightPx}px`; - dock.style.setProperty('--dock-location-pinned-height', `${locationHeightPx}px`); - dock.style.setProperty('--dock-presets-pinned-height', `${presetsHeightPx}px`); - dock.style.setProperty('--dock-lower-pinned-height', `${Math.ceil(lowerPinnedHeight)}px`); - dock.style.setProperty('--dock-pinned-stack-height', stackHeight); - dock.classList.toggle('dock-has-pinned-tray', pinnedCount > 0); - dock.classList.toggle('dock-has-two-pinned-trays', pinnedCount > 1); - } - - /** - * One-time toast when stored v6 panel positions are superseded by the v7 - * layout defaults (positions reset; collapsed states are preserved). - * @returns {void} - */ - _maybeNotifyLayoutReset() { - try { - const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`; - if (localStorage.getItem(marker)) return; - localStorage.setItem(marker, '1'); - const hadOldPositions = Object.keys(localStorage) - .some((key) => key.startsWith('godsEyeView.v6.panelPos.')); - if (hadOldPositions) { - this._showToast('Panel layout updated — positions reset to new defaults'); - } - } catch { - // storage unavailable - } - } - - /** - * Configures intentional hover-expand / leave-collapse behavior on a panel. - * Uses separate open/close timers to prevent accidental flicker from fast - * mouse passes. Wheel events cancel pending opens to avoid surprise expansion - * during scroll-through. - * @param {string} panelId - DOM id of the panel element. - * @param {object} [options] - * @param {number} [options.openDelayMs=850] - Hover dwell time before auto-expanding. - * @param {number} [options.closeDelayMs=1000] - Delay after pointer leaves before collapsing. - * @returns {void} - */ - _initAutoHoverPanel(panelId, { openDelayMs = 850, closeDelayMs = 1000 } = {}) { - const panel = document.getElementById(panelId); - if (!panel) return; - this._hoverPanelControls ??= new Map(); - this._hoverPanelControls.get(panelId)?.destroy(); - const controller = createHoverDisclosure({ - panel, - documentRef: document, - disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), - openDelayMs, - closeDelayMs, - isActive: () => !this._disposed, - onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), - onEscape: (event) => this._collapsePanelOnEscape(event, panelId), - focusTarget: panelId === 'control-panel' ? () => ( - panel.querySelector('.map-stack-chip.active') || panel.querySelector('.map-stack-chip') - ) : null, - }); - this._hoverPanelControls.set(panelId, controller); - if (panelId === 'control-panel') { - this._cancelMapSourceFocus?.(); - this._cancelMapSourceFocus = controller.cancelPendingFocus; - } - } - - /** - * Sets up drag-to-reposition for legacy floating controls. The right rail - * and left accordion remain fixed so their HUD alignment is deterministic. - * @returns {void} - */ - _initPanelDrag() { - const dragSpecs = [ - { - id: 'pp-toggles', - panel: this._ppToggles, - handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), - }, - ].filter(Boolean); - - for (const spec of dragSpecs) { - if (!spec.panel || !spec.handle) continue; - this._restorePanelPosition(spec.id, spec.panel); - this._makePanelDraggable(spec.id, spec.panel, spec.handle); - } - // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its - // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and - // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. - if (this._ppToggles && typeof ResizeObserver !== 'undefined') { - this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels()); - this._draggableResizeObserver.observe(this._ppToggles); - } - } - - _persistAwarenessSelection(event, cleared = false) { - if (!this._dataManager) return; - const origin = String(event?.detail?.origin || 'programmatic'); - if (!isExplicitLayerStateOrigin(origin)) return; - const layerId = String(event?.detail?.layerId || ''); - const config = { - flights: { - key: 'selectedFlightsTrackingId', - normalize: (value) => String(value ?? '').trim().toLowerCase() || null, - }, - military: { - key: 'selectedMilitaryTrackingId', - normalize: (value) => String(value ?? '').trim().toLowerCase() || null, - }, - satellites: { - key: 'selectedSatTrackingId', - normalize: (value) => { - const candidate = Number(value); - return Number.isFinite(candidate) && candidate > 0 ? Math.trunc(candidate) : null; - }, - }, - }[layerId]; - if (!config) return; - const selectedValue = cleared ? null : config.normalize(event?.detail?.id); - if (cleared || selectedValue === null) { - this._dataManager.adoptLayerParams?.(layerId, { - [config.key]: selectedValue, - }, { origin }); - return; - } - // A direct selection promotes a Context-owned tracker dependency into - // durable visibility before its selected ID is normalized. Context exit - // also keeps this adopted layer instead of tearing down the user's track. - const visibilityAdopted = this._dataManager.adoptLayerVisibility?.( - layerId, - true, - { origin, adoptedFromSelection: true }, - ); - if (visibilityAdopted === false) return; - // Clear the prior family before publishing the replacement. Otherwise the - // coordinator briefly sees two IDs and correctly treats them as an - // ambiguous incoming state, which would discard the new durable target. - for (const [otherLayerId, otherKey] of [ - ['flights', 'selectedFlightsTrackingId'], - ['military', 'selectedMilitaryTrackingId'], - ['satellites', 'selectedSatTrackingId'], - ]) { - if (otherLayerId === layerId) continue; - this._dataManager.setLayerParams(otherLayerId, { [otherKey]: null }, { origin }); - } - this._dataManager.adoptLayerParams?.(layerId, { - [config.key]: selectedValue, - }, { origin }); - } - - /** - * Connects the layer data manager for traffic sync, CCTV state subscription, - * and layer enable/disable operations. - * @param {object|null} dataManager - The DataManager instance, or null to detach. - * @returns {void} - */ - attachDataManager(dataManager) { - if (this._dataManagerBeforeDestroyUnsubscribe) { - this._dataManagerBeforeDestroyUnsubscribe(); - this._dataManagerBeforeDestroyUnsubscribe = null; - } - if (this._dataManagerVisibilityGuardUnsubscribe) { - this._dataManagerVisibilityGuardUnsubscribe(); - this._dataManagerVisibilityGuardUnsubscribe = null; - } - if (this._dataManagerVisibilityRequestUnsubscribe) { - this._dataManagerVisibilityRequestUnsubscribe(); - this._dataManagerVisibilityRequestUnsubscribe = null; - } - this._dataManager = dataManager || null; - this.hud.attachDataManager(this._dataManager); - this._updateTrafficSyncChip(); - if (this._dataManagerUnsubscribe) { - this._dataManagerUnsubscribe(); - this._dataManagerUnsubscribe = null; - } - if (typeof this._dataManager?.subscribe === 'function') { - this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (String(change?.type || '').startsWith('visibility')) { - this._handleContextLayerChange(change); - } - this._loadingFeedbackEvent = change; - this._updateGlobalLoadingFeedback(performance.now()); - }); - } - this._updateGlobalLoadingFeedback(performance.now()); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { - if (shouldCaptureContextSession(change)) { - // This event is synchronous with intent publication, before an - // awaited guard or Clear All can alter the rest of the layer set. - // Manager effective visibility already includes both the new entry - // intent and Clear's reserved OFF baseline. - this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); - if (shouldDeferContextEntryDuringClear({ - change, - clearInFlight: Boolean(this._clearSelectedLayersPromise), - })) { - this._contextModeDeferredEntryIntent = { - layerId: change.layerId, - intentEpoch: change.intentEpoch, - origin: change.origin, - }; - this._contextModeEntering = 'space-missions'; - this._syncContextModeButtons(); - } - } else if ( - change?.layerId === 'rocket-launches' - && change.enabled === false - && isExplicitUserIntentOrigin(change.origin, change.layerId) - ) { - this._contextModeDeferredEntryIntent = null; - if (this._clearSelectedLayersPromise) { - this._contextSessionSnapshot = null; - this._contextModeEntering = null; - this._syncContextModeButtons(); - } - } - }); - } - if (typeof this._dataManager?.addVisibilityGuard === 'function') { - this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { - const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; - const reason = contextLayerEnableBlockReason({ - contextMode: this._contextModeEntering || this._contextMode, - change, - layerName, - }); - if (reason) return reason; - if ( - change.enabled - && ['military-awareness', 'rocket-launches'].includes(change.layerId) - && shouldCaptureContextSession(change) - && ( - !this._contextModeChanging - || ( - change.layerId === 'rocket-launches' - && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch - ) - ) - ) { - const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; - const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; - // A deferred entry owns the state after Clear settles. Restoring - // Clear's transient busy flag here would leave Context stuck. - const priorChanging = deferredClearEntry ? false : this._contextModeChanging; - const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); - const ownsNotificationToken = !change.notificationToken; - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.add(notificationToken); - } - this._contextModeEntering = entryMode; - this._contextModeChanging = true; - try { - if (deferredClearEntry) { - await this._clearSelectedLayersManagerPromise; - if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; - this._contextModeDeferredEntryIntent = null; - } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); - } catch (error) { - this._contextModeEntering = null; - console.warn(`[Context] ${change.layerId} isolation failed`, error); - try { - await this._restoreContextSession({ - excludeLayerIds: [change.layerId], - notificationToken, - }); - } catch (restoreError) { - console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); - } - return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; - } finally { - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - settleContextModeChange(this, priorChanging); - } - } - return null; - }); - } - if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { - this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { - if (!this._contextSessionSnapshot) return; - await runWithContextModeChanging(this, async () => { - this._contextMode = null; - this.cockpitView?.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); - }); - }); - } - this._syncContextModeButtons(); - if (this._cctvUnsubscribe) { - this._cctvUnsubscribe(); - this._cctvUnsubscribe = null; - } - if (typeof cctvLayer.subscribe === 'function') { - this._cctvUnsubscribe = cctvLayer.subscribe((state) => { - this._renderCctvState(state); - }); - } - if (typeof cctvLayer.getUIState === 'function') { - this._renderCctvState(cctvLayer.getUIState()); - } - if (this._radioUnsubscribe) { - this._radioUnsubscribe(); - this._radioUnsubscribe = null; - } - if (typeof radioLayer.subscribe === 'function') { - this._radioUnsubscribe = radioLayer.subscribe((state) => { - this._renderRadioState(state); - }); - } - if (!this._awarenessSelectedHandler) { - this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); - this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true); - window.addEventListener('gev:awareness-subject-selected', this._awarenessSelectedHandler); - window.addEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler); - } - this._layerStateCoordinator?.destroy(); - this._layerStateCoordinator = null; - this._layerStateRestorePromise = null; - if (this._dataManager) { - this._layerStateCoordinator = new LayerStateCoordinator( - this._dataManager, - this.shareLinkManager, - { - onDurableStateChange: (state) => this._syncModels3dFromLayerState(state), - onTrackingRestoreStatus: (result) => this._handleShareTrackingRestoreStatus(result), - }, - ); - this._layerStateRestorePromise = this._layerStateCoordinator.start({ - shareLayerState: this._initialShareState?.layerState || null, - shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null, - // Any valid camera/style share isolates recipient-local preferences, - // including legacy and malformed-v2 layer payloads. - allowLocalState: !this._initialShareState, - }); - if (this._initialShareSelectionSuperseded) { - this._layerStateCoordinator.cancelPendingShareTracking( - 'superseded-before-layer-coordinator-start', - { clearSelection: true }, - ); - } - void this._layerStateRestorePromise.then(() => { - this._syncModels3dFromLayerState(this._layerStateCoordinator?.getDurableState()); - }); - } - } - - _handleShareTrackingRestoreStatus(result) { - if (!result || this._disposed) return; - const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`; - if (result.classification === 'pending') { - this._shareTrackingNoticeGeneration += 1; - this._shareTrackingAcquiringKey = trackingKey; - this._showGlobalStatusNotice('ACQUIRING', { - state: 'acquiring', - detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`, - persistent: true, - }); - return; - } - const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey; - if (ownsAcquiringNotice) { - this._shareTrackingNoticeGeneration += 1; - this._shareTrackingAcquiringKey = null; - if (this._globalStatusNotice?.state === 'acquiring') { - this._globalStatusNotice = null; - this._updateGlobalLoadingFeedback(); - } - } - if (result.classification === 'followed' || result.classification === 'cancelled') return; - // A stale terminal result must never replace a newer target's acquisition. - if (this._shareTrackingAcquiringKey) return; - const noticeGeneration = ownsAcquiringNotice - ? this._shareTrackingNoticeGeneration - : ++this._shareTrackingNoticeGeneration; - const subject = result.label || 'entity'; - const message = result.classification === 'expired' - ? `Shared ${subject} follow expired` - : result.classification === 'source-unavailable' - ? `Shared ${subject} could not be restored — feed unavailable` - : `Shared ${subject} is unavailable`; - const showAfterStartupCover = () => { - requestAnimationFrame(() => { - if (!canPresentDeferredStatusNotice( - noticeGeneration, - this._shareTrackingNoticeGeneration, - this._disposed, - )) return; - const startupCover = document.getElementById('loading-screen'); - if (!startupCover || getComputedStyle(startupCover).visibility === 'hidden') { - this._showGlobalStatusNotice(message); - return; - } - let fallbackTimer = null; - const showOnce = () => { - startupCover.removeEventListener('transitionend', showOnce); - if (fallbackTimer) clearTimeout(fallbackTimer); - if (canPresentDeferredStatusNotice( - noticeGeneration, - this._shareTrackingNoticeGeneration, - this._disposed, - )) this._showGlobalStatusNotice(message); - }; - startupCover.addEventListener('transitionend', showOnce, { once: true }); - fallbackTimer = setTimeout(showOnce, 1000); - }); - }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } - showAfterStartupCover(); - } - - _initGlobalContextPanel() { - const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); - contextTabs.forEach((tab, index) => tab.addEventListener('keydown', (event) => { - let nextIndex = null; - if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; - else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = contextTabs.length - 1; - if (nextIndex === null) return; - event.preventDefault(); - contextTabs[nextIndex].focus({ preventScroll: true }); - contextTabs[nextIndex].click(); - })); - this._globalContextFlightsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'flights' ? null : 'flights'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Contacts could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'contacts', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._globalContextMissionsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Space Missions could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'space-missions', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._installationsSearchBtn?.addEventListener('click', () => { - if (!this._dataManager?.layers?.has('military-installations')) return; - const button = this._installationsSearchBtn; - if (button.getAttribute('aria-busy') === 'true') return; - button.setAttribute('aria-disabled', 'true'); - button.setAttribute('aria-busy', 'true'); - void this._runUserFacingContextAction(async (notificationToken) => { - const enabled = await this._dataManager.setEnabled('military-installations', true, { - origin: 'user', - notificationToken, - }); - if (enabled === false || !this._dataManager.isEnabled('military-installations')) return false; - const searched = await militaryInstallationsLayer.searchNearby?.(); - if (searched === false) return false; - const stats = militaryInstallationsLayer.getStats?.(); - this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in' - ? 'Zoom in to search mapped installations' - : 'Nearby installations refreshed')); - return true; - }, 'Nearby installations could not be refreshed; try again').finally(() => { - button.setAttribute('aria-disabled', 'false'); - button.setAttribute('aria-busy', 'false'); - }); - }); - } - - async _runUserFacingContextAction( - operation, - message = 'Context could not restore every layer; try again', - { falseIsFailure = true } = {}, - ) { - const notificationToken = Symbol('user-facing-context-action'); - this._userFacingContextNotificationTokens.add(notificationToken); - try { - return await settleUserFacingContextAction({ - operation: () => operation(notificationToken), - falseIsFailure, - onFailure: (error) => { - console.warn('[Context] user-facing transition failed', error); - this._showToast(message); - }, - }); - } finally { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - } - - _trackContextLayerReaction(promise) { - const tracked = Promise.resolve(promise); - this._contextLayerReactionPromises.add(tracked); - void tracked.finally(() => this._contextLayerReactionPromises.delete(tracked)); - return tracked; - } - - async _waitForContextLayerSettlement() { - while (this._contextLayerReactionPromises.size > 0) { - await Promise.allSettled([...this._contextLayerReactionPromises]); - } - } - - _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { - if (!this._dataManager || this._contextSessionSnapshot) return; - const params = {}; - for (const layerId of ['military-awareness', 'satellites']) { - const value = this._dataManager.getLayerParams(layerId); - if (value) params[layerId] = value; - } - this._contextSessionSnapshot = { - enabledLayerIds: contextSnapshotLayerIds( - this._dataManager.getEnabledLayerIds(), - this._contextRestoreState?.enabledLayerIds, - excludeLayerIds, - ), - userAdded: new Set(), - userRemoved: new Set(), - params, - }; - } - - async _restoreContextSession({ - excludeLayerIds = [], - notificationToken = null, - signal = null, - } = {}) { - const snapshot = this._contextSessionSnapshot; - if (!snapshot || !this._dataManager) return; - // Clear the stored session before emitting restore notifications so none - // of those transitions can be mistaken for a fresh Context entry. - this._contextSessionSnapshot = null; - const restoreState = { - enabledLayerIds: contextRestoreLayerIds(snapshot), - explicitLayerStates: new Map(), - }; - this._contextRestoreState = restoreState; - for (const [layerId, params] of Object.entries(snapshot.params)) { - this._dataManager.setLayerParams(layerId, params); - } - let restoreError = null; - const restoreSnapshot = async (restoreSignal = null) => { - // Contacts owns the dependency intents it starts. Settle that - // coordinator before restoring the remaining snapshot, otherwise its - // dependency releases can supersede the restore's same-target requests - // and make a valid Contacts-to-Missions handoff look like a failure. - const contactsCoordinatorId = 'military-awareness'; - const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) - && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); - if (settleContactsCoordinator) { - const coordinatorSettled = await this._dataManager.setEnabled( - contactsCoordinatorId, - false, - { - origin: 'context-restore', - ...(notificationToken ? { notificationToken } : {}), - ...(restoreSignal ? { signal: restoreSignal } : {}), - }, - ); - if (coordinatorSettled === false) { - const error = new Error('Failed to settle Contacts before restoring Context'); - error.failedLayerIds = [contactsCoordinatorId]; - throw error; - } - } - await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { - origin: 'context-restore', - excludeLayerIds: settleContactsCoordinator - ? [...excludeLayerIds, contactsCoordinatorId] - : excludeLayerIds, - notificationToken, - ...(restoreSignal ? { signal: restoreSignal } : {}), - }); - }; - try { - await restoreSnapshot(signal); - } catch (error) { - restoreError = error; - // A caller abort can arrive after only part of the exact restore has - // settled. Finish that same target without the stale caller signal while - // this restoreState still records newer explicit intents; replay below - // then gives those newer intents final authority. - if (signal?.aborted && !restoreState.cancelled) { - try { - await restoreSnapshot(null); - restoreError = null; - } catch (compensationError) { - restoreError = mergeContextTransitionErrors(restoreError, compensationError); - } - } - } finally { - if (this._contextRestoreState === restoreState) this._contextRestoreState = null; - } - // Clear Selected Layers owns a newer global OFF intent. A restore that was - // already awaiting lifecycle work must not replay its captured companion - // intent or recreate the discarded session after Clear invalidates it. - if (restoreState.cancelled) return; - // A direct Radio command may finish after restore has already copied its - // target and queued the opposite state. Replay that newer intent only - // after the stale queue drains; the replay origin cannot recurse here. - const replaySignal = signal?.aborted ? null : signal; - const replayError = await settleContextIntentReplay({ - restoreState, - setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( - layerId, - enabled, - { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, - ), - notificationToken, - }); - restoreError = mergeContextTransitionErrors(restoreError, replayError); - if (restoreError && !this._contextSessionSnapshot) { - // Keep the exact still-pending target so a later exit/teardown can retry - // instead of silently losing the user's pre-Context layer state. - this._contextSessionSnapshot = { - enabledLayerIds: new Set(restoreState.enabledLayerIds), - userAdded: new Set(), - userRemoved: new Set(), - params: snapshot.params, - }; - } - if (restoreError) throw restoreError; - } - - /* - * Cross-mode cancellation and failure deliberately settle on Context OFF. - * - * A reinstatement transaction lived here for three review rounds and was - * removed on purpose. Restoring the prior mode is genuinely racy: the prior - * mode has to be read before the teardown, but a second request arriving - * while the first reinstatement is mid-activation reads `_contextMode` as - * null and inherits nothing, so two overlapping cancellations still land on - * OFF — and the only fix is a cross-transaction "logical prior mode" chain, - * which is new shared mutable state read while an earlier transaction is - * still awaiting. That trades a rare wrong resting state for a permanent - * interleaving hazard. - * - * The defect that started this was the LIE, not the OFF: the transition - * claimed to have cancelled cleanly while silently leaving Context off. So - * the resting state stays OFF and is REPORTED as such, with the failed layer - * ids preserved. A restore feature can be rebuilt post-launch on the - * generation discipline the surrounding transaction already follows. - */ - - async _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { - await this._dataManager?.waitForLayerSettled?.(layerId); - return this._restoreContextSession({ notificationToken }); - } - - /** - * Claim the visual restore lane for an explicit Context transition. - * - * Contacts OWNS detection while active (forced Dense @ 75%), so entering or - * leaving it is a visual-lane gesture exactly like the HUD or detection - * controls. Without this claim, the shared-view restore that lands 1.5 s into - * startup re-applied the link's `dm`/`dd` over the forced preset and Contacts - * lost its own overlay mid-session. - * - * Deliberately does NOT set `_detectionUserOverridden`: that flag means the - * OPERATOR hand-edited detection and suppresses the military-style - * auto-enable for the rest of the session. Context entry is not that, and - * conflating them would silently disable a separate landed behavior. - * - * Call only for VALIDATED explicit transitions — never for programmatic or - * restore-driven ones, which must stay eligible for the shared visual state. - */ - _claimContextVisualAuthority() { - this.shareLinkManager?.claimRestoreLane?.('visual'); - } - - async _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { - if (!this._dataManager) return false; - if (this._clearSelectedLayersPromise) return false; - this._contextTransitionFailedLayerIds = []; - const generation = ++this._contextModeGeneration; - const isCurrent = () => generation === this._contextModeGeneration; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeChanging = true; - this._syncContextModeButtons(); - try { - if (mode !== 'flights' && this.cockpitView?.active) { - this.cockpitView.exit({ restoreTracking: false }); - } - if (!mode) { - this._contextMode = null; - this._syncContextModeButtons(); - await this._restoreContextSession({ notificationToken, signal }); - return isCurrent(); - } - // A cross-mode switch dismantles the prior mode BEFORE the new one is - // committed. If the caller aborts in that window the switch never lands, - // and the resting state is Context OFF — reported as such by - // setContextMode rather than dressed up as a clean cancellation. See the - // note above _restoreContextSessionAfterLayerSettles. - const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); - if (crossModeSwitch) { - this._contextMode = null; - await this._restoreContextSession({ notificationToken, signal }); - if (!isCurrent()) return false; - if (signal?.aborted) return false; - } - this._captureContextSessionSnapshot(); - this._contextMode = mode; - this._syncContextModeButtons(); - // Replay isolation must settle before Space Missions starts. Contacts - // keeps its non-dependency teardown in the background so slow source - // shutdown does not delay cockpit entry. - try { - await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); - } catch (error) { - if (!isCurrent()) return false; - let transitionError = error; - console.warn(`[Context] ${mode} isolation failed`, error); - this._contextMode = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; - throw transitionError; - } - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - return false; - } - if (!isCurrent()) return false; - // Entry is one transaction: isolation succeeded above, so a failed mode - // activation must roll the cleared layers back instead of stranding the - // user in a half-entered mode with an orphaned snapshot. - const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; - if (mode === 'flights') { - this._dataManager.setLayerParams('military-awareness', { passive: false }); - } - let activated = false; - let activationError = null; - let activationIntent = null; - let terminalIntentOutcome = null; - try { - activationIntent = this._dataManager._setEnabledWithIntent( - entryLayerId, - true, - { notificationToken, ...(signal ? { signal } : {}) }, - ); - this._contextModeEntryIntent = { - generation, - layerId: entryLayerId, - intentEpoch: activationIntent.intentEpoch, - }; - activated = await activationIntent.promise; - terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - activationIntent.intentEpoch, - ); - } catch (error) { - activationError = error; - } - if (!isCurrent()) return false; - let replacementIntent = mode === 'space-missions' - && this._contextModeReplacementIntent?.generation === generation - && this._contextModeReplacementIntent.layerId === entryLayerId - ? this._contextModeReplacementIntent - : null; - while (replacementIntent) { - const outcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - replacementIntent.intentEpoch, - ); - terminalIntentOutcome = outcome; - if (!isCurrent()) return false; - const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch - && outcome.enabled === true - && outcome.succeeded === true; - if (replacementOwnsMode) { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - return true; - } - const successorEpoch = outcome?.cancellationReason === 'superseded' - && outcome.successorEnabled === true - && Number.isInteger(outcome.successorIntentEpoch) - && outcome.successorIntentEpoch > replacementIntent.intentEpoch - ? outcome.successorIntentEpoch - : null; - replacementIntent = successorEpoch === null ? null : { - generation, - layerId: entryLayerId, - intentEpoch: successorEpoch, - }; - } - if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { - const cancelledAndSettled = terminalIntentOutcome?.succeeded === false - && ['caller-abort', 'resource-abort', 'superseded'].includes( - terminalIntentOutcome.cancellationReason, - ); - let transitionError = null; - if (!cancelledAndSettled) { - transitionError = activationError instanceof Error - ? activationError - : new Error(`Context activation failed for: ${entryLayerId}`); - transitionError.failedLayerIds = [...new Set([ - ...(transitionError.failedLayerIds || []), - entryLayerId, - ])]; - this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; - console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); - } - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ - excludeLayerIds: [entryLayerId], - notificationToken, - }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - throw transitionError; - } - // `null` means the requested entry was cancelled and its exact rollback - // completed. The action wrapper treats that as a silent non-commit, - // while callers still require literal `true` before expanding Context. - return cancelledAndSettled ? null : false; - } - this._contextModeEntryIntent = null; - return true; - } finally { - if (isCurrent()) { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - } - - async _deactivateContextForLayerChange({ notificationToken = null } = {}) { - this._contextModeGeneration += 1; - this._contextModeChanging = true; - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } finally { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - - async _clearLayersOutsideContextMode( - mode = null, - { notificationToken = null, signal = null } = {}, - ) { - const allowed = contextAllowedLayerIds(mode); - const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); - } - } - const results = await Promise.all(pending.map(({ transition }) => transition)); - const failed = pending - .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) - .map(({ layerId }) => layerId); - if (failed.length > 0) { - const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); - error.failedLayerIds = failed; - throw error; - } - } - - _handleContextLayerChange(change) { - if (change?.layerId === 'radio' && [ - 'visibility-transition', - 'visibility', - 'visibility-cancelled', - 'visibility-failed', - ].includes(change.type)) { - this._renderRadioState(radioLayer.getUIState()); - } - if (change?.type === 'visibility-transition') return; - // The effective mode must be read BEFORE the entering flag is cleared: - // the entry layer's own enable event is the one that clears it, and the - // session bookkeeping below needs to know a mode was being entered. - const effectiveContextMode = this._contextModeEntering || this._contextMode; - if (change?.type === 'visibility-cancelled') { - const cancellationDisposition = spaceMissionEntryCancellationDisposition({ - change, - }); - if ( - this._contextModeDeferredEntryIntent?.layerId === change.layerId - && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch - ) { - if (cancellationDisposition !== 'replacement') { - this._contextModeDeferredEntryIntent = null; - } - } - if (cancellationDisposition === 'replacement') { - this._contextModeEntering = 'space-missions'; - const entryIntent = this._contextModeEntryIntent; - if ( - entryIntent?.generation === this._contextModeGeneration - && entryIntent.layerId === change.layerId - && entryIntent.intentEpoch === change.intentEpoch - ) { - this._contextModeReplacementIntent = { - generation: entryIntent.generation, - layerId: change.layerId, - intentEpoch: change.successorIntentEpoch, - }; - } - } else if (cancellationDisposition === 'restore') { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this._contextSessionSnapshot && !this._contextModeChanging) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - return true; - }, - 'Space Missions cancellation could not restore the previous layer state', - )); - } - } - this._syncContextModeButtons(); - return; - } - if ( - change?.layerId === 'rocket-launches' - && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) - ) { - this._contextModeEntering = null; - } - if (change?.type === 'visibility-blocked') { - if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(change.reason || 'That layer is unavailable in the current Context mode'); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-failed') { - const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; - // A failed direct Context-shell START has already had its siblings - // cleared by the visibility guard. Wait outside the synchronous manager - // notification for this queue to settle, then reconcile the complete - // snapshot, including an uncertain failed shell. - const needsDeferredShellRestore = ( - ['military-awareness', 'rocket-launches'].includes(change.layerId) - && change.enabled - && this._contextSessionSnapshot - && !this._contextModeChanging - ); - if (needsDeferredShellRestore) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - // The wrapper owns failure announcements. On a successful rollback - // announce the original activation failure here so the same direct - // action still produces exactly one accessible notification. - this._showToast(failureMessage); - return true; - }, - failureMessage, - )); - } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(failureMessage); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-will-change') { - // Explicit entry capture happens on the synchronous visibility-requested - // boundary. Keeping this later branch side-effect free prevents an - // awaited Clear/guard from replacing that authoritative pre-entry view. - return; - } - // Session bookkeeping must run BEFORE any exit path below: the exit - // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes - // a layer resurrected against the user's explicit disable. - recordContextSessionUserChange({ - snapshot: this._contextSessionSnapshot, - change, - effectiveContextMode, - }); - recordContextRestoreExplicitChange({ - restoreState: this._contextRestoreState, - change, - }); - if (shouldExitContextForLayerChange({ - contextMode: this._contextMode, - globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), - change, - })) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - return; - } - if (!this._contextModeChanging) { - if (change.layerId === 'military-awareness') { - // The coordinator remains manager-addressable for restoration and - // programmatic routes, but Contacts is selected only from the - // dedicated right-side Global Context chooser. - this._contextMode = change.enabled - ? null - : (this._contextMode === 'flights' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if (change.layerId === 'rocket-launches') { - const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) - || this._contextMode === 'space-missions' - || effectiveContextMode === 'space-missions'; - if (!ownsContextEntry) return; - this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if ( - this._contextMode === 'flights' - && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) - && !change.enabled - ) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } - if (this.cockpitView?.active && !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - })) { - this.cockpitView.exit({ restoreTracking: false }); - } - this._syncContextModeButtons(); - } - - _syncContextModeButtons() { - const flightsActive = this._contextMode === 'flights'; - const missionsActive = this._contextMode === 'space-missions'; - const panel = document.getElementById('global-context-panel'); - panel?.classList.toggle('context-enabled', flightsActive || missionsActive); - panel?.setAttribute('data-context-mode', this._contextMode || 'none'); - this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); - this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); - this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); - this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); - const transitionBusy = Boolean(this._contextModeChanging); - // Both Context choices stay in the ordinary Tab sequence. Arrow keys still - // provide tablist navigation, but must not be the only way to reach Space - // Missions from the keyboard. Semantic busy state keeps them perceivable - // while synchronous click guards prevent a second transition. - for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { - if (!button) continue; - button.disabled = false; - button.tabIndex = 0; - button.setAttribute('aria-disabled', String(transitionBusy)); - button.setAttribute('aria-busy', String(transitionBusy)); - } - if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; - if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; - if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; - this.cockpitView?.syncEntry(); - // Every _contextMode mutation funnels through here; the sync no-ops until - // the transaction settles, so this is the activation/deactivation edge. - this._syncContactsDetection(); - this._scheduleRightPanelLayout(); - } - - /** Wire the independent Radio companion controls. */ - _initRadioPanel() { - if (!this._radioPanel) return; - this._radioTunerAbort?.abort(); - this._radioTunerAbort = new AbortController(); - const tunerListenerOptions = { signal: this._radioTunerAbort.signal }; - const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { - const open = Boolean(expanded); - this._contextRadioDock?.classList.toggle('disclosure-open', open); - if (this._contextRadioMini) this._contextRadioMini.hidden = !open; - this._syncContextRadioLauncherState(); - if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); - }; - this._setRadioDisclosure = setRadioDisclosure; - const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { - const displayOpen = kind === 'display' && Boolean(expanded); - const radioOpen = kind === 'radio' && Boolean(expanded); - if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true); - if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; - if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; - this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', displayOpen); - this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', radioOpen); - this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); - this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); - if (displayOpen) this._revealCockpitStyleParameters(); - if (this._cockpitDisplayToggleBtn) { - const action = displayOpen ? 'Collapse' : 'Expand'; - this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; - this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); - this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; - } - if (this._cockpitRadioToggleBtn) { - const action = radioOpen ? 'Collapse' : 'Expand'; - this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; - this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); - this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; - } - if (!expanded && returnFocus) { - (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) - ?.focus({ preventScroll: true }); - } - if (!displayOpen && !radioOpen - && this.cockpitView?.active - && !this.cockpitView.signalUserCollapsed) { - this.cockpitView.setSignalCollapsed(false); - } - this.cockpitView?.scheduleContextLayout(); - }; - this._setCockpitDisclosure = setCockpitDisclosure; - const syncTunerTape = (coordinate) => { - const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); - const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); - if (!scale || !dial) return null; - const model = buildRadioTunerTicks( - coordinate, - this._radioTunerStations.length, - dial.getBoundingClientRect().width, - ); - while (scale.children.length < model.ticks.length) { - const tick = document.createElement('span'); - tick.className = 'radio-tuner-tick'; - scale.append(tick); - } - while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); - model.ticks.forEach((entry, index) => { - const tick = scale.children[index]; - tick.style.left = `${entry.xPx}px`; - tick.textContent = entry.label; - tick.dataset.stationIndex = String(entry.stationIndex); - tick.classList.toggle('is-current', entry.current); - }); - scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); - return model; - }; - const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { - const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); - const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; - const resolvedCoordinate = this._radioTunerStations.length <= 1 - ? 0 - : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); - const ratio = this._radioTunerStations.length === 1 - ? 0.5 - : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); - this._radioTunerCoordinate = resolvedCoordinate; - this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); - this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); - syncTunerTape(resolvedCoordinate); - if (this._radioTunerValue) { - this._radioTunerValue.textContent = station - ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` - : 'NO STATIONS'; - } - if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; - if (this._radioTunerSlider) { - this._radioTunerSlider.setAttribute('aria-valuetext', station - ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` - : 'No station available'); - } - if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate }); - return station; - }; - const setTunerDirectory = (pool) => { - this._radioTunerPool = [...pool]; - this._radioTunerStations = [...pool]; - this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); - if (this._radioTunerSlider) { - this._radioTunerSlider.min = '0'; - this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); - this._radioTunerSlider.step = '1'; - } - }; - const refreshTunerBand = ({ force = false } = {}) => { - if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; - const selectedId = this._radioState?.selected?.id || null; - const pool = radioLayer.getTunerStations(750); - const poolSignature = pool.map((station) => station.id).join('|'); - const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); - if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; - setTunerDirectory(pool); - this._radioTunerSelectedId = selectedId; - const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); - const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); - return true; - }; - this._refreshRadioTunerBand = refreshTunerBand; - const beginTuner = () => { - if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; - refreshTunerBand(); - if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false; - // A tuner-owned camera preview must never replace the frozen directory. - // Only an explicit globe pointer/wheel gesture releases camera pinning. - this._radioTunerBandPinnedForNavigation = true; - this._radioTunerDragging = true; - this._radioTuner?.classList.add('is-dragging'); - const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); - const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - this._radioTunerDragStartSlot = slot.slot; - this._radioTunerDragSnapshot = { - stations: [...this._radioTunerStations], - bandSignature: this._radioTunerBandSignature, - selectedId: this._radioTunerSelectedId, - slot: slot.slot, - coordinate: this._radioTunerCoordinate, - }; - this._radioTunerLastSlot = slot.slot; - this._radioTunerDragDirection = 0; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - return true; - }; - const finishTuner = (commit) => { - if (!this._radioTunerDragging) return; - const dragSnapshot = this._radioTunerDragSnapshot; - if (!commit && dragSnapshot) { - this._radioTunerStations = [...dragSnapshot.stations]; - this._radioTunerPool = [...dragSnapshot.stations]; - this._radioTunerBandSignature = dragSnapshot.bandSignature; - this._radioTunerSelectedId = dragSnapshot.selectedId; - if (this._radioTunerSlider) { - const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); - this._radioTunerSlider.max = String(startSlot.max); - this._radioTunerSlider.value = String(startSlot.slot); - } - this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) - ? dragSnapshot.coordinate - : dragSnapshot.slot; - } else if (!commit && this._radioTunerSlider) { - this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); - this._radioTunerCoordinate = this._radioTunerDragStartSlot; - } - let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); - if (commit && !station && this._radioTunerSlider) { - const snapped = radioTunerCommitSlot( - this._radioTunerSlider.value, - this._radioTunerStations.length, - ); - this._radioTunerSlider.value = String(snapped.slot); - this._radioTunerCoordinate = snapped.stationIndex; - station = tunerPreview({ coordinate: this._radioTunerCoordinate }); - } - let result = null; - if (commit && station) { - // Keep the exact band used by the drag so the selected channel cannot - // jump to a refreshed catalog slot while its camera flight settles. - this._radioTunerBandPinnedForNavigation = true; - result = radioLayer.commitTuningStation(station.id, { origin: 'user' }); - } else if (!commit) { - radioLayer.cancelTuning(); - } else { - radioLayer.endTuning(); - } - // Radio emits selection/tuning state synchronously. Keep both the logical - // drag and the no-transition class active until that state has settled, - // then restore the exact selected slot before permitting CSS motion. - this._radioTunerDragging = false; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - if (commit && (!result || result.ok)) refreshTunerBand(); - this._radioTunerDragSnapshot = null; - // Flush the snapped position while transitions are still disabled so - // removing the drag class cannot interpolate from the released gap. - void this._radioTunerNeedle?.offsetLeft; - this._radioTuner?.classList.remove('is-dragging'); - if (result && !result.ok) { - this._radioTunerBandPinnedForNavigation = false; - if (result.reason === 'station-unavailable') { - if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; - if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; - this._radioTunerSlider?.setAttribute( - 'aria-valuetext', - 'Station unavailable after directory refresh', - ); - } - } - }; - const cycleRadio = (direction, { rotate = true } = {}) => { - this._radioTunerBandPinnedForNavigation = true; - const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - const cycled = radioLayer.cycleStation(direction, { - rotate, - stationIds: pool.map((station) => station.id), - origin: 'user', - }); - if (!cycled) { - this._radioTunerBandPinnedForNavigation = false; - return; - } - }; - const toggleRadio = async (trigger) => { - if (!this._dataManager?.layers?.has('radio')) return; - if (trigger.getAttribute('aria-busy') === 'true') return; - const enabling = !this._dataManager.isEnabled('radio'); - const revealAfterEnable = enabling && trigger === this._radioEnableBtn; - trigger.setAttribute('aria-disabled', 'true'); - trigger.setAttribute('aria-busy', 'true'); - try { - const toggled = await this._runUserFacingContextAction( - (notificationToken) => this._dataManager.setEnabled('radio', enabling, { - origin: 'user', - notificationToken, - }), - `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, - ); - if (toggled === false) return; - if (enabling && trigger === this._radioEnableBtn - && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - } - if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); - } finally { - trigger.setAttribute('aria-disabled', 'false'); - trigger.setAttribute('aria-busy', 'false'); - if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); - } - }; - this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn)); - this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); - this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn)); - this._contextRadioToggleBtn?.addEventListener('click', () => { - const contextPanel = document.getElementById('global-context-panel'); - if (contextPanel && !contextPanel.classList.contains('collapsed')) { - setRadioDisclosure(false); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - void this._revealRadioPanelInsideContext({ - focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), - }); - return; - } - setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); - }); - this._contextRadioMiniCloseBtn?.addEventListener('click', () => { - setRadioDisclosure(false, { returnFocus: true }); - }); - this._contextRadioDetailsBtn?.addEventListener('click', () => { - if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - setRadioDisclosure(false); - this._radioEnableBtn?.focus({ preventScroll: true }); - }); - this._cockpitRadioToggleBtn?.addEventListener('click', () => { - const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; - setCockpitDisclosure('radio', !open); - }); - document.addEventListener('pointerdown', (event) => { - if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; - if (event.target?.closest?.('#context-radio-dock')) return; - setRadioDisclosure(false); - }, tunerListenerOptions); - document.addEventListener('pointerdown', (event) => { - if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; - if (event.target?.closest?.('.cockpit-vision-controls')) return; - if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; - event.preventDefault(); - // Immediate: a plain stopPropagation() still lets every LATER listener on - // this same document run, so closing the disclosure ALSO dismissed the - // first-run launcher — one key, two actions. Matches the cockpit - // disclosure handler directly below. - event.stopImmediatePropagation(); - const escapedFromDisclosure = event.target === this._contextRadioToggleBtn - || this._contextRadioToggleBtn?.contains?.(event.target); - setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape') return; - const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; - const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; - if (!displayOpen && !radioOpen) return; - const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); - if (nestedPanel) return; - event.preventDefault(); - event.stopImmediatePropagation(); - const kind = displayOpen ? 'display' : 'radio'; - const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; - const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) disclosure?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - window.addEventListener('gev:cockpit-mode-changed', (event) => { - if (event?.detail?.active) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-signal-expanded', () => { - setCockpitDisclosure('display', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-context-expanded', () => { - this.setPanelCollapsed('data-panel', true); - }, tunerListenerOptions); - this._radioFilter?.addEventListener('change', () => { - const presentation = radioLayer.getUIState(); - if (!presentation.presentationActive) { - this._radioFilter.value = presentation.filter; - return; - } - if (this._radioTunerDragging) finishTuner(false); - if (!this._dataManager?.setLayerParams('radio', { - filter: this._radioFilter.value, - }, { origin: 'user' })) { - this._radioFilter.value = radioLayer.getUIState().filter; - return; - } - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerPool = []; - refreshTunerBand({ force: true }); - }); - this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._radioNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' })); - this._radioVolume?.addEventListener('input', () => { - const value = Number(this._radioVolume.value); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - // Cockpit owns the Cesium camera even though it intentionally clears - // viewer.trackedEntity. Station changes must never start the map-view - // rotation/fallback flights that would compete with its preUpdate pose. - this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false })); - this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false })); - this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._contextRadioMiniVolume?.addEventListener('input', () => { - const value = Number(this._contextRadioMiniVolume.value); - if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._cockpitRadioVolume?.addEventListener('input', () => { - const value = Number(this._cockpitRadioVolume.value); - if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - const updateTunerFromPointer = (event) => { - const rect = this._radioTunerSlider?.getBoundingClientRect(); - if (!rect || !this._radioTunerStations.length) return false; - const position = radioTunerPointerPosition( - event.clientX, - rect.left, - rect.width, - this._radioTunerStations.length, - ); - if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = position.stationIndex; - this._radioTunerCoordinate = position.coordinate; - this._radioTunerSlider.value = String(position.stationIndex); - tunerPreview({ coordinate: position.coordinate }); - return true; - }; - this._radioTunerSlider?.addEventListener('pointerdown', (event) => { - if (!beginTuner()) return; - this._radioTunerPointerId = event.pointerId; - this._radioTunerSlider.focus({ preventScroll: true }); - try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointermove', (event) => { - if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('input', () => { - if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; - if (!this._radioTunerDragging && !beginTuner()) return; - const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); - if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = inputSlot.slot; - this._radioTunerCoordinate = inputSlot.stationIndex; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('change', () => { - if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointerup', (event) => { - if (this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - finishTuner(true); - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointercancel', (event) => { - if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => { - if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this._radioTunerDragging) { - event.preventDefault(); - finishTuner(false); - return; - } - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; - if (!this._radioTunerDragging && !beginTuner()) return; - event.preventDefault(); - this._radioTunerKeyboardKey = event.key; - const max = Math.max(0, this._radioTunerStations.length - 1); - const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; - const page = Math.max(1, Math.round(max / 10)); - const next = event.key === 'Home' ? 0 - : event.key === 'End' ? max - : event.key === 'PageUp' ? current + page - : event.key === 'PageDown' ? current - page - : current + (event.key === 'ArrowRight' ? 1 : -1); - const slot = radioTunerSlot(next, this._radioTunerStations.length); - if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = slot.slot; - this._radioTunerCoordinate = slot.stationIndex; - this._radioTunerSlider.value = String(slot.slot); - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keyup', (event) => { - if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; - event.preventDefault(); - finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions); - const releaseNavigationBand = () => { - this._radioTunerBandPinnedForNavigation = false; - }; - this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions); - this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions); - // The directory order is catalog/filter authority, not camera authority. - // Globe motion therefore never rebuilds or re-ranks the frequency band. - this._radioTunerCameraRemove?.(); - this._radioTunerCameraRemove = null; - this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false); - document.addEventListener('gev:radio-selected', this._radioSelectedHandler); - } - - /** - * Reveal the newly enabled directory and transport inside Context without - * moving focus, the page, or the globe. Only the expanded Enable path calls - * this helper. - * @param {HTMLElement} trigger Initiating Radio Enable button. - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioControlsAfterExplicitEnable(trigger) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - const directory = this._radioPanel?.querySelector('.radio-directory-row'); - const transport = this._radioPanel?.querySelector('.radio-transport'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !directory || !transport || !this._radioState?.enabled) return false; - - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (!this._radioState?.enabled || !trigger?.isConnected) return false; - - const viewport = scroller.getBoundingClientRect(); - const directoryRect = directory.getBoundingClientRect(); - const transportRect = transport.getBoundingClientRect(); - const margin = 10; - const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); - const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); - const desired = minimum <= maximum - ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) - : minimum; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - if (Math.abs(next - scroller.scrollTop) < 1) return false; - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - return true; - } - - /** - * Bring the embedded Radio section into the expanded Context scroller. - * This never changes Radio power, playback, selection, or Context mode. - * @param {{focusTarget?: HTMLElement|null}} [options] - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; - - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; - - const viewport = scroller.getBoundingClientRect(); - const radioRect = this._radioPanel.getBoundingClientRect(); - const desired = scroller.scrollTop + radioRect.top - viewport.top - 10; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - const moved = Math.abs(next - scroller.scrollTop) >= 1; - if (moved) { - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - } - focusTarget?.focus?.({ preventScroll: true }); - return moved; - } - - /** Keep the Context header Radio shortcut truthful for its current route. */ - _syncContextRadioLauncherState() { - if (!this._contextRadioToggleBtn) return; - const contextPanel = document.getElementById('global-context-panel'); - const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); - if (contextExpanded) { - const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); - const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; - this._contextRadioToggleBtn.setAttribute('aria-label', label); - this._contextRadioToggleBtn.title = label; - return; - } - const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); - const action = compactOpen ? 'Close' : 'Open'; - this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); - this._contextRadioToggleBtn.title = `${action} compact Radio controls`; - } - - /** Render Radio state without making playback or Context decisions. */ - _renderRadioState(state) { - if (!state || !this._radioPanel) return; - const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null; - const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); - state = { - ...state, - enabled: lifecycle ? lifecycle.enabled : state.enabled, - lifecycleState, - lifecycleUncertain: lifecycle?.uncertain || false, - }; - this._radioState = state; - const enabled = Boolean(state.enabled); - const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; - const uncertain = Boolean(state.lifecycleUncertain); - const interactive = enabled && !transitioning && !uncertain; - const selected = state.selected || null; - const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); - document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); - this._radioPanel.classList.toggle('radio-enabled', enabled); - this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); - this._contextRadioDock?.classList.toggle('active', enabled); - if (this._contextRadioToggleBtn) { - this._contextRadioToggleBtn.classList.toggle('active', enabled); - } - this._syncContextRadioLauncherState(); - this._radioLayerState?.classList.toggle('active', enabled); - if (this._radioLayerState) { - this._radioLayerState.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); - } - if (this._radioEnableBtn) { - this._radioEnableBtn.classList.toggle('active', enabled); - this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._radioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._radioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._radioEnableBtn.disabled = false; - this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._contextRadioMiniEnableBtn) { - this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); - this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._contextRadioMiniEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._contextRadioMiniEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._contextRadioMiniEnableBtn.disabled = false; - this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._cockpitRadioEnableBtn) { - this._cockpitRadioEnableBtn.classList.toggle('active', enabled); - this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._cockpitRadioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._cockpitRadioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._cockpitRadioEnableBtn.disabled = false; - this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - - if (this._radioFilter) { - const prior = state.filter || 'all'; - const categorySignature = state.categories - .map((category) => `${category.id}:${category.count}:${category.color}`) - .join('|'); - if (categorySignature !== this._radioCategorySignature) { - this._radioFilter.replaceChildren(...state.categories.map((category) => { - const option = document.createElement('option'); - option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; - option.dataset.radioColor = category.color; - option.style.color = category.color; - option.setAttribute('aria-label', `${category.label} (${category.count})`); - return option; - })); - this._radioCategorySignature = categorySignature; - } - this._radioFilter.value = prior; - const activeCategory = state.categories.find((category) => category.id === prior); - this._radioFilter.style.color = activeCategory?.color || ''; - this._radioFilter.disabled = !interactive || !state.stationCount; - } - - const tunerAvailable = interactive && state.filteredCount > 0; - if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; - if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; - if (this._radioTunerBandLabel) { - const activeCategory = state.categories.find((category) => category.id === state.filter); - this._radioTunerBandLabel.textContent = state.filter === 'all' - ? 'DIRECTORY BAND' - : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; - } - this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); - if (tunerAvailable) this._refreshRadioTunerBand?.(); - if (!tunerAvailable && this._radioTunerDragging) { - this._radioTunerDragging = false; - this._radioTunerDragSnapshot = null; - this._radioTunerStations = []; - this._radioTuner?.classList.remove('is-static', 'is-dragging'); - } - if (!tunerAvailable) { - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - } - - if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; - if (this._radioStationMeta) { - const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; - const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; - this._radioStationMeta.textContent = selected - ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' - : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); - } - if (this._radioStationTags) { - const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; - this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; - } - if (this._radioStationHomepage) { - const homepage = selected?.homepage || ''; - this._radioStationHomepage.hidden = !homepage; - if (homepage) this._radioStationHomepage.href = homepage; - else this._radioStationHomepage.removeAttribute('href'); - } - - if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; - if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; - if (this._radioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._radioPlayBtn.disabled = !interactive || !hasStations; - this._radioPlayBtn.classList.toggle('active', activePlayback); - this._radioPlayBtn.textContent = action.toUpperCase(); - this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - } - if (this._contextRadioMiniPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; - this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); - this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._contextRadioMiniPlayBtn.title = action; - } - if (this._cockpitRadioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; - this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); - this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._cockpitRadioPlayBtn.title = action; - } - if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; - if (this._radioVolume) this._radioVolume.disabled = !interactive; - if (this._radioVolume && document.activeElement !== this._radioVolume) { - this._radioVolume.value = String(Math.round(state.volume * 100)); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { - this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); - } - if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; - if (this._contextRadioMiniVolumeValue) { - this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { - this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); - } - if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; - if (this._cockpitRadioVolumeValue) { - this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniStation) { - this._contextRadioMiniStation.textContent = uncertain - ? 'RADIO STATE UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); - } - if (this._cockpitRadioStation) { - this._cockpitRadioStation.textContent = uncertain - ? 'UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); - } - if (this._radioPlaybackState) { - const catalogSuffix = state.degraded - ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') - : (state.stale ? ' · stale directory' : ''); - const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; - const messages = { - stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', - loading: 'Connecting directly to broadcaster…', - buffering: 'Buffering broadcaster stream…', - playing: `Playing ${selected?.name || 'station'}`, - paused: `Paused ${selected?.name || 'station'}`, - error: state.audioError || 'Broadcaster stream unavailable', - }; - const voiceSuffix = state.voiceDucked - ? ' · muted during voice interaction' - : (state.voiceRestoring ? ' · restoring volume after voice' : ''); - const tuningSuffix = state.tuningAwaitingStationId - ? (state.audioState === 'error' - ? ' · static indicates no broadcaster audio' - : ' · tuning static until broadcaster starts') - : ''; - const unavailable = state.tuningUnavailableStationId - ? 'Station unavailable after directory refresh — choose another channel' - : null; - const lifecycleMessage = transitioning - ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') - : null; - const uncertainMessage = uncertain - ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' - : null; - this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; - this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); - } - if ( - !enabled - && !transitioning - && !this._preservePanelStateDuringLayerClear - && !this._radioPanel.classList.contains('collapsed') - ) { - this.setPanelCollapsed('radio-panel', true); - } - this._scheduleRightPanelLayout(); - } - - /** - * Activates an explicit CCTV target, then releases tracking before its camera - * flight. Cockpit mode keeps tracking and suppresses only the flight. - * @param {Function} activate CCTV target activation returning its camera ID. - * @param {Function} focus CCTV camera flight receiving the activated ID. - * @returns {*} Focus operation result. - */ - _runExplicitCctvFocus(activate, focus) { - if (this._disposed) return false; - const cameraId = activate(); - if (!cameraId) return false; - return this._runExplicitNavigation('camera', () => focus(cameraId)); - } - - /** - * Wires up all CCTV panel controls: enable/disable, nearest/prev/next camera, - * camera select dropdown, focus, coverage, auto-hop, projection, - * manual calibration sliders, and save/reset buttons. - * @returns {void} - */ - _initCctvPanel() { - if (!this._cctvPanel) return; - - this._cctvEnableBtn?.addEventListener('click', async () => { - await this._toggleCctvEnabled(); - }); - - this._cctvNearestBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.focusNearest({ focus: false }), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.8), - ); - }); - - this._cctvPrevBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(-1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvNextBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvSelect?.addEventListener('change', async () => { - const cameraId = this._cctvSelect.value; - if (!cameraId) return; - if (!await this._toggleCctvEnabled(true)) return; - // Picking a camera from the dropdown flies to it. The catalog spans - // three metros, so a bare selection used to leave the view in the old - // city with a camera active thousands of km away. - this._runExplicitCctvFocus( - () => (cctvLayer.selectCamera(cameraId) ? cameraId : null), - (selectedId) => cctvLayer.focusCamera(selectedId, 2.2), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: cameraId }, { origin: 'user' }); - }); - - this._cctvFocusBtn?.addEventListener('click', async () => { - const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; - if (!selected) return; - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => selected, - (cameraId) => cctvLayer.focusCamera(cameraId, 1.9), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: selected }, { origin: 'user' }); - }); - - this._cctvCoverageBtn?.addEventListener('click', () => { - const current = this._cctvState?.coverageMode - || (this._cctvState?.showCoverage ? 'on' : 'off'); - const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; - this._dataManager?.setLayerParams('cctv', { coverageMode: next }, { origin: 'user' }); - }); - - this._cctvAutoHopBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.autoHop; - this._dataManager?.setLayerParams('cctv', { autoHop: !current }, { origin: 'user' }); - }); - - this._cctvProjectionBtn?.addEventListener('click', () => { - const current = this._cctvState?.showProjection !== false; - this._dataManager?.setLayerParams('cctv', { showProjection: !current }, { origin: 'user' }); - }); - - this._cctvAdjustBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.calibrationMode; - this._dataManager?.setLayerParams('cctv', { calibrationMode: !current }, { origin: 'user' }); - }); - - // Click-to-edit pose readout: each chip swaps to a number input; Enter or - // blur commits (converted to a calibration offset against basePose), - // Escape cancels. Delegated so re-renders never re-bind. - this._cctvCalReadout?.addEventListener('click', (event) => { - const chip = event.target.closest?.('.cctv-cal-value'); - if (!chip || chip.disabled || chip.querySelector('input')) return; - this._beginCctvCalValueEdit(chip); - }); - - this._cctvCalibSaveBtn?.addEventListener('click', () => { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, save: true }, - }, { origin: 'user' }); - this._showToast('CCTV calibration saved'); - }); - - this._cctvCalibResetBtn?.addEventListener('click', () => { - this._resetCctvCalibration(); - }); - - this._renderCctvState(null); - this._syncCctvPanelViewport(); - } - - /** - * Returns the currently active CCTV camera ID from state or the select dropdown. - * @returns {string} Camera ID, or empty string if none. - */ - _activeCctvCameraId() { - return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; - } - - /** - * Clears the preview and invalidates any in-flight preload. - * @returns {void} - */ - _clearCctvFrame() { - this._cctvFrameRequestToken += 1; - this._cctvFramePreloader = null; - if (this._cctvFrame) { - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrame.dataset.cameraId = ''; - this._cctvFrame.dataset.currentSrc = ''; - this._cctvFrame.dataset.loading = ''; - this._cctvFrame.dataset.error = ''; - } - this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); - } - - /** - * Fetches a replacement frame OFF-DOM and assigns it to the live element - * only once it has decoded. - * - * The live is never pointed at an unresolved URL. A completed - * preload is already in the HTTP cache, so assigning `src` swaps in a - * single paint — the browser's own atomic behavior. A slow or failed - * fetch never reaches the element at all, so settled pixels survive. - * - * (A two-slot crossfade was tried and reverted: with no z-index the slots - * paint in DOM order, so promotion was asymmetric and yanked the visible - * layer once per refresh — a flicker on every feed cycle. Measured against - * main, which never blanked on a successful refresh in the first place.) - * - * @param {string} src - * @param {string} cameraId - * @param {boolean} cameraChanged - * @returns {void} - */ - _queueCctvFrame(src, cameraId, cameraChanged) { - if (!this._cctvFrame || !src) return; - - if (cameraChanged) { - // A different camera gets an honest acquisition state. Never retain - // the prior camera's pixels under the newly selected metadata. - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrameWrap?.classList.remove('has-frame'); - } - - const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame') - ); - - const preloader = new Image(); - this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); - preloader.onerror = () => this._settleCctvFrame(token, src, false); - preloader.src = src; - } - - /** - * Commits a decoded frame to the live element, or records the failure - * without disturbing whatever is already on screen. - * @param {number} token - Request token; a stale one is ignored. - * @param {string} src - * @param {boolean} ok - * @returns {void} - */ - _settleCctvFrame(token, src, ok) { - if (!this._cctvFrame || token !== this._cctvFrameRequestToken) return; - this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); - - const syncBadge = () => this._syncCctvSourceBadge( - this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this._dataManager?.isEnabled('cctv') - ); - - if (!ok) { - // Leave the element untouched — a settled frame stays on screen. - this._cctvFrame.dataset.error = 'true'; - syncBadge(); - return; - } - - this._cctvFrame.dataset.error = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); - syncBadge(); - } - - /** - * Keeps the source badge truthful about the visible frame lifecycle. Health - * may already be OK while the browser is still decoding the requested image. - * @param {object|null} activeCamera - * @param {boolean} enabled - * @returns {void} - */ - _syncCctvSourceBadge(activeCamera, enabled) { - if (!this._cctvSourceBadge) return; - if (!enabled || !activeCamera) { - this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; - this._cctvSourceBadge.dataset.frameState = 'idle'; - return; - } - const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); - if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · LOADING'; - this._cctvSourceBadge.dataset.frameState = 'loading'; - return; - } - if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; - this._cctvSourceBadge.dataset.frameState = 'error'; - return; - } - const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); - const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); - this._cctvSourceBadge.textContent = `${kind} · ${status}`; - this._cctvSourceBadge.dataset.frameState = 'ready'; - } - - /** - * Resets calibration for the active CCTV camera to its server defaults. - * @returns {void} - */ - _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { - cameraId, - reset: true, - }, - }, { origin: 'user' }); - this._showToast('CCTV calibration reset'); - } - - /** - * Swaps a readout chip's text for an inline number input. Enter/blur commits - * (converted to a calibration offset patch), Escape cancels. The next state - * re-render restores the chip text either way. - * @param {HTMLButtonElement} chip - The clicked `.cctv-cal-value` element. - * @returns {void} - */ - _beginCctvCalValueEdit(chip) { - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - const activeCamera = this._cctvState?.activeCamera; - if (!field || !activeCamera?.basePose) return; - const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; - chip.textContent = `${field.label} `; - chip.appendChild(input); - input.focus(); - input.select(); - - let finished = false; - const finish = (commit) => { - if (finished) return; - finished = true; - const typed = parseFloat(input.value); - input.remove(); - if (commit && Number.isFinite(typed)) { - const cameraId = this._activeCctvCameraId(); - if (cameraId && this._dataManager) { - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, patch: field.toPatch(typed, activeCamera.basePose) }, - }, { origin: 'user' }); - return; // re-render restores the chip text from fresh state - } - } - this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); - }; - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') finish(false); - event.stopPropagation(); - }); - input.addEventListener('blur', () => finish(true)); - input.addEventListener('click', (event) => event.stopPropagation()); - } - - /** - * Synchronizes the pose readout chips, ADJUST button, and SAVE/RESET - * disabled states with the active camera. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @param {object|null} activeCamera - The active camera data object (may be null). - * @returns {void} - */ - _syncCctvCalReadout(enabled, activeCamera) { - const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` - : `${field.label} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; - } - } - - /** - * Toggles the CCTV layer enabled state. When enabling and no camera is active, - * auto-focuses on the nearest camera. - * @param {boolean} [forceState] - Explicit on/off. Omit to toggle. - * @returns {Promise} True if the layer is now in the requested state. - */ - async _toggleCctvEnabled(forceState) { - if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { - this._showToast('CCTV layer unavailable'); - return false; - } - const enabled = this._dataManager.isEnabled('cctv'); - const target = typeof forceState === 'boolean' ? forceState : !enabled; - if (target === enabled) return true; - await runCctvLayerEnableTransition({ - target, - setEnabled: (next) => this._dataManager.setEnabled('cctv', next, { origin: 'user' }), - readOwnership: () => ({ - trackedEntity: this.viewer?.trackedEntity, - cockpitActive: !!this.cockpitView?.active, - }), - shouldFocus: () => !this._cctvState?.activeCameraId, - activate: () => cctvLayer.focusNearest({ focus: false }), - fly: (cameraId) => this._runExplicitCctvFocus( - () => cameraId, - (selectedId) => cctvLayer.focusCamera(selectedId, 1.6), - ), - }); - return true; - } - - /** - * Maps a `deriveCalBadge` value (cctv.js) to its panel copy. Single source - * of truth for CAL-badge casing — used by both the quality chip and the - * meta line so the two never drift onto different label conventions. - * @param {'calibrated'|'curated'|'raw-prior'|null} badge - Badge state from cctv.js. - * @returns {string} Display label, or '--' when there is no active camera. - */ - _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } - } - - /** - * Full re-render of the CCTV panel UI from a CCTV layer state snapshot. - * Updates enable button, camera select dropdown, navigation buttons, - * coverage/auto-hop/projection toggles, quality chip, source badge, - * metadata line, frame image, calibration controls, and summary text. - * @param {object|null} state - CCTV layer UI state, or null to render empty. - * @returns {void} - */ - _renderCctvState(state) { - this._cctvState = state || null; - const cameras = state?.cameras || []; - const enabled = !!state?.enabled && !!this._dataManager?.isEnabled('cctv'); - const activeId = state?.activeCameraId || ''; - const activeCamera = state?.activeCamera || null; - - // Auto-expand the panel when the active camera CHANGES to a new non-null - // id while the layer is enabled. Covers click-on-globe, panel controls, - // and voice (selectCamera/cycleCamera/focusNearest all notify through - // this subscription). The last-seen guard keeps routine notifications - // from re-expanding a panel the user deliberately collapsed, and timed - // auto-hop transitions only expand on the first activation so the panel - // does not pop open on every hop. - const effectiveActiveId = enabled ? (activeId || null) : null; - const isFirstActivation = this._lastSeenCctvActiveId === null; - if (effectiveActiveId - && effectiveActiveId !== this._lastSeenCctvActiveId - && (!state?.autoHop || isFirstActivation)) { - this.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); - } - this._lastSeenCctvActiveId = effectiveActiveId; - - this._updateCctvSyncChip(state?.loading, enabled); - - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } - - if (this._cctvSelect) { - const shouldRebuild = this._cctvSelect.options.length !== cameras.length - || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); - if (shouldRebuild) { - this._cctvSelect.innerHTML = ''; - for (const camera of cameras) { - const option = document.createElement('option'); - option.value = camera.id; - option.textContent = `${camera.city} · ${camera.name}`; - this._cctvSelect.appendChild(option); - } - } - this._cctvSelect.disabled = !enabled || cameras.length === 0; - if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { - this._cctvSelect.value = activeId; - } else if (!activeId) { - this._cctvSelect.selectedIndex = -1; - } - } - - for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { - if (!btn) continue; - btn.disabled = !enabled || cameras.length === 0; - } - if (this._cctvFocusBtn) { - this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; - } - - if (this._cctvCoverageBtn) { - // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed - // (color-coded volumes). The click handler cycles; this renders. - const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); - this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); - this._cctvCoverageBtn.textContent = mode === 'viewshed' - ? 'VIEWSHED ON' - : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; - this._cctvCoverageBtn.disabled = !enabled; - } - - if (this._cctvAutoHopBtn) { - const autoHop = !!state?.autoHop; - this._cctvAutoHopBtn.classList.toggle('active', autoHop); - this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; - this._cctvAutoHopBtn.disabled = !enabled; - } - - if (this._cctvProjectionBtn) { - const showProjection = state?.showProjection !== false; - this._cctvProjectionBtn.classList.toggle('active', showProjection); - this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; - this._cctvProjectionBtn.disabled = !enabled; - } - - if (this._cctvQualityChip) { - // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, - // no in-world tint): three states driven by cctv.js's deriveCalBadge, - // no client-side scoring math. Casing is unified via _calBadgeLabel so - // the chip and the meta line never drift onto different conventions. - // Save-gated persistence (viewshed design §3e): unsaved live edits show - // EDITED on top of whatever the persisted badge state is — SAVE CAL - // promotes to CALIBRATED, RESET CAL clears. - const badge = activeCamera?.calBadge || null; - const dirty = !!activeCamera?.calDirty; - this._cctvQualityChip.textContent = dirty - ? 'CAL · EDITED (UNSAVED)' - : `CAL · ${this._calBadgeLabel(badge)}`; - this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); - } - - this._syncCctvCalReadout(enabled, activeCamera); - - if (this._cctvMeta) { - if (activeCamera) { - const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; - const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; - const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; - const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; - this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; - } else if (cameras.length > 0) { - this._cctvMeta.textContent = enabled - ? `${cameras.length} cameras loaded · click a camera to activate` - : `${cameras.length} cameras loaded · enable CCTV to activate`; - } else { - this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; - } - } - - if (this._cctvFrame) { - const nextSrc = enabled ? activeCamera?.frameUrl : null; - const nextCameraId = enabled ? (activeCamera?.id || '') : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; - // A same-camera refresh waits for the current image to settle. Replacing - // src every 10 seconds can cancel a slow but healthy decode forever and - // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are - // immediate so navigation never waits on the prior camera's request. - if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { - this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); - } - if (!nextSrc) { - this._clearCctvFrame(); - } - } - - this._syncCctvSourceBadge(activeCamera, enabled); - this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); - } - - /** - * Typewriter-animates CCTV summary text into the summary element. - * Skips animation if the text hasn't changed since the last call. - * Advances 3 characters per 20ms tick for a fast teletype effect. - * @param {string} text - Summary text to display. - * @returns {void} - */ - _typeCctvSummary(text) { - if (!this._cctvSummary) return; - const nextText = String(text || '').trim() || 'No summary available.'; - if (nextText === this._lastCctvSummaryText) return; - this._lastCctvSummaryText = nextText; - - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; - let idx = 0; - this._cctvSummaryTypingTimer = setInterval(() => { - idx += 3; - if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummaryTypingTimer = null; - return; - } - this._cctvSummary.textContent = nextText.slice(0, idx); - }, 20); - } - - /** - * Returns the versioned localStorage key for a panel's saved position. - * @param {string} panelId - DOM id of the panel. - * @returns {string} localStorage key. - */ - _panelStorageKey(panelId) { - return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`; - } - - /** - * Returns the versioned localStorage key for a panel's collapsed state. - * @param {string} panelId - DOM id of the panel. - * @returns {string} localStorage key. - */ - _panelCollapseStorageKey(panelId) { - return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`; - } - - /** - * Restores a panel's collapsed/expanded state from localStorage. - * Falls back to the CSS class default if no saved state exists. - * @param {string} panelId - DOM id of the panel. - * @returns {void} - */ - _restorePanelCollapsedState(panelId, { allowStored = true } = {}) { - const panelEl = document.getElementById(panelId); - if (!panelEl) return; - let collapsed = panelEl.classList.contains('collapsed'); - let stored = null; - if (allowStored) { - try { - stored = localStorage.getItem(this._panelCollapseStorageKey(panelId)); - if (stored === '1') collapsed = true; - if (stored === '0') collapsed = false; - } catch { - // storage unavailable - } - } - // DISPLAY starts COLLAPSED for a first-time visitor, then respects the - // user's persisted choice like every other panel. - // - // It used to start expanded, to advertise the HUD / DETECT / 3D toggles. - // That reason expired when those became ON by default: the rail now opens - // to offer controls for things already happening, while competing with the - // first-run mission card for the one first impression there is. A stored - // choice still wins in both directions, so anyone who opens it keeps it. - if (panelId === 'pp-toggles' && stored === null) collapsed = true; - panelEl.classList.toggle('collapsed', collapsed); - this._syncPanelCollapseButton(panelEl); - } - - /** - * Persists a panel's collapsed state ('1' or '0') to localStorage. - * @param {string} panelId - DOM id of the panel. - * @param {boolean} collapsed - Whether the panel is collapsed. - * @returns {void} - */ - _savePanelCollapsedState(panelId, collapsed) { - try { - localStorage.setItem(this._panelCollapseStorageKey(panelId), collapsed ? '1' : '0'); - } catch { - // storage unavailable - } - } - - /** - * Builds one fixed right-side rail from Display, CCTV, its parameter - * controls, and Global Context (which owns the nested Radio companion). - * The rail then measures the live HUD chrome at runtime so it can stay - * aligned and within the available vertical corridor. - * @returns {void} - */ - _initRightPanelAdaptiveLayout() { - const stack = this._rightPanelStack; - if (!stack || !this._ppToggles) return; - - this._ppToggles.style.removeProperty('top'); - this._ppToggles.style.removeProperty('right'); - this._ppToggles.style.removeProperty('bottom'); - this._ppToggles.style.removeProperty('left'); - this._ppToggles.style.removeProperty('z-index'); - this._ppToggles.classList.remove('panel-draggable', 'panel-dragging'); - this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title'); - stack.prepend(this._ppToggles); - const globalContextPanel = document.getElementById('global-context-panel'); - if (this._cctvPanel) { - this._cctvPanel.style.removeProperty('top'); - this._cctvPanel.style.removeProperty('right'); - this._cctvPanel.style.removeProperty('bottom'); - this._cctvPanel.style.removeProperty('left'); - this._cctvPanel.style.removeProperty('z-index'); - this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging'); - stack.insertBefore(this._cctvPanel, globalContextPanel); - this._syncPanelCollapseButton(this._cctvPanel); - } - if (this._sliderPanel) { - this._sliderPanel.style.removeProperty('top'); - this._sliderPanel.style.removeProperty('right'); - this._sliderPanel.style.removeProperty('bottom'); - this._sliderPanel.style.removeProperty('left'); - this._sliderPanel.style.removeProperty('max-height'); - const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group'); - if (detectionGroup) detectionGroup.after(this._sliderPanel); - else this._ppToggles.append(this._sliderPanel); - } - if (typeof ResizeObserver !== 'undefined') { - this._rightStackResizeObserver = new ResizeObserver(() => { - this._scheduleRightPanelLayout(); - }); - this._rightStackResizeObserver.observe(stack); - for (const panel of [this._ppToggles, this._cctvPanel, globalContextPanel]) { - if (panel) this._rightStackResizeObserver.observe(panel); - } - document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR).forEach((element) => { - this._rightStackResizeObserver.observe(element); - }); - } - - if (typeof MutationObserver !== 'undefined') { - this._rightStackMutationObserver = new MutationObserver(() => { - this._scheduleRightPanelLayout(); - }); - this._rightStackMutationObserver.observe(stack, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ['class', 'hidden', 'data-variant'], - }); - const hud = document.getElementById('intel-hud'); - if (hud) { - this._rightStackMutationObserver.observe(hud, { - subtree: true, - attributes: true, - attributeFilter: ['class', 'hidden', 'data-variant'], - }); - } - } - - const transitionHud = document.getElementById('intel-hud'); - if (transitionHud) { - this._rightStackHudTransitionHandler = (event) => { - if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - }; - transitionHud.addEventListener('transitionend', this._rightStackHudTransitionHandler); - } - - this._scheduleRightPanelLayout(); - } - - _scheduleRightPanelLayout({ reconsiderAutoCollapse = false } = {}) { - if (reconsiderAutoCollapse) this._rightStackReconsiderAutoCollapse = true; - if (!this._rightPanelStack || this._rightStackLayoutFrame !== null) return; - this._rightStackLayoutFrame = requestAnimationFrame(() => { - this._rightStackLayoutFrame = null; - if (this._rightStackReconsiderAutoCollapse) { - this._rightStackReconsiderAutoCollapse = false; - for (const panel of this._rightPanelStack.querySelectorAll('.layout-auto-collapsed')) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - this._syncRightPanelAdaptiveLayout(); - }); - } - - /** - * Places the right rail inside the visible HUD-safe corridor. When the - * corridor is too short, the expanded panel receives the remaining height - * with internal scrolling. Tactical HUD hides collapsed sibling launchers - * while a panel is expanded; other HUD layouts keep them visible. - * @returns {void} - */ - _syncRightPanelAdaptiveLayout() { - layoutRightPanelRail({ - stack: this._rightPanelStack, - obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR), - windowRef: window, - hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, - preferredPanelId: this._rightStackPreferredPanelId, - onCollapse: (panel) => this._syncPanelCollapseButton(panel), - onRetry: () => this._scheduleRightPanelLayout(), - leftStack: this._leftPanelStack, - displayPanel: this._ppToggles, - readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' - ? this._standardDisplayScrollTop - : (this._ppToggles?.scrollTop || 0), - }); - } - - /** - * Initializes the adaptive left accordion. The layout engine measures the - * actual HUD/chrome rectangles that intersect the left lane, then decides - * whether collapsed sibling labels can remain visible beside the expanded - * panel. No decision is keyed to a specific panel or HUD variant. - * @returns {void} - */ - _initLeftPanelAdaptiveLayout() { - const stack = this._leftPanelStack; - if (!stack) return; - - if (typeof ResizeObserver !== 'undefined') { - this._leftStackResizeObserver = new ResizeObserver(() => { - this._scheduleLeftPanelLayout(); - }); - this._leftStackResizeObserver.observe(stack); - stack.querySelectorAll(':scope > [data-panel-id]').forEach((panel) => { - this._leftStackResizeObserver.observe(panel); - const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); - if (inner) this._leftStackResizeObserver.observe(inner); - }); - document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR).forEach((element) => { - this._leftStackResizeObserver.observe(element); - }); - } - - if (typeof MutationObserver !== 'undefined') { - this._leftStackMutationObserver = new MutationObserver(() => { - this._scheduleLeftPanelLayout(); - }); - this._leftStackMutationObserver.observe(stack, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ['class'], - }); - const hud = document.getElementById('intel-hud'); - if (hud) { - this._leftStackMutationObserver.observe(hud, { - attributes: true, - attributeFilter: ['class', 'data-variant'], - }); - } - const credits = document.getElementById('cesium-credits'); - if (credits) { - this._leftStackMutationObserver.observe(credits, { - subtree: true, - childList: true, - }); - } - } - - const transitionHud = document.getElementById('intel-hud'); - if (transitionHud) { - this._leftStackHudTransitionHandler = (event) => { - if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - // The Cockpit strip hangs off the HUD's REC readout, so it has to - // remeasure on the same event: the readout keeps its rect through - // the whole fade and only stops counting once the HUD has retired. - this.cockpitView?.scheduleContextLayout(); - } - }; - transitionHud.addEventListener('transitionend', this._leftStackHudTransitionHandler); - } - - this._leftStackCockpitModeHandler = () => { - // Cockpit mode repositions the peripheral HUD and reveals its own - // bottom-left context card. Measure after those styles have committed so - // the accordion remains in the same obstacle-safe lane instead of - // jumping to a cockpit-specific top anchor. - this._scheduleLeftPanelLayout(); - requestAnimationFrame(() => this._scheduleLeftPanelLayout()); - setTimeout(() => this._scheduleLeftPanelLayout(), 300); - }; - window.addEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler); - - this._scheduleLeftPanelLayout(); - } - - /** - * Batches adaptive accordion work into one animation frame. - * @returns {void} - */ - _scheduleLeftPanelLayout({ reconsiderAutoCollapse = false } = {}) { - if (reconsiderAutoCollapse) this._leftStackReconsiderAutoCollapse = true; - if (!this._leftPanelStack || this._leftStackLayoutFrame !== null) return; - this._leftStackLayoutFrame = requestAnimationFrame(() => { - this._leftStackLayoutFrame = null; - if (this._leftStackReconsiderAutoCollapse) { - this._leftStackReconsiderAutoCollapse = false; - for (const panel of this._leftPanelStack.querySelectorAll('.layout-auto-collapsed')) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - this._syncLeftPanelAdaptiveLayout(); - }); - } - - /** - * Measures a live obstacle-free corridor for the left accordion and toggles - * focus mode only when the expanded panel plus sibling labels cannot fit. - * Safe boundaries are written as viewport-relative CSS values. - * @returns {void} - */ - _syncLeftPanelAdaptiveLayout() { - layoutLeftPanelRail({ - stack: this._leftPanelStack, - obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR), - windowRef: window, - hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, - preferredPanelId: this._leftStackPreferredPanelId, - onCollapse: (panel) => this._syncPanelCollapseButton(panel), - onRetry: () => this._scheduleLeftPanelLayout(), - collapsedHeights: this._leftStackCollapsedHeights, - onAligned: () => this._scheduleRightPanelLayout(), - }); - } - - /** - * Updates collapse button glyphs based on panel state. Right-rail panels - * use directional arrows; left-stack panels use +/- symbols. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _syncPanelCollapseButton(panelEl) { - const isRightRail = ['pp-toggles', 'cctv-panel', 'global-context-panel'].includes(panelEl?.id); - const collapsed = panelEl.classList.contains('collapsed'); - panelEl.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((btn) => { - const owner = btn.closest('[data-panel-id], #param-slider-panel'); - if (owner !== panelEl) return; - if (isRightRail) { - btn.textContent = collapsed ? '◀' : '▶'; - } else { - btn.textContent = collapsed ? '+' : '−'; - } - btn.setAttribute('aria-expanded', String(!collapsed)); - const panelName = panelEl.querySelector('.panel-title, .pp-header-label')?.textContent?.trim() || 'panel'; - const action = collapsed ? 'Expand' : 'Collapse'; - btn.title = `${action} ${panelName}`; - btn.setAttribute('aria-label', `${action} ${panelName}`); - if (panelEl.id === 'radio-panel') { - const action = collapsed ? 'Expand' : 'Collapse'; - btn.title = `${action} Radio`; - btn.setAttribute('aria-label', `${action} Radio section`); - } - }); - const dockToggle = panelEl.querySelector(`[data-dock-toggle-target="${panelEl.id}"]`); - if (dockToggle) { - const panelName = panelEl.querySelector('.panel-title, .location-toolbar-label')?.textContent?.trim() || 'panel'; - const action = collapsed ? 'Expand' : 'Collapse'; - dockToggle.setAttribute('aria-expanded', String(!collapsed)); - dockToggle.setAttribute('aria-label', `${action} ${panelName}`); - dockToggle.title = `${action} ${panelName}`; - } - if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) { - this._contextRadioDetailsBtn.setAttribute('aria-expanded', String(!collapsed)); - } - if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') { - this._syncContextRadioLauncherState(); - } - } - - /** - * Converts a panel from left-positioned to right-anchored so it expands - * leftward on resize. Used for the right-rail parameter panel. - * @param {HTMLElement} panelEl - The panel to re-anchor. - * @returns {void} - */ - _pinPanelToRight(panelEl) { - if (!panelEl) return; - const rect = panelEl.getBoundingClientRect(); - const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right)); - panelEl.style.right = `${rightOffset}px`; - panelEl.style.left = 'auto'; - } - - /** - * Restores a panel's top/left position from localStorage. - * Right-rail panels are additionally pinned to the right edge. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _restorePanelPosition(panelId, panelEl) { - try { - const raw = localStorage.getItem(this._panelStorageKey(panelId)); - if (!raw) return; - const pos = JSON.parse(raw); - if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; - // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at - // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. - const { left, top } = this._clampToViewport(Math.round(pos.left), Math.round(pos.top), panelEl); - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - } catch { - // ignore malformed saved panel position - } - } - - /** - * Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag - * clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe. - * @param {number} left - desired left (px) - * @param {number} top - desired top (px) - * @param {HTMLElement} panelEl - the panel element - * @returns {{left:number, top:number}} - */ - _clampToViewport(left, top, panelEl) { - const rect = panelEl.getBoundingClientRect(); - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - return { - left: Math.max(6, Math.min(maxLeft, left)), - top: Math.max(6, Math.min(maxTop, top)), - }; - } - - /** - * Persists a panel's current bounding-rect position to localStorage. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _savePanelPosition(panelId, panelEl) { - const rect = panelEl.getBoundingClientRect(); - try { - localStorage.setItem(this._panelStorageKey(panelId), JSON.stringify({ - left: Math.round(rect.left), - top: Math.round(rect.top), - })); - } catch { - // storage unavailable - } - } - - /** - * Makes a panel draggable via its handle element. Implements: - * - Z-order promotion: each pointerdown increments the global z-counter - * so the clicked panel floats above siblings. - * - Viewport clamping: drag moves are clamped to a 6px inset from all edges. - * - Right-rail pinning: pp-toggles panel is re-anchored right after drag. - * - CCTV viewport sync: cctv-panel recalculates scroll height after drag. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @param {HTMLElement} handleEl - The drag handle element within the panel. - * @returns {void} - */ - /** - * Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX]. - * Renormalizes all promoted panels when the band is exhausted so panels can - * never climb above the voice pill (150), toasts (200), or clean-view exit (300). - * @param {HTMLElement} panelEl - Panel to bring to front. - * @returns {void} - */ - _promotePanelZ(panelEl) { - this._panelZCounter += 1; - if (this._panelZCounter > PANEL_Z_MAX) { - const promoted = [...document.querySelectorAll('.panel-draggable')] - .filter((el) => el.style.zIndex) - .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex)); - let z = PANEL_Z_BASE + 1; - for (const el of promoted) { - el.style.zIndex = String(z); - z += 1; - } - this._panelZCounter = z; - } - panelEl.style.zIndex = String(this._panelZCounter); - } - - _makePanelDraggable(panelId, panelEl, handleEl) { - // Z-order promotion: bring clicked panel to front of the stacking context - panelEl.addEventListener('pointerdown', () => { - this._promotePanelZ(panelEl); - }); - - handleEl.addEventListener('pointerdown', (event) => { - if (event.button !== 0) return; - if (event.target.closest('.panel-collapse-btn')) return; - if (event.target.closest('input, select, option, button:not(.panel-collapse-btn)')) return; - - event.preventDefault(); - const rect = panelEl.getBoundingClientRect(); - const startX = event.clientX; - const startY = event.clientY; - const offsetX = startX - rect.left; - const offsetY = startY - rect.top; - - panelEl.style.left = `${rect.left}px`; - panelEl.style.top = `${rect.top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; - panelEl.classList.add('panel-dragging'); - this._promotePanelZ(panelEl); - - const onMove = (moveEvent) => { - const nextLeftRaw = moveEvent.clientX - offsetX; - const nextTopRaw = moveEvent.clientY - offsetY; - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); - const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); - panelEl.style.left = `${nextLeft}px`; - panelEl.style.top = `${nextTop}px`; - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - const onUp = () => { - panelEl.classList.remove('panel-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - this._savePanelPosition(panelId, panelEl); - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); - }); - } - - _buildSharePanelState() { - const specs = []; - for (const spec of SHARE_PANEL_STATE_SPECS) { - const panelEl = document.getElementById(spec.id); - if (!panelEl) continue; - // Responsive auto-collapse is presentation only; the recipient should - // restore the user's explicit expanded preference at its own viewport. - const collapsed = panelEl.classList.contains('layout-auto-collapsed') - ? false - : panelEl.classList.contains('collapsed'); - const entry = { id: spec.id, collapsed }; - if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); - specs.push(entry); - } - return specs.length ? { specs } : null; - } - - _restorePanelState(panelState) { - if (!panelState || !Array.isArray(panelState.specs)) return; - const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec])); - for (const spec of SHARE_PANEL_STATE_SPECS) { - const state = specsById.get(spec.id); - if (!state || typeof state.collapsed !== 'boolean') continue; - if (spec.pinnable && typeof state.pinned === 'boolean') { - this._setCommandDockPanelPinState(spec.id, state.pinned, { - restore: true, - persist: false, - syncShare: false, - }); - } - const nextCollapsed = state.pinned && spec.pinnable ? false : state.collapsed; - this.setPanelCollapsed(spec.id, nextCollapsed, { - restore: true, - persist: false, - syncShare: false, - }); - } - this.shareLinkManager?.onPanelStateChange?.(); - } - - /** - * Programmatically collapses or expands a panel, persists the state, - * and triggers layout recalculation for dependent panels. - * @param {string} panelId - DOM id of the panel. - * @param {boolean} collapsed - Whether to collapse the panel. - * @param {object} [options] Disclosure ownership options. - * @param {boolean} [options.explicit=false] Whether a direct user action owns the panel lane. - * @returns {void} - */ - setPanelCollapsed(panelId, collapsed, { - explicit = false, - restore = false, - persist = true, - syncShare = true, - } = {}) { - if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.(); - const panelEl = document.getElementById(panelId); - if (!panelEl) return; - if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); - const nextCollapsed = Boolean(collapsed); - const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed'); - const leftOwnerPanel = this._leftPanelStack?.contains(panelEl) ? panelEl : null; - const rightOwnerPanel = panelId === 'radio-panel' - ? document.getElementById('global-context-panel') - : (this._rightPanelStack?.contains(panelEl) ? panelEl : null); - const priorLeftOwner = this._leftStackPreferredPanelId; - const priorRightOwner = this._rightStackPreferredPanelId; - if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { - this._leftStackPreferredPanelId = leftOwnerPanel.id; - } else if (explicit && !restore && nextCollapsed && leftOwnerPanel?.id === this._leftStackPreferredPanelId) { - this._leftStackPreferredPanelId = null; - } - if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { - this._rightStackPreferredPanelId = rightOwnerPanel.id; - } else if ( - explicit - && !restore - && nextCollapsed - && rightOwnerPanel?.id === this._rightStackPreferredPanelId - ) { - this._rightStackPreferredPanelId = null; - } - if (panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed) { - this._syncPanelCollapseButton(panelEl); - if (priorLeftOwner !== this._leftStackPreferredPanelId) { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - } - if (priorRightOwner !== this._rightStackPreferredPanelId) { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - return; - } - panelEl.classList.remove('layout-auto-collapsed'); - if (!nextCollapsed && this.cockpitView?.active && panelId === 'data-panel') { - this._cockpitContextCollapsedForDataPanel = !this.cockpitView.contextCollapsed; - if (this._cockpitContextCollapsedForDataPanel) { - this.cockpitView.setContextCollapsed(true); - } - } - if (!nextCollapsed && panelId === 'global-context-panel' - && this._contextRadioDock?.classList.contains('disclosure-open')) { - this._setRadioDisclosure?.(false); - } - if (!nextCollapsed && panelId === 'radio-panel' - && document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.setPanelCollapsed('global-context-panel', false, { restore, persist, syncShare }); - } - if (!nextCollapsed && !restore && panelId === 'location-bar') { - const otherPanel = document.getElementById('control-panel'); - if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { - this.setPanelCollapsed('control-panel', true, { restore, persist, syncShare }); - } - } else if (!nextCollapsed && !restore && panelId === 'control-panel') { - const otherPanel = document.getElementById('location-bar'); - if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { - this.setPanelCollapsed('location-bar', true, { restore, persist, syncShare }); - } - } - panelEl.classList.toggle('collapsed', nextCollapsed); - if (nextCollapsed && this.cockpitView?.active && panelId === 'data-panel' - && this._cockpitContextCollapsedForDataPanel) { - this._cockpitContextCollapsedForDataPanel = false; - this.cockpitView.setContextCollapsed(false); - } - this._syncPanelCollapseButton(panelEl); - if (persist !== false) this._savePanelCollapsedState(panelId, nextCollapsed); - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (this._rightPanelStack?.contains(panelEl)) { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - requestAnimationFrame(() => this._updateCommandDockTrayStack()); - this._scheduleLeftPanelLayout({ - reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true, - }); - if (syncShare) this.shareLinkManager?.onPanelStateChange?.(); - } - - /** - * Toggles "clean view" mode which hides all UI panels via a CSS body class. - * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle. - * @returns {void} - */ - toggleCleanView(forceEnabled) { - const shouldEnable = typeof forceEnabled === 'boolean' - ? forceEnabled - : !document.body.classList.contains('ui-clean-view'); - document.body.classList.toggle('ui-clean-view', shouldEnable); - if (this._cleanViewBtn) { - this._cleanViewBtn.classList.toggle('active', shouldEnable); - } - this._scheduleLeftPanelLayout(); - } - - // ── Public control facade ────────────────────────────────────────────── - // Deliberate API for voice tools and scripting. Every setter keeps the DOM - // sliders, share-link state, and scene snapshots in sync, and returns - // { ok, ...resultingState } so callers confirm only what actually happened. - - /** - * Sets HUD visibility mode. 'auto' restores style-driven show/hide. - * @param {'on'|'off'|'auto'} mode - Visibility mode. - * @returns {{ok: boolean, visible?: boolean, layout?: string, error?: string}} - */ - setHudVisible(mode) { - const normalized = String(mode ?? '').toLowerCase(); - if (!['on', 'off', 'auto'].includes(normalized)) { - return { ok: false, error: `Unknown HUD visibility mode: ${mode}` }; - } - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.setMode(normalized); - this._updateHudButtonState(); - this._syncShareState(); - return { ok: true, visible: !!this.hud.visible, mode: normalized, layout: this.hud.getVariant() }; - } - - /** - * Switches the HUD layout variant. - * @param {'tactical'|'operator'|'minimal'} variantName - Layout variant. - * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}} - */ - setHudLayout(variantName) { - const variant = String(variantName ?? '').toLowerCase(); - if (!['tactical', 'operator', 'minimal'].includes(variant)) { - return { ok: false, error: `Unknown HUD layout: ${variantName}` }; - } - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(variant); - return { ok: true, layout: this.hud.getVariant(), visible: !!this.hud.visible }; - } - - /** - * Reads current detection overlay state (engine mode + UI density percent). - * @returns {{detectionMode: string, densityPct: number|null, allocationStrategy:string, fadePct:number, outsideOpacityPct:number}} - */ - getDetectionState() { - const pct = this._detectionDensitySlider - ? parseInt(this._detectionDensitySlider.value, 10) - : null; - return { - detectionMode: getDetectionMode(), - densityPct: pct, - allocationStrategy: getDetectionTuning().allocationStrategy, - fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), - }; - } - - /** Read-only overlay diagnostics used by browser QA and regression harnesses. */ - getDetectionDiagnostics() { - return readDetectionDiagnostics(); - } - - /** - * Controls the detection overlay: on/off, mode, and density percent. - * Density writes the slider AND the engine so share links and scene - * snapshots stay truthful. - * @param {object} [options] - * @param {boolean} [options.enabled] - false forces OFF; true restores the current density profile. - * @param {'sparse'|'balanced'|'dense'|'panoptic'} [options.mode] - Profile (legacy aliases accepted). - * @param {number} [options.densityPct] - 0-100 density percent. - * @param {'elastic'|'weighted'} [options.allocationStrategy] - Layer-capacity policy. - * @param {number} [options.fadePct] - Fade distance as 0-40% of the keyhole radius. - * @param {number} [options.outsideOpacityPct] - Opacity beyond the fade distance, 0-100%. - * @returns {{ok: boolean, detectionMode?: string, densityPct?: number|null, error?: string}} - */ - setDetection({ enabled, mode, densityPct, allocationStrategy, fadePct, outsideOpacityPct } = {}) { - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid detection enabled value: ${enabled}`, ...this.getDetectionState() }; - } - let requestedProfile = null; - if (typeof mode === 'string' && mode.trim()) { - requestedProfile = normalizeProfile(mode); - if (!requestedProfile) { - return { ok: false, error: `Unknown detection mode: ${mode}`, ...this.getDetectionState() }; - } - } - let requestedDensity = null; - if (densityPct != null) { - if (!Number.isFinite(Number(densityPct))) { - return { ok: false, error: `Invalid density: ${densityPct}`, ...this.getDetectionState() }; - } - requestedDensity = canonicalizeDensity(Number(densityPct)); - } - if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity != null - && profileForDensity(requestedDensity) !== requestedProfile) { - return { - ok: false, - error: `Detection mode ${requestedProfile} conflicts with density ${requestedDensity}%`, - ...this.getDetectionState(), - }; - } - let requestedAllocation = null; - if (allocationStrategy != null) { - requestedAllocation = String(allocationStrategy).trim().toUpperCase(); - if (!ALLOCATION_STRATEGIES.includes(requestedAllocation)) { - return { ok: false, error: `Unknown allocation strategy: ${allocationStrategy}`, ...this.getDetectionState() }; - } - } - if (fadePct != null) { - if (!Number.isFinite(Number(fadePct))) { - return { ok: false, error: `Invalid fade distance: ${fadePct}`, ...this.getDetectionState() }; - } - } - if (outsideOpacityPct != null) { - if (!Number.isFinite(Number(outsideOpacityPct))) { - return { ok: false, error: `Invalid outside opacity: ${outsideOpacityPct}`, ...this.getDetectionState() }; - } - } - const hasExplicitVisualChange = typeof enabled === 'boolean' - || requestedProfile !== null - || requestedDensity !== null - || requestedAllocation !== null - || fadePct != null - || outsideOpacityPct != null; - if (hasExplicitVisualChange) { - // Voice/scripted detection control counts as an explicit user choice, so - // neither style presets nor a still-pending shared visual restore can - // overwrite it afterward. - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - } - if (requestedAllocation) { - this._setDetectionAllocation(requestedAllocation, { syncShare: false }); - } - if (fadePct != null && this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(Math.max(0, Math.min(40, Math.round(Number(fadePct))))); - } - if (outsideOpacityPct != null && this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(Math.max(0, Math.min(100, Math.round(Number(outsideOpacityPct))))); - } - if (fadePct != null || outsideOpacityPct != null) this._applyDetectionFadeFromUi(); - - if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity == null) { - requestedDensity = defaultDensityForProfile(requestedProfile); - } - if (requestedDensity != null && this._detectionDensitySlider) { - this._detectionDensitySlider.value = String(requestedDensity); - this._applyDetectionDensityFromUi(); - } - - if (enabled === false || requestedProfile === 'OFF') { - setDetectionModeByLabel('OFF'); - } else if (requestedProfile) { - setDetectionModeByLabel(requestedProfile); - } else if (enabled === true && getDetectionMode() === 'OFF') { - setDetectionModeByLabel(profileForDensity( - requestedDensity ?? this._detectionDensitySlider?.value ?? 50, - )); - } - this._syncDetectionUiFromEngine(); - this._syncShareState(); - return { ok: true, ...this.getDetectionState() }; - } - - /** - * Switches the basemap stack and reports whether the switch landed. - * @param {string} stackId - One of mapStackController.getStacks() ids. - * @returns {Promise<{ok: boolean, activeStack?: string, error?: string|null, available?: string[]}>} - */ - async setMapStack(stackId) { - if (!this.mapStackController) { - return { ok: false, error: 'Map stack controller unavailable' }; - } - const stacks = this.mapStackController.getStacks(); - const target = stacks.find((stack) => stack.id === stackId); - if (!target) { - return { ok: false, error: `Unknown map stack: ${stackId}`, available: stacks.map((s) => s.id) }; - } - if (!target.available) { - return { ok: false, error: `${target.label} requires a Cesium ion token`, activeStack: this.mapStackController.getActiveId() }; - } - await this._setMapStack(stackId); - const state = this.mapStackController.getState(); - const landed = state.activeId === stackId; - return { - ok: landed, - activeStack: state.activeId, - error: landed ? null : (state.lastError || 'Map stack did not switch'), - }; - } - - /** - * Controls bloom post-processing. Intensity is the UI percent (0-200). - * @param {object} [options] - * @param {boolean} [options.enabled] - * @param {number} [options.intensityPct] - 0-200. - * @returns {{ok: boolean, bloom: {enabled: boolean, intensityPct: number|null}}} - */ - setBloom({ enabled, intensityPct } = {}) { - const current = () => ({ - enabled: !!this.bloomEnabled, - intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, - }); - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid bloom enabled value: ${enabled}`, bloom: current() }; - } - if (intensityPct !== undefined - && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { - return { ok: false, error: `Invalid bloom intensity: ${intensityPct}`, bloom: current() }; - } - const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; - if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); - if (intensityPct !== undefined) { - this._setBloomIntensity(Math.round(Math.max(0, Math.min(200, intensityPct)))); - } - if (enabled !== undefined) this._setBloomEnabled(enabled); - return { - ok: true, - bloom: current(), - }; - } - - /** - * Controls sharpen post-processing. Intensity is the UI percent (0-100). - * @param {object} [options] - * @param {boolean} [options.enabled] - * @param {number} [options.intensityPct] - 0-100. - * @returns {{ok: boolean, sharpen: {enabled: boolean, intensityPct: number|null}}} - */ - setSharpen({ enabled, intensityPct } = {}) { - const current = () => ({ - enabled: !!this.sharpenEnabled, - intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, - }); - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid sharpen enabled value: ${enabled}`, sharpen: current() }; - } - if (intensityPct !== undefined - && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { - return { ok: false, error: `Invalid sharpen intensity: ${intensityPct}`, sharpen: current() }; - } - const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; - if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); - if (intensityPct !== undefined) { - const pct = Math.round(Math.max(0, Math.min(100, intensityPct))); - if (this._sharpenSlider) this._sharpenSlider.value = String(pct); - if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - this._syncShareState(); - } - if (enabled !== undefined) this._setSharpenEnabled(enabled); - return { - ok: true, - sharpen: current(), - }; - } - - /** Whether the full-globe celestial overlay is enabled by user preference. */ - get celestialRingEnabled() { - return !!this.celestialRing?.enabled; - } - - /** - * Controls the celestial ring. The Display button uses `focus=true` when the - * ring is disabled or unavailable at the current zoom, turning the control - * into a reveal action instead of requiring a separate globe-navigation step. - * @param {boolean} enabled - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - * @param {boolean} [options.focus=false] - * @returns {{ok:boolean, celestialRing:{enabled:boolean,visible:boolean}, cameraFocused:boolean, error?:string}} - */ - setCelestialRingEnabled(enabled, { syncShare = true, focus = false } = {}) { - const styleSupported = isCelestialRingStyleSupported(this.activeStyle); - const current = () => ({ - enabled: this.celestialRingEnabled, - visible: !!this.celestialRing?.visible, - }); - if (typeof enabled !== 'boolean') { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: `Invalid celestial ring enabled value: ${enabled}`, - }; - } - if (typeof syncShare !== 'boolean' || typeof focus !== 'boolean') { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: 'Celestial ring options must be boolean', - }; - } - if (!styleSupported && enabled) { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: 'Celestial ring is available only in Normal style', - }; - } - if (syncShare) this.shareLinkManager?.claimRestoreLane?.('visual'); - const nextEnabled = styleSupported && enabled; - this.celestialRing?.setEnabled(nextEnabled); - this._celestialBtn?.classList.toggle('active', nextEnabled); - this._celestialBtn?.setAttribute('aria-pressed', String(nextEnabled)); - if (this._celestialBtn) { - this._celestialBtn.disabled = !styleSupported; - this._celestialBtn.setAttribute('aria-disabled', String(!styleSupported)); - this._celestialBtn.title = styleSupported - ? 'Celestial ring — reveal the full globe' - : 'Celestial ring — available in Normal style'; - } - let cameraFocused = false; - if (nextEnabled && focus) { - cameraFocused = !!this.celestialRing?.focusFullGlobe(); - } - if (syncShare) this._syncShareState(); - return { - ok: styleSupported || !enabled, - celestialRing: current(), - cameraFocused, - }; - } - - /** - * Starts or stops orbiting the active POI. - * @param {boolean} [enabled] - Omit to toggle. - * @returns {{ok: boolean, orbiting: boolean, error?: string}} - */ - setOrbit(enabled) { - const active = !!this.orbitController?.active; - if (typeof enabled === 'boolean' && enabled === active) { - return { ok: true, orbiting: active }; - } - if (enabled === false) { - this._stopOrbit(); - return { ok: true, orbiting: false }; - } - if (!this._currentTarget) { - return { ok: false, orbiting: false, error: 'No active landmark to orbit — fly to a landmark first' }; - } - this._toggleOrbit(); - return { ok: true, orbiting: !!this.orbitController?.active }; - } - - /** - * Enables/disables clean view (hides all UI chrome). - * @param {boolean} [enabled] - Omit to toggle. - * @returns {{ok: boolean, cleanView: boolean}} - */ - setCleanView(enabled) { - this.toggleCleanView(enabled); - return { ok: true, cleanView: document.body.classList.contains('ui-clean-view') }; - } - - /** - * Reads global context mode state for voice/state-sync consumers. - * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}} - */ - getContextModeState() { - return { - mode: this._contextMode || null, - active: Boolean(this._contextMode), - changing: Boolean(this._contextModeChanging), - entering: this._contextModeEntering || null, - canContact: !this._contextMode || this._contextMode === 'flights', - canMission: !this._contextMode || this._contextMode === 'space-missions', - snapshotCaptured: Boolean(this._contextSessionSnapshot), - }; - } - - /** - * Sets global context mode (Contacts / Space Missions / off) for voice. - * @param {'contacts'|'space-missions'|'off'|null} mode - Requested context target. - * @param {object} [options] - * @param {string|Symbol|null} [options.notificationToken] - * @param {AbortSignal|null} [options.signal] - * @param {Function|null} [options.isCurrent] - * @param {boolean} [options.claimVisualAuthority] Whether this request is a - * genuine operator/voice Context intent that should take the visual restore - * lane. Cockpit choreography calls this facade INTERNALLY for its own - * enter/rollback steps; those transitions are not a Context request by the - * operator and must stay inert, so they pass `false`. - * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>} - */ - async setContextMode(mode, { - notificationToken = null, - signal = null, - isCurrent = null, - claimVisualAuthority = true, - } = {}) { - const requestIsCurrent = () => !signal?.aborted - && (typeof isCurrent !== 'function' || isCurrent()); - const cancellationResult = () => ({ - ok: false, - action: 'set_context_mode', - cancelled: true, - error: 'Context request was superseded by a newer voice turn', - ...this.getContextModeState(), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }); - if (!requestIsCurrent()) return cancellationResult(); - try { - if (!mode || mode === 'off') { - // Validated explicit transition — Context owns detection, so take the - // visual lane before a delayed shared restore can reclaim it. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const result = await this._selectContextMode(null, { notificationToken, signal }); - if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); - const state = this.getContextModeState(); - return { - ok: result === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(result === true ? {} : { error: 'Context mode transition did not complete' }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } - const canonical = mode === 'contacts' ? 'flights' : mode; - if (!['flights', 'space-missions'].includes(canonical)) { - return { - ok: false, - action: 'set_context_mode', - error: `Unknown context mode: ${mode}`, - mode: this._contextMode, - ...this.getContextModeState(), - }; - } - const priorMode = this._contextMode; - // Claimed only after the mode enum validates above, so a rejected request - // takes no authority and leaves the shared visual state eligible. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); - if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); - const state = this.getContextModeState(); - // A cross-mode switch tears the prior mode down before it commits, so a - // cancelled or failed switch rests on Context OFF. Say that plainly: - // reporting a bare "did not complete" while the operator's Context is - // gone is the dishonesty this whole path was fixed for. The state fields - // below carry the same verdict, so text and state cannot disagree. - const crossModeSwitchLost = transitioned !== true - && Boolean(priorMode) && priorMode !== canonical && !state.mode; - return { - ok: transitioned === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(transitioned === true ? {} : { - // Named in the operator's vocabulary, not the internal id: this - // string is read by the voice model, which takes 'contacts'. - error: crossModeSwitchLost - ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` - : 'Context mode transition did not complete', - ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), - }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } catch (error) { - if (!requestIsCurrent()) { - return { - ...cancellationResult(), - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - }; - } - return { - ok: false, - action: 'set_context_mode', - error: error?.message || 'Context mode transition failed', - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - ...this.getContextModeState(), - }; - } - } - - /** - * Returns cockpit status for voice/state sync and navigation operations. - * @returns {{active:boolean, entryAllowed:boolean, visionMode:string, subject:{id:string,layerId:string}|null, navigation:{canPrevious:boolean,canNext:boolean,canFocus:boolean}|null, awareness?: object}|null} - */ - getCockpitState() { - const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); - const info = this.cockpitView?.readAircraftInfo?.(); - const active = Boolean(this.cockpitView?.active); - const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); - // "Could Cockpit be ENTERED right now" — so it is false while already - // inside, unconditionally. Cockpit takes the entity off - // `viewer.trackedEntity` on entry and NEXT puts one back, which made this - // flip true/false between calls while `active` stayed true; readers - // (including the voice model) read that as a broken half-entered state. - const entryAllowed = !active && Boolean( - gateOpen - && info - && this.viewer?.trackedEntity?.position, - ); - return { - active, - entryAllowed, - // Why entry is unavailable, so a refusal can be explained rather than - // guessed at. - entryBlockedReason: entryAllowed || active - ? null - : (!gateOpen - ? (this._contextModeChanging ? 'contacts-starting' : 'contacts-inactive') - : 'no-tracked-aircraft'), - visionMode: this.cockpitView?.visionMode || null, - subject: info ? { - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, - } : null, - navigation: snapshot ? { - canPrevious: Boolean(snapshot.navigation?.canPrevious), - canNext: Boolean(snapshot.navigation?.canNext), - canFocus: Boolean(snapshot.navigation?.canFocus), - } : null, - awareness: snapshot ? { - radiusM: Number.isFinite(snapshot.radiusM) ? snapshot.radiusM : null, - subject: snapshot.subject ? { - id: snapshot.subject.id || null, - layerId: snapshot.subject.layerId || null, - } : null, - cohorts: Array.isArray(snapshot.cohorts) - ? snapshot.cohorts.map((cohort) => ({ - id: cohort?.id || null, - source: cohort?.source || null, - count: Number.isFinite(cohort?.count) ? cohort.count : null, - relationship: cohort?.relationship || null, - reason: cohort?.reason || null, - coverage: cohort?.coverage || null, - })) - : [], - navigation: snapshot.navigation ? { - canPrevious: Boolean(snapshot.navigation.canPrevious), - canNext: Boolean(snapshot.navigation.canNext), - canFocus: Boolean(snapshot.navigation.canFocus), - } : null, - } : null, - activeTracked: this.cockpitView?.active ? Boolean(this.cockpitView?.trackedEntity) : false, - activeMapView: !this.cockpitView?.active && entryAllowed, - }; - } - - /** - * Point Cockpit entry at a requested contact layer before it enters. - * - * Reuses the filtered Context navigation NEXT already uses, so "cockpit in - * that military helicopter" lands on the same contact "next military - * helicopter" would. Cockpit flies aircraft only; vessel and installation - * layers are refused by name rather than silently ignored. - * @param {object} options Retarget request. - * @param {string} options.targetLayer Requested contact layer. - * @param {string|null} options.aircraftClass Optional class filter. - * @param {{layerId: string}|null} options.currentTarget Current tracker. - * @param {{layerId: string}|null} options.selectedTarget Pending selection. - * @returns {{ok: boolean, retargeted?: boolean, error?: string}} Outcome. - */ - _retargetCockpitEntryLayer({ targetLayer, aircraftClass, currentTarget, selectedTarget }) { - if (!['flights', 'military'].includes(targetLayer)) { - return { - ok: false, - error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`, - }; - } - const activeLayer = selectedTarget?.layerId || currentTarget?.layerId || null; - const alreadyOnLayer = activeLayer === targetLayer; - if (alreadyOnLayer && !aircraftClass) return { ok: true, retargeted: false }; - const moved = militaryAwarenessLayer?.navigateNext - ? !!militaryAwarenessLayer.navigateNext({ - targetLayer, - aircraftClass, - origin: 'voice', - }) - : false; - if (moved) return { ok: true, retargeted: true }; - // A filter that matched nothing still enters, as long as the layer is - // already right — the operator asked for that layer and is on it. - if (alreadyOnLayer) return { ok: true, retargeted: false }; - const label = targetLayer === 'military' ? 'military' : 'civilian'; - const filtered = aircraftClass ? `${aircraftClass} ` : ''; - return { - ok: false, - error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`, - }; - } - - /** - * Controls cockpit entry/exit and context navigation. - * @param {'enter'|'exit'|'next'|'previous'|'status'} action - Cockpit action. - * @param {object} [options] - * @param {string|Symbol|null} [options.notificationToken] - * @param {'flights'|'military'|'ais-live-vessels'|'military-installations'|null} [options.targetLayer] - * @param {string|null} [options.aircraftClass] - * @param {{layerId:'flights'|'military',id:string}|null} [options.selectedTarget] - * @param {{layerId:'flights'|'military',id:string}|null} [options.rollbackTarget] - * @returns {{ok:boolean, action:string, error?:string, state?:object}} - */ - controlCockpit(action, { - notificationToken = null, - targetLayer = null, - aircraftClass = null, - selectedTarget = null, - rollbackTarget = undefined, - } = {}) { - const normalized = String(action || '').toLowerCase(); - if (!this.cockpitView) { - return { - ok: false, - action: 'control_cockpit', - error: 'Cockpit controller unavailable', - state: this.getCockpitState(), - }; - } - if (normalized === 'status') { - return { - ok: true, - action: 'control_cockpit', - state: this.getCockpitState(), - notificationToken: notificationToken || null, - }; - } - if (normalized === 'enter') { - // Entry is gated exactly as the manual entry chip is. Attempting it while - // the gate is shut produced the half-entered look the operator reported - // (a plane anchored under the camera with no cockpit around it), so - // refuse with the reason instead of trying. - if (!this.cockpitView.isEntryAllowed?.()) { - return { - ok: false, - action: 'control_cockpit', - error: this._contextModeChanging - ? 'Contacts is still starting up — try Cockpit again in a moment' - : 'Contacts must be active to enter Cockpit — say "open contacts" first', - state: this.getCockpitState(), - }; - } - let currentTarget = this.getAircraftTrackingTarget(); - const layerForTarget = (target) => target?.layerId === 'military' - ? militaryFlightsLayer - : target?.layerId === 'flights' ? flightsLayer : null; - // A requested layer retargets BEFORE entry, through the same filtered - // navigation NEXT uses. Ignoring it entered on whatever was already - // tracked and reported success, so "cockpit in that military helicopter" - // silently put the operator in an airliner. - if (targetLayer) { - const requested = this._retargetCockpitEntryLayer({ - targetLayer, - aircraftClass, - currentTarget, - selectedTarget, - }); - if (!requested.ok) { - return { - ok: false, - action: 'control_cockpit', - error: requested.error, - state: this.getCockpitState(), - }; - } - if (requested.retargeted) { - // The retarget is now the authority; a selection sampled before it - // would drag entry back to the wrong layer. - selectedTarget = null; - rollbackTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget; - currentTarget = this.getAircraftTrackingTarget(); - } - } - const selectedLayer = selectedTarget?.layerId === 'military' - ? militaryFlightsLayer - : selectedTarget?.layerId === 'flights' ? flightsLayer : null; - const entry = enterCockpitWithTracking({ - cockpitView: this.cockpitView, - selectedLayer, - selectedTarget, - currentLayer: layerForTarget(currentTarget), - rollbackLayer: layerForTarget( - rollbackTarget === undefined ? currentTarget : rollbackTarget, - ), - rollbackTarget, - selectionOrigin: 'voice', - }); - return { - ok: entry.entered, - action: 'control_cockpit', - state: this.getCockpitState(), - error: entry.error, - }; - } - if (normalized === 'exit') { - const exited = !!this.cockpitView.exit(); - return { - ok: exited, - action: 'control_cockpit', - state: this.getCockpitState(), - error: exited ? null : 'Cockpit was already inactive', - }; - } - if (normalized === 'next' || normalized === 'previous') { - const changed = this.cockpitView.navigateContext( - normalized === 'next' ? 1 : -1, - { - targetLayer, - aircraftClass, - origin: 'voice', - }, - ); - return { - ok: changed, - action: 'control_cockpit', - state: this.getCockpitState(), - error: changed ? null : 'No further context target was available', - }; - } - return { - ok: false, - action: 'control_cockpit', - error: `Unknown cockpit action: ${action}`, - state: this.getCockpitState(), - }; - } - - /** - * Full control-state snapshot — single source for voice read-back so the - * agent confirms from the same state it acted on. - * @returns {object} Current style/stack/HUD/detection/post-processing state. - */ - getControlState() { - return { - style: this.activeStyle || 'normal', - mapStack: this.mapStackController?.getActiveId?.() || null, - hud: { visible: !!this.hud?.visible, layout: this.hud?.getVariant?.() || null }, - detection: this.getDetectionState(), - bloom: { - enabled: !!this.bloomEnabled, - intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, - }, - sharpen: { - enabled: !!this.sharpenEnabled, - intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, - }, - celestialRing: { - enabled: this.celestialRingEnabled, - visible: !!this.celestialRing?.visible, - }, - orbiting: !!this.orbitController?.active, - recording: !!this._recordingMode, - cleanView: document.body.classList.contains('ui-clean-view'), - }; - } - - /** - * Captures the current camera position and orientation as a serializable object. - * @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null} - */ - getCameraState() { - const carto = this.viewer.camera.positionCartographic; - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - alt: carto.height, - heading: Cesium.Math.toDegrees(this.viewer.camera.heading), - pitch: Cesium.Math.toDegrees(this.viewer.camera.pitch), - roll: Cesium.Math.toDegrees(this.viewer.camera.roll), - }; - } - - /** - * Flies the camera to a previously captured camera state using cubic ease-in-out. - * @param {{lat: number, lon: number, alt: number, heading?: number, pitch?: number, roll?: number}} cameraState - * @param {number} [duration=2.8] - Flight duration in seconds. - * @returns {void} - */ - applyCameraState(cameraState, duration = 2.8) { - if (!cameraState) return; - this.viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - cameraState.lon, - cameraState.lat, - cameraState.alt - ), - orientation: { - heading: Cesium.Math.toRadians(cameraState.heading || 0), - pitch: Cesium.Math.toRadians(cameraState.pitch || -35), - roll: Cesium.Math.toRadians(cameraState.roll || 0), - }, - duration: Math.max(0.2, duration || 0), - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); - } - - /** - * Snapshots the full visual state (active style, bloom, sharpen, HUD, detection, - * per-style shader uniform values) for serialization or scene recipe capture. - * @returns {object} Serializable visual state object. - */ - getVisualState() { - const styleParams = {}; - for (const [styleName, stage] of Object.entries(this.stages)) { - const shader = STYLES[styleName]; - if (!shader?.uniforms) continue; - styleParams[styleName] = {}; - for (const uniformName of Object.keys(shader.uniforms)) { - styleParams[styleName][uniformName] = stage.uniforms[uniformName]; - } - } - - return { - style: this.activeStyle, - bloom: { - enabled: this.bloomEnabled, - intensity: this._getBloomIntensity(), - version: BLOOM_SCALE_VERSION, - }, - sharpen: { - enabled: this.sharpenEnabled, - intensity: parseInt(this._sharpenSlider?.value || '49', 10), - }, - hud: { - visible: this.hud.visible, - variant: this.hud.getVariant(), - }, - detection: { - mode: getDetectionMode(), - density: parseInt(this._detectionDensitySlider?.value || '50', 10), - allocation: getDetectionTuning().allocationStrategy, - fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), - }, - scope: { - enabled: isScopeMaskEnabled(), - featherPct: Math.round(getScopeMaskFeather() * 100), - }, - mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', - styleParams, - }; - } - - /** - * Restores a full visual state snapshot, applying style, bloom, sharpen, - * HUD, detection, and per-style shader uniforms. Used by scene recipes - * and share-link restore. Async so the map-stack switch resolves before - * the share state is synced; callers may fire-and-forget. - * @param {object} [state={}] - Visual state object (as returned by getVisualState). - * @param {object} [options] - * @param {(() => boolean)|null} [options.isCurrent] Caller liveness predicate. - * The map-stack switch is this method's ONLY suspension point, and the - * shader-uniform writes come after it — so a caller superseded while that - * switch is in flight would otherwise resume and commit the look of a - * state the operator has already moved past. Scene playback reproduced - * exactly that: a stale shot's uniforms landing on top of the live run. - * Omit it and the method behaves as it always has. - * @returns {Promise
Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.
Open-source mapped\/observed context\./, @@ -1352,12 +1353,12 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // disable() hides the collection but keeps the records, so a map lookup // alone would report a preserved subject as FRESH from hidden stale data. for (const [name, source, guard] of [ - ['flights', fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'), - /hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/], - ['militaryFlights', fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'), - /hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/], - ['aisLiveVessels', fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'), - /hasContact\(mmsi\) \{\s*\n\s*if \(!state\.enabled \|\| !state\.vesselMap \|\| state\.vesselMap\.size === 0\) return null;/], + ['flights', readLayerSource(new URL('./flights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + ['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + ['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), + /hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/], ]) { assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`); } @@ -1527,14 +1528,14 @@ test('a deliberate source clear still fully clears the subject', () => { test('production eviction sites actually tag their clears', () => { // The event contract above is worthless if the real cull paths never set the // origin, so pin the three production call sites. - const flightsSource = fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'); - const militarySource = fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'); - const vesselsSource = fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'); + const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url)); + const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url)); + const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)); for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) { assert.match( source, - /if \(icao24 === _trackedIcao\) \{\s*\n\s*_clearTracking\(false, \{ evicted: true \}\);/, + /if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/, `${name} must mark its aged-out cull as an eviction`, ); assert.match( @@ -1553,9 +1554,9 @@ test('production eviction sites actually tag their clears', () => { // The tag alone is not enough — see the behavioral test in // firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod, // whose registration sweep deletes the record the clear needs to see. - const firmsSource = fs.readFileSync(new URL('./firmsHeatmap.js', import.meta.url), 'utf8'); + const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url)); const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });'); - const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);'); + const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);'); assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction'); assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding'); assert.ok( @@ -1588,9 +1589,9 @@ test('cockpit blocks only non-aircraft Context camera flights', () => { }); test('vessel entry and selection framing both use the 3 km focus radius', () => { - assert.match(militaryAwarenessSource, /const VESSEL_FOCUS_RADIUS_M = 3000;/); + assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/); const focusRadiusUses = militaryAwarenessSource.match( - /new Cesium\.BoundingSphere\(vessel\.position, VESSEL_FOCUS_RADIUS_M\)/g, + /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, ) || []; assert.equal(focusRadiusUses.length, 2); }); diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index 220747dcd..a37fc0933 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -1,3878 +1,67 @@ -import * as Cesium from 'cesium'; -import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { - bindTrackingClickGesture, - isTrackingClickGesture, - isTrackingSelectionGesture, -} from './trackingClickGesture.js'; -import { createTrail } from './trailRenderer.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; -import { - screenProjectedRotation, - stabilizeScreenRotation, - horizonOccluder, - cameraPoseSignature, -} from './iconOrientation.js'; -import { stickyText, stickyNumber } from './aircraftMeta.js'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from './aircraftClass.js'; -import { modelAnchorWorld, modelVisualAnchor, trailAnchorForModel, trailHeadStart, visualCenterForModel } from './modelVisualAnchor.js'; -import { aircraftIcon, TRACKED_ICON_PX } from './aircraftIcons.js'; -import { - isTr3b, tr3bAircraftClass, tr3bConvertedIds, tr3bIconKind, tr3bTypeLabel, -} from './tr3bRegistry.js'; -import { cockpitContactDotImage } from './cockpitContactDot.js'; -import { nextCockpitNearContacts } from './cockpitAirLod.js'; -import { - applyTrackedCameraFrame, - trackedModelScaleForPixelCap, -} from './trackedCamera.js'; -import { - courseBetweenCartesians, limitCourseStep, turnRateFromFixHistory, arcOffsetEnu, - lerpAngleDeg, speedRamp, courseSlewCapDps, displayedKinematics, staleCoastLimitSeconds, - liftRepeatedGroundFix, synthesizeForwardKinematicsFix, COURSE_HOLD_SPEED_MPS, -} from './motionModel.js'; -import { setMilitaryLayerActive, registerMilitaryIcaos } from './militaryRegistry.js'; -import { formatFlightLevel } from './detectionDraw.js'; -import { createGroundSnap } from './groundSnap.js'; -import { trackedModelZoomActive } from './trackedModelRegime.js'; -import { pickRenderAltitudeM } from './renderAltitude.js'; -import { cachedGroundFloor, floorAltitudeM, warmGroundFloor, resolveGroundFloorCellsBounded, GROUND_FLOOR_LIFT_M } from './groundFloor.js'; -import { sampleMeshFloorCells } from './meshFloorSampler.js'; -import { ensureGeoidReady, geoidHeight } from './geoid.js'; -import { - advanceProjectedSpriteFocus, - clearFocusTarget, - focusNowMs, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, -} from './focusDeemphasis.js'; -import { - applyAircraftBillboardTreatment, - applyAircraftModelTreatment, -} from './aircraftRecession.js'; -import { refreshTrackedReadout, trackedLabelModelFromText } from './trackedReadout.js'; -import { - clearTrackedSubjectContext, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -/** - * @module militaryFlights - * @description Real-time military flight tracking layer powered by the adsb.lol API. - * - * Renders aircraft as amber chevron billboards in a single BillboardCollection - * for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft - * spawns an Entity the camera follows, whose position is driven by a - * dead-reckoning CallbackProperty. The whole fleet renders at - * now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes - * instead of extrapolating ahead and snapping back when the next poll lands. - * - * Billboard orientation uses alignedAxis set to the WGS84 surface normal at each - * position so that the rotation angle (heading/track) operates in the local tangent - * plane where 0 deg = north; near nadir the icons switch to screen-aligned - * rotation (camera.heading - track) with hysteresis. - */ - -/** @constant {string} API endpoint proxied to adsb.lol military feed */ -const API_URL = '/api/adsblol/mil'; -/** @constant {number} Milliseconds to wait before retrying after a transient error */ -const ERROR_BACKOFF_INTERVAL = 20000; -/** @constant {number} Longer cooldown (ms) after a 429 rate-limit, mirroring flights.js */ -const BACKOFF_INTERVAL = 45000; -/** @constant {number} Max position samples retained per aircraft for dead reckoning */ -const POSITION_HISTORY_LIMIT = 5; -/** @constant {number} Base billboard display scale */ -const BILLBOARD_SCALE = 0.7; - -/** @constant {Cesium.Color} Default amber tint for untracked military billboards */ -const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); -/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */ -const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); - -// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- -// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate -// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude -// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable, -// sticky metadata updating normally. The on-ground flip restyles the existing -// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes -// draw no trails. In 3D mode they take model slots like airborne planes (owner -// decision 2026-07-03 — no air/ground distinction), placed by the one-shot -// ground snap (see _modelDisplayPosition). -// -// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field -// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can -// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail; -// "feed-dropped, coasting" stays the 45%-alpha stale fade. -/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ -const GROUND_SCALE = 0.8; - -/** Depth-test policy for aircraft billboards (mirror of flights.js — see the - * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact - * renders depth-test-free at every distance — a uniform always-visible rule; - * the fleet tick's horizon occluder still removes far-side contacts. */ -function _groundDepthDistance() { - return Number.POSITIVE_INFINITY; -} - -// --- 3D model rendering (mirrors flights.js) ----------------------------------------- -// When enabled, military aircraft render as 3D glTF jet models once the camera is below -// MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is -// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') -// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion -// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer -// apart from the commercial flights layer. -const JET_MODEL_URL = '/models/jet.glb'; -const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) -const MODEL_MIN_PX = 24; // floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) -const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff -export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -const MODEL_NATIVE_RADIUS_M = 29.83; -// jet.glb is transform-applied at real-world scale — native bounding radius -// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching -// flights.js world sizes. At the old copied ×24, models rendered -// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by -// modelScale.test.mjs. -const MODEL_SCALE = 1; -// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity -// and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). -const MODEL_MAX = 150; // 'proximity' cap -const MODEL_MAX_ALL = 350; // 'all' cap -const MODEL_PROX_ADD_M = 150000; // proximity: model NEW planes within 150 km -const MODEL_PROX_KEEP_M = 185000; // proximity: KEEP modeled planes out to 185 km - -// Cockpit keeps the standard Display radii but lowers the GLB budget. Contacts -// inside the selected band remain AIR silhouettes when they cannot own a model; -// contacts outside the band use compact dots. -const COCKPIT_MODEL_MAX = 60; // max concurrent GLBs in cockpit (never raises the map cap) -const MODEL_ALL_ADD_M = 400000; // all: model NEW planes within 400 km (~to the horizon) -const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km -const MODEL_HEADING_OFFSET_DEG = 180; // every aircraft GLB is exported nose -X in the shared transform-applied convention -// Preserve the layer's amber identity while reducing approved texture/livery -// contribution to a weak diffuse hint, matching civilian launch presentation. -const MODEL_COLOR_BLEND_AMOUNT = 0.94; -// airplane.glb (the shared 747) constants for this layer's heavy classes. The -// asset has its former 24× runtime calibration baked into transform-applied -// meter-scale geometry; these values mirror flights.js and are regression-pinned. -const PLANE_MODEL_URL = '/models/airplane.glb'; -const PLANE_MODEL_SCALE = 1; -const PLANE_NATIVE_RADIUS_M = 34.41; -const PLANE_BELLY_OFFSET_NATIVE = 6.719; -/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ -const _specCache = new Map(); -function _modelSpec(klass) { - let spec = _specCache.get(klass); - if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { url: real.url, scale: 1, nativeRadiusM: real.radiusM, bellyM: real.bellyM, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(real.url), trailAnchorNative: trailAnchorForModel(real.url) }; - } else if (klass === 'airliner' || klass === 'quadjet' || klass === 'glider') { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: PLANE_MODEL_URL, scale, nativeRadiusM: PLANE_NATIVE_RADIUS_M, bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL) }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: JET_MODEL_URL, scale, nativeRadiusM: MODEL_NATIVE_RADIUS_M, bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, visualCenterNative: visualCenterForModel(JET_MODEL_URL), trailAnchorNative: trailAnchorForModel(JET_MODEL_URL) }; - } - _specCache.set(klass, spec); - return spec; -} -// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this -// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node -// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). -// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model -// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to -// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. -const MODEL_BELLY_OFFSET_NATIVE = 5.631; -/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ -const _groundSnap = createGroundSnap(); -const _scratchGroundCarto = new Cesium.Cartographic(); -const _scratchGroundPos = new Cesium.Cartesian3(); -/** @type {Cesium.PrimitiveCollection|null} */ -let _modelCollection = null; -/** @type {Map} icao24 → model */ -const _models = new Map(); -/** @type {Set} icao24 currently loading (async) */ -const _modelPending = new Set(); -/** @type {Map} icao24 → load generation; bumped on release to invalidate - * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ -const _modelGen = new Map(); -/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle - * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ -let _modelEpoch = 0; -/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs - * layer-state restoration, so this initializer — not the codec — is what the app - * actually starts with; it must stay in lockstep with the `models3d` default in - * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would - * light a button the layer has not armed. */ -let _models3dEnabled = true; -let _models3dMode = 'proximity'; // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) -let _lastModelCapWarnMs = 0; // throttle the "more planes in view than the cap" console notice -// The tracked entity's billboard goes transparent (rather than hidden) once the model takes -// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its -// alpha after the GLB is preloaded so the model is ready to render the instant the billboard -// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not -// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. -let _planeModelLoaded = false; -/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ -let _preloadModel = null; -/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ -const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); -const _scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); -const _scratchModelMtx = new Cesium.Matrix4(); -const _scratchModelBS = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); // frustum-visibility test -/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ -const _billboardLimbScale = new WeakMap(); - -// --------------------------------------------------------------------------- -// Module-level state -// --------------------------------------------------------------------------- - -/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ -let _billboardCollection = null; -/** @type {Map} ICAO hex -> billboard primitive */ -let _billboards = new Map(); -/** Stable lightweight records reused by the detection overlay between polls. */ -let _detectionObjects = new Map(); -/** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ -let _flightData = new Map(); -/** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ -let _positionHistory = new Map(); -/** @type {number} Current number of visible aircraft */ -let _count = 0; -/** @type {number|null} Epoch ms of last successful API update */ -let _lastUpdate = null; -/** @type {boolean} True when in error-backoff mode */ -let _backoff = false; -/** @type {number} Epoch ms after which the next retry is allowed */ -let _retryAt = 0; -/** @type {string|null} Human-readable description of the last error */ -let _lastError = null; -const _activeUpdateControllers = new Set(); - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); -} -/** @type {number|null} HTTP status code from the last API response */ -let _lastStatus = null; -/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ -let _geoidReady = false; -/** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ -const _geoidNCache = new Map(); - -// -- Click-to-track state -- -/** @type {string|null} ICAO hex of the currently tracked aircraft */ -let _trackedIcao = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: 'adsb.lol', -}; -/** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ -let _trackedEntity = null; -/** Disposes the single active tracked-camera framing owner. */ -let _trackedCameraFrameStop = null; -/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on - * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere - * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ -let _trackedModel = null; -let _trackedModelGen = 0; -let _trackedModelLoading = false; -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ -let _clickHandler = null; -/** @type {Cesium.Viewer|null} Cached viewer reference */ -let _viewer = null; -/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ -let _cockpitContactMode = false; -/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ -let _cockpitNearContacts = new Set(); -/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ -let _cockpitSubjectId = null; -/** @type {((event: CustomEvent) => void)|null} */ -let _cockpitModeListener = null; - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - -function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb?.position || !info) return false; - if (_trackedEntity) _trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'military', - id: icao24, - label: _toCleanText(info.callsign) || _toCleanText(info.registration) || icao24, - position: Cesium.Cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; -} - -/** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ -function _contextSubjectMetadata(icao24) { - const described = _describeFlight(icao24); - if (!described) return null; - const info = _flightData.get(icao24); - const label = described.callsign || described.registration || icao24; - return { - id: icao24, - layerId: 'military', - layerName: 'Military Flights', - source: 'adsb.lol', - label, - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: label, - operator: _toCleanText(info?.operator) || '', - callsign: described.callsign || '', - registration: described.registration || '', - // Converted contacts report their class as TR-3B — the same override - // the readout and Contacts card show. - type: tr3bTypeLabel(icao24, _toCleanText(info?.type) || ''), - altitude: described.onGround ? 'on ground' : _formatAltitude(info?.altitudeFt), - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) ? `${Math.round(described.track)}°` : '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; -} - -function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; -} - -const COCKPIT_CONTACT_SIZE_PX = 6; -const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( - 1000, 3.0, 8000000, 0.5, -); - -function _normalBillboardScaleByDistance() { - // Match commercial flights and preserve the established close-range 3× - // default. Smaller owner-visible scaling must be proposed separately. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); -} - -function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); -} - -function _militaryBillboardScale(icao24) { - const meta = _flightData.get(icao24); - return BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); -} - -/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ -function _applyFleetBillboardPresentation(icao24, bb) { - if (!bb) return; - const limbScale = _billboardLimbScale.get(bb) ?? 1; - const isCockpitContact = _cockpitContactMode && icao24 !== _trackedIcao; - const isCockpitNear = isCockpitContact && _cockpitNearContacts.has(icao24); - if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; - bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); - bb.rotation = 0; - return; - } - - const meta = _flightData.get(icao24); - bb.image = aircraftIcon(_iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined); - bb.width = icao24 === _trackedIcao ? 24 : 20; - bb.height = icao24 === _trackedIcao ? 24 : 20; - bb.scale = _militaryBillboardScale(icao24) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); -} - -/** Refresh the AIR-only Cockpit near band independently from model admission. */ -function _refreshCockpitNearContacts() { - if (!_cockpitContactMode || !_viewer?.camera?.positionWC) { - if (_cockpitNearContacts.size) _cockpitNearContacts = new Set(); - return; - } - const previous = _cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - Cesium.Cartesian3.distanceSquared(_viewer.camera.positionWC, bb.position), - ]); - } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - _modelAddDistM(), - _modelKeepDistM(), - ); - _cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of _billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - _applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) _lastCamPoseSig = ''; -} - -/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ -function _setCockpitContactMode(active) { - const next = active === true; - if (_cockpitContactMode === next) return; - _cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else _cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. - if (_modelCollection) _modelCollection.show = true; - _trail?.setVisible(!next); - if (_trailHeadEntity) _trailHeadEntity.show = !next; - for (const [icao24, bb] of _billboards) _applyFleetBillboardPresentation(icao24, bb); - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; -} - -function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - _cockpitSubjectId = active - ? String(detail?.subjectId || '').trim().toLowerCase() || null - : null; - _setCockpitContactMode(active); -} - -// --------------------------------------------------------------------------- -// Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the -// tracked aircraft. The accumulation array is intentionally SEPARATE from -// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) -// so the visible trail can grow to TRAIL_MAX_POINTS fixes. -// --------------------------------------------------------------------------- - -/** @constant {string} Military trail hue (PRD F4, pinned). */ -const TRAIL_COLOR = '#FFB800'; -/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ -const TRAIL_MAX_POINTS = 400; -/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ -let _trail = null; -/** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ -let _trailHeadEntity = null; -/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ -let _trailHeadSeq = 0; -/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ -let _trailPositions = []; -/** @type {number} Monotonic token — invalidates in-flight backfill responses */ -let _trailBackfillToken = 0; - -// --------------------------------------------------------------------------- -// Render-behind smoothing (mirrors flights.js): the fleet renders at -// now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes -// instead of extrapolating ahead and snapping back when the next poll lands. -// Removing the delay reintroduces the back/forward oscillation and is a -// regression. -// --------------------------------------------------------------------------- - -/** @constant {number} Display latency in seconds (= one poll interval). */ -const RENDER_DELAY_SEC = 15; -/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */ -const MISSING_POLL_LIMIT = 3; -// --- Landed-plane fast cull (mirror of flights.js; owner field report -// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown). -// The feed's ground flag lags the actual landing, so a landed plane's last -// airborne fixes show it low + slow on the runway; when it then drops out of -// the poll it has landed, not hit a transient gap — evict after ONE missed -// poll. Thresholds: below ~500 ft baro (≈150 m MSL — near-sea-level fields -// only; a high-elevation airport ghost falls back to the normal grace) AND -// below ~45 kts ground speed (≈23 m/s — rollout/taxi; nothing in normal -// FLIGHT is this slow, so cruise planes always keep the full grace). -/** @constant {number} Max baro altitude (ft, MSL) for the landed fast cull (~150 m). */ -const LANDED_ALT_MAX_FT = 500; -/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ -const LANDED_SPEED_MAX_MPS = 23; -/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ -const LANDED_MISSING_POLL_LIMIT = 1; -// Field-test fix (RS46, 2026-07-06): only contacts rendering below this -// ellipsoidal height get the below-ground floor clamp + a coarse floor-cell -// warm. Terrain outside the extreme Himalaya tops out well under this, so -// cruise traffic (which can never be below ground) costs zero terrain -// lookups; low pattern/heli work — the class that actually clips hillsides — -// gets the floor. -/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ -const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; -/** @type {Map} icao24 -> consecutive missed polls */ -let _missingPolls = new Map(); - -// --------------------------------------------------------------------------- -// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment -// degenerates when the camera looks straight down (the normal is parallel to -// the view direction and the shader's screen-projected angle is 0/0). Near -// nadir we switch to screen-aligned billboards with -// rotation = camera.heading - track (screen-up points at azimuth -// camera.heading; a CW track t appears t-h from screen-up; billboard rotation -// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations -// are continuous at the boundary with this formula. -// --------------------------------------------------------------------------- - -/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ -const FLEET_DR_INTERVAL_MS = 80; -/** @constant {number} Max ms between rotation passes while the camera is idle. */ -const ROTATION_REFRESH_MS = 1000; -/** @type {number} Epoch ms of the last fleet dead-reckoning pass */ -let _lastFleetTickMs = 0; -/** @type {string} Camera pose signature at the last rotation pass */ -let _lastCamPoseSig = ''; -/** @type {number} Epoch ms of the last full rotation pass */ -let _lastRotPassMs = 0; -/** @type {number} Last computed rotation for the tracked entity (radians) */ -let _lastTrackedRotation = 0; -/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ -let _preRenderRemove = null; -let _trackedModelPreUpdateRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ -let _moveEndRemove = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; - -// -- Scratch variables (reused each frame to avoid GC pressure) -- -const _scratchOffset = new Cesium.Cartesian3(); -const _scratchCarto = new Cesium.Cartographic(); -const _scratchEnu = new Cesium.Matrix4(); -const _scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; -const _scratchRenderTime = new Cesium.JulianDate(); -const _scratchFleetPos = new Cesium.Cartesian3(); -const _scratchDrRaw = new Cesium.Cartesian3(); -const _scratchWarmupTime = new Cesium.JulianDate(); -const _trackedPosHolder = new Cesium.Cartesian3(); - -// -- Per-frame dead-reckoning cache for the tracked entity -- -/** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ -let _cachedDRPosition = null; -/** @type {number} Frame number the cache was last populated for */ -let _cachedDRFrame = -1; - -/** Course (deg) of the position `_deadReckon` most recently returned — set on - * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same - * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ -let _drCourseDeg = null; -/** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion - * `_deadReckon` just returned, and whether that motion is too slow for ANY - * course source to be trusted (hover/GPS drift — consumers HOLD their - * previous display course instead of chasing noise). */ -let _drSpeedMps = null; -let _drCourseHold = false; -/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ -let _cachedDRCourse = null; -/** Frame-cached siblings of _cachedDRCourse (same discipline). */ -let _cachedDRSpeedMps = null; -let _cachedDRHold = false; -/** Wall-clock of the tracked course limiter's last advance (dt source only — - * the course VALUE lives in the shared per-icao _displayCourse map below). */ -let _trackedCourseMs = 0; -/** Per-aircraft smoothed display course — the SINGLE source of truth for the - * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 - * field fix). The fleet pass reads/writes it at tick cadence for untracked - * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for - * the tracked plane (the fleet pass skips the tracked icao, so exactly one - * writer owns an entry at a time). Sharing the entry — including its slew - * state — keeps the tracked↔fleet handoff seamless (separate states froze - * the fleet entry while tracked → nose flip on click / click-away). */ -const _displayCourse = new Map(); -/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary - * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ -const COURSE_MAX_DPS = 60; -/** Never spend a long render stall's full elapsed time in one visible course step. */ -const COURSE_SLEW_DT_MAX_SEC = 0.25; - -// -- Tracked-display reconciliation (see flights.js for the rationale): absorb a raw -// position step (warm-up→interpolation handoff, feed glitch, backfill splice) into a -// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked icon, -// camera, and trail head never visibly jump — with zero steady-state lag. -- -const DR_CORRECTION_MS = 900; -const _drCorrection = new Cesium.Cartesian3(0, 0, 0); -let _drCorrectionStartMs = 0; -const _drPrevRaw = new Cesium.Cartesian3(); -const _drPrevDisplay = new Cesium.Cartesian3(); -let _drPrevMs = 0; -let _drReconcileValid = false; -/** @type {string|null} icao the reconciliation state currently belongs to */ -let _drReconcileIcao = null; - -/** - * Coerce a value to a finite number, returning null if not possible. - * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). - * @param {*} value - Raw value from the API response - * @returns {number|null} Finite number or null - */ -function _toFiniteNumber(value) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const n = Number.parseFloat(value.trim()); - return Number.isFinite(n) ? n : null; - } - return null; -} - -/** - * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. - * @param {*} value - Raw value to stringify - * @returns {string} Trimmed string - */ -function _toCleanText(value) { - return String(value || '').trim(); -} - -/** - * Format an altitude value in feet for display, with fallback text. - * @param {number|null|undefined} altitudeFt - Barometric altitude in feet - * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") - */ -function _formatAltitude(altitudeFt) { - if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; - return `${Math.round(altitudeFt)} ft`; -} - -/** - * True when the aircraft's latest metadata reads "on or about the runway" - * (low + slow) — see the landed fast-cull rationale above. Both gates must - * hold, so a plane missing either datum keeps the normal grace. - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @returns {boolean} - */ -function _likelyLanded(icao24) { - const info = _flightData.get(icao24); - if (!info) return false; - // Round 7 (mirror of flights.js): fast cull only for contacts seen - // AIRBORNE this session — parked contacts ride the normal grace so feed - // flaps don't churn their identity/floor state. - if (info.wasAirborne !== true) return false; - if (info.onGround) return true; - return Number.isFinite(info.altitudeFt) && info.altitudeFt < LANDED_ALT_MAX_FT - && Number.isFinite(info.speedMps) && info.speedMps < LANDED_SPEED_MAX_MPS; -} - -/** - * Build the multi-line presentation text for the protected tracked host card. - * Lines: callsign, type/registration, operator/altitude. - * While the plane is in its missed-poll grace (coasting on dead reckoning - * with sticky metadata) the first line carries a "· STALE" cue — the fleet's - * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity - * owns the visual), so without this the readout would present last-known - * altitude/speed as live. - * @param {Object|null} info - Flight metadata from _flightData - * @param {string} icao24 - ICAO hex identifier (fallback display name) - * @returns {string} Newline-separated label text - */ -function _buildTrackedLabel(info, icao24) { - const stale = (_missingPolls.get(icao24) || _backoff) ? ' · STALE' : ''; - const callsign = (_toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24) + stale; - // Converted contacts report their class as TR-3B — that override is exactly - // what the Easter egg replaces the real type with. - const type = tr3bTypeLabel(icao24, _toCleanText(info?.type) || 'Type unknown'); - const registration = _toCleanText(info?.registration) || 'Reg unknown'; - const operator = _toCleanText(info?.operator) || 'Operator unknown'; - const altitude = _formatAltitude(info?.altitudeFt); - const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; - const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; - return [ - callsign, - `${type} · ${registration}`, - `${operator} · ${tail}`, - ].join('\n'); -} - -/** Write the explicit tracked presentation model and refresh its host entry. */ -function _updateTrackedLabelModel(icao24) { - if (!_trackedEntity || icao24 !== _trackedIcao) return; - _trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(_flightData.get(icao24), icao24), - '#ffd166', - ); - refreshTrackedReadout(_trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); -} - -/** Sprite kind for one contact's billboard. Identity for every aircraft except - * the ones the operator converted into a TR-3B (Easter egg), which draw the - * black-triangle glyph — its thermal-reactive variant while an IR style owns - * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a - * conversion survive the poll reconciler and the two-tier raster swap. */ -const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: _irBoost }); - -/** Re-image the tracked entity's billboard from the current class/conversion. */ -function _syncTrackedBillboardImage() { - if (!_trackedIcao || !_trackedEntity?.billboard) return; - _trackedEntity.billboard.image = aircraftIcon( - _iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), - TRACKED_ICON_PX, - ); -} - -/** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Mirror of flights.js: converting drops any 3D model so the triangle - * owns the visual, then the billboard, tracked entity, and card re-derive. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ -function _refreshTr3bContact(icao24) { - const id = String(icao24 || '').trim().toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - if (_models.has(id) || _modelPending.has(id)) _releaseModel(id); - const modelled = _billboards.get(id); - if (modelled && id !== _trackedIcao) modelled.show = true; // horizon cull re-asserts next tick - if (id === _trackedIcao) { - _releaseTrackedModel(); - _syncTracked2dRotation(); - } - } - const bb = _billboards.get(id); - if (bb) _applyFleetBillboardPresentation(id, bb); - if (id === _trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); - } - _viewer?.scene?.requestRender?.(); - return _billboards.has(id) || id === _trackedIcao; -} - -/** Re-image every converted contact this layer owns (IR style flip). */ -function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (_billboards.has(id) || id === _trackedIcao) _refreshTr3bContact(id); - } -} - -/** - * Seed only the mutable state needed to exercise tracked-card refreshes through - * the production military poll reconciler. Tests still call - * `militaryFlightsLayer.update()` rather than invoking the writer directly. - * @param {object} state - * @param {string} state.icao24 - * @param {object} state.entity - * @param {object} state.meta - * @param {object} state.billboard - * @param {object} state.billboardCollection - * @param {object} state.viewer - * @param {Array} [state.history=[]] - * @param {boolean} [state.tracked=true] - * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed - * by icao24, for the billboard-hidden/model-shown handoff state. - */ -export function _setTrackedMilitaryRefreshStateForTest({ - icao24, - entity, - meta, - billboard, - billboardCollection, - viewer, - history = [], - tracked = true, - models = [], - modelCollection = null, -}) { - _viewer = viewer; - _modelCollection = modelCollection; - _billboardCollection = billboardCollection; - _billboards = new Map([[icao24, billboard]]); - _models.clear(); - for (const [key, model] of models) _models.set(key, model); - _detectionObjects = new Map(); - _flightData = new Map([[icao24, meta]]); - _positionHistory = new Map([[icao24, history]]); - _missingPolls = new Map(); - _displayCourse.clear(); - _geoidNCache.clear(); - _trackedIcao = tracked ? icao24 : null; - _trackedEntity = tracked ? entity : null; - _trackedModel = null; - _cancelPendingTrackingRestore(); - _trackedModelLoading = false; - // NOTE: deliberately does NOT reset the per-selection latches. They are - // production state owned by the tracking lifecycle (_resetTrackedSelectionState), - // and clearing them here would mask exactly the deselect→re-track hole this - // seam is used to test. - _backoff = false; - _retryAt = 0; -} - -/** Seed the authoritative snapshot outcome used by share-Follow tests. */ -export function _setMilitaryTrackingRefreshOutcomeForTest({ - status = 'accepted', - ids = [], - source = 'adsb.lol', -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch, - status, - ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), - source, - }; -} - -/** Add a cached contact so tests can model a target arriving on a later feed. */ -export function _addMilitaryTrackingCandidateForTest({ icao24, meta, billboard, history = [] }) { - _billboards.set(icao24, billboard); - _flightData.set(icao24, meta); - _positionHistory.set(icao24, history); -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingMilitaryTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated feed refresh. */ -export function _applyPendingMilitaryTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Set the exact Cockpit subject through the production state transition for focused tests. */ -export function _setCockpitDetectionSubjectForTest(active, subjectId = null) { - _applyCockpitState({ active, subjectId }); -} - -/** Evaluate the TRACKED contact's zoom regime through the production predicate. - * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) - * and otherwise only observable through a live scene, so tests drive it here. */ -export function _trackedModelRegimeActiveForTest() { - return _trackedModelRegimeActive(); -} - -/** Run one frame of the production tracked-model driver (normally a - * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ -export function _updateTrackedModelForTest() { - return _updateTrackedModel(); -} - -/** Evaluate the production tracked-billboard handoff colour for focused tests. */ -export function _trackedBillboardColorForTest() { - return _modelOwnsVisual(_trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR; -} - -/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ -export function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { - return _driveFleetModelHandoff( - icao24, - _models.get(icao24), - _billboards.get(icao24), - position, - course, - ); -} - -/** Exercise the exact asynchronous fleet loader and return its admitted model. */ -export async function _ensureFleetModelForTest(icao24) { - await _ensureModel(icao24); - return _models.get(icao24) || null; -} - -/** - * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. - * - * Renders one poll interval behind real time so positions interpolate between - * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the - * newest fix is older than the delayed render time (stale position), projects - * forward using the aircraft's ground speed and track through the freshest - * source contact plus a bounded grace window. - * - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. - * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history - */ -function _deadReckon(icao24, result) { - const history = _positionHistory.get(icao24); - const info = _flightData.get(icao24); - if (!history || history.length === 0) { - _drCourseDeg = null; _drSpeedMps = null; _drCourseHold = false; - return null; - } - - const out = result || new Cesium.Cartesian3(); - // Render one poll interval behind real time so we interpolate between - // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchRenderTime - ); - - // Bracketing pair: interpolate — no extrapolation error, no snap-back. - for (let i = history.length - 1; i >= 1; i--) { - const a = history[i - 1]; - const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; - // Course of the DISPLAYED motion. The chord is only trustworthy when the - // segment covers real ground (at hover its direction is GPS jitter; on a - // slow tight turn it STEPS the whole per-segment turn at each boundary), - // so it is blended against the reported per-fix track by displayed - // ground speed — and the track is TIME-INTERPOLATED between the fixes so - // a slow turner's nose advances continuously through the segment instead - // of snapping once per poll. Helicopters always use the reported track - // (rotorcraft chords are noise-dominated at their typical speeds). - const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); - const segSpeed = span > 0 ? chordLenM / span : ((info && info.speedMps) || 0); - const fallbackTrack = (info && info.track) || 0; - const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; - const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; - const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); - const w = (info && info.klass === 'helicopter') ? 0 : speedRamp(segSpeed); - const chordCourse = w > 0 ? courseBetweenCartesians(a.position, b.position) : null; - _drCourseDeg = chordCourse != null ? lerpAngleDeg(trackCourse, chordCourse, w) : trackCourse; - _drSpeedMps = segSpeed; - _drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; - return Cesium.Cartesian3.lerp(a.position, b.position, t, out); - } - } - - const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference(renderTime, newest.time); - if (elapsedSec <= 0) { - // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking - // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing - // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time - // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history - // fills, renderTime advances toward the first fix and the icon glides FORWARD into - // the bracketing interpolation above with NO freeze and NO backward snap. (Holding - // the oldest fix froze the icon until enough history accrued, then jumped.) - const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference(oldest.time, renderTime); // ≥ 0 - return _extrapolateFix(oldest, info, -Math.min(lookbackSec, 60), out, (info && info.turnRateDps) || 0); - } - - // Continue through fresh adsb.lol contacts even when the position epoch has - // not advanced, but keep an absolute stale-feed drift ceiling. - const coastLimitSec = staleCoastLimitSeconds({ - // Captured at poll normalization so the fleet tick does not allocate a - // Date for every aircraft twelve times per second. - fixEpochMs: Number.isFinite(newest.epochMs) - ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), - lastContactEpochMs: info?.lastContactEpochMs, - }); - return _extrapolateFix( - newest, - info, - Math.min(elapsedSec, coastLimitSec), - out, - (info && info.turnRateDps) || 0, - ); -} - -/** - * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a - * constant-rate-turn arc when `turnRateDps` is significant (straight line - * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` - * projects BACKWARD (before the fix) — used for warm-up, estimating where the - * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, - * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets - * `_drCourseDeg` to the arc's instantaneous end course on every path. - * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). - */ -function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { - const speed = Number.isFinite(fix.velocity) ? fix.velocity : ((info && info.speedMps) || 0); - const heading = Number.isFinite(fix.track) ? fix.track : ((info && info.track) || 0); - _drSpeedMps = speed; - _drCourseHold = speed < COURSE_HOLD_SPEED_MPS; - if (speed === 0 || dt === 0) { - _drCourseDeg = heading; - return Cesium.Cartesian3.clone(fix.position, out); - } - // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a - // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. - arcOffsetEnu(speed, heading, turnRateDps, dt, _scratchArc); - _drCourseDeg = _scratchArc.endCourseDeg; - Cesium.Cartesian3.fromElements(_scratchArc.east, _scratchArc.north, 0, _scratchOffset); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(fix.position, Cesium.Ellipsoid.WGS84, _scratchEnu); - return Cesium.Matrix4.multiplyByPoint(enu, _scratchOffset, out); -} - -/** - * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) - * predates its oldest real fix — _deadReckon is extrapolating backward with no real - * history yet behind the displayed icon, so the trail must draw nothing. - * @returns {boolean} - */ -function _isTrackWarmingUp() { - if (!_trackedIcao) return false; - const history = _positionHistory.get(_trackedIcao); - if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); -} - -/** Resolve the selected aircraft's actual rendered square extent this frame. */ -function _trackedFocusSizePx(icao24, position) { - const camera = _viewer?.camera; - const scene = _viewer?.scene; - if (!camera?.positionWC || !position || !scene) return 28; - const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); - if (_modelOwnsVisual(icao24)) { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - const scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM, - viewportHeightPx: scene.canvas.clientHeight, - fovyRad: camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - const focalLengthPx = scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); - const projectedDiameterPx = (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; - return Math.max(TRACKED_MODEL_MIN_PX, Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx)); - } - - const billboard = _trackedEntity?.billboard; - const time = _viewer.clock.currentTime; - const width = billboard?.width?.getValue(time) ?? 28; - const height = billboard?.height?.getValue(time) ?? 28; - const scale = billboard?.scale?.getValue(time) - ?? (BILLBOARD_SCALE * (CLASS_SCALE_2D[_flightData.get(icao24)?.klass] || 1)); - const scaleByDistance = billboard?.scaleByDistance?.getValue(time) - ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; - const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); - return Math.max(width, height) * scale * distanceScale; -} - -/** - * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium - * frame number so the position / rotation / trail-head callbacks share ONE computation - * (and one reconciliation-state update) per frame. Returns a stable module holder, or - * null when the aircraft has no fix. - * @param {string} icao24 - * @returns {Cesium.Cartesian3|null} - */ -function _trackedDisplayPosition(icao24) { - const frame = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frame === _cachedDRFrame && icao24 === _drReconcileIcao) return _cachedDRPosition; - - // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting - // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) - const sameTrack = _drReconcileValid && _drReconcileIcao === icao24; - const raw = _deadReckon(icao24, _scratchDrRaw); - _cachedDRCourse = _drCourseDeg; - _cachedDRSpeedMps = _drSpeedMps; - _cachedDRHold = _drCourseHold; - _cachedDRFrame = frame; - _drReconcileIcao = icao24; - if (!raw) { - _cachedDRPosition = null; - _drReconcileValid = false; - clearFocusTarget('militaryFlights', icao24); - return null; - } - - const nowMs = Date.now(); - const info = _flightData.get(icao24); - if (sameTrack) { - const dtSec = Math.max(0.001, (nowMs - _drPrevMs) / 1000); - const speed = (info && info.speedMps) || 0; - // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. - const plausible = speed * dtSec * 4 + 25; - if (Cesium.Cartesian3.distance(raw, _drPrevRaw) > plausible) { - // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. - Cesium.Cartesian3.subtract(_drPrevDisplay, raw, _drCorrection); - _drCorrectionStartMs = nowMs; - } - } else { - Cesium.Cartesian3.fromElements(0, 0, 0, _drCorrection); - _drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed - } - - const elapsed = nowMs - _drCorrectionStartMs; - const factor = elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; - const display = Cesium.Cartesian3.multiplyByScalar(_drCorrection, factor, _trackedPosHolder); - Cesium.Cartesian3.add(raw, display, display); - - Cesium.Cartesian3.clone(raw, _drPrevRaw); - Cesium.Cartesian3.clone(display, _drPrevDisplay); - _drPrevMs = nowMs; - _drReconcileValid = true; - _cachedDRPosition = display; - const focusSizePx = _trackedFocusSizePx(icao24, _cachedDRPosition); - // Publish the exact frame cache shared by entity + follow camera. A second - // DR sample here would advance reconciliation in a different frame phase - // and visibly jitter the focus rectangle against the tracked aircraft. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'militaryFlights', - id: icao24, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _cachedDRPosition, - widthPx: focusSizePx, - heightPx: focusSizePx, - }); - return display; -} - -/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already - * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at - * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the - * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ -function _trackedDisplayCached() { - return (_drReconcileValid && _drReconcileIcao === _trackedIcao) ? _cachedDRPosition : null; -} - -/** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ -const _trackedVisualPos = new Cesium.Cartesian3(); -const _trackedTrailPos = new Cesium.Cartesian3(); -/** Scratch for the tracked model's world-space origin (the envelope centre). */ -const _scratchTrailClip = new Cesium.Cartesian3(); -/** Scratch for the shortened trail-head start. Owned by the head-segment - * callback alone, so nothing else can overwrite it mid-frame. */ -const _scratchTrailHead = new Cesium.Cartesian3(); - -/** - * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D - * model is actually rendering with when the model owns the visual, otherwise the cached - * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. - * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, - * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its - * follow-camera anti-jitter contract untouched. - */ -function _trackedVisualCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - return modelVisualAnchor( - _trackedModel.modelMatrix, - spec.visualCenterNative, - Number.isFinite(_trackedModel.computedScale) ? _trackedModel.computedScale : spec.scale, - _trackedVisualPos, - ); - } - return _trackedDisplayCached(); -} - -/** The tracked model's rendered bounding radius (m), or 0 when no model of this - * contact is drawing. Carries Cesium's effective `computedScale`, which may be - * above `scale` to satisfy minimumPixelSize — the trail head has to be judged - * against the size the operator SEES, not the nominal one. */ -/** World-space origin of the tracked model, or null when no model of this - * contact is drawing. This is the centre the rendered bounding sphere is - * measured from, so the trail clip and the envelope agree on one frame. */ -function _trackedModelCenterWorld() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return null; - return Cesium.Matrix4.getTranslation(_trackedModel.modelMatrix, _scratchTrailClip); -} - -function _trackedModelEnvelopeM() { - if (!_trackedIcao || !_trackedModel || !_modelOwnsVisual(_trackedIcao)) return 0; - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const scale = Number.isFinite(_trackedModel.computedScale) - ? _trackedModel.computedScale - : spec.scale; - return spec.nativeRadiusM * scale; -} - -/** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on - * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ -function _trackedTrailCached() { - if (_trackedIcao && _modelOwnsVisual(_trackedIcao)) { - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - // Through the model's OWN render chain (modelVisualAnchor's hand-rolled - // half-correction put this offset on the lateral axis — see - // modelAnchorWorld). - return modelAnchorWorld(_trackedModel, spec.trailAnchorNative, _trackedTrailPos); - } - return _trackedDisplayCached(); -} - -/** Smoothed world course for the tracked aircraft this frame. Reads the - * frame-cached course (set by _trackedDisplayPosition — the follow-camera's - * own computation), NEVER re-runs _deadReckon. Safe to call more than once - * per frame: the second call sees dt≈0 and the limiter is a no-op. - * - * The smoothed value lives in the SHARED per-icao _displayCourse entry (see - * its declaration): on click the limiter continues from whatever nose the - * fleet pass was displaying, and on untrack the fleet pass continues from - * whatever nose this path last wrote — tracked and fleet consumers of the - * same aircraft can never disagree across the handoff (mirror of flights.js). */ -function _trackedDisplayCourse() { - const info = _flightData.get(_trackedIcao); - const fallback = (info && info.track) || 0; - const cacheValid = _drReconcileValid && _drReconcileIcao === _trackedIcao && _cachedDRCourse != null; - const raw = cacheValid ? _cachedDRCourse : fallback; - const nowMs = Date.now(); - const dt = _trackedCourseMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _trackedCourseMs) / 1000) - : 0; - _trackedCourseMs = nowMs; - const prev = _displayCourse.get(_trackedIcao); - // Hover hold: at near-zero displayed speed both the chord and the reported - // track are noise — keep the last stable nose direction instead of chasing. - if (cacheValid && _cachedDRHold && prev != null) return prev; - const cap = courseSlewCapDps(cacheValid ? _cachedDRSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS); - const course = limitCourseStep(prev, raw, cap, dt); - _displayCourse.set(_trackedIcao, course); - return course; -} - -/** Reset reconciliation + per-frame cache when tracking stops or switches - * target. Deliberately does NOT touch _displayCourse: the smoothed course - * entry belongs to the AIRCRAFT (not to the tracked session) and must - * survive the handoff back to the fleet pass. */ -function _resetTrackedDisplay() { - _drReconcileValid = false; - _drReconcileIcao = null; - _cachedDRFrame = -1; - _cachedDRPosition = null; - _cachedDRCourse = null; - _cachedDRSpeedMps = null; - _cachedDRHold = false; - _trackedCourseMs = 0; -} - -/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ -function _modelColor(icao24) { - if (icao24 === _trackedIcao) return TRACKED_ICON_COLOR; - return MIL_ICON_COLOR; -} - -/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). Mirror of flights.js. */ -function _modelRegimeActive() { - if (!_models3dEnabled) return false; - const h = _viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; -} - -/** Hysteresis latch for the tracked contact's zoom regime, plus the selection it - * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER - * ceiling instead of inheriting the previous target's looser EXIT band. */ -let _trackedZoomLatched = false; -let _trackedZoomLatchIcao = null; - -/** Bounded on-demand loading for the tracked model (mirror of flights.js). The - * tracked regime is DEFAULT-ON and its driver runs every `scene.preUpdate`, so - * a missing or corrupt GLB — or a dead network — would otherwise spin - * load→reject at frame rate for as long as the contact stays selected. - * Failures are counted PER SELECTION: a short backoff absorbs a transient - * blip, then the layer stops asking until the operator selects something else. - * The billboard is the visual throughout (the handoff only fades it once a - * model actually renders), so a latched failure degrades to exactly the - * pre-3D presentation. */ -const TRACKED_MODEL_MAX_LOAD_FAILS = 3; -const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; -let _trackedModelFailIcao = null; -let _trackedModelFailCount = 0; -let _trackedModelRetryAtMs = 0; - -/** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to this layer) never - * makes `_trackedIcao` *observably* change, so the guard never fires. Without - * the reset, a contact dropped inside the hysteresis band comes back as a - * MODEL above the ENTER ceiling, and a contact whose GLB had already failed - * out would never get its retries back. Mirror of flights.js. */ -function _resetTrackedSelectionState() { - _trackedZoomLatched = false; - _trackedZoomLatchIcao = null; - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; -} - -/** Whether the driver may start another tracked-model load this frame. */ -function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (_trackedModelFailIcao !== _trackedIcao) return true; // untried selection - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) return false; - return nowMs >= _trackedModelRetryAtMs; -} - -/** Record a rejected tracked-model load and arm the backoff / give-up latch. */ -function _noteTrackedModelLoadFailure(url, err) { - if (_trackedModelFailIcao !== _trackedIcao) { - _trackedModelFailIcao = _trackedIcao; - _trackedModelFailCount = 0; - } - _trackedModelFailCount += 1; - _trackedModelRetryAtMs = Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (_trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Military] tracked 3D model gave up after ${_trackedModelFailCount} failed loads of ${url} — ` - + 'this contact stays 2D until another is selected', - err, - ); - } -} - -/** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the - * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET - * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live - * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the - * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km - * ceiling this used to inherit), hand back only above - * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. - * First-person means your own airframe is not drawn in cockpit — the eye sits - * metres from its origin. - */ -function _trackedModelRegimeActive() { - if (_trackedZoomLatchIcao !== _trackedIcao) { - _trackedZoomLatchIcao = _trackedIcao; - _trackedZoomLatched = false; - } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. Mirror of flights.js. - if (!_trackedIcao || _cockpitContactMode || isTr3b(_trackedIcao)) { - _trackedZoomLatched = false; - return false; - } - _trackedZoomLatched = trackedModelZoomActive( - _viewer?.camera?.positionCartographic?.height, - _trackedZoomLatched, - ); - return _trackedZoomLatched; -} - -/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ -function _syncTracked2dRotation() { - if (!_trackedIcao || !_viewer) return; - const pos = _trackedDisplayCached() || _billboards.get(_trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation(_lastTrackedRotation, projected, 0); - if (rotation !== null) _lastTrackedRotation = rotation; -} - -/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the - * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ -function _modelCap() { - const mapCap = _models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. - return _cockpitContactMode ? Math.min(COCKPIT_MODEL_MAX, mapCap) : mapCap; -} - -/** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ -function _modelAddDistM() { - return _models3dMode === 'all' ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; -} -function _modelKeepDistM() { - return _models3dMode === 'all' ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; -} - -/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable - * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on - * the last-written transform, and the per-frame tracked write made it flicker). */ -function _modelMatrix(pos, headingDeg, result = _scratchModelMtx, offsetDeg = MODEL_HEADING_OFFSET_DEG) { - _scratchModelHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg); - _scratchModelHpr.pitch = 0; - _scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, _scratchModelHpr, Cesium.Ellipsoid.WGS84, undefined, result, - ); -} - -/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of - * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its - * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches - * picked-object IDs — every billboard AND model here carries its icao as `id` — plus - * the tracked entity object itself. Built lazily, only when a sample actually fires. */ -function _groundSampleExclusions() { - const out = [..._billboards.keys()]; - if (_trackedEntity) out.push(_trackedEntity); - return out; -} - -/** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). - * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin - * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly - * offset so they sit on their - * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the - * depth-test-free billboard visible and the model hidden while this returns null; a - * contact that has already resolved once holds that measurement through a later - * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ -function _modelDisplayPosition(icao24, pos, result) { - const meta = _flightData.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = _groundSnap.heightFor(_viewer, icao24, pos, _groundSampleExclusions); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchGroundCarto); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Cesium.Ellipsoid.WGS84, result); -} - -/** Atomically hand one fleet contact from its billboard to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the billboard owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ -function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { - if (!model) { - bb.show = true; - return false; - } - const displayPos = _modelDisplayPosition(icao24, pos, _scratchGroundPos); - if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; - return false; - } - // The matrix is written BEFORE the readiness test on purpose: a model can flip - // `ready` during scene update after this tick, and a first rendered frame on a - // stale load-start matrix is the one-frame jump this ordering prevents. - _modelMatrix( - displayPos, - course, - model.modelMatrix, - _modelSpec(_flightData.get(icao24)?.klass).headingOffsetDeg, - ); - if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; - return false; - } - beforeShow?.(); - if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders - return true; -} - -/** A model owns the visual only while it is ready, shown, and (for the tracked - * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ -function _modelOwnsVisual(icao24) { - if (icao24 === _trackedIcao) { - return _trackedModelRegimeActive() && _modelIsRendering(_trackedModel); - } - return _modelIsRendering(_models.get(icao24)); -} - -/** `show` is sufficient evidence of a safe placement because only the handoff and - * the tracked driver ever set it true, and both do so after committing a matrix; - * everything else — admission, unresolved ground, an unready glTF, the limb cull, - * a regime exit — only ever clears it. Mirror of flights.js. */ -function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; -} - -/** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ -const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; -}; -/** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the - * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ -function _syncModelToClass(icao24) { - const key = _specKeyFor(_flightData.get(icao24)?.klass); - const current = _models.get(icao24); - if ((current && current._gevSpecKey !== key) || (!current && _modelPending.has(icao24))) { - const bb = _billboards.get(icao24); - if (bb && icao24 !== _trackedIcao) bb.show = true; - _releaseModel(icao24); - } - if (icao24 === _trackedIcao && _trackedModel && _trackedModel._gevSpecKey !== key) { - _releaseTrackedModel(); - } -} - -/** IR hot-target mode — mirror of flights.js: under the luminance-mapped - * NVG/FLIR post-styles every model renders flat white (hottest); the dominant - * amber/tracked tint restores on style exit. */ -let _irBoost = false; -/** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR - * field rounds): material-stage tint still sun-shades, so side-on planes - * read near-black under a high sun; UNLIT emits flat white regardless. - * customShader on a READY model is a silent no-op (field-verified), so the - * boost flips by release-and-reload — creation-time options carry it. */ -const _IR_UNLIT_SHADER = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT }); -/** Boost flip = BATCHED release through the fleet tick (mirror of flights.js — - * destroying the whole fleet synchronously in the style handler stalls the - * render thread). Models tagged with their load-time boost state; stale queue - * entries skip; in-flight loads invalidated immediately; tracked reloads - * synchronously (single primitive). */ -const IR_RELOAD_BATCH = 40; -let _irReloadQueue = null; -function _reloadModelsForIrBoost() { - _irReloadQueue = [..._models.keys()]; - for (const icao of _modelPending) { - if (!_models.has(icao)) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - } - _releaseTrackedModel(); -} -function _drainIrReloadQueue() { - if (!_irReloadQueue) return; - const batch = _irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = _models.get(icao); - if (!model || model._gevIrBoost === _irBoost) continue; // already right state - const bb = _billboards.get(icao); - if (bb && icao !== _trackedIcao) bb.show = true; - _releaseModel(icao); - } - if (_irReloadQueue.length === 0) _irReloadQueue = null; -} - -/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ -async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === _trackedIcao) return; - if (_models.has(icao24) || _modelPending.has(icao24)) return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). - if ((_models.size + _modelPending.size) >= _modelCap()) return; - const epoch = _modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = _modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - _modelPending.add(icao24); - let model = null; - // Spec identity captured at load START (mirror of flights.js): a mid-load - // reclassification makes the post-await admission reject the stale asset. - // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(_flightData.get(icao24)?.klass); - const loadIrBoost = _irBoost; - try { - const spec = _modelSpec(_flightData.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: spec.url, - asynchronous: false, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: _irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === _modelEpoch) { _modelPending.delete(icao24); _cleanupModelGen(icao24); } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== _modelEpoch) { try { model.destroy(); } catch { /* gone */ } return; } - _modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = (_modelGen.get(icao24) || 0) !== gen - || !_modelRegimeActive() || !_modelCollection || _modelCollection.isDestroyed() - || !_flightData.has(icao24) || icao24 === _trackedIcao - || _models.has(icao24) || _models.size >= _modelCap() - // Class reclassified mid-load → this GLB/scale is for the OLD class. - || _specKeyFor(_flightData.get(icao24)?.klass) !== specKey - // IR boost flipped mid-load → this model baked the wrong shader/tint. - || _irBoost !== loadIrBoost; - if (stale) { - try { model.destroy(); } catch { /* already gone */ } - _cleanupModelGen(icao24); // bound the map - return; - } - // Keep the pick identity explicit on the resolved primitive. This also - // protects injected/custom loaders that do not copy the creation option. - model.id = icao24; - model._gevSpecKey = specKey; // class-change sync compares against this - model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this - // Admitted, not yet the visual. Cesium's default is show=true, which would let - // an unplaced primitive claim ownership from the billboard for the frames - // between admission and the next fleet tick (and draw at the identity matrix, - // i.e. the Earth's centre). The handoff turns it on once it has a matrix. - model.show = false; - _modelCollection.add(model); - _models.set(icao24, model); - _planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard -} - -/** Remove the 3D model for ONE aircraft (removal / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ -function _releaseModel(icao24) { - const m = _models.get(icao24); - const pending = _modelPending.has(icao24); - // Only bump the generation when there's something to invalidate (an in-flight load or a - // live model) — so removing never-modeled aircraft doesn't grow _modelGen. - if (m || pending) { - _modelGen.set(icao24, (_modelGen.get(icao24) || 0) + 1); - } - if (m) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - _models.delete(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); -} - -/** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ -function _cleanupModelGen(icao24) { - if (!_modelPending.has(icao24) && !_models.has(icao24)) _modelGen.delete(icao24); -} - -/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ -function _releaseModels() { - _irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of _modelPending) _modelGen.set(icao, (_modelGen.get(icao) || 0) + 1); - if (_modelCollection && !_modelCollection.isDestroyed()) { - for (const m of _models.values()) { try { _modelCollection.remove(m); } catch { /* gone */ } } - } - _models.clear(); -} - -/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ -function _releaseTrackedModel() { - _trackedModelGen++; - _trackedModelLoading = false; - if (_trackedModel) { - if (_modelCollection && !_modelCollection.isDestroyed()) { try { _modelCollection.remove(_trackedModel); } catch { /* gone */ } } - _trackedModel = null; - } -} - -/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The - * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ -function _updateTrackedModel() { - const active = _trackedIcao && _trackedModelRegimeActive() - && _modelCollection && !_modelCollection.isDestroyed(); - if (!active) { if (_trackedModel) _trackedModel.show = false; return; } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = _trackedDisplayPosition(_trackedIcao) || _billboards.get(_trackedIcao)?.position; - if (!pos) { if (_trackedModel) _trackedModel.show = false; return; } - if (!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed()) { - _trackedModelLoading = true; - const gen = _trackedModelGen; - const trackedSpec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - const trackedKey = _specKeyFor(_flightData.get(_trackedIcao)?.klass); - const trackedIrBoost = _irBoost; - Cesium.Model.fromGltfAsync({ - url: trackedSpec.url, - asynchronous: false, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: _irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: _irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: _irBoost ? _IR_UNLIT_SHADER : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: _trackedIcao, - }).then((m) => { - if (gen !== _trackedModelGen || !_modelCollection || _modelCollection.isDestroyed()) { try { m.destroy(); } catch { /* gone */ } return; } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if (_specKeyFor(_flightData.get(_trackedIcao)?.klass) !== trackedKey || _irBoost !== trackedIrBoost) { - try { m.destroy(); } catch { /* gone */ } - _trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = _trackedIcao; - m._gevSpecKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed - // Seed the world transform before the primitive enters the scene. A model - // can become ready+shown between render phases; leaving Cesium's identity - // default here produces a one-frame jump to the Earth's center. - const currentPos = _trackedDisplayCached() - || _billboards.get(_trackedIcao)?.position; - if (currentPos) { - const displayPos = _modelDisplayPosition(_trackedIcao, currentPos, _scratchGroundPos); - if (displayPos) { - _modelMatrix(displayPos, _trackedDisplayCourse(), m.modelMatrix, trackedSpec.headingOffsetDeg); - } - } - _trackedModel = m; - _trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - _trackedModelFailIcao = null; - _trackedModelFailCount = 0; - _trackedModelRetryAtMs = 0; - _modelCollection.add(m); - _planeModelLoaded = true; - }).catch((err) => { - if (gen !== _trackedModelGen) return; // superseded load — not this selection's failure - _trackedModelLoading = false; - _noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; - } - if (_trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition(_trackedIcao, pos, _scratchGroundPos); - if (!displayPos) { - _trackedModel.show = false; // no ground evidence → the billboard carries it - return; - } - const spec = _modelSpec(_flightData.get(_trackedIcao)?.klass); - _modelMatrix(displayPos, _trackedDisplayCourse(), _trackedModel.modelMatrix, spec.headingOffsetDeg); - if (!_trackedModel.ready) return; - _trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance(_viewer.camera.positionWC, displayPos), - viewportHeightPx: _viewer.scene.canvas.clientHeight, - fovyRad: _viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - _trackedModel.show = true; - } -} - -/** - * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, - * horizon-culls billboards beyond the limb (no far-side depth with the globe - * hidden), and refreshes screen-projected icon rotations whenever the camera - * pose changed (plus a 1s drift catch-up while idle). Driven by - * scene.preRender — NOT camera.changed, whose granularity is globally - * degraded by other layers mutating camera.percentageChanged. - * @returns {void} - */ -function _fleetTick() { - if (!_viewer || !_billboardCollection || !_billboardCollection.show) return; - const scene = _viewer.scene; - const camera = _viewer.camera; - const nowMs = focusNowMs(Date.now()); - - // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz - // primitive rebuild here. The body rebuilds only when a real fix arrives.) - - if ((nowMs - _lastFleetTickMs) < FLEET_DR_INTERVAL_MS) return; - const tickDtSec = _lastFleetTickMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - _lastFleetTickMs) / 1000) - : 0.08; - _lastFleetTickMs = nowMs; - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (_cockpitContactMode) _refreshCockpitNearContacts(); - const poseSig = cameraPoseSignature(camera); - // Only nearby Cockpit silhouettes need projected course; far dots remain - // rotation-free through the per-contact gate below. - const doRotations = (poseSig !== _lastCamPoseSig || (nowMs - _lastRotPassMs) >= ROTATION_REFRESH_MS); - if (doRotations) { - _lastCamPoseSig = poseSig; - _lastRotPassMs = nowMs; - } - - const occluder = horizonOccluder(camera); - const focusTarget = getFocusTarget(); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if (!useModels && (_models.size || _modelPending.size)) _releaseModels(); - - // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — - // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen - // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP - // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). - let modelEligible = null; - if (useModels) { - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC); - const cand = []; - for (const [icao, bb] of _billboards) { - if (icao === _trackedIcao) continue; - // A converted TR-3B renders as a billboard and can never take a model, so - // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded - // at selection time, not just at the handoff below. - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded - // placement is handled by the one-shot ground snap in _modelDisplayPosition). - const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); - if (d2 > keepDistSq) continue; // beyond keep radius → never eligible - Cesium.Cartesian3.clone(bb.position, _scratchModelBS.center); - cand.push([icao, d2, cull.computeVisibility(_scratchModelBS) !== Cesium.Intersect.OUTSIDE]); - } - cand.sort((a, b) => a[1] - b[1]); - if (cand.length > cap && (nowMs - _lastModelCapWarnMs) > 5000) { - console.warn(`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${_models3dMode}). On-screen prioritized.`); - _lastModelCapWarnMs = nowMs; - } - modelEligible = new Set(); - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (inF && _models.has(icao)) modelEligible.add(icao); } // 1. KEEP on-screen - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 2. ADD on-screen - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && _models.has(icao)) modelEligible.add(icao); } // 3. KEEP off-screen (can't starve visible) - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 4. ADD off-screen leftover - const toRelease = []; - for (const icao of _models.keys()) { - if (icao !== _trackedIcao && !modelEligible.has(icao)) toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); - } - - for (const [icao24, bb] of _billboards) { - if (icao24 === _trackedIcao) continue; // tracked entity owns its own motion - - const dr = _deadReckon(icao24, _scratchFleetPos); - // Gate the write — assigning Billboard.position dirties the whole - // collection's vertex buffer, so skip sub-meter moves. - if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { - bb.position = dr; - } - - // Round 6: occlusion-test a LIFTED point for contacts at/below the - // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb - // read "beyond the horizon" and would hide low contacts awaiting floors). - const beyondHorizon = !occluder.isPointVisible(_flightData.get(icao24)?.cullPosition || bb.position); - // A billboard flipping INTO view (horizon reveal while the camera idles) - // gets its rotation refreshed THIS tick even without a pose change — - // otherwise it reappears wearing its stale (often creation-north) nose for - // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false - // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; - if (beyondHorizon) { - // Also hide any 3D model — otherwise a model that crossed the limb would keep - // rendering through the hidden globe at its last matrix. - const m = _models.get(icao24); - if (m && m.show) m.show = false; - continue; - } - - const info = _flightData.get(icao24); - - // One sprite-owned write site composes freshness × focus × limb haze and - // base class/ground scale × limb taper. The locked NearFarScalar remains - // untouched and multiplicative; there is no cull or zero-alpha path. - const cameraDistanceM = Cesium.Cartesian3.distance(camera.positionWC, bb.position); - const distanceScale = nearFarScalarValueAtDistance(bb.scaleByDistance, cameraDistanceM); - const focus = advanceProjectedSpriteFocus( - bb, - bb.position, - scene, - camera, - nowMs, - focusTarget, - undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, - ); - const isCockpitNear = _cockpitContactMode && _cockpitNearContacts.has(icao24); - const treatment = applyAircraftBillboardTreatment({ - billboard: bb, - baseScale: _cockpitContactMode && !isCockpitNear ? 1 : _militaryBillboardScale(icao24), - baseAlpha: _missingPolls.get(icao24) ? 0.45 : 1, - baseColor: MIL_ICON_COLOR, - focusFactor: focus.factor, - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }); - _billboardLimbScale.set(bb, treatment.factors.scale); - // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on - // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so - // focus/limb recession counts) with hysteresis (atlas has no mips). - if (!_cockpitContactMode || isCockpitNear) { - const glyphDevPx = (bb.width || 20) * (bb.scale || 1) - * distanceScale * (globalThis.devicePixelRatio || 1); - const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; - if (wantLarge !== !!bb._gevIconLarge) { - bb._gevIconLarge = wantLarge; - bb.image = aircraftIcon(_iconKind(icao24, _flightData.get(icao24)?.klass), wantLarge ? TRACKED_ICON_PX : undefined); - } - } - - // Smoothed display course: the path direction _deadReckon just reported - // for THIS aircraft (nothing else calls _deadReckon in between), rate- - // limited so segment-boundary course steps glide instead of snapping. - // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering - // aircraft (hold flag) keeps its previous nose direction outright. - const rawCourse = _drCourseDeg != null ? _drCourseDeg : ((info && info.track) || 0); - const prevCourse = _displayCourse.get(icao24); - const course = (_drCourseHold && prevCourse != null) - ? prevCourse - : limitCourseStep( - prevCourse, rawCourse, - courseSlewCapDps(_drSpeedMps != null ? _drSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS), - tickDtSec, - ); - _displayCourse.set(icao24, course); - - // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the - // billboard stays shown until the model is actually READY to render, so a plane is never both - // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every - // tick regardless so it's framed the instant it becomes ready. - // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter - // egg IS the triangle and there is no GLB for it, so the model handoff is - // suppressed. The billboard keeps rendering, so the contact still satisfies - // the getNearby/getDetectableObjects visibility guards. - if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { - _ensureModel(icao24); - const model = _models.get(icao24); - const ownsVisual = _driveFleetModelHandoff( - icao24, - model, - bb, - dr, - course, - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write; boosted - // models also skip the recession fade (mirror of flights.js — - // billboards keep their normal fade, hot MODELS stay full-strength). - baseColor: _irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - alpha: _irBoost ? 1 : treatment.alpha, - }); - }, - ); - if (ownsVisual) continue; // skip billboard rotation - } - - if ((!_cockpitContactMode || isCockpitNear) && (doRotations || revealed)) { - const rot = screenProjectedRotation(scene, bb.position, course, bb.rotation); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; - } - } - } -} - -/** - * Build a public descriptor for one aircraft using its best current position - * (dead-reckoned when history exists, billboard position otherwise). - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Descriptor with a cloned position, or null if the aircraft is unknown. - */ -function _describeFlight(icao24) { - const info = _flightData.get(icao24); - const bb = _billboards.get(icao24); - const basePos = _deadReckon(icao24) || (bb ? bb.position : null); - if (!basePos) return null; - const displayed = displayedKinematics({ - derivedSpeedMps: _drSpeedMps, - derivedTrackDeg: _drCourseDeg, - reportedSpeedMps: info?.speedMps, - reportedTrackDeg: info?.track, - }); - const carto = Cesium.Cartographic.fromCartesian(basePos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) return null; - return { - icao24, - callsign: _toCleanText(info?.callsign) || null, - // Additive: the label chain's middle link, trimmed like `callsign`, so - // getTrackedSubject and the voice narration can read it straight off the - // descriptor instead of reaching back into `_flightData`. - registration: _toCleanText(info?.registration) || null, - position: Cesium.Cartesian3.clone(basePos), - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - // Keep the cockpit readout on the reported aviation altitude. Render - // terrain height is a separate visual datum and may be below zero. - altitudeM: Number.isFinite(info?.altitudeM) ? info.altitudeM : carto.height, - renderAltitudeM: Number.isFinite(info?.renderAltitudeM) ? info.renderAltitudeM : carto.height, - onGround: info?.onGround === true, - velocityMps: displayed.speedMps, - track: displayed.trackDeg, - stale: Boolean(_missingPolls.get(icao24) || _backoff), - }; -} - -/** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ -const _scratchTrailCarto = new Cesium.Cartographic(); - -/** - * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm - * coarse ground cell — a pure LIFT (above-floor positions pass through - * untouched, unknown floors change nothing). Grounded military billboards - * deliberately render at the pre-datum default height (T7 groundSnap - * invariant), so every position entering the TRAIL subsystem (seed, append, - * per-frame head) goes through this instead — the visible taxi history sits - * on the surface without touching the billboard/model machinery. - * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). - * @returns {Cesium.Cartesian3} The same or a lifted position. - */ -function _trailFloorPosition(position) { - const carto = Cesium.Cartographic.fromCartesian(position, Cesium.Ellipsoid.WGS84, _scratchTrailCarto); - if (!carto) return position; - const latDeg = Cesium.Math.toDegrees(carto.latitude); - const lonDeg = Cesium.Math.toDegrees(carto.longitude); - const floored = floorAltitudeM(carto.height, cachedGroundFloor(latDeg, lonDeg)); - if (floored == null || floored === carto.height) return position; - return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); -} - -/** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {Cesium.Cartesian3} position - New fix position, appended at the head. - */ -function _appendTrailFix(position) { - _trailPositions.push(_trailFloorPosition(position)); - if (_trailPositions.length > TRAIL_MAX_POINTS) _trailPositions.shift(); - _refreshTrailDisplay(); -} - -/** - * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That - * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. - * The cheap per-frame _trailHeadEntity segment bridges the last body point to the - * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll - * cadence), never at motion cadence. - */ -function _refreshTrailDisplay() { - if (!_trail) return; - _trail.setPositions(_trailPositions.length > 1 ? _trailPositions.slice(0, -1) : _trailPositions); -} - -/** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an adsb.lol trace backfill (~24 h of real history). - * @param {string} icao24 - ICAO hex identifier being tracked. - */ -function _startTrail(icao24) { - _trailBackfillToken += 1; - _trailPositions = []; - const history = _positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join via _appendTrailFix as they age. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, _scratchWarmupTime - ); - for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { - // Floor grounded-history seeds (round 2): a grounded contact's stored - // fixes carry the pre-datum default height — the trail copy sits on - // the surface instead (pure lift; airborne fixes pass through). - _trailPositions.push(_trailFloorPosition(Cesium.Cartesian3.clone(fix.position))); - } - } - if (!_trail && _viewer) { - _trail = createTrail(_viewer, { color: TRAIL_COLOR, width: 2.5 }); - } - _trail?.setVisible(!_cockpitContactMode); - // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated - // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, - // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued - // to the 12 Hz icon instead of lagging ~1 s behind it. - if (!_trailHeadEntity && _viewer) { - _trailHeadEntity = _viewer.entities.add({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick - // owner so a click on the head segment never reads as empty space. - id: `gev-trail:mil-head-${++_trailHeadSeq}`, - show: !_cockpitContactMode, - polyline: { - positions: new Cesium.CallbackProperty(() => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the - // segment would draw IN FRONT of the plane. Likewise during warm-up the icon - // predates all real history, so there is no valid body point behind it. - if (!_trackedIcao || _trailPositions.length < 2 || _isTrackWarmingUp()) return []; - const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = _trailPositions[_trailPositions.length - 2]; - // Floor the head too (round 2): a grounded tracked contact's DR - // display height is the pre-datum default — without this the last - // segment dives underground while taxiing. - const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. Measured against - // the FLOORED endpoint, which is the one actually drawn. - // See trailHeadStart. - const from = trailHeadStart( - start, end, _trackedModelCenterWorld(), _trackedModelEnvelopeM(), _scratchTrailHead, - ); - if (!from) return []; - return [from, end]; - }, false), - width: 2.5, - material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads). - depthFailMaterial: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) - }, - }); - } - _refreshTrailDisplay(); - - const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 - : Infinity; - _backfillTrail(icao24, _trailBackfillToken, oldestFixEpochSec); -} - -/** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are - * stride-thinned to the remaining vertex budget, then spliced strictly older - * than the oldest seeded fix AHEAD of the locally accumulated fine segment, - * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ - * malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO hex identifier being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ -async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let baseEpochSec = null; - let trace = null; - try { - const response = await fetch('/api/adsblol/trace?hex=' + encodeURIComponent(icao24), { - signal: AbortSignal.timeout(8000), - }); - if (!response.ok) return; - const data = await response.json(); - baseEpochSec = Number(data?.timestamp); - trace = Array.isArray(data?.trace) ? data.trace : null; - } catch { - return; // silent fallback to the accumulated trail - } - if (!trace || !Number.isFinite(baseEpochSec)) return; - if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a - // readsb trace point's altitude is barometric feet (same datum as the live - // alt_baro, NOT geometric), so a trail waypoint's render height is the same - // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) - // — geometrically approximate, not exact. - await ensureGeoidReady(); - - // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] - const parsed = []; - for (const point of trace) { - if (!Array.isArray(point)) continue; - const t = baseEpochSec + Number(point[0]); - const lat = Number(point[1]); - const lon = Number(point[2]); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, altFt: point[3] }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse - // ellipsoidal ground along the trace and clamp every waypoint at it. A - // 'ground'/null point sits ON the local surface — the old fixed 50 m - // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m - // ellipsoidal) and dragged the whole pattern-work loop with it. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return; - - let older = []; - let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet - for (const { lat, lon, altFt } of parsed) { - const baroM = (altFt === 'ground' || altFt == null || !Number.isFinite(Number(altFt))) - ? null - : Number(altFt) * 0.3048 + geoidHeight(lat, lon); - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // Ground/no-alt point with an unresolved floor: hold the previous - // waypoint's altitude (continuity — never a dive to a made-up depth). - // Leading points with nothing to carry keep the old low breadcrumb - // sentinel (an arbitrary placeholder, never a reported altitude). - if (altM == null) altM = lastAltM != null ? lastAltM : 50; - lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); - } - - // Stride-thin the backfill so it plus the live fine segment fit the cap. - const budget = Math.max(1, TRAIL_MAX_POINTS - _trailPositions.length); - if (older.length > budget) { - const stride = Math.ceil(older.length / budget); - const thinned = []; - for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); - older = thinned; - } - - _trailPositions = older.concat(_trailPositions); - if (_trailPositions.length > TRAIL_MAX_POINTS) { - _trailPositions = _trailPositions.slice(_trailPositions.length - TRAIL_MAX_POINTS); - } - _refreshTrailDisplay(); -} - -/** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ -function _clearTrail() { - _trailBackfillToken += 1; - _trailPositions = []; - if (_trail) _trail.clear(); - if (_trailHeadEntity && _viewer && !_viewer.isDestroyed()) { - try { _viewer.entities.remove(_trailHeadEntity); } catch { /* already gone */ } - } - _trailHeadEntity = null; -} - -/** - * Destroy the trail primitive entirely (layer disable/teardown). - */ -function _destroyTrail() { - _clearTrail(); - if (_trail) { - _trail.destroy(); - _trail = null; - } -} - -/** - * Stop tracking the current aircraft and clean up all tracking state. - * Restores the original billboard, removes the tracked Entity, invalidates - * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo - * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back - * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms - * way up and loses my context"). The camera stays at its current - * position/orientation, immediately free to orbit/zoom. Applies to every - * deselect path: click-empty-space, Escape, aged-out plane, layer disable, - * and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ -function _clearTracking(skipViewerUntrack = false, { - evicted = false, - origin = 'programmatic', -} = {}) { - _trackedCameraFrameStop?.(); - _trackedCameraFrameStop = null; - if (!_trackedIcao) { - clearFocusTarget('militaryFlights'); - return; - } - const clearedIcao = _trackedIcao; - clearFocusTarget('militaryFlights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (_billboards.has(_trackedIcao)) { - const bb = _billboards.get(_trackedIcao); - const meta = _flightData.get(_trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; tint is full amber on the ground and in the air). - bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; - bb.rotation = _lastTrackedRotation; - } - _lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the - // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). - // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in - // place, so the view stays where the follow left it and the user can immediately orbit/zoom. - if (_viewer && !skipViewerUntrack) { - _viewer.trackedEntity = undefined; - } - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - _trackedIcao = null; - _applyFleetBillboardPresentation(clearedIcao, _billboards.get(clearedIcao)); - clearTrackedSubjectContext('military'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'military', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache so a same-frame re-track cannot read - // the previous aircraft's cached position. - _resetTrackedDisplay(); - _clearTrail(); -} - -function _normalizeTrackedIcao(candidate) { - const normalized = String(candidate ?? '').trim().toLowerCase(); - return normalized || null; -} - -function _isUsableMilitaryAircraft(aircraft) { - if (!aircraft || Array.isArray(aircraft) || typeof aircraft !== 'object') return false; - if (typeof aircraft.hex !== 'string' || !_normalizeTrackedIcao(aircraft.hex)) return false; - return Number.isFinite(_toFiniteNumber(aircraft.lon)) - && Number.isFinite(_toFiniteNumber(aircraft.lat)); -} - -function _applyPendingTrackingRestore() { - const pending = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration) return false; - if (!_billboardCollection?.show || !_billboards.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _pendingTrackingRestore = null; -} - -/** - * Begin tracking a specific aircraft. Clears any previous tracking, hides the - * billboard, creates a new Entity with dead-reckoning CallbackProperties for - * smooth continuous motion, and sets the viewer's trackedEntity so the camera - * follows. - * @param {string} icao24 - ICAO hex identifier of the aircraft to track - */ -function _trackFlight(icao24, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = _billboards.get(icao24); - const info = _flightData.get(icao24); - if (!bb || !info) return; - - _trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - _cachedDRFrame = -1; - _lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - _releaseModel(icao24); - - // Hide the billboard -- the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/rotation/trail-head callbacks, with - // discontinuity reconciliation). Falls back to the last billboard position when the - // aircraft has no fix. - const getTrackedPosition = () => _trackedDisplayPosition(icao24) || bb.position; - - // Dead-reckoning position property evaluated every render frame. - // isConstant = false so CesiumJS re-evaluates each frame. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - - // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D - // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is - // what makes the follow-camera's bounding sphere always ready (see _trackedModel). - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - // Keep Cesium's EntityView in the same ENU frame as the close-range - // camera guard; AUTO may otherwise alternate with a velocity frame. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon(_iconKind(_trackedIcao, _flightData.get(_trackedIcao)?.klass), TRACKED_ICON_PX), - width: 28, - height: 28, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[_flightData.get(_trackedIcao)?.klass] || 1), - // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: new Cesium.CallbackProperty(() => ( - _modelOwnsVisual(_trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR - ), false), - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the amber icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: new Cesium.CallbackProperty(() => { - const tracked = _flightData.get(_trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !_viewer) return _lastTrackedRotation; - const projected = screenProjectedRotation( - _viewer.scene, - pos, - _trackedDisplayCourse(), - _lastTrackedRotation, - ); - const rot = stabilizeScreenRotation(_lastTrackedRotation, projected); - if (rot !== null) _lastTrackedRotation = rot; - return _lastTrackedRotation; - }, false), - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `military:${icao24}`; - _trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(info, icao24), - '#ffd166', - ); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; - const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - _trackedEntity.viewFrom = new Cesium.Cartesian3(0, -followRange * 0.8, followRange * 0.55); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked - // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires - // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. - // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a - // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). - _trackedEntity.gevDisplayPosition = _trackedDisplayCached; - // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the - // follow-camera anti-jitter contract; presentation that must weld to the aircraft you - // can see reads `gevVisualPosition`. - _trackedEntity.gevVisualPosition = _trackedVisualCached; - refreshTrackedReadout(_trackedEntity); - _viewer.camera.cancelFlight(); - // Camera follows the tracked entity - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - - // Track-history trail (PRD F2): seed from local history + async backfill. - // Ground traffic draws NO trail — if the plane takes off while tracked, the - // ground→air transition in update() starts one. - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Trail positions are floored - // at the surface (_trailFloorPosition), so ground legs drape, never dive. - _startTrail(icao24); - - const callsign = _toCleanText(info.callsign) || _toCleanText(info.registration) || icao24; - _publishTrackedSelection(icao24, origin); - console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); -} - -/** - * Map one military aircraft's internal poll record to a plain JSON-safe - * analyst record (analyst query engine seam) — same shape as the flights - * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium - * types, no fetches. Missing/unknown fields are null, never NaN/undefined. - * adsb.lol carries no origin-country field and this layer has no route - * enrichment, so originCountry/routeOrigin/routeDestination are always null. - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @param {Object|null|undefined} info - `_flightData` record for this aircraft. - * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, - * lon: number|null, altitudeM: number|null, speedMps: number|null, - * heading: number|null, verticalRateMps: number|null, onGround: boolean, - * military: boolean, aircraftClass: string|null, originCountry: null, - * operator: string|null, routeOrigin: null, routeDestination: null}} - */ -export function mapAnalystRecord(icao24, info) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const callsign = text(info?.callsign); - return { - id: callsign || text(info?.registration) || icao24, - icao24, - callsign, - lat: num(info?.rawLat), - lon: num(info?.rawLon), - // altitudeFt is the sticky barometric/MSL aviation field — converted to - // meters here for shape parity with the flights layer. - altitudeM: Number.isFinite(info?.altitudeFt) ? info.altitudeFt * 0.3048 : null, - speedMps: num(info?.speedMps), - heading: num(info?.track), - verticalRateMps: num(info?.verticalRateMps), - onGround: info?.onGround === true, - military: true, - // A converted contact reports the class it RENDERS as (mirror of - // flights.js), so an analyst filter/superlative agrees with the triangle. - aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), - originCountry: null, - operator: text(info?.operator), - routeOrigin: null, - routeDestination: null, - }; -} - -/** - * Layer descriptor object for the military flights data layer. - * Conforms to the layer manager contract: init, enable, disable, update, destroy, - * plus optional getNearby, getDetectableObjects, and getStats accessors. - * @type {Object} - */ -const militaryFlightsLayer = { - id: 'military', - name: 'Military Flights', - icon: '🎖️', - source: 'adsb.lol', - /** @type {number} Polling interval in ms between API fetches */ - updateInterval: 15000, - - /** - * Initialize the layer: create the billboard collection, reset all state, - * and install the click-to-track handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - init(viewer) { - clearFocusTarget('militaryFlights'); - _viewer = viewer; - _billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(_billboardCollection); - registerSpriteCollection('military', _billboardCollection); - _modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(_modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!_preloadModel) { - const epoch = _modelEpoch; - Cesium.Model.fromGltfAsync({ url: JET_MODEL_URL, asynchronous: false }) - .then((m) => { - if (epoch === _modelEpoch) { _preloadModel = m; _planeModelLoaded = true; } - else { try { m.destroy(); } catch { /* gone */ } } - }) - .catch(() => { /* tracked plane just stays a billboard a beat longer */ }); - } - _billboards = new Map(); - _detectionObjects = new Map(); - _flightData = new Map(); - _positionHistory = new Map(); - _displayCourse.clear(); - _groundSnap.clear(); - _count = 0; - _lastUpdate = null; - _backoff = false; - _retryAt = 0; - _lastError = null; - _lastStatus = null; - _trackedIcao = null; - _resetTrackedSelectionState(); - _trackedEntity = null; - _cockpitSubjectId = null; - _cockpitContactMode = document.body.classList.contains('cockpit-mode'); - _cockpitNearContacts = new Set(); - if (!_cockpitModeListener) { - _cockpitModeListener = (event) => _applyCockpitState(event?.detail); - window.addEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - } - - _installClickHandler(viewer); - - restoreSpriteOrder(viewer); - - console.log('[Data:Military] Initialized with billboard icons'); +import { createMilitaryFlightLayer } from '../layers/military/index.js'; +import { createAdsbLolSource } from '../sources/live/standalone.js'; +import * as picking from './pickRegistry.js'; +import * as sprites from './spriteOrder.js'; +import * as trails from './trailRenderer.js'; +import * as aircraftPresentation from './tr3bRegistry.js'; +import * as camera from './trackedCamera.js'; +import * as militaryRegistry from './militaryRegistry.js'; +import * as labels from './detectionDraw.js'; +import * as groundFloor from './groundFloor.js'; +import * as meshFloor from './meshFloorSampler.js'; +import * as geoid from './geoid.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as groundSnap from './groundSnap.js'; +import * as recession from './aircraftRecession.js'; + +const militaryFlightsLayer = createMilitaryFlightLayer({ + source: createAdsbLolSource(), + resolveAsset: (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, }, - - /** - * Show the layer and re-install the click handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - enable(viewer) { - if (_billboardCollection) _billboardCollection.show = true; - holdContinuousRender('military'); // per-frame animator (perf wave 2) - if (_modelCollection) _modelCollection.show = true; - _setCockpitContactMode(document.body.classList.contains('cockpit-mode')); - // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop - // only reads geoidHeight() synchronously after this resolves (guarded by - // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. - if (!_geoidReady) { - ensureGeoidReady() - .then(() => { _geoidReady = true; }) - .catch(() => { /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); - } - _installClickHandler(viewer); - registerPickOwner('military', (pickedId) => _billboards.has(pickedId)); - // The flights layer suppresses its military duplicates while we render them - setMilitaryLayerActive(true); - // Force a fresh rotation pass on the first tick after re-enable - _lastCamPoseSig = ''; - if (!_preRenderRemove && viewer?.scene) { - _preRenderRemove = viewer.scene.preRender.addEventListener(_fleetTick); - } - if (!_trackedModelPreUpdateRemove && viewer?.scene) { - _trackedModelPreUpdateRemove = viewer.scene.preUpdate.addEventListener(_updateTrackedModel); - } - if (!_moveEndRemove && viewer?.camera) { - // Arrival polish (mirror of flights.js — see the comment there): a settled - // camera move forces a full rotation pass on the very next frame, because - // the pose-signature gate can eat the settle (final easing frames land - // inside one quantization bucket) and leave stale noses for up to - // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. - _moveEndRemove = viewer.camera.moveEnd.addEventListener(() => { - _lastCamPoseSig = ''; - _lastFleetTickMs = 0; - }); - } - restoreSpriteOrder(viewer); - }, - - /** - * Hide the layer, clear any active tracking, and remove input handlers - * so clicks do not get intercepted while the layer is off. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - if (_billboardCollection) _billboardCollection.show = false; - releaseContinuousRender('military'); - _releaseModels(); - if (_modelCollection) _modelCollection.show = false; - _clearTracking(); - _destroyTrail(); - // Remove click handler + keydown listener while disabled to avoid - // intercepting input when the layer is off - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('military'); - // Flights layer takes over rendering known-military aircraft (amber) - setMilitaryLayerActive(false); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - }, - - /** @deprecated Compatibility alias for {@link enable}. */ - show(viewer) { - this.enable(viewer); - }, - - /** @deprecated Compatibility alias for {@link disable}. */ - hide(viewer) { - this.disable(viewer); - }, - - /** - * Fetch the latest military aircraft positions from adsb.lol and reconcile - * with the billboard collection. Handles error backoff, feed-time-stamped - * position history updates, and grace-period removal of absent aircraft. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - * @returns {Promise} - */ - async update(viewer, { signal = null } = {}) { - const nowMs = Date.now(); - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - ids: new Set(), - source: 'adsb.lol', - }; - if (_retryAt && nowMs < _retryAt) { - _backoff = true; - return; - } - - const resourceController = new AbortController(); - _activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - const response = await fetch(API_URL, { signal: updateSignal }); - _lastStatus = response.status; - - if (!response.ok) { - _backoff = true; - // Parity with flights.js: a 429 gets the LONGER cooldown + a friendly - // rate-limit label instead of the generic transient backoff, so we don't - // hammer the upstream and the UI reads honestly. - if (response.status === 429) { - _retryAt = nowMs + BACKOFF_INTERVAL; - _lastError = 'adsb.lol rate limited'; - return; - } - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - let detail = ''; - try { - const body = await response.json(); - updateSignal.throwIfAborted(); - detail = _toCleanText(body?.error || body?.message); - } catch { - detail = ''; - } - _lastError = detail || `adsb.lol HTTP ${response.status}`; - return; - } - - // adsb.lol returns { ac: [...aircraft], msg: "...", ... } - const data = await response.json(); - updateSignal.throwIfAborted(); - if (!data || !Array.isArray(data.ac)) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed adsb.lol response'; - return; - } - - const usableAircraft = data.ac.filter(_isUsableMilitaryAircraft); - if (data.ac.length > 0 && usableAircraft.length === 0) { - _backoff = true; - _retryAt = nowMs + ERROR_BACKOFF_INTERVAL; - _lastError = 'Malformed adsb.lol aircraft rows'; - return; - } - - _backoff = false; - _retryAt = 0; - _lastError = null; - const currentIcaos = new Set(); - const cacheAgeMs = Number(response.headers?.get?.('x-ads-b-cache-age-ms')); - const receiptNowMs = Date.now() - (Number.isFinite(cacheAgeMs) && cacheAgeMs > 0 ? cacheAgeMs : 0); - _backoff = response.headers?.get?.('x-ads-b-cache') === 'STALE'; - // Field-test fix (RS46): coarse floor cells to warm for the below-ground - // clamp — collected during the loop (low airborne contacts only), - // batch-resolved once after it. Never a fetch inside the loop. - const _floorWarmPoints = []; - - // -- Parse each aircraft record from the adsb.lol response -- - // Fields used: hex (ICAO), lon, lat, alt_baro (ft, barometric/MSL), - // alt_geom (ft, geometric/WGS84 ellipsoidal — probed live 2026-07-05: - // present on ~45% of records; readsb omits it when the aircraft hasn't - // reported a geometric altitude this cycle), track (deg true), gs - // (ground speed in knots), seen_pos (age of last position, seconds), - // flight (callsign), t (type), r (registration), ownOp (operator). - for (const aircraft of usableAircraft) { - const icao24 = _toCleanText(aircraft?.hex).toLowerCase(); - const lon = _toFiniteNumber(aircraft?.lon); - const lat = _toFiniteNumber(aircraft?.lat); - - currentIcaos.add(icao24); - _missingPolls.delete(icao24); - - const prevMeta = _flightData.get(icao24); - // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no - // separate boolean). Grounded planes fall back to their last known - // altitude (field elevation is unknowable here), else 0 m — never the - // 3 km airborne default (a parked plane must not float). - const rawAltBaro = aircraft?.alt_baro; - const onGround = typeof rawAltBaro === 'string' && rawAltBaro.trim().toLowerCase() === 'ground'; - const altitudeFt = _toFiniteNumber(rawAltBaro); - const altitudeM = Number.isFinite(altitudeFt) - ? altitudeFt * 0.3048 - : (onGround - ? (Number.isFinite(prevMeta?.altitudeFt) ? prevMeta.altitudeFt * 0.3048 : 0) - : 3048); - const track = _toFiniteNumber(aircraft?.track) || 0; - // Convert ground speed from knots to meters/sec for dead reckoning - const speedKt = _toFiniteNumber(aircraft?.gs); - const speedMps = Number.isFinite(speedKt) ? speedKt * 0.514444 : 0; - // Analyst seam (additive): readsb baro_rate is ft/min; keep m/s. - const baroRateFtMin = _toFiniteNumber(aircraft?.baro_rate); - const verticalRateMps = Number.isFinite(baroRateFtMin) ? baroRateFtMin * 0.00508 : null; - - const callsign = _toCleanText(aircraft?.flight); - const type = _toCleanText(aircraft?.t); - const registration = _toCleanText(aircraft?.r); - const operator = _toCleanText(aircraft?.ownOp); - const seenSec = _toFiniteNumber(aircraft?.seen); - - // Height-datum fix (Task 7, mirror of flights.js Task 6): `altitudeM` - // (above) stays the AVIATION field — the sticky barometric/MSL altitude - // read by labels (FL/altitude readout) and the landed-fast-cull - // heuristic. It is NEVER overwritten or renamed. Where the aircraft - // actually RENDERS on the ellipsoidal globe (billboard path only — a - // ground-snapped 3D MODEL uses groundSnap.js's own tileset sample and - // ignores this value entirely) is a SEPARATE value, renderAltitudeM: - // alt_geom when readsb reports it (already WGS84 ellipsoidal), else - // alt_baro+geoid as a visual fallback, else ground surface when parked. - const altGeomFt = _toFiniteNumber(aircraft?.alt_geom); - const geoAltitudeM = Number.isFinite(altGeomFt) ? altGeomFt * 0.3048 : null; - const baroAltitudeM = Number.isFinite(altitudeFt) ? altitudeFt * 0.3048 : null; - - // geoid undulation N: cached per-aircraft (negligible drift — see - // task brief) once the geoid grid has loaded; unavailable pre-load - // just means the baro fallback branch below adds N=0 for a beat. - let geoidN = _geoidNCache.get(icao24); - if (geoidN === undefined && _geoidReady) { - geoidN = geoidHeight(lat, lon); - _geoidNCache.set(icao24, geoidN); - } - - // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a - // grounded plane's MODEL already rides groundSnap.js's one-shot tileset - // sample (_modelDisplayPosition), which is the visual on the ground, and - // its billboard is depth-test-free (_groundDepthDistance) so its exact - // height is cosmetic. Deliberately pass surfaceM=null so - // pickRenderAltitudeM's on-ground surface branch never fires here: - // 1. It would be the SECOND correction of the same grounded plane - // (model tileset-snap is the first) — the exact double-correct the - // brief forbids. - // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the - // grounded billboard sits at 0 m until a surface value warms; letting - // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls - // drags the model's ground-snap input past groundSnap's 50 m - // move-invalidation threshold and forces a re-sample every time the - // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant - // the track regression locks (qa: sampleHeight count must stay flat). - // Grounded planes therefore keep the pre-existing `altitudeM` default - // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what - // matters for AIRBORNE military planes — the actual "renders at MSL" bug. - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: baroAltitudeM, - onGround, - surfaceM: null, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // alt_geom nor alt_baro was ever reported for this aircraft (not even - // stickily) — fall back to the SAME existing default policy `altitudeM` - // already uses (which also carries the on-ground 0 m / last-known-baro - // case), so the two never disagree on the "no data yet" case. - let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; - // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only - // AIRBORNE contact near steep terrain can compute a render height - // BELOW the local surface (no alt_geom; baro+N carries QNH error - // larger than the height above ground). Floor it at the coarse-grid - // ellipsoidal ground (warm-cache read only — the batch warm below - // fills cells for later polls). Grounded contacts are deliberately - // NOT touched: their model rides groundSnap's tileset sample and - // their billboard is depth-test-free (see the surfaceM:null block - // above — same one-shot-invariant reasoning). - if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, cachedGroundFloor(lat, lon)); - _floorWarmPoints.push({ lat, lon }); - } else if (onGround) { - // Grounded contacts: warm the floor cell, and — round 4 — when NO - // 3D model owns this contact's visual, lift the billboard itself - // onto the floor (mesh-first): the R20053 heli sat "straight up in - // the ground" because grounded rows render at the legacy ~0 m. - // With a model present the billboard stays put (it hides behind - // the tileset-snapped model, and moving it would drag groundSnap's - // input past its move-invalidation threshold — the T7 one-shot - // invariant the track regression locks). - _floorWarmPoints.push({ lat, lon }); - const modelOwnsVisual = _modelOwnsVisual(icao24); - if (!modelOwnsVisual) { - const floor = cachedGroundFloor(lat, lon); - if (Number.isFinite(floor)) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); - } - } - } - - const position = Cesium.Cartesian3.fromDegrees(lon, lat, renderAltitudeM); - // Landing/takeoff transition: the ground flip restyles IN PLACE. - const groundFlipped = !!prevMeta && (prevMeta.onGround === true) !== onGround; - // Either flip direction retires the model's ground snap: a departing plane - // flies free of it, a landing plane earns a fresh sample where it rolls out. - if (groundFlipped) _groundSnap.forget(icao24); - - // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold - // last-known-good (bounded by the layer's eviction, which deletes the entry). - const stickyType = stickyText(type, prevMeta?.type); - const meta = { - callsign: stickyText(callsign, prevMeta?.callsign), - type: stickyType, - // Type outranks category automatically inside classifyAircraft. - klass: classifyAircraft({ typeCode: stickyType, category: aircraft?.category }), - registration: stickyText(registration, prevMeta?.registration), - operator: stickyText(operator, prevMeta?.operator), - altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitudeFt` — never rename/replace it (labels, - // the FL readout, and the landed-fast-cull heuristic all still read - // altitudeFt/baro). - geoAltitudeM, - renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), - // Analyst seam (additive): sticky like the other kinematics. - verticalRateMps: stickyNumber(verticalRateMps, prevMeta?.verticalRateMps, null), - lastContactEpochMs: stickyNumber( - Number.isFinite(seenSec) ? receiptNowMs - seenSec * 1000 : null, - prevMeta?.lastContactEpochMs, - null, - ), - turnRateDps: prevMeta?.turnRateDps || 0, - onGround, - // Round 7: sticky airborne history (see _likelyLanded). - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for at/below-ellipsoid - // renders (mirror of flights.js). - cullPosition: renderAltitudeM < 10 ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) : null, - // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded - // re-floor sweep keys floors off these (mirror of flights.js). - rawLat: lat, - rawLon: lon, - }; - _flightData.set(icao24, meta); - - const isTracked = icao24 === _trackedIcao; - - // Append to position history stamped with the FEED's fix epoch. - // adsb.lol's seen_pos is the AGE in seconds of the last position - // report, so the fix epoch is receipt time minus that age. Only - // append when the fix actually advances, so stale repeats don't - // create zero-dt segments. - const seenPos = _toFiniteNumber(aircraft?.seen_pos); - const fixEpochMs = receiptNowMs - (Number.isFinite(seenPos) ? seenPos * 1000 : 0); - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); - if (!_positionHistory.has(icao24)) { - _positionHistory.set(icao24, []); - } - const history = _positionHistory.get(icao24); - const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { - // Per-fix kinematics: the fix's own velocity/track ride along so the - // extrapolation paths use the values that BELONG to the fix they - // extend, not whatever the latest poll reported. - history.push({ - time: fixTime, - epochMs: fixEpochMs, - position: position.clone(), - velocity: meta.speedMps, - track: meta.track, - }); - if (history.length > POSITION_HISTORY_LIMIT) { - history.shift(); - } - // Turn rate from the fix-track history — computed once per new fix - // (≤5 samples), consumed by the extrapolation paths at tick rate. - meta.turnRateDps = turnRateFromFixHistory(history); - // Trail accumulation is separate from the 5-fix DR history (PRD F2) - // so the visible trail keeps growing while tracked. Round 2 - // (owner): ground traffic appends too — taxi history stays live - // after touchdown; _appendTrailFix floors grounded positions at - // the surface so the ground leg drapes instead of diving. - if (isTracked) _appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = _modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - const kinematicsChanged = newest.velocity !== meta.speedMps - || newest.track !== meta.track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.speedMps, - track: meta.track, - turnRateDps: meta.turnRateDps, - }); - if (synthetic) { - history.push(synthetic); - if (history.length > POSITION_HISTORY_LIMIT) history.shift(); - meta.turnRateDps = turnRateFromFixHistory(history); - if (isTracked) _appendTrailFix(synthetic.position.clone()); - } - } - } - - if (_billboards.has(icao24)) { - const bb = _billboards.get(icao24); - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - // Ground flips (landing/takeoff) restyle this SAME billboard in - // place — the transition is never a removal. - if (prevMeta?.klass !== meta.klass || groundFlipped || _cockpitContactMode) { - _applyFleetBillboardPresentation(icao24, bb); - } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) _syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - } else { - const bb = _billboardCollection.add({ - position, - image: aircraftIcon(_iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[meta.klass] || 1) * (meta.onGround ? GROUND_SCALE : 1), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, - sizeInMeters: false, - scaleByDistance: _normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: _groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - _billboards.set(icao24, bb); - _applyFleetBillboardPresentation(icao24, bb); - } - - // Takeoff while TRACKED: ground traffic drew no trail, so start one - // from the fresh airborne history (touchdown needs no action — the - // append gate above simply freezes the existing trail). - if (isTracked && groundFlipped && !meta.onGround) _startTrail(icao24); - - // If this is the tracked aircraft, update label text - // (position updates automatically via dead-reckoning CallbackProperty) - if (isTracked && _trackedEntity) { - const info = _flightData.get(icao24); - _updateTrackedLabelModel(icao24); - } - } - - // Field-test fix (RS46): one batch warm of the low-airborne floor cells - // collected above — fire-and-forget, single-flight, results read - // synchronously by NEXT poll's clamp (same contract as flights.js's - // grounded-surface warm). - warmGroundFloor(_floorWarmPoints); - // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — - // a parked contact whose feed went quiet froze at its pre-warm height. - // The model-ownership gate matches the live grounded-clamp path (a - // tileset-snapped model owns its visual; moving its billboard would - // disturb groundSnap's input — the T7 one-shot invariant). - for (const [icao24, info] of _flightData) { - if (!info?.onGround || currentIcaos.has(icao24)) continue; - if (_modelOwnsVisual(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; - const floor = cachedGroundFloor(info.rawLat, info.rawLon); - if (!Number.isFinite(floor)) continue; - const lifted = floor + GROUND_FLOOR_LIFT_M; - if (Number.isFinite(info.renderAltitudeM) && info.renderAltitudeM >= floor - 1) continue; - info.renderAltitudeM = lifted; - info.cullPosition = null; - const liftedPos = Cesium.Cartesian3.fromDegrees(info.rawLon, info.rawLat, lifted); - const hist = _positionHistory.get(icao24); - const newest = hist?.[hist.length - 1]; - if (newest) newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position); - const bbStale = _billboards.get(icao24); - if (bbStale) bbStale.position = liftedPos; - } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding - // this layer's own billboards/models from the probe. - { - const viewerCarto = _viewer?.camera?.positionCartographic || null; - sampleMeshFloorCells(_viewer?.scene, _floorWarmPoints, { - excludeObjects: [..._billboards.values(), ..._models.values(), _trackedModel].filter(Boolean), - viewerLat: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) : undefined, - viewerLon: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : undefined, - }); - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // adsb.lol routinely drops aircraft for a single poll; immediate removal - // made icons blink and yanked the camera off actively tracked flights. - // EXCEPTION: likely-landed planes (last fix low + slow) get only - // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a - // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of _billboards) { - if (currentIcaos.has(icao24)) continue; - const misses = (_missingPolls.get(icao24) || 0) + 1; - const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; - if (misses < limit) { - _missingPolls.set(icao24, misses); - if (icao24 === _trackedIcao && _trackedEntity) { - // Honest readout: the tracked plane has no faded billboard (its - // entity owns the visual), so refresh the label — with icao24 now - // in _missingPolls, _buildTrackedLabel appends the STALE cue so - // last-known altitude/speed aren't presented as live. - _updateTrackedLabelModel(icao24); - } - continue; - } - _missingPolls.delete(icao24); - - // If the tracked flight is truly gone, clear tracking BEFORE deleting - // its state (M3 ordering, keep it): teardown reads the maps this loop - // is about to delete (billboard restore, DR cache reset), and we must - // never leave the camera mid-follow with stale tracking state. The - // camera is then RELEASED IN PLACE — it stays where the follow left - // it, fully free (owner decision 2026-07-02: no overview flyTo). - if (icao24 === _trackedIcao) { - _clearTracking(false, { evicted: true }); - } - - _billboardCollection.remove(bb); - _billboards.delete(icao24); - _releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - _flightData.delete(icao24); - _positionHistory.delete(icao24); - _displayCourse.delete(icao24); - _groundSnap.forget(icao24); - _geoidNCache.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - _lastCamPoseSig = ''; - - // Feed the shared registry so the flights layer can classify/suppress - registerMilitaryIcaos(currentIcaos); - - _count = _billboards.size; - _lastUpdate = receiptNowMs; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'accepted', - ids: currentIcaos, - source: 'adsb.lol', - }; - console.log(`[Data:Military] Updated: ${_count} aircraft`); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Military update aborted', 'AbortError'); - } - console.warn('[Data:Military] Fetch error:', e); - _backoff = true; - _retryAt = Date.now() + ERROR_BACKOFF_INTERVAL; - _lastError = 'adsb.lol network error'; - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - /** - * Tear down the layer: clear tracking, remove handlers, remove the billboard - * collection from the scene, and release all state. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) - _clearTracking(); - _destroyTrail(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - if (_cockpitModeListener) { - window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); - _cockpitModeListener = null; - } - unregisterPickOwner('military'); - if (_preRenderRemove) { - _preRenderRemove(); - _preRenderRemove = null; - } - if (_trackedModelPreUpdateRemove) { - _trackedModelPreUpdateRemove(); - _trackedModelPreUpdateRemove = null; - } - if (_moveEndRemove) { - _moveEndRemove(); - _moveEndRemove = null; - } - _releaseModels(); - if (_billboardCollection) { - viewer.scene.primitives.remove(_billboardCollection); - _billboardCollection = null; - } - if (_modelCollection) { - viewer.scene.primitives.remove(_modelCollection); // removing destroys it + its models - _modelCollection = null; - } - _modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - _modelPending.clear(); - _modelGen.clear(); - if (_preloadModel) { try { _preloadModel.destroy(); } catch { /* gone */ } _preloadModel = null; } - _planeModelLoaded = false; - _billboards.clear(); - _detectionObjects.clear(); - _flightData.clear(); - _positionHistory.clear(); - _displayCourse.clear(); - _groundSnap.clear(); - _missingPolls.clear(); - _count = 0; - _lastUpdate = null; - _cockpitContactMode = false; - _cockpitNearContacts = new Set(); - _cockpitSubjectId = null; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - ids: new Set(), - source: 'adsb.lol', - }; - _resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - _viewer = null; - }, - - /** - * Live layer params. - * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, - * surrounding military aircraft become 3D models once the camera is zoomed in past - * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by - * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). - * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). - * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params - */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - _cancelPendingTrackingRestore(); - } - if (typeof params.models3d === 'boolean' && params.models3d !== _models3dEnabled) { - _models3dEnabled = params.models3d; - if (!_models3dEnabled) { - _releaseModels(); - _syncTracked2dRotation(); - // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked - // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). - if (_billboardCollection) for (const [icao, bb] of _billboards) { if (icao !== _trackedIcao) bb.show = true; } - } - } - if ((params.models3dMode === 'proximity' || params.models3dMode === 'all') && params.models3dMode !== _models3dMode) { - // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. - _models3dMode = params.models3dMode; - if (_cockpitContactMode) { - _refreshCockpitNearContacts(); - _lastFleetTickMs = 0; - } - } - if (typeof params.irBoost === 'boolean' && params.irBoost !== _irBoost) { - _irBoost = params.irBoost; - _reloadModelsForIrBoost(); - // Sprites don't reload with the models — swap the TR-3B glyph between its - // cold and thermal-reactive variants directly (mirror of flights.js). - _refreshTr3bForStyle(); - } - if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - const requested = _normalizeTrackedIcao(params.selectedMilitaryTrackingId); - if (requested === _trackedIcao) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedIcao) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedIcao) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - getParams() { - return { - models3d: _models3dEnabled, - models3dMode: _models3dMode, - irBoost: _irBoost, - selectedMilitaryTrackingId: _trackedIcao, - }; - }, - - /** - * Re-render a contact whose TR-3B conversion just flipped (Easter egg). - * Callers own the registry write; this only re-derives what renders. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when this layer owns the contact. - */ - refreshTr3b(icao24) { return _refreshTr3bContact(icao24); }, - - /** - * Find military aircraft near a given ECEF position, sorted by distance. - * Used by proximity-based features (e.g. detection overlay, spatial queries). - * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates - * @param {number} range - Maximum distance in meters (Infinity if not specified) - * @param {number} [maxCount=50] - Maximum number of results to return - * @param {object} [options] Query membership options. - * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. - * @returns {Array} Sorted array of nearby aircraft descriptors - */ - getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if (!center || !_billboardCollection || !_billboardCollection.show) return []; - - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 50; - const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - - const now = Cesium.JulianDate.now(); - const nearby = []; - - for (const [icao24, bb] of _billboards) { - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so proximity counts - // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. - if (!aircraftIncludedInNearby({ - isTracked, - billboardShown: bb.show, - modelRendering: _modelOwnsVisual(icao24), - includeHidden, - })) continue; - - const trackedPos = isTracked ? _trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; - if (!pos) continue; - - const distance = Cesium.Cartesian3.distance(center, pos); - if (distance > maxRange) continue; - - const info = _flightData.get(icao24); - nearby.push({ - id: info?.callsign?.trim() || info?.registration?.trim() || icao24, - icao24, - position: pos, - distance, - // Filter surface (mirror of flights.js): the cockpit next/previous path - // matches on this field, so it follows the conversion. - aircraftClass: tr3bAircraftClass(icao24, String(info?.klass || info?.type || '').trim().toLowerCase() || null), - track: info?.track ?? null, - // Display type — converted too, so a Contacts row can't still name the - // airframe the triangle replaced (the filter matcher reads this as a - // fallback candidate as well). - type: tr3bTypeLabel(icao24, info?.type || null), - registration: info?.registration || null, - operator: info?.operator || null, - altitudeFt: info?.altitudeFt ?? null, - }); - } - - nearby.sort((a, b) => a.distance - b.distance); - return nearby.slice(0, limit); - }, - - /** - * Return a subset of aircraft suitable for the detection overlay system. - * Uses a deterministic stride-based sampling to keep the count manageable - * while distributing selections evenly across the collection. - * @param {Object} [options={}] - Options - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) - * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!_billboardCollection || !_billboardCollection.show) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _billboards.size; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Deterministic stride: evenly space selections across the billboard map - const stride = Math.max(1, Math.ceil(_billboards.size / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [icao24, bb] of _billboards) { - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (_cockpitContactMode && icao24.toLowerCase() === _cockpitSubjectId) continue; - const isTracked = icao24 === _trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so the detection box - // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. - const model = _models.get(icao24); - const modelOwnsVisual = _modelOwnsVisual(icao24); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; - const info = _flightData.get(icao24); - const callsign = info?.callsign?.trim(); - const registration = info?.registration?.trim(); - let object = _detectionObjects.get(icao24); - if (!object) { - object = { - sourceId: icao24, type: 'AIR', tier: 'military', _weldPos: new Cesium.Cartesian3(), - }; - _detectionObjects.set(icao24, object); - } - // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned - // contacts read the translation the fleet tick already wrote into their - // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket - // are co-located anyway. - const spec = modelOwnsVisual ? _modelSpec(info?.klass) : null; - const pos = isTracked - ? (_trackedVisualCached() || bb.position) - : (modelOwnsVisual - ? modelVisualAnchor( - model.modelMatrix, - spec.visualCenterNative, - Number.isFinite(model.computedScale) ? model.computedScale : spec.scale, - object._weldPos || (object._weldPos = new Cesium.Cartesian3()), - ) - : bb.position); - if (!pos) continue; - object.position = pos; - object.skipLabel = isTracked; - const id = callsign || registration || icao24; - if (object.id !== id) object.id = id; - // Card surface: detectionDraw composes its secondary line from - // `[src.klass, src.metric]`, so a converted contact's card must name the - // TR-3B, not the airframe underneath it. - const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); - if (object.klass !== klass) object.klass = klass; - const altitudeFt = info?.altitudeFt ?? 0; - if (object._altitudeFt !== altitudeFt) { - object._altitudeFt = altitudeFt; - // military metadata stores altitudeFt (feet); the helper takes metres - object.metric = formatFlightLevel(altitudeFt * 0.3048); - } - result.push(object); - if (result.length >= maxCount) break; - } - if (_detectionObjects.size > _billboards.size + 512) { - for (const icao24 of _detectionObjects.keys()) { - if (!_billboards.has(icao24)) _detectionObjects.delete(icao24); - } - } - return result; - }, - - /** - * Find a single aircraft by free-text query. - * Match priority: exact icao24 hex, exact callsign, callsign prefix, - * then callsign substring (all case-insensitive, trimmed). - * @param {string} query - ICAO hex or full/partial callsign. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Best match with a cloned, dead-reckoned position, or null if none. - */ - findByQuery(query) { - if (!_flightData || _flightData.size === 0) return null; - const q = String(query || '').trim().toLowerCase(); - if (!q) return null; - - // Registration is searched alongside callsign because it is what the - // operator SEES: a callsign-less contact reads as its tail number on the - // card, in the analyst's answer, and in the Contacts list. Matching only - // callsigns meant "follow 6606" — and the analyst → track_entity handoff - // the tool instructions prescribe — answered "nothing matched" for the - // very identity the app had just shown (owner field session 2026-08-21, - // 23:48: three failed track_entity retries before a fallback stuck). - // Ranking is shared with the flights layer (contactMatch.js) so the two - // cannot disagree, and it is strictly tiered so a registration can never - // out-rank a real callsign on feed order alone. - let best = null; - for (const [icao24, info] of _flightData) { - const candidate = { - tier: rankContactMatch({ - query: q, - hex: icao24, - callsign: info?.callsign, - registration: info?.registration, - }), - id: icao24, - }; - if (!contactMatchWins(candidate, best)) continue; - best = candidate; - if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; - } - return best ? _describeFlight(best.id) : null; - }, - - /** - * Return id/label/position for up to maxCount currently rendered aircraft. - * Cheap snapshot for voice-tool framing — billboard positions only, no - * dead reckoning and no cloning. - * @param {number} [maxCount=500] - Maximum number of entries to return. - * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} - */ - /** - * Whether this layer still carries a contact, in O(1). - * - * Mirror of `flights.hasContact`: presence consumers must not infer absence - * from the capped `getAllPositions` rows. Id matching follows trackById — - * exact key first, then lowercase. - * A disabled layer keeps its records but hides the collection, so it must - * decline rather than answer from data the user can no longer see. - * @param {string} icao24 Contact identifier. - * @returns {boolean|null} Presence, or null when the layer is disabled or - * holds no data and therefore cannot answer. - */ - hasContact(icao24) { - if (!_billboardCollection || !_billboardCollection.show || _billboards.size === 0) return null; - if (!icao24) return false; - const id = String(icao24).trim(); - return _billboards.has(id) || _billboards.has(id.toLowerCase()); - }, - - getAllPositions(maxCount = 500) { - if (!_billboardCollection || _billboards.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 500; - - const result = []; - for (const [icao24, bb] of _billboards) { - const pos = bb.position; - if (!pos) continue; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _scratchCarto); - if (!carto) continue; - const info = _flightData.get(icao24); - result.push({ - id: icao24, // identity (trackById/Context resolve this) - // Last surface still reading as the raw hex for a callsign-less - // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. - label: _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24, - position: pos, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - }); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Snapshot the layer's in-memory 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 (!_billboardCollection || !_billboardCollection.show || _flightData.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const [icao24, info] of _flightData) { - result.push(mapAnalystRecord(icao24, info)); - if (result.length >= limit) break; - } - return result; - }, - - /** - * Start camera-tracking an aircraft by ICAO hex identifier. - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {boolean} True if the aircraft exists and tracking started. - */ - trackById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if (!_billboards.has(id)) return false; - if (_isExplicitTrackingOrigin(origin)) _cancelPendingTrackingRestore(); - if (_trackedIcao === id) return _publishTrackedSelection(id, origin); - _trackFlight(id, { origin }); - return true; - }, - - /** Resolve a shared Follow target only against the latest accepted refresh. */ - async resolveTrackingRestoreTarget(icao24, { signal = null, origin = 'share-restore' } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = String(icao24 ?? '').trim().toLowerCase(); - if (!id) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - if (outcome.status !== 'accepted') { - return { - status: 'source-unavailable', - reason: 'adsb.lol snapshot unavailable', - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (!outcome.ids.has(id)) { - return { - status: 'missing', - reason: 'target-absent-from-snapshot', - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const followed = this.trackById(id, { origin }); - return followed - ? { status: 'found', refreshEpoch: outcome.epoch, source: outcome.source } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }, - - /** Reapply the canonical follow frame without recreating the selected flight. */ - refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24 || _cockpitContactMode || !_viewer || !_trackedEntity) return false; - let id = String(icao24).trim(); - if (!_billboards.has(id)) id = id.toLowerCase(); - if ( - id !== _trackedIcao - || !_viewer.entities?.contains?.(_trackedEntity) - || _viewer.trackedEntity !== _trackedEntity - ) return false; - _trackedCameraFrameStop?.(); - _viewer.camera.cancelFlight(); - _viewer.trackedEntity = _trackedEntity; - _trackedCameraFrameStop = applyTrackedCameraFrame( - _viewer, - _trackedEntity, - _trackedEntity.viewFrom, - ) || null; - _publishTrackedSelection(id, origin); - return true; - }, - - /** - * Stop tracking the currently followed aircraft (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Tracked aircraft info, or null when nothing is tracked. - */ - getTrackedInfo() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described) return null; - const { position, ...rest } = described; - return rest; - }, - - /** - * Return the current aircraft as a Context subject without changing - * tracking or camera ownership. - * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} - * Detached subject descriptor, or null when no aircraft is tracked. - */ - getTrackedSubject() { - if (!_trackedIcao) return null; - const described = _describeFlight(_trackedIcao); - if (!described?.position) return null; - return { - layerId: 'military', - id: described.icao24, - // Same label chain as getNearby/getDetectableObjects: a callsign-less - // contact reads as its registration, never as the raw ICAO hex. - label: described.callsign || described.registration || described.icao24, - position: Cesium.Cartesian3.clone(described.position), - }; - }, - - /** - * Return current layer health/status for the HUD status chip. - * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} - */ - getStats() { - const retryInSec = _retryAt ? Math.max(0, Math.ceil((_retryAt - Date.now()) / 1000)) : 0; - return { - count: _count, - lastUpdate: _lastUpdate, - stale: _backoff, - error: _lastError, - status: _lastStatus, - retryInSec, - source: 'adsb.lol', - fallback: false, - }; - }, -}; - -/** - * Global keydown handler: pressing Escape clears the active aircraft tracking. - * @param {KeyboardEvent} e - The keyboard event - */ -function _onKeyDown(e) { - if (e.key === 'Escape' && _trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -/** - * Install a left-click handler on the scene canvas for aircraft selection. - * Clicking a military billboard starts tracking that aircraft; clicking empty - * space or a non-military object deselects. Also attaches the Escape key - * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ -function _installClickHandler(viewer) { - if (_clickHandler) return; // already installed - - // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching viewer.trackedEntity (the new owner controls it). - if (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (_trackedIcao && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindTrackingClickGesture(_clickHandler, (click, gesture) => { - // Camera drags never select or deselect, even if they finish over a plane. - // Duration alone is allowed through so a stationary long press can still - // select/switch contacts; the destructive empty-space branch below applies - // the full travel + duration click classifier. - if (!isTrackingSelectionGesture(gesture)) return; - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference. Empty globe clicks are inert until the user - // exits with C, Escape, or the dedicated button. - if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking the tracked entity itself -- ignore (don't deselect) - if (picked.id === _trackedEntity) return; - - // Clicking the plane we're ALREADY tracking (its standalone 3D model or - // any pick carrying its icao) — same no-op as the 2D tracked-entity click - // above. H1: the model used to have no pick id, so this fell through to - // "empty space" and deselected the very plane being tracked. - if (_trackedIcao) { - const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; - if (picked.primitive === _trackedModel || rawPick === _trackedIcao) return; - } - - // BillboardCollection picks: CesiumJS may expose the billboard as - // picked.primitive (with .id = icao24) or directly as picked.id - const billboard = picked.primitive; - if (billboard && billboard.id && _billboards.has(billboard.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Fallback: some CesiumJS versions surface the id string at picked.id - if (picked.id && typeof picked.id === 'string' && _billboards.has(picked.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); - return; - } - } - - // A pick that belongs to a sibling layer (commercial flight, satellite, - // vessel, station, CCTV camera…) is not "empty space" — leave tracking - // alone and let that layer handle it. resolvePickId String()-coerces the - // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the - // registry predicates can recognize them (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; - } - - // Clicked empty space -- deselect only for a clean, short click. A slow - // stationary press may select above, but cannot release existing tracking. - if (!isTrackingClickGesture(gesture)) return; - if (_trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }); - - document.addEventListener('keydown', _onKeyDown); -} - +}); +export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; +export const _setTrackedMilitaryRefreshStateForTest = + militaryFlightsLayer.testing._setTrackedMilitaryRefreshStateForTest; +export const _setMilitaryTrackingRefreshOutcomeForTest = + militaryFlightsLayer.testing._setMilitaryTrackingRefreshOutcomeForTest; +export const _addMilitaryTrackingCandidateForTest = + militaryFlightsLayer.testing._addMilitaryTrackingCandidateForTest; +export const _pendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._pendingMilitaryTrackingRestoreForTest; +export const _applyPendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._applyPendingMilitaryTrackingRestoreForTest; +export const _setCockpitDetectionSubjectForTest = + militaryFlightsLayer.testing._setCockpitDetectionSubjectForTest; +export const _trackedModelRegimeActiveForTest = + militaryFlightsLayer.testing._trackedModelRegimeActiveForTest; +export const _updateTrackedModelForTest = + militaryFlightsLayer.testing._updateTrackedModelForTest; +export const _trackedBillboardColorForTest = + militaryFlightsLayer.testing._trackedBillboardColorForTest; +export const _driveFleetModelHandoffForTest = + militaryFlightsLayer.testing._driveFleetModelHandoffForTest; +export const _ensureFleetModelForTest = + militaryFlightsLayer.testing._ensureFleetModelForTest; +export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord; export default militaryFlightsLayer; diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index 0013146d1..af61d8c91 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,681 +1,23 @@ -import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; import { - clearSelectedEntityContextForLayer, - getSelectedEntityContext, - registerEntityContext, - removeEntityContextsForLayer, - selectEntityContext, -} from './contextStore.js'; -import { - cachedGroundFloor, - floorAltitudeM, - resolveGroundFloorCellsBounded, -} from './groundFloor.js'; -// The shared batched/chunked/session-cached DEM warm chain. The module name is -// historical (it shipped with the FIRMS fire anchors); the mechanism itself is -// generic — cold coarse floor cells for a rendered point set, resolved strictly -// sequentially so overlapping renders cannot stack requests on the proxy. -import { warmFireAnchorFloors } from './fireAnchors.js'; -import { normalizeMilitaryInstallations } from './militaryInstallationData.js'; -import { installationFeedback } from './installationFeedback.js'; -import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; + createInstallationsLayer, + createInstallationSource, +} from '../layers/installations/index.js'; +import * as render from '../renderGovernor.js'; +import * as context from './contextStore.js'; +import * as ground from './groundFloor.js'; +import * as anchors from './fireAnchors.js'; +import * as picking from './pickRegistry.js'; -const LAYER_ID = 'military-installations'; -const REQUEST_DEBOUNCE_MS = 500; -const MAX_VIEWPORT_DEGREES = 10; -const MAX_RENDERED = 700; -const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); -const COLOR_BY_CLASS = { - airfield: '#5aa9ff', - naval_base: '#48c7d5', - range: '#d9a85d', - military_land: '#9ca6b0', - places_candidate: '#c58cff', -}; -const EARTH_MEAN_RADIUS_M = 6371008.8; -const DISTANCE_PREFILTER_MARGIN_M = 5000; -const distanceEndpointScratch = new Cesium.Cartographic(); -const distanceGeodesicScratch = new Cesium.EllipsoidGeodesic(); - -/** - * Allocation-free spherical distance used only as a conservative rejection - * pass before the exact ellipsoidal geodesic calculation. - */ -export function approximateSurfaceDistanceM(latitudeARad, longitudeARad, latitudeBDeg, longitudeBDeg) { - const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg); - const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg); - const latitudeDelta = latitudeBRad - latitudeARad; - const longitudeDelta = Math.atan2( - Math.sin(longitudeBRad - longitudeARad), - Math.cos(longitudeBRad - longitudeARad), - ); - const sinLatitude = Math.sin(latitudeDelta / 2); - const sinLongitude = Math.sin(longitudeDelta / 2); - const haversine = sinLatitude * sinLatitude - + Math.cos(latitudeARad) * Math.cos(latitudeBRad) * sinLongitude * sinLongitude; - return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine))); -} - -const state = { - viewer: null, - dataSource: null, - enabled: false, - records: [], - recordById: new Map(), - selectedId: null, - lastUpdate: null, - error: null, - status: 'idle', - stale: false, - /** Whether the upstream truncated at its element cap for the current view. */ - saturated: false, - loading: false, - abort: null, - /** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */ - retryTimer: null, - /** Current backoff step for that retry; 0 = next failure starts at the minimum. */ - retryDelayMs: 0, - retryAt: 0, - failureReason: null, - moveEndRemove: null, - clickHandler: null, - timer: null, - googleSearchRequested: false, -}; - -function colorFor(record) { - return Cesium.Color.fromCssColorString(COLOR_BY_CLASS[record.class] || '#9ca6b0'); -} - -/** - * Classify a Places text-search result without turning a name match into a - * mapped military-land claim. Google currently has no documented military - * Places type, so ordinary results remain visually distinct candidates; the - * explicit branch is retained for any source response that does carry one. - * @param {object} place Google Places result. - * @returns {string|null} Installation class, or null when not authoritative. - */ -export function classifyGoogleMilitaryPlace(place) { - const types = new Set([ - place?.primaryType, - ...(Array.isArray(place?.types) ? place.types : []), - ].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)); - return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type)) - ? 'military_land' - : 'places_candidate'; -} - -/** @param {object} record @returns {string} Human-readable source attribution. */ -export function installationSourceLabel(record) { - const names = [...new Set((Array.isArray(record?.sources) ? record.sources : []) - .map((source) => String(source?.name || '').trim()) - .filter(Boolean))]; - return names.join(' + ') || 'Unknown mapped source'; -} - -/** - * Shared rendered-surface height for an installation anchor or footprint. - * @param {{latitude:number, longitude:number}} record Installation record. - * @returns {number} Ellipsoidal render height in metres. - */ -export function installationSurfaceHeightM(record) { - return floorAltitudeM( - null, - cachedGroundFloor(record?.latitude, record?.longitude), - ) ?? 0; -} - -/** - * Whether a mapped record belongs to the REQUESTED viewport. - * - * The proxy snaps the request bbox outward onto a shared cache grid, so a - * response is a SUPERSET of what was asked for, and rendering that superset - * would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY" - * context claim. What may be tested depends on how much of a feature's geometry - * we actually hold: - * - * - A NODE is a point: its centre IS its whole geometry, so an exact - * containment test is correct and loses nothing. - * - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox - * queries return features that merely INTERSECT the box, so centre-testing - * these would drop large bases whose centre sits just outside. - * - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on - * their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without - * one, so their true extent is unknown here — and Overpass already proved - * they intersect the queried bbox. Centre-testing them would erase exactly - * the biggest installations. The honest cost is slight over-inclusion, - * bounded by one snap cell (~5.5 km) around the viewport. - * - * @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record - * @param {{south:number, west:number, north:number, east:number}} box Requested viewport. - * @returns {boolean} - */ -export function installationWithinViewport(record, box) { - if (!record || !box) return false; - const { latitude, longitude, footprint } = record; - const centreInside = latitude >= box.south && latitude <= box.north - && longitude >= box.west && longitude <= box.east; - if (centreInside) return true; - if (Array.isArray(footprint) && footprint.length) { - let minLat = Infinity; let maxLat = -Infinity; - let minLon = Infinity; let maxLon = -Infinity; - for (const [lon, lat] of footprint) { - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; - if (lon < minLon) minLon = lon; - if (lon > maxLon) maxLon = lon; - } - return maxLat >= box.south && minLat <= box.north - && maxLon >= box.west && minLon <= box.east; - } - // Unknown extent: inclusive. Only a point feature may be excluded on centre. - return record.osmType !== 'node'; -} - -/** - * Whether a response was truncated at the upstream element cap. - * - * The proxy states this outright, but a `saturated`-less payload is NOT - * evidence of a complete answer: entries cached before the saturation guard - * shipped predate the field and live for 30 days. Fall back to deriving it from - * the element count against the cap the payload itself reports. - * @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload - * @returns {boolean} - */ -export function installationResponseSaturated(payload) { - if (typeof payload?.saturated === 'boolean') return payload.saturated; - const cap = Number(payload?.elementCap); - if (!Number.isFinite(cap) || cap <= 0) return false; - return Array.isArray(payload?.elements) && payload.elements.length >= cap; -} - -/** - * Commit a status/error transition and buy the one frame it needs. - * - * With the render governor idle — Contacts has released its hold and nothing - * else animates — no frame would otherwise arrive to re-read this, so a load - * that fails after the scene went quiet would leave the last healthy readout on - * screen indefinitely. - * @param {string} status @param {?string} error - */ -function setInstallationStatus(status, error = null) { - if (state.status === status && state.error === error) return; - state.status = status; - state.error = error; - governorRequestRender('installations-status'); -} - -function viewportBox(viewer) { - const rectangle = viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid); - if (!rectangle) return null; - const south = Cesium.Math.toDegrees(rectangle.south); - const north = Cesium.Math.toDegrees(rectangle.north); - const west = Cesium.Math.toDegrees(rectangle.west); - const east = Cesium.Math.toDegrees(rectangle.east); - // Cross-dateline/global views require a zoom before a bounded request. - if (!Number.isFinite(south + north + west + east) || east <= west || north - south > MAX_VIEWPORT_DEGREES || east - west > MAX_VIEWPORT_DEGREES) return null; - return { south, west, north, east }; -} - -function clearRendered() { - if (state.dataSource?.entities) state.dataSource.entities.removeAll(); - removeEntityContextsForLayer(LAYER_ID); -} - -/** - * The records that get entities this paint: the nearest `MAX_RENDERED`, plus - * the selected one when it falls outside that window. - * - * Context navigation walks the FULL nearby cohort, which is not bounded by the - * render cap, so selecting item 701+ used to produce no entity at all — the - * camera flew, `getById` returned null, and the selection was silently dropped - * on the floor, leaving the Context subject stale so NEXT offered the same - * installation forever. One extra entity keeps every cohort item selectable - * and the cohort count honest. - * @returns {Array} Records to render this paint. - */ -function renderableRecords() { - const rendered = state.records.slice(0, MAX_RENDERED); - if (!state.selectedId) return rendered; - if (rendered.some((record) => record.id === state.selectedId)) return rendered; - const selected = state.recordById.get(state.selectedId); - return selected ? [...rendered, selected] : rendered; -} - -function renderRecords({ claimSelection = false } = {}) { - // Context navigation can select another layer without a canvas click. - // A delayed floor/data repaint must not steal that newer selection back. - const selectedContext = getSelectedEntityContext(); - if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) { - state.selectedId = null; - } - // Post-moveEnd debounced fetches commit after the camera settles; the - // rebuilt entities need one frame in idle mode. (perf wave 2 fix) - governorRequestRender('installations-render'); - clearRendered(); - for (const record of renderableRecords()) { - const color = colorFor(record); - const surfaceHeightM = installationSurfaceHeightM(record); - const displayPosition = Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - surfaceHeightM, - ); - const entity = state.dataSource.entities.add({ - id: record.id, - position: displayPosition, - point: { - pixelSize: record.id === state.selectedId ? 13 : 9, - color: record.id === state.selectedId ? Cesium.Color.WHITE : color, - outlineColor: Cesium.Color.BLACK.withAlpha(0.8), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - polygon: record.footprint ? { - hierarchy: new Cesium.PolygonHierarchy(record.footprint.map(([longitude, latitude]) => Cesium.Cartesian3.fromDegrees(longitude, latitude))), - material: color.withAlpha(0.12), - outline: true, - outlineColor: color.withAlpha(0.65), - height: surfaceHeightM, - } : undefined, - }); - entity.gevTrackedId = `installations:${record.id}`; - entity.gevDisplayPosition = () => displayPosition; - entity.gevLabelModel = { - title: record.name || 'MAPPED INSTALLATION', - details: [String(record.class || 'installation').replaceAll('_', ' ').toUpperCase()], - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }; - registerEntityContext(entity, { - id: record.id, - layerId: LAYER_ID, - layerName: record.kind === 'place_candidate' - ? 'Military Site Search Candidates' - : 'Mapped Military Installations', - source: installationSourceLabel(record), - label: record.name, - latitude: record.latitude, - longitude: record.longitude, - properties: { - class: record.class, - primaryType: record.primaryType || null, - placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], - validation: record.validation, - retrievedAt: record.retrievedAt, - }, - }); - } - const selectedEntity = state.selectedId - ? state.dataSource.entities.getById(state.selectedId) - : null; - if (selectedEntity) selectEntityContext(selectedEntity); - else state.selectedId = null; -} - -/** - * Second paint for floors that missed the bounded pre-render deadline. - * - * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so - * a cold DEM can never hold the dots hostage — but the resolve keeps running - * and lands seconds later, and without this the records it covers stay pinned - * at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest - * 2026-08-18: "orange dots at the bottom"). - * - * This is the render -> warm -> re-render chain FIRMS already uses, with one - * difference the installations path forces: the trigger is whether a cell that - * was COLD AT PAINT TIME is warm now, not whether this particular batch warmed - * it. The bounded resolve above is still running against the same cells, so - * asking "did MY batch warm anything" would answer false exactly when the other - * resolve won the race — the common case. Still terminating: a set that is - * wholly cold afterwards re-renders zero times and the next camera-driven load - * retries. - * @param {Array} records Records just rendered. - * @returns {void} - */ -function warmInstallationFloors(records) { - const cold = records - .filter((record) => cachedGroundFloor(record.latitude, record.longitude) == null) - .map((record) => ({ lat: record.latitude, lon: record.longitude })); - if (!cold.length) return; - warmFireAnchorFloors(cold).then(() => { - if (!state.enabled || !state.dataSource) return; - if (!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)) return; - renderRecords(); - }); -} - -function selectRecord(id) { - const record = state.recordById.get(id); - if (!record || !state.dataSource) return false; - state.selectedId = id; - renderRecords({ claimSelection: true }); - // renderRecords drops selectedId when the record produced no entity. - return state.selectedId === id; -} - -function installInteraction(viewer) { - if (state.clickHandler) return; - state.clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - state.clickHandler.setInputAction((click) => { - if (!state.enabled) return; - const picked = viewer.scene.pick(click.position); - const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; - if (id && state.recordById.has(id) && id !== state.selectedId) { - selectRecord(id); - } else if (state.selectedId) { - // Clicking the selected site again, empty map, or another contact - // releases this layer's selection. Clear only our shared context so a - // sibling click handler's newly selected aircraft/site stays intact. - state.selectedId = null; - clearSelectedEntityContextForLayer(LAYER_ID); - renderRecords(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); -} - -/** - * Backoff progression for the unavailable-state retry: 30 s, doubling to a - * 240 s ceiling. Pure so the progression is pinnable without booting the layer. - */ -export function installationRetryDelayMs(prevDelayMs) { - const RETRY_MIN_MS = 30000; - const RETRY_CEIL_MS = 240000; - if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS; - return Math.min(prevDelayMs * 2, RETRY_CEIL_MS); -} - -/** - * 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only - * on enable and on camera moveEnd, so a parked camera whose first request died - * (one flaky Overpass mirror is enough) stayed unavailable forever while the - * proxy sat healthy while the layer refused to show its features. While the - * layer is enabled and - * unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load, - * zoom-out, or disable cancels it. - */ -function scheduleUnavailableRetry() { - if (!state.enabled) return; - clearTimeout(state.retryTimer); - state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs); - state.retryAt = Date.now() + state.retryDelayMs; - state.retryTimer = setTimeout(() => { - state.retryTimer = null; - state.retryAt = 0; - if (state.enabled && !state.loading) loadInstallations(); - }, state.retryDelayMs); -} - -function clearUnavailableRetry({ resetBackoff = true } = {}) { - clearTimeout(state.retryTimer); - state.retryTimer = null; - state.retryAt = 0; - if (resetBackoff) state.retryDelayMs = 0; -} - -function scheduleLoad() { - if (!state.enabled) return; - // A user-driven load supersedes any pending retry; the load reschedules on - // failure, so the backoff step is kept rather than reset. - clearUnavailableRetry({ resetBackoff: false }); - clearTimeout(state.timer); - state.timer = setTimeout(() => { loadInstallations(); }, REQUEST_DEBOUNCE_MS); -} - -async function loadInstallations() { - if (!state.enabled || !state.viewer) return; - const box = viewportBox(state.viewer); - // Guidance, not a fault: the layer chose not to query because the view is - // unbounded (a global view, or Cockpit looking at the horizon). Keep it out - // of `error` — the manager derives refresh failures and the global status - // chip derives LOAD FAILED from that field, and a "zoom in" prompt turned - // every Cockpit refresh into a reported failure. The row and toast read the - // prompt from `statusMessage` (installationFeedback) instead. - if (!box) { - state.abort?.abort(); - state.abort = null; - state.loading = false; - clearUnavailableRetry(); - setInstallationStatus('zoom-in'); - return; - } - state.abort?.abort(); - const requestAbort = new AbortController(); - state.abort = requestAbort; - state.loading = true; - clearUnavailableRetry({ resetBackoff: false }); - // The previous attempt's failure is not the outcome of this new attempt. - setInstallationStatus('loading'); - try { - const fetchInstallations = async (exact) => { - const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)])); - if (exact) query.set('exact', '1'); - const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal }); - const body = await response.json(); - if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), { - failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable', - }); - return body; - }; - - let payload = await fetchInstallations(false); - // A SATURATED snapped tile was truncated upstream, so features from the - // snap's extra ring may have crowded out sites actually on screen. Re-ask - // for the exact viewport (separately keyed and cached) before rendering. - let saturated = installationResponseSaturated(payload); - if (saturated) { - payload = await fetchInstallations(true); - saturated = installationResponseSaturated(payload); - } - const normalized = normalizeMilitaryInstallations(payload, payload.retrievedAt || new Date().toISOString()); - // The proxy answers a bbox at least as large as the viewport; keep only what - // was actually asked for so nothing off-screen reaches the map or the - // "current viewport only" context claim. - const records = normalized.records.filter((record) => installationWithinViewport(record, box)); - let placesError = null; - if (state.googleSearchRequested) { - state.googleSearchRequested = false; - const latitude = (box.south + box.north) / 2; - const longitude = (box.west + box.east) / 2; - const radiusM = Math.min(50000, Math.max(1000, Math.round(Math.max(box.north - box.south, box.east - box.west) * 55_000))); - try { - const placesResponse = await fetch(`/api/google/text-search?${new URLSearchParams({ - q: 'military installation', lat: latitude.toFixed(5), lon: longitude.toFixed(5), radiusM: String(radiusM), - })}`, { signal: requestAbort.signal }); - const placesPayload = await placesResponse.json(); - if (!placesResponse.ok) throw new Error(placesPayload?.error || `Google Places HTTP ${placesResponse.status}`); - const seen = new Set(records.map((record) => `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`)); - for (const place of Array.isArray(placesPayload?.places) ? placesPayload.places : []) { - if (!place?.id || !place?.name || !Number.isFinite(place.latitude) || !Number.isFinite(place.longitude)) continue; - const placeClass = classifyGoogleMilitaryPlace(place); - const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`; - if (seen.has(signature)) continue; - seen.add(signature); - const retrievedAt = new Date().toISOString(); - records.push({ - id: `google:${place.id}`, - kind: placeClass === 'military_land' ? 'installation' : 'place_candidate', - class: placeClass, - name: String(place.name).trim(), - latitude: place.latitude, - longitude: place.longitude, - footprint: null, - primaryType: place.primaryType || null, - placeTypes: Array.isArray(place.types) ? place.types : [], - sources: [{ name: 'Google Maps Places', id: place.id, retrievedAt }], - validation: 'unreviewed', - retrievedAt, - }); - } - } catch (error) { - if (error?.name === 'AbortError') return; - placesError = 'Google Places search unavailable; showing mapped sites'; - } - } - await resolveGroundFloorCellsBounded(records.map((record) => ({ - lat: record.latitude, - lon: record.longitude, - }))); - if (requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled) return; - state.records = records; - state.recordById = new Map(state.records.map((record) => [record.id, record])); - state.lastUpdate = Date.now(); - state.stale = payload.status === 'stale'; - // Even the exact-viewport retry can saturate in a dense area. Say so rather - // than implying the view is completely surveyed. - state.saturated = saturated; - state.failureReason = null; - clearUnavailableRetry(); - setInstallationStatus( - state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty', - payload.status === 'stale' - ? 'Serving cached mapped context' - : (saturated ? 'Too many mapped sites in view to list them all' : placesError), - ); - renderRecords(); - warmInstallationFloors(state.records); - } catch (error) { - if (error?.name === 'AbortError') return; - state.failureReason = error?.failureReason || 'unavailable'; - setInstallationStatus('unavailable', error?.message || 'Installation context unavailable'); - scheduleUnavailableRetry(); - } finally { - // An older aborted request must not clear a newer request's busy state. - if (state.abort === requestAbort) { - state.abort = null; - state.loading = false; - } - } -} - -const militaryInstallationsLayer = { - id: LAYER_ID, - name: 'Mapped Installations', - icon: '⌖', - source: 'OpenStreetMap + optional Google Maps Places', - updateInterval: 0, - statsRefreshInterval: 1000, - init(viewer) { - state.viewer = viewer; - state.dataSource = new Cesium.CustomDataSource('military-installations'); - viewer.dataSources.add(state.dataSource); - state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad); - installInteraction(viewer); - }, - enable() { - state.enabled = true; - registerPickOwner(LAYER_ID, (id) => state.recordById.has(id)); - state.dataSource.show = true; - // DataLayerManager invokes update() immediately after enable(), which owns - // the first fetch. Avoid racing it with a second aborting request here. - }, - disable() { - state.enabled = false; - unregisterPickOwner(LAYER_ID); - clearUnavailableRetry(); - clearTimeout(state.timer); - state.abort?.abort(); - state.abort = null; - state.loading = false; - if (state.dataSource) state.dataSource.show = false; - clearSelectedEntityContextForLayer(LAYER_ID); - state.selectedId = null; - state.failureReason = null; - setInstallationStatus('idle'); - }, - update() { return loadInstallations(); }, - /** Request a one-shot Google Maps Places search around the current map view. */ - searchNearby() { - state.googleSearchRequested = true; - return loadInstallations(); - }, - destroy(viewer) { - this.disable(); - state.moveEndRemove?.(); - state.clickHandler?.destroy(); - state.clickHandler = null; - clearRendered(); - if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true); - state.dataSource = null; - }, - getNearby(center, rangeM, maxCount = 50) { - if (!center) return []; - const range = Number.isFinite(rangeM) ? rangeM : Infinity; - const centerCartographic = Cesium.Cartographic.fromCartesian(center); - if (!centerCartographic) return []; - const nearby = []; - const approximateLimit = Number.isFinite(range) - ? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M - : Infinity; - for (const record of state.records) { - if (record.kind !== 'installation') continue; - if (approximateSurfaceDistanceM( - centerCartographic.latitude, - centerCartographic.longitude, - record.latitude, - record.longitude, - ) > approximateLimit) continue; - // The awareness disk is projected onto the ground. Confirm candidates - // with an exact ellipsoidal surface distance and reusable scratch state. - distanceEndpointScratch.longitude = Cesium.Math.toRadians(record.longitude); - distanceEndpointScratch.latitude = Cesium.Math.toRadians(record.latitude); - distanceEndpointScratch.height = 0; - distanceGeodesicScratch.setEndPoints(centerCartographic, distanceEndpointScratch); - const distanceM = distanceGeodesicScratch.surfaceDistance; - if (!Number.isFinite(distanceM) || distanceM > range) continue; - nearby.push({ - ...record, - position: Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - installationSurfaceHeightM(record), - ), - distanceM, - }); - } - nearby.sort((a, b) => a.distanceM - b.distanceM); - return nearby.slice(0, Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50); - }, - /** - * Select and frame a mapped installation from another contextual UI. - * @param {string} id Source-backed installation id. - * @returns {boolean} True when an available installation was focused. - */ - focusById(id) { - const record = state.recordById.get(String(id)); - if (!record || !state.viewer) return false; - // No camera flight without a real selection: a flight plus a stale subject - // reads as success to Context navigation and strands NEXT on this item. - if (!selectRecord(record.id)) return false; - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - installationSurfaceHeightM(record), - ), - 18000, - ), - { duration: 1.4 }, - ); - return true; - }, - getStats() { - return { - count: state.records.length, - lastUpdate: state.lastUpdate, - stale: state.stale, - saturated: state.saturated, - error: state.error, - status: state.status, - loading: state.loading, - retryAt: state.retryAt, - retrying: state.loading && Boolean(state.failureReason), - failureReason: state.failureReason, - statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }), - loadingLabel: state.loading ? 'loading mapped installation context' : '', - }; - }, -}; - -export default militaryInstallationsLayer; +const layer = createInstallationsLayer({ + source: createInstallationSource(), + services: { render, context, ground, anchors, picking }, +}); +export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; +export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace; +export const installationSourceLabel = layer.installationSourceLabel; +export const installationSurfaceHeightM = layer.installationSurfaceHeightM; +export const installationWithinViewport = layer.installationWithinViewport; +export const installationResponseSaturated = + layer.installationResponseSaturated; +export const installationRetryDelayMs = layer.installationRetryDelayMs; +export default layer; diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index fff2c6eef..5825ed221 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui import { installationRetryDelayMs } from './militaryInstallations.js'; import fs from 'node:fs'; -const installationsSource = fs.readFileSync( +const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => { @@ -797,10 +798,10 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', test('the retry is wired to every lifecycle edge, not just declared', () => { assert.match(installationsSource, - /setInstallationStatus\('unavailable',[^]*?\);\n\s*scheduleUnavailableRetry\(\);/, + /setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/, 'a failed load schedules the retry immediately after reporting unavailable'); assert.match(installationsSource, - /clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*state\.records\.length/, + /clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/, 'a successful load clears the pending retry and resets the backoff'); assert.match(installationsSource, /clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/, @@ -811,6 +812,6 @@ test('the retry is wired to every lifecycle edge, not just declared', () => { /function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/, 'a user-driven load supersedes the retry without resetting the backoff step'); assert.match(installationsSource, - /state\.enabled && !state\.loading\) loadInstallations\(\)/, + /layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/, 'the fired retry re-checks enablement and never races an in-flight load'); }); diff --git a/src/data/militaryRegistry.js b/src/data/militaryRegistry.js index d5af68fe4..8cd3e1ed9 100644 --- a/src/data/militaryRegistry.js +++ b/src/data/militaryRegistry.js @@ -1,119 +1,11 @@ -/** - * Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix). - * - * adsb.lol /v2/mil tags aircraft via its database's military flag; OpenSky - * carries no such tag, so military aircraft (e.g. ADAPT91/92) appeared in - * BOTH layers as duplicate icons and tracks. This registry reconciles them: - * - * - The military layer, while enabled, feeds every poll's ICAO set here and - * marks itself active — the commercial flights layer then SUPPRESSES its - * duplicates (military layer wins icon, track, and click). - * - While the military layer is OFF, the flights layer keeps a low-rate - * poll (60s against the dev proxy's cached /api/adsblol/mil) so known - * military aircraft are still classified and styled amber. - */ +import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; +import { createAdsbLolSource } from '../sources/live/standalone.js'; -const MIL_POLL_INTERVAL_MS = 60000; - -/** @type {Set} Lowercase ICAO24 hexes known to be military. */ -const _milIcaos = new Set(); -/** @type {boolean} True while the dedicated military layer is enabled. */ -let _militaryLayerActive = false; -/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ -const _activeChangeListeners = new Set(); -/** @type {number} Epoch ms of the last registry refresh (any source). */ -let _lastRefreshMs = 0; -/** @type {boolean} A self-poll fetch is in flight. */ -let _polling = false; - -/** - * True when the dedicated military layer currently renders these aircraft - * (the flights layer should suppress duplicates rather than restyle them). - * @returns {boolean} - */ -export function isMilitaryLayerActive() { - return _militaryLayerActive; -} - -/** - * Marks the dedicated military layer enabled/disabled. On a TRANSITION - * (value actually changed) the registered change listeners fire so the - * flights layer can reconcile its duplicates immediately instead of waiting - * out its 30 s poll (pre-ship audit M2). - * @param {boolean} active - Whether the military layer renders. - * @returns {void} - */ -export function setMilitaryLayerActive(active) { - const next = !!active; - if (next === _militaryLayerActive) return; - _militaryLayerActive = next; - for (const listener of _activeChangeListeners) { - try { - listener(next); - } catch { - // a broken listener must never break the layer toggle - } - } -} - -/** - * Subscribes to military-layer active-state transitions (fired only when the - * value changes, AFTER the new state is committed). - * @param {(active: boolean) => void} listener - Change callback. - * @returns {() => void} Unsubscribe function. - */ -export function onMilitaryLayerActiveChange(listener) { - if (typeof listener !== 'function') return () => {}; - _activeChangeListeners.add(listener); - return () => _activeChangeListeners.delete(listener); -} - -/** - * Replaces/extends the known-military set from a fresh poll. - * Adds only — transient dropouts from one poll must not declassify an - * aircraft mid-session (the set stays small: a few hundred hexes). - * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. - * @returns {void} - */ -export function registerMilitaryIcaos(icaos) { - for (const icao of icaos || []) { - const hex = String(icao || '').trim().toLowerCase(); - if (hex) _milIcaos.add(hex); - } - _lastRefreshMs = Date.now(); -} - -/** - * Whether an aircraft is known military. - * @param {string} icao24 - ICAO24 hex (any case). - * @returns {boolean} - */ -export function isMilitaryIcao(icao24) { - return _milIcaos.has(String(icao24 || '').toLowerCase()); -} - -/** - * Refreshes the registry from /api/adsblol/mil when stale — used by the - * flights layer so classification works while the military layer is off - * (the military layer's own polls keep it fresh otherwise). The dev proxy - * caches upstream responses, so this is nearly free. - * @returns {void} Fire-and-forget; failures leave the current set intact. - */ -export function refreshMilitaryRegistryIfStale() { - if (_militaryLayerActive) return; // military layer's polls keep us fresh - if (_polling || (Date.now() - _lastRefreshMs) < MIL_POLL_INTERVAL_MS) return; - _polling = true; - (async () => { - try { - const response = await fetch('/api/adsblol/mil', { signal: AbortSignal.timeout(10000) }); - if (!response.ok) return; - const data = await response.json(); - const aircraft = Array.isArray(data?.ac) ? data.ac : []; - registerMilitaryIcaos(aircraft.map((entry) => entry?.hex)); - } catch { - // keep the existing set on any failure - } finally { - _polling = false; - } - })(); -} +const registry = createMilitaryRegistry({ source: createAdsbLolSource() }); +export const isMilitaryLayerActive = registry.isMilitaryLayerActive; +export const setMilitaryLayerActive = registry.setMilitaryLayerActive; +export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange; +export const registerMilitaryIcaos = registry.registerMilitaryIcaos; +export const isMilitaryIcao = registry.isMilitaryIcao; +export const refreshMilitaryRegistryIfStale = + registry.refreshMilitaryRegistryIfStale; diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index aa42f1936..d91914805 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // Regression test for the giant-military-jet bug: MODEL_SCALE must put every // rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is. // @@ -358,7 +359,7 @@ function nativeVisualCenter(file) { // --- per-layer constants, read from source (see header) -------------------- function layerConstants(sourceFile) { - const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8'); + const src = readLayerSource(path.join(ROOT, sourceFile)); const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/); assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`); const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/); @@ -367,8 +368,8 @@ function layerConstants(sourceFile) { } function normalBillboardScaleByDistance(sourceFile) { - const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8'); - const fn = src.match(/function _normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); + const src = readLayerSource(path.join(ROOT, sourceFile)); + const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); @@ -391,7 +392,7 @@ const LAYERS = [ name: 'military', source: 'src/data/militaryFlights.js', asset: (() => { - const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8'); + const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/); assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found'); return m[1]; @@ -491,7 +492,7 @@ for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) { // PLANE_* constants — pin them to the measured meter-scale GLB + flights' // calibration so the copies cannot drift. test('military layer airplane.glb constants match the measured GLB and flights calibration', () => { - const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8'); + const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); const grab = (name) => { const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`)); assert.ok(m, `militaryFlights.js: ${name} not found`); diff --git a/src/data/radio.js b/src/data/radio.js index bc7c57f65..bb454344e 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,2876 +1,99 @@ -/** - * Internet-radio station directory and direct-playback layer. - * - * Radio Browser supplies public-domain directory metadata through the local - * `/api/radio/*` broker. Audio always travels directly from the broadcaster - * to one active HTMLAudioElement after an explicit user action; GEV does not - * proxy, cache, record, or redistribute streams. - * - * @module radio - */ -import * as Cesium from 'cesium'; -import { cachedGroundFloor, warmGroundFloor } from './groundFloor.js'; -import { normalizeRadioCountryInput } from './radioCountry.js'; -import { normalizeRadioFilter } from './layerState.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { - isOwnedByOtherLayer, - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { - earthDiscScreenRadius, - getKeyholeGeometry, - GLOBE_ENTER_CLEARANCE_PX, - isFullGlobeInsideKeyhole, - projectEarthDiscToViewport, -} from '../celestialRing.js'; -import { governorRequestRender } from '../renderGovernor.js'; +import { createRadioSource } from '../layers/radio/source.js'; +import { createRadioLayer } from '../layers/radio/index.js'; +import * as ground from './groundFloor.js'; +import * as picking from './pickRegistry.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as globe from '../celestialRing.js'; +import * as render from '../renderGovernor.js'; -const RADIO_PREFIX = 'radio:'; -const DIRECTORY_ENDPOINT = '/api/radio/stations'; -const HORIZON_TICK_MS = 250; -const HORIZON_CAMERA_MOVE_EPSILON_M = 1; -const MARKER_LIFT_M = 2.5; -const SELECTED_LIFT_M = 5; -const RADIO_PICK_TOLERANCE_PX = 8; -const RADIO_TUNER_DIRECTORY_LIMIT = 750; -const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT; -const RADIO_TUNER_STATIC_MAX_GAIN = 0.018; -const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000; -const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; -const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; -const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export const RADIO_OVERLAY_SOURCE_ID = 'radio'; -export const RADIO_OVERLAY_COHORT_LIMIT = 64; -export const RADIO_SINGLETON_GLOBAL_LIMIT = 16; -export const RADIO_SINGLETON_MID_LIMIT = 32; -export const RADIO_SINGLETON_NEAR_LIMIT = 48; -export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000; -export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000; -export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: RADIO_OVERLAY_COHORT_LIMIT, - collisionCapacity: 96, - moving: false, +const layer = createRadioLayer({ + source: createRadioSource(), + services: { ground, picking, overlays, globe, render }, }); -const RADIO_PICK_OFFSETS = Object.freeze([ - [0, -RADIO_PICK_TOLERANCE_PX], - [RADIO_PICK_TOLERANCE_PX, 0], - [0, RADIO_PICK_TOLERANCE_PX], - [-RADIO_PICK_TOLERANCE_PX, 0], - [6, -6], - [6, 6], - [-6, 6], - [-6, -6], -]); -const _radioEarthScreenCenter = new Cesium.Cartesian2(); -const _radioEarthToCenter = new Cesium.Cartesian3(); -export const DEFAULT_RADIO_FILTER = 'all'; -export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; - -const MUSIC_GENRES = Object.freeze([ - ['alternative', 'Alternative'], - ['ambient', 'Ambient'], - ['blues', 'Blues'], - ['classical', 'Classical'], - ['country', 'Country'], - ['dance', 'Dance'], - ['electronic', 'Electronic'], - ['folk', 'Folk'], - ['funk', 'Funk'], - ['hip hop', 'Hip-Hop'], - ['house', 'House'], - ['indie', 'Indie'], - ['jazz', 'Jazz'], - ['latin', 'Latin'], - ['metal', 'Metal'], - ['oldies', 'Oldies'], - ['pop', 'Pop'], - ['punk', 'Punk'], - ['r&b', 'R&B'], - ['reggae', 'Reggae'], - ['rock', 'Rock'], - ['soul', 'Soul'], - ['techno', 'Techno'], - ['trance', 'Trance'], - ['world', 'World'], -]); - -const CATEGORY_MATCHERS = Object.freeze({ - news: ['news', 'current affairs', 'journalism'], - talk: ['talk', 'spoken word', 'interview', 'podcast'], - weather: ['weather', 'emergency', 'noaa'], - 'public-safety': ['public safety', 'scanner', 'police', 'fire', 'ems', 'dispatch', 'emergency'], - 'aviation-marine': ['aviation', 'air traffic', 'atc', 'airport', 'marine', 'maritime', 'coast guard'], - 'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'], -}); - -const RADIO_CATEGORY_COLORS = Object.freeze({ - all: '#b9fbff', - news: '#44adff', - talk: '#f2b84b', - weather: '#ff5c78', - 'public-safety': '#ff8b4a', - 'aviation-marine': '#a87cff', - 'traffic-transit': '#ffd166', - music: '#54d17a', - other: '#9aa7b3', -}); - -const RADIO_CLUSTER_LABELS = Object.freeze({ - news: 'NEWS', - talk: 'TALK', - weather: 'WEATHER', - 'public-safety': 'SAFETY', - 'aviation-marine': 'AIR / SEA', - 'traffic-transit': 'TRANSIT', - music: 'MUSIC', - other: 'OTHER', -}); - -const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([ - 'news', - 'public-safety', - 'weather', - 'aviation-marine', - 'traffic-transit', - 'talk', - 'music', -]); -const DEFAULT_RADIO_VOLUME = 0.8; -const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({ - instance: null, - generation: null, - updatedAt: null, - stations: Object.freeze([]), - stationIds: Object.freeze([]), -}); - -let _viewer = null; -let _dataSource = null; -let _enabled = false; -let _managerLifecyclePresentation = null; -let _loading = false; -let _stale = false; -let _degraded = false; -let _error = null; -let _updatedAt = null; -let _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; -let _stations = []; -let _stationById = new Map(); -let _categories = []; -let _renderById = new Map(); -let _filter = DEFAULT_RADIO_FILTER; -let _selectedId = null; -let _selectedEntity = null; -let _selectionGeneration = 0; -let _selectionTimer = null; -let _radioCameraNavigationGeneration = 0; -let _radioCameraFlightSequence = 0; -let _activeRadioCameraFlight = null; -let _audio = null; -let _audioStationId = null; -let _audioState = 'stopped'; -let _audioError = null; -let _userVolume = DEFAULT_RADIO_VOLUME; -let _tuningActive = false; -let _tuningStatic = false; -let _tuningAwaitingStationId = null; -let _tuningPreviewId = null; -let _tuningStartStationId = null; -let _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; -let _tuningStationById = new Map(); -let _tuningUnavailableStationId = null; -let _cancelledTuningPresentationStation = null; -let _tuningCameraNavigation = null; -let _tuningNoiseContext = null; -let _tuningNoiseSource = null; -let _tuningNoiseFilter = null; -let _tuningNoiseGain = null; -let _voiceDucked = false; -let _voiceRestoring = false; -let _voiceRestoreTimer = null; -let _volumeFadeFrame = null; -let _volumeTransitionGeneration = 0; -let _playGeneration = 0; -let _playAttemptSequence = 0; -let _activePlaybackAttempt = null; -let _playFallbackId = null; -let _playFallbackFocus = null; -let _playFallbackOrigin = 'programmatic'; -let _playFallbackAttemptId = null; -let _clickHandler = null; -let _horizonTimer = null; -let _lastHorizonCameraPosition = null; -let _horizonScanCount = 0; -let _abortController = null; -let _requestGeneration = 0; -let _sessionGeneration = 0; -let _removeClusterListener = null; -let _overlayPublishTimer = null; -let _clusterOverlayIdentitySequence = 0; -let _clusterOverlayIdentities = []; -let _overlayDiagnostics = { - entryCount: 0, - selectedCount: 0, - singletonTexts: [], - singletonIds: [], - clusterTexts: [], - clusterIds: [], - clusterMemberships: [], -}; -const _listeners = new Set(); -const _playbackControlListeners = new Set(); -const VOICE_RESTORE_DELAY_MS = 650; -const VOICE_RESTORE_DURATION_MS = 1800; - -function isNonGlobalRadioIpv4(hostname) { - const pieces = hostname.split('.'); - if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) return false; - const values = pieces.map(Number); - if (values.some((value) => value > 255)) return true; - const [a, b, c] = values; - return a === 0 || a === 10 || a === 127 || a >= 224 - || (a === 100 && b >= 64 && b <= 127) - || (a === 169 && b === 254) - || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && b === 0) - || (a === 192 && b === 88 && c === 99) - || (a === 192 && b === 168) - || (a === 198 && (b === 18 || b === 19)) - || (a === 198 && b === 51 && c === 100) - || (a === 203 && b === 0 && c === 113); -} - -function isSafeRadioHttpsUrl(value) { - if (typeof value !== 'string' || !value) return false; - try { - const url = new URL(value); - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); - if (url.protocol !== 'https:' || url.username || url.password || !hostname) return false; - return !( - hostname === 'localhost' - || hostname.endsWith('.localhost') - || hostname.endsWith('.local') - || isNonGlobalRadioIpv4(hostname) - || hostname.includes(':') - ); - } catch { - return false; - } -} - -function isValidRadioDirectoryStation(station) { - const cleanText = (value, maxLength, { allowEmpty = true } = {}) => ( - typeof value === 'string' - && value.length <= maxLength - && (allowEmpty || value.trim().length > 0) - && !/[\u0000-\u001f\u007f]/.test(value) - && value === value.trim() - && !/\s{2,}/.test(value) - ); - const textArray = (value, limit, itemMaxLength) => ( - Array.isArray(value) - && value.length <= limit - && value.every((item) => cleanText(item, itemMaxLength, { allowEmpty: false })) - ); - return Boolean( - station - && RADIO_UUID_RE.test(station.id) - && cleanText(station.name, 140, { allowEmpty: false }) - && Number.isFinite(station.lat) && station.lat >= -90 && station.lat <= 90 - && Number.isFinite(station.lon) && station.lon >= -180 && station.lon <= 180 - && isSafeRadioHttpsUrl(station.streamUrl) - && (station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) - && textArray(station.tags, 24, 80) - && textArray(station.languages, 8, 40) - && cleanText(station.state, 80) - && cleanText(station.country, 80) - && cleanText(station.countryCode, 2) - && (station.countryCode === '' || normalizeRadioCountryInput(station.countryCode).valid) - && station.metadataTrust === 'untrusted-community' - && cleanText(station.codec, 16, { allowEmpty: false }) - && /^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) - && (station.bitrate === null - || (Number.isInteger(station.bitrate) && station.bitrate >= 8 && station.bitrate <= 1024)) - ); -} - -function freezeRadioStation(station) { - return Object.freeze({ - id: station.id, - name: station.name, - lat: station.lat, - lon: station.lon, - streamUrl: station.streamUrl, - homepage: station.homepage, - tags: Object.freeze([...station.tags]), - languages: Object.freeze([...station.languages]), - state: station.state, - country: station.country, - countryCode: station.countryCode, - metadataTrust: station.metadataTrust, - codec: station.codec, - bitrate: station.bitrate, - }); -} - -function createAcceptedCatalogSnapshot(instance, generation, updatedAt, stations) { - if (!Number.isSafeInteger(generation) || generation < 1) return null; - if (typeof instance !== 'string' || !instance) return null; - const frozenStations = Object.freeze(stations.map(freezeRadioStation)); - return Object.freeze({ - instance, - generation, - updatedAt, - stations: frozenStations, - stationIds: Object.freeze(frozenStations.map((station) => station.id)), - }); -} - -const RADIO_GLOBE_LABEL_MAX_CHARS = 30; -const RADIO_LABEL_SEGMENTER = typeof Intl?.Segmenter === 'function' - ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) - : null; - -function compactRadioLabelText(value, maxChars) { - const text = String(value || '').replace(/\s+/g, ' ').trim(); - const graphemes = RADIO_LABEL_SEGMENTER - ? [...RADIO_LABEL_SEGMENTER.segment(text)].map((segment) => segment.segment) - : Array.from(text); - if (graphemes.length <= maxChars) return text; - return `${graphemes.slice(0, Math.max(1, maxChars - 1)).join('').trimEnd()}…`; -} - -function cleanRadioLabelName(value) { - return String(value || '') - .replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '') - .replace(/\s+/g, ' ') - .replace(/\s+\([^()]{1,40}\)$/, '') - .replace(/\s+FM$/i, '') - .trim(); -} - -/** Return compact frequency-first text for a Radio globe label. */ -export function radioGlobeLabel(station) { - const fullName = String(station?.name || '').replace(/\s+/g, ' ').trim(); - if (!fullName) return ''; - - let frequency = null; - let labelName = ''; - const explicitMatches = [...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi)]; - const explicit = explicitMatches.find((match) => { - const value = Number(match[1]); - return value >= 64 && value <= 108; - }); - if (explicit) { - frequency = explicit[1]; - const before = cleanRadioLabelName(fullName.slice(0, explicit.index)); - const after = cleanRadioLabelName(fullName.slice(explicit.index + explicit[0].length)); - labelName = before || after; - } else { - // Infer only a leading decimal in the conventional FM band. Integers such - // as "80's" or "100 GREATEST" and domains such as "1.fm" stay names. - const leading = fullName.match(/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/); - const value = Number(leading?.[1]); - if (leading && value >= 87.5 && value <= 108) { - frequency = leading[1]; - labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]); - } - } - - if (!frequency) return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS); - const prefix = `${frequency} FM`; - if (!labelName) return prefix; - const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3; - return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`; -} - -/** Build the protected selected-station text published through WorldOverlay. */ -export function createRadioSelectedOverlayEntry(station, position) { - if (!station?.id || !station?.name || !position) return null; - return { - id: `selected:${station.id}`, - position, - variant: 'selected', - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-label', - priority: Number.MAX_SAFE_INTEGER, - title: radioGlobeLabel(station), - accent: radioCategoryColor(radioStationCategoryId(station)), - interactive: false, - anchorRadiusPx: 20, - minAnchorGapPx: 8, - verticalOnly: true, - placement: 'above', - gapPx: 8, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - }; -} - -/** Build one bounded ambient cluster badge for the shared overlay host. */ -export function createRadioClusterOverlayEntry({ id, position, text, accent, stationCount }) { - if (!id || !position || !text) return null; - return { - id: `cluster:${id}`, - position, - variant: 'label', - paintLane: 'ambient-label', - collisionGroup: 'ambient-label', - priority: Math.max(1, Number(stationCount) || 1), - title: text, - accent: accent || RADIO_CATEGORY_COLORS.other, - interactive: false, - anchorRadiusPx: Math.min(13, 6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8), - minAnchorGapPx: 4, - verticalOnly: true, - placement: 'above', - gapPx: 6, - // Cluster membership/counts update continuously while the camera moves. - // Paint them as hard-opacity incumbents so a replacement count never - // cross-fades with its predecessor or dims merely for leaving the keyhole. - // Collision, viewport rejection, and horizon culling remain host-owned. - stateless: true, - edgeFade: 'none', - horizonCull: true, - terrainOcclusion: false, - // The camera can sit above 24,000 km in the supported full-globe view, and - // horizon clusters are farther from it than the camera's surface altitude. - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - distanceScale: { - near: 100_000, - nearValue: 1.08, - far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - farValue: 0.92, - }, - }; -} - -/** Build one ambient station label while Cesium retains its point and picking. */ -export function createRadioSingletonOverlayEntry({ station, position, priority = 1 }) { - if (!station?.id || !station?.name || !position) return null; - return { - id: `station:${station.id}`, - position, - variant: 'label', - paintLane: 'ambient-label', - collisionGroup: 'ambient-label', - priority: Math.max(1, Number(priority) || 1), - title: radioGlobeLabel(station), - accent: radioCategoryColor(radioStationCategoryId(station)), - interactive: false, - anchorRadiusPx: 9, - minAnchorGapPx: 4, - verticalOnly: true, - placement: 'above', - gapPx: 5, - // Camera/filter changes replace this bounded cohort. Prevent the prior - // cohort from fading over the current visible points. - stateless: true, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - distanceScale: { - near: 100_000, - nearValue: 1, - far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - farValue: 0.86, - }, - }; -} - -/** Return the bounded singleton-label allowance for the current camera scale. */ -export function radioSingletonLabelLimit(cameraHeightM) { - const height = Math.max(0, Number(cameraHeightM) || 0); - if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT; - if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT; - return RADIO_SINGLETON_NEAR_LIMIT; -} - -/** Rank visible singleton stations by camera distance and stable station id. */ -export function selectRadioSingletonCandidates(candidates, limit = RADIO_SINGLETON_NEAR_LIMIT) { - const distance = (candidate) => { - const value = Number(candidate?.distanceM); - return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY; - }; - return [...(candidates || [])] - .sort((a, b) => ( - distance(a) - distance(b) - || String(a?.station?.id || a?.id || '').localeCompare(String(b?.station?.id || b?.id || '')) - )) - .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); -} - -/** Rank and cap ambient Radio clusters before shared-host entry allocation. */ -export function selectRadioClusterCandidates(candidates, limit = RADIO_OVERLAY_COHORT_LIMIT) { - return [...(candidates || [])] - .sort((a, b) => ( - (Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) - || String(a?.id || '').localeCompare(String(b?.id || '')) - )) - .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); -} - -/** - * Preserve one-to-one overlay identity for overlapping clusters. - * Cesium clustering is screen-space, so a small camera move may alter exact - * membership even when the same geographic cluster remains visible. Every - * positive overlap participates in greatest-contributor discovery, while - * inheritance remains mutual-best: a current cluster accepts only one of its - * greatest contributors and a prior identity transfers only to one of its - * strongest split children. - */ -export function reconcileRadioClusterCandidates(candidates, previous = [], createId = null) { - const current = (Array.isArray(candidates) ? candidates : []).map((candidate, index) => { - const stationIds = [...new Set((candidate?.stationIds || []).map((id) => String(id)))].sort(); - const membershipId = String(candidate?.id || `membership:${stationIds.join('|')}`); - return { - ...candidate, - _reconcileIndex: index, - _reconcileMembershipId: membershipId, - _reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`, - stationIds, - }; - }); - const priorMembershipsByIdentity = new Map(); - for (const candidate of Array.isArray(previous) ? previous : []) { - const identityId = String(candidate?.identityId || candidate?.id || ''); - if (!identityId) continue; - if (!priorMembershipsByIdentity.has(identityId)) { - priorMembershipsByIdentity.set(identityId, new Set()); - } - const membership = priorMembershipsByIdentity.get(identityId); - for (const stationId of candidate?.stationIds || []) membership.add(String(stationId)); - } - const prior = [...priorMembershipsByIdentity] - .map(([identityId, stationIds]) => ({ - identityId, - stationIds: [...stationIds].sort(), - })) - .filter((candidate) => candidate.stationIds.length) - .sort((a, b) => a.identityId.localeCompare(b.identityId)); - const priorByStation = new Map(); - for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) { - for (const stationId of prior[priorIndex].stationIds) { - if (!priorByStation.has(stationId)) priorByStation.set(stationId, []); - priorByStation.get(stationId).push(priorIndex); - } - } - - const edges = []; - const greatestOverlapByCurrent = new Map(); - const greatestOverlapByPrior = new Map(); - for (let currentIndex = 0; currentIndex < current.length; currentIndex += 1) { - const overlapByPrior = new Map(); - for (const stationId of current[currentIndex].stationIds) { - for (const priorIndex of priorByStation.get(stationId) || []) { - overlapByPrior.set(priorIndex, (overlapByPrior.get(priorIndex) || 0) + 1); - } - } - for (const [priorIndex, overlap] of overlapByPrior) { - const union = current[currentIndex].stationIds.length + prior[priorIndex].stationIds.length - overlap; - const smaller = Math.min(current[currentIndex].stationIds.length, prior[priorIndex].stationIds.length); - const score = smaller > 0 ? overlap / smaller : 0; - const similarity = union > 0 ? overlap / union : 0; - edges.push({ currentIndex, priorIndex, overlap, score, similarity }); - greatestOverlapByCurrent.set( - currentIndex, - Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap), - ); - greatestOverlapByPrior.set( - priorIndex, - Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap), - ); - } - } - const eligibleEdges = edges.filter((edge) => ( - edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) - && edge.overlap === greatestOverlapByPrior.get(edge.priorIndex) - )); - eligibleEdges.sort((a, b) => ( - // Identity follows the contributor representing the largest share of the - // new cluster. Overlap-coefficient-first incorrectly lets a fully retained - // two-station minority beat a larger partial contributor during a merge. - b.overlap - a.overlap - || b.score - a.score - || b.similarity - a.similarity - || current[a.currentIndex]._reconcileCanonicalKey - .localeCompare(current[b.currentIndex]._reconcileCanonicalKey) - || prior[a.priorIndex].identityId.localeCompare(prior[b.priorIndex].identityId) - )); - - const inheritedByCurrent = new Map(); - const usedPriorIdentities = new Set(); - for (const edge of eligibleEdges) { - const identityId = prior[edge.priorIndex].identityId; - if (inheritedByCurrent.has(edge.currentIndex) || usedPriorIdentities.has(identityId)) continue; - inheritedByCurrent.set(edge.currentIndex, prior[edge.priorIndex].identityId); - usedPriorIdentities.add(identityId); - } - - const generatedByCurrent = new Map(); - if (typeof createId === 'function') { - const freshIndices = current - .map((candidate, index) => ({ candidate, index })) - .filter(({ index }) => !inheritedByCurrent.has(index)) - .sort((a, b) => ( - a.candidate._reconcileCanonicalKey.localeCompare(b.candidate._reconcileCanonicalKey) - )); - for (const { candidate, index } of freshIndices) { - generatedByCurrent.set(index, String(createId(candidate, index) || '')); - } - } - - return current.map((candidate, index) => { - const membershipId = candidate._reconcileMembershipId; - const inherited = inheritedByCurrent.get(index); - const generated = generatedByCurrent.get(index) || ''; - const identityId = inherited || generated || membershipId; - const { - _reconcileIndex, - _reconcileMembershipId, - _reconcileCanonicalKey, - ...rest - } = candidate; - return { ...rest, membershipId, identityId, id: identityId }; - }); -} - -function resetRadioClusterOverlayIdentities() { - _clusterOverlayIdentities = []; -} - -function emptyRadioOverlayDiagnostics() { - return { - entryCount: 0, - selectedCount: 0, - singletonTexts: [], - singletonIds: [], - clusterTexts: [], - clusterIds: [], - clusterMemberships: [], - }; -} - -function clampRadioVolume(value) { - return Math.min(1, Math.max(0, Number(value) || 0)); -} - -/** Return whether camera translation materially changes horizon visibility. */ -export function radioCameraPositionChanged(previous, current, epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M) { - if (!previous || !current) return true; - const dx = Number(current.x) - Number(previous.x); - const dy = Number(current.y) - Number(previous.y); - const dz = Number(current.z) - Number(previous.z); - if (![dx, dy, dz].every(Number.isFinite)) return true; - const threshold = Math.max(0, Number(epsilonM) || 0); - return dx * dx + dy * dy + dz * dz > threshold * threshold; -} - -function cancelRadioVolumeTransition() { - _volumeTransitionGeneration += 1; - if (_voiceRestoreTimer) clearTimeout(_voiceRestoreTimer); - _voiceRestoreTimer = null; - if (_volumeFadeFrame) { - if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(_volumeFadeFrame); - else clearTimeout(_volumeFadeFrame); - } - _volumeFadeFrame = null; -} - -function scheduleVolumeFrame(callback) { - if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback); - return setTimeout(() => callback(Date.now()), 16); -} - -function volumeClock() { - return typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); -} - -/** Normalize one directory tag to a stable, lower-case display token. */ -export function normalizeRadioTag(value) { - return String(value ?? '') - .trim() - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .slice(0, 80); -} - -function stationTags(station) { - if (Array.isArray(station?.tags)) return station.tags.map(normalizeRadioTag).filter(Boolean); - return String(station?.tags ?? '') - .split(',') - .map(normalizeRadioTag) - .filter(Boolean); -} - -function hasTag(station, needles) { - const tags = stationTags(station); - return needles.some((needle) => tags.some((tag) => tag === needle || tag.includes(needle))); -} - -function detectedGenres(station) { - return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(([genre]) => genre); -} - -/** Return whether a station belongs in a station-tag category. */ -export function stationMatchesRadioCategory(station, categoryId) { - if (categoryId === 'all') return true; - if (categoryId.startsWith('genre:')) { - return detectedGenres(station).includes(categoryId.slice('genre:'.length)); - } - if (categoryId === 'music') { - return detectedGenres(station).length > 0 - || hasTag(station, ['music', 'hits', 'songs']); - } - if (categoryId === 'other') { - return !Object.entries(CATEGORY_MATCHERS).some(([id]) => stationMatchesRadioCategory(station, id)) - && !stationMatchesRadioCategory(station, 'music'); - } - return hasTag(station, CATEGORY_MATCHERS[categoryId] || []); -} - -/** Return the shared CSS color for a canonical or detected-genre category. */ -export function radioCategoryColor(categoryId = 'other') { - const normalized = String(categoryId || 'other'); - const canonical = normalized.startsWith('genre:') ? 'music' : normalized; - return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other; -} - -/** Format the concise count/category badge shown above a Radio cluster. */ -export function radioClusterBadgeText(categoryId = 'other', count = 0) { - const normalized = String(categoryId || 'other'); - const canonical = normalized.startsWith('genre:') ? 'music' : normalized; - const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other; - const stationCount = Math.max(0, Math.floor(Number(count) || 0)); - return `${stationCount} ${label}`; -} - -/** Choose the category advertised by a cluster in the active station-tag view. */ -export function radioClusterCategoryId(stations, activeFilter = 'all') { - const filter = String(activeFilter || 'all'); - if (filter !== 'all') { - if (filter.startsWith('genre:') || RADIO_MARKER_CATEGORY_ORDER.includes(filter) || filter === 'other') { - return filter; - } - return 'other'; - } - const categoryCounts = new Map(); - for (const station of Array.isArray(stations) ? stations : []) { - const categoryId = radioStationCategoryId(station); - categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1); - } - let clusterCategory = 'other'; - let clusterCategoryCount = 0; - for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) { - const count = categoryCounts.get(categoryId) || 0; - if (count > clusterCategoryCount) { - clusterCategory = categoryId; - clusterCategoryCount = count; - } - } - if ((categoryCounts.get('other') || 0) > clusterCategoryCount) return 'other'; - return clusterCategory; -} - -/** Build the code-native four-corner bracket used by the selected station. */ -export function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) { - const stroke = /^#[0-9a-f]{6}$/i.test(String(color)) ? String(color) : RADIO_CATEGORY_COLORS.other; - return ``; -} - -/** Choose one stable display category for a station that may match several filters. */ -export function radioStationCategoryId(station) { - return RADIO_MARKER_CATEGORY_ORDER.find((categoryId) => ( - stationMatchesRadioCategory(station, categoryId) - )) || 'other'; -} - -/** Build canonical and detected-genre categories from station-level tags. */ -export function buildRadioCategories(stations) { - const rows = Array.isArray(stations) ? stations : []; - const categories = [ - { id: 'all', label: 'All' }, - { id: 'news', label: 'News' }, - { id: 'talk', label: 'Talk' }, - { id: 'weather', label: 'Weather / Emergency' }, - { id: 'public-safety', label: 'Public Safety' }, - { id: 'aviation-marine', label: 'Aviation / Marine' }, - { id: 'traffic-transit', label: 'Traffic / Transit' }, - { id: 'music', label: 'Music' }, - ]; - - for (const [genre, label] of MUSIC_GENRES) { - const id = `genre:${genre}`; - if (rows.some((station) => stationMatchesRadioCategory(station, id))) { - categories.push({ id, label }); - } - } - categories.push({ id: 'other', label: 'Other' }); - return categories.map((category) => ({ - ...category, - color: radioCategoryColor(category.id), - count: rows.filter((station) => stationMatchesRadioCategory(station, category.id)).length, - })); -} - -/** Filter stations without changing the active stream or selection. */ -export function filterRadioStations(stations, categoryId = 'all') { - return (Array.isArray(stations) ? stations : []) - .filter((station) => stationMatchesRadioCategory(station, categoryId)); -} - -/** Return whether Radio Browser metadata identifies a station as English-language. */ -export function isEnglishRadioStation(station) { - const languages = Array.isArray(station?.languages) ? station.languages : []; - return languages.some((language) => { - const normalized = normalizeRadioTag(language); - return normalized === 'en' || normalized === 'eng' || normalized.startsWith('english'); - }); -} - -function radioAngularDistance(station, anchor) { - const lat1 = Cesium.Math.toRadians(Number(anchor?.lat)); - const lon1 = Cesium.Math.toRadians(Number(anchor?.lon)); - const lat2 = Cesium.Math.toRadians(Number(station?.lat)); - const lon2 = Cesium.Math.toRadians(Number(station?.lon)); - if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return Number.POSITIVE_INFINITY; - const deltaLat = lat2 - lat1; - const deltaLon = lon2 - lon1; - const haversine = Math.sin(deltaLat / 2) ** 2 - + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2; - return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine)))); -} - -/** Rank a copied station list by viewport distance, with an optional English-first tier. */ -export function rankRadioStationsForViewport(stations, anchor, { preferEnglish = false } = {}) { - return (Array.isArray(stations) ? stations : []) - .map((station, index) => ({ - station, - index, - distance: radioAngularDistance(station, anchor), - languageTier: preferEnglish && !isEnglishRadioStation(station) ? 1 : 0, - })) - .sort((a, b) => a.languageTier - b.languageTier || a.distance - b.distance || a.index - b.index) - .map(({ station }) => station); -} - -/** Rank stations for an explicit voice/player request without moving the camera. */ -export function rankRadioStationsForRequest(stations, { - categoryId = 'all', - anchor = null, - country = '', - stationQuery = '', -} = {}) { - const countryFilter = normalizeRadioCountryInput(country); - if (!countryFilter.valid) return []; - const query = normalizeRadioTag(stationQuery); - let matches = filterRadioStations(stations, categoryId); - if (countryFilter.code || countryFilter.name) { - matches = matches.filter((station) => { - const stationCode = String(station?.countryCode || '').trim().toUpperCase(); - const stationCountry = normalizeRadioCountryInput(station?.country); - return (countryFilter.code && stationCode === countryFilter.code) - || (countryFilter.code && stationCountry.valid && stationCountry.code === countryFilter.code); - }); - } - if (query) { - matches = matches.filter((station) => [ - station?.id, - station?.name, - station?.state, - station?.country, - station?.countryCode, - ...(Array.isArray(station?.tags) ? station.tags : []), - ].some((value) => normalizeRadioTag(value).includes(query))); - } - return anchor - ? rankRadioStationsForViewport(matches, anchor) - : matches.slice(); -} - -/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */ -export function radioViewIsGlobal(altitudeM) { - return Number.isFinite(altitudeM) && Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M; -} - -/** Pure stale-response guard shared by the async directory update path. */ -export function radioRequestIsCurrent( - generation, - currentGeneration, - enabled, - sessionGeneration = null, - currentSessionGeneration = sessionGeneration, -) { - return generation === currentGeneration - && Boolean(enabled) - && sessionGeneration === currentSessionGeneration; -} - -/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ -export function radioStationIdFromPick(picked) { - const pending = [picked?.id, picked?.primitive?.id]; - const seen = new Set(); - while (pending.length) { - const value = pending.shift(); - if (typeof value === 'string' || typeof value === 'number') { - const id = String(value); - if (id.startsWith(`${RADIO_PREFIX}selected:`)) return id.slice(`${RADIO_PREFIX}selected:`.length); - if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length); - continue; - } - if (!value || typeof value !== 'object' || seen.has(value)) continue; - seen.add(value); - if (Array.isArray(value)) pending.push(...value); - else { - pending.push(value.id); - if (value.primitive) pending.push(value.primitive.id); - } - } - return null; -} - -/** Map an integer tuner slot directly to one available directory station. */ -export function radioTunerSlot(value, stationCount) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 }; - const max = Math.max(0, count - 1); - const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); - return { - slot, - max, - locked: true, - stationIndex: slot, - leftIndex: slot, - rightIndex: slot, - }; -} - -/** Snap a tuner release to the nearest available directory station. */ -export function radioTunerCommitSlot(value, stationCount) { - return radioTunerSlot(value, stationCount); -} - -/** Map one pointer coordinate to continuous absolute directory progress. */ -export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; - if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); - const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth)); - const coordinate = ratio * (count - 1); - return { - ratio, - coordinate, - stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), - }; -} - -/** Build a bounded virtual tuner tape around one continuous directory coordinate. */ -export function buildRadioTunerTicks(coordinate, stationCount, width, { - insetPx = 7, - minPitchPx = 14, - speedFactor = 5, - overscan = 2, - labelStep = 6, -} = {}) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - const dialWidth = Math.max(0, Number(width) || 0); - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(0, dialWidth - inset * 2); - if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 }; - const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); - if (count === 1) { - return { - ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], - needleX: inset + usableWidth / 2, - pitchPx: Math.max(1, Number(minPitchPx) || 14), - ratio: 0.5, - }; - } - const directoryStep = usableWidth / (count - 1); - const pitchPx = Math.max( - Math.max(1, Number(minPitchPx) || 14), - directoryStep * Math.max(1, Number(speedFactor) || 5), - ); - const needleX = inset + directoryStep * value; - const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); - const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); - const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); - const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); - const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6)); - const labelWidth = Math.max(2, String(count).length); - const ticks = []; - for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { - const channel = stationIndex + 1; - const current = stationIndex === currentIndex; - const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; - ticks.push({ - stationIndex, - channel, - xPx: needleX + pitchPx * (stationIndex - value), - current, - label: labelled ? String(channel).padStart(labelWidth, '0') : '', - }); - } - return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; -} - -/** Plan a station-centered camera move that preserves altitude and view angle. */ -export function radioStationCameraPlan(station, cameraState = {}) { - const targetLat = Number(station?.lat); - const targetLon = Number(station?.lon); - const height = Math.max(1, Number(cameraState.height) || 1); - const heading = Number.isFinite(cameraState.heading) ? cameraState.heading : 0; - const pitch = Number.isFinite(cameraState.pitch) ? cameraState.pitch : -Math.PI / 2; - const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0; - if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null; - - const downAngle = Math.min(Math.PI / 2, Math.max(0.08, Math.abs(Math.min(-0.001, pitch)))); - const groundOffsetM = downAngle > Math.PI / 2 - 1e-6 - ? 0 - : Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle))); - const angularDistance = groundOffsetM / 6_378_137; - const targetLatRad = targetLat * Math.PI / 180; - const cameraBearing = heading + Math.PI; - const cameraLatRad = Math.asin( - Math.sin(targetLatRad) * Math.cos(angularDistance) - + Math.cos(targetLatRad) * Math.sin(angularDistance) * Math.cos(cameraBearing), - ); - const cameraLonRad = targetLon * Math.PI / 180 + Math.atan2( - Math.sin(cameraBearing) * Math.sin(angularDistance) * Math.cos(targetLatRad), - Math.cos(angularDistance) - Math.sin(targetLatRad) * Math.sin(cameraLatRad), - ); - const cameraLon = ((cameraLonRad * 180 / Math.PI + 540) % 360) - 180; - return { - lat: cameraLatRad * 180 / Math.PI, - lon: cameraLon, - height, - heading, - pitch, - roll, - }; -} - -/** - * Decide whether Radio navigation should first restore a centered Earth view. - * Fit-capable clipped discs and closer views whose optical center has reached - * the Earth limb use the staged path; ordinary centered local views stay direct. - */ -export function radioGlobeNeedsRecentering(geometry) { - if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false; - const centeredGeometry = { - ...geometry, - earthCenterX: geometry.keyholeCenterX, - earthCenterY: geometry.keyholeCenterY, - }; - if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true; - const values = [ - geometry.earthCenterX, - geometry.earthCenterY, - geometry.earthRadius, - geometry.keyholeCenterX, - geometry.keyholeCenterY, - ]; - if (!values.every(Number.isFinite) || geometry.earthRadius <= 0) return false; - const centerOffset = Math.hypot( - geometry.earthCenterX - geometry.keyholeCenterX, - geometry.earthCenterY - geometry.keyholeCenterY, - ); - return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX; -} - -/** Preserve closer zoom while capping an extreme full-globe recovery. */ -export function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) { - if (!Number.isFinite(currentHeight) || currentHeight < 0) return null; - return fullGlobeCapable - ? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M) - : currentHeight; -} - -/** Bound one already ordered filtered directory without injecting outside selections. */ -export function buildRadioTunerBand(rankedStations, selected, limit = RADIO_TUNER_STATION_LIMIT) { - const boundedLimit = Math.min( - RADIO_TUNER_DIRECTORY_LIMIT, - Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)), - ); - return (Array.isArray(rankedStations) ? rankedStations : []).slice(0, boundedLimit); -} - -/** Decide whether tuner static should be audible for the current handoff state. */ -export function radioTuningStaticShouldPlay({ - tuningActive = false, - tuningStatic = false, - awaitingStationId = null, - voiceDucked = false, -} = {}) { - return Boolean(tuningStatic && !voiceDucked && (tuningActive || awaitingStationId)); -} - -function markerPosition(station, liftM = MARKER_LIFT_M) { - const floor = cachedGroundFloor(station.lat, station.lon); - return Cesium.Cartesian3.fromDegrees( - station.lon, - station.lat, - (Number.isFinite(floor) ? floor : 0) + liftM, - ); -} - -function selectedStation() { - return _selectedId ? _stationById.get(_selectedId) || null : null; -} - -function selectedPresentationStation() { - if (_tuningActive) return tuningResolutionStation(_tuningPreviewId); - return _cancelledTuningPresentationStation || selectedStation(); -} - -function visibleStations() { - return filterRadioStations(_stations, _filter); -} - -function viewportRadioAnchor() { - const camera = _viewer?.camera; - const scene = _viewer?.scene; - if (!camera) return null; - const altitudeM = Number(camera.positionCartographic?.height); - let cartographic = null; - const canvas = scene?.canvas; - if (canvas && typeof camera.pickEllipsoid === 'function') { - const center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2); - const position = camera.pickEllipsoid(center, scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84); - if (position) cartographic = Cesium.Cartographic.fromCartesian(position); - } - cartographic ||= camera.positionCartographic || null; - if (!cartographic) return null; - return { - lat: Cesium.Math.toDegrees(cartographic.latitude), - lon: Cesium.Math.toDegrees(cartographic.longitude), - altitudeM, - globalView: radioViewIsGlobal(altitudeM), - }; -} - -function rankedVisibleStations() { - const anchor = viewportRadioAnchor(); - return rankRadioStationsForViewport(visibleStations(), anchor, { - preferEnglish: Boolean(anchor?.globalView), - }); -} - -/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */ -export function getRadioAcceptedCatalogSnapshot() { - return _acceptedCatalogSnapshot; -} - -/** Compare every station field that determines tuner presentation and playback. */ -export function radioStationResolutionMatches(frozenStation, currentStation) { - if (!frozenStation || !currentStation) return false; - for (const key of [ - 'id', 'name', 'lat', 'lon', 'streamUrl', 'homepage', 'state', 'country', - 'countryCode', 'metadataTrust', 'codec', 'bitrate', - ]) { - if (frozenStation[key] !== currentStation[key]) return false; - } - return ['tags', 'languages'].every((key) => { - const frozenValues = Array.isArray(frozenStation[key]) ? frozenStation[key] : []; - const currentValues = Array.isArray(currentStation[key]) ? currentStation[key] : []; - return frozenValues.length === currentValues.length - && frozenValues.every((value, index) => value === currentValues[index]); - }); -} - -function captureRadioTuningResolutionSnapshot() { - return _acceptedCatalogSnapshot; -} - -function tuningResolutionStation(id) { - return id ? _tuningStationById.get(String(id)) || null : null; -} - -/** Return an immutable snapshot consumed by the right-rail UI. */ -export function getRadioUIState() { - const visible = visibleStations(); - const selected = selectedStation(); - return Object.freeze({ - enabled: _enabled, - loading: _loading, - stale: _stale, - degraded: _degraded, - error: _error, - updatedAt: _updatedAt, - filter: _filter, - categories: _categories, - acceptedCatalogGeneration: _acceptedCatalogSnapshot.generation, - presentationActive: radioPresentationAllowed(), - stationCount: _stations.length, - filteredCount: visible.length, - selected, - selectedIndex: selected ? visible.findIndex((station) => station.id === selected.id) : -1, - audioState: _audioState, - audioError: _audioError, - playingStationId: _audioStationId, - volume: _userVolume, - effectiveVolume: _audio?.volume ?? (_voiceDucked ? 0 : _userVolume), - voiceDucked: _voiceDucked, - voiceRestoring: _voiceRestoring, - tuningActive: _tuningActive, - tuningStatic: _tuningStatic, - tuningAwaitingStationId: _tuningAwaitingStationId, - tuningPreviewStationId: _tuningPreviewId, - tuningRestoredStationId: _cancelledTuningPresentationStation?.id || null, - tuningCatalogGeneration: _tuningResolutionSnapshot.generation, - tuningUnavailableStationId: _tuningUnavailableStationId, - }); -} - -function emitState() { - const snapshot = getRadioUIState(); - for (const listener of _listeners) { - try { - listener(snapshot); - } catch { - // A broken consumer must not break playback or rendering. - } - } -} - -/** Subscribe to radio state; the current state is delivered immediately. */ -export function subscribeToRadio(listener) { - if (typeof listener !== 'function') return () => {}; - _listeners.add(listener); - listener(getRadioUIState()); - return () => _listeners.delete(listener); -} - -/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */ -export function subscribeToRadioPlaybackControls(listener) { - if (typeof listener !== 'function') return () => {}; - _playbackControlListeners.add(listener); - return () => _playbackControlListeners.delete(listener); -} - -function emitPlaybackControl(action, origin, attemptId = _activePlaybackAttempt?.id || null) { - const event = { action, origin, attemptId }; - for (const listener of _playbackControlListeners) { - try { - listener(event); - } catch { - // Playback controls must remain usable if an observer fails. - } - } -} - -function audioEventBelongsToActiveAttempt(audio) { - const attempt = _activePlaybackAttempt; - return _audio === audio - && Boolean(attempt) - && attempt.generation === _playGeneration - && attempt.stationId === _audioStationId; -} - -function installAudio({ replace = false } = {}) { - if (_audio && !replace) return; - if (typeof Audio === 'undefined') return; - const previousAudio = _audio; - _audio = null; - if (previousAudio) { - try { previousAudio.pause(); } catch { /* already stopped */ } - try { previousAudio.removeAttribute('src'); } catch { /* no source */ } - try { previousAudio.load(); } catch { /* detached media */ } - } - const audio = new Audio(); - _audio = audio; - audio.preload = 'none'; - audio.volume = _voiceDucked ? 0 : _userVolume; - audio.addEventListener('playing', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - _audioState = 'playing'; - _audioError = null; - if (_tuningAwaitingStationId === _audioStationId) clearRadioTuningNoise({ emit: false }); - emitState(); - }); - audio.addEventListener('pause', () => { - if (_audio !== audio) return; - if (_audioState === 'stopped' || _audioState === 'loading') return; - _audioState = 'paused'; - emitState(); - }); - audio.addEventListener('waiting', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - _audioState = 'buffering'; - emitState(); - }); - audio.addEventListener('error', () => { - if (_audio !== audio) return; - if (!audioEventBelongsToActiveAttempt(audio)) return; - if (!['loading', 'buffering', 'playing'].includes(_audioState)) return; - const failedId = _audioStationId; - const attempt = _activePlaybackAttempt; - if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return; - _audioState = 'error'; - _audioError = 'Broadcaster stream is unavailable or blocked by the browser.'; - emitState(); - }); -} - -function stopTuningNoiseSource() { - if (_tuningNoiseSource) { - try { _tuningNoiseSource.stop(); } catch { /* already stopped */ } - try { _tuningNoiseSource.disconnect(); } catch { /* already disconnected */ } - } - try { _tuningNoiseFilter?.disconnect(); } catch { /* already disconnected */ } - try { _tuningNoiseGain?.disconnect(); } catch { /* already disconnected */ } - _tuningNoiseSource = null; - _tuningNoiseFilter = null; - _tuningNoiseGain = null; -} - -function syncTuningNoiseGain() { - if (!_tuningNoiseContext || !_tuningNoiseGain) return; - const audible = radioTuningStaticShouldPlay({ - tuningActive: _tuningActive, - tuningStatic: _tuningStatic, - awaitingStationId: _tuningAwaitingStationId, - voiceDucked: _voiceDucked, - }); - const target = audible - ? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, _userVolume * 0.03) - : 0; - const now = _tuningNoiseContext.currentTime; - _tuningNoiseGain.gain.cancelScheduledValues(now); - _tuningNoiseGain.gain.setValueAtTime(_tuningNoiseGain.gain.value, now); - _tuningNoiseGain.gain.linearRampToValueAtTime(target, now + 0.025); -} - -function installTuningNoise() { - const AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext; - if (!AudioContextClass) return false; - if (!_tuningNoiseContext) _tuningNoiseContext = new AudioContextClass(); - const resumed = _tuningNoiseContext.resume?.(); - if (resumed?.catch) void resumed.catch(() => {}); - if (_tuningNoiseSource) return true; - - const frameCount = Math.max(1, Math.floor(_tuningNoiseContext.sampleRate * 0.75)); - const buffer = _tuningNoiseContext.createBuffer(1, frameCount, _tuningNoiseContext.sampleRate); - const channel = buffer.getChannelData(0); - for (let index = 0; index < channel.length; index += 1) channel[index] = Math.random() * 2 - 1; - - _tuningNoiseSource = _tuningNoiseContext.createBufferSource(); - _tuningNoiseFilter = _tuningNoiseContext.createBiquadFilter(); - _tuningNoiseGain = _tuningNoiseContext.createGain(); - _tuningNoiseSource.buffer = buffer; - _tuningNoiseSource.loop = true; - _tuningNoiseFilter.type = 'bandpass'; - _tuningNoiseFilter.frequency.value = 1_650; - _tuningNoiseFilter.Q.value = 0.55; - _tuningNoiseGain.gain.value = 0; - _tuningNoiseSource.connect(_tuningNoiseFilter); - _tuningNoiseFilter.connect(_tuningNoiseGain); - _tuningNoiseGain.connect(_tuningNoiseContext.destination); - _tuningNoiseSource.start(); - return true; -} - -/** Return the complete accepted filtered directory in stable catalog order. */ -export function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) { - if (!radioPresentationAllowed() || !_acceptedCatalogSnapshot.stations.length) return []; - return buildRadioTunerBand(visibleStations(), selectedStation(), limit); -} - -/** Begin a direct-manipulation tuning gesture and pause the current stream. */ -export function beginRadioTuning() { - if (!radioPresentationAllowed() || !visibleStations().length) return false; - const snapshot = captureRadioTuningResolutionSnapshot(); - if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length) return false; - _tuningResolutionSnapshot = snapshot; - _tuningStationById = new Map( - _tuningResolutionSnapshot.stations.map((station) => [station.id, station]), - ); - _tuningUnavailableStationId = null; - _tuningActive = true; - _tuningStatic = false; - _tuningAwaitingStationId = null; - _cancelledTuningPresentationStation = null; - _tuningStartStationId = _selectedId; - _tuningPreviewId = _selectedId; - installTuningNoise(); - pauseRadioPlayback({ origin: 'user' }); - syncTuningNoiseGain(); - emitState(); - return true; -} - -/** Toggle low-volume synthesized static for tuner preview and stream handoff. */ -export function setRadioTuningStatic(active) { - if (!radioPresentationAllowed() || !_tuningActive) return false; - const next = Boolean(active); - if (next === _tuningStatic) return true; - _tuningStatic = next; - syncTuningNoiseGain(); - emitState(); - return true; -} - -function radioCameraState(camera = _viewer?.camera) { - if (!camera) return null; - return { - height: camera.positionCartographic?.height, - heading: camera.heading, - pitch: camera.pitch, - roll: camera.roll, - }; -} - -/** Radio may move the globe only while no tracked entity owns the follow camera. */ -export function radioCameraNavigationAllowed(viewer = _viewer) { - return Boolean(viewer?.camera) && !viewer.trackedEntity; -} - -function radioGlobeRecenterPlan(viewer = _viewer) { - const camera = viewer?.camera; - const canvas = viewer?.scene?.canvas; - const width = canvas?.clientWidth || canvas?.width; - const height = canvas?.clientHeight || canvas?.height; - const cartographic = camera?.positionCartographic; - if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null; - const geometry = projectEarthDiscToViewport( - viewer, - width, - height, - _radioEarthScreenCenter, - _radioEarthToCenter, - ); - let fullGlobeCapable = false; - if (geometry) { - if (!radioGlobeNeedsRecentering(geometry)) return null; - fullGlobeCapable = isFullGlobeInsideKeyhole({ - ...geometry, - earthCenterX: geometry.keyholeCenterX, - earthCenterY: geometry.keyholeCenterY, - }, false); - } else { - const earthRadius = earthDiscScreenRadius( - Cesium.Cartesian3.magnitude(camera.positionWC), - height, - camera.frustum?.fovy, - ); - const facingEarthCenter = Cesium.Cartesian3.dot( - camera.directionWC, - _radioEarthToCenter, - ) > 0; - if (!earthRadius || facingEarthCenter) return null; - const keyhole = getKeyholeGeometry(width, height); - fullGlobeCapable = earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius; - } - const recenterHeight = radioGlobeRecenterHeight( - cartographic.height, - fullGlobeCapable, - ); - if (recenterHeight == null) return null; - return { - destination: Cesium.Cartesian3.fromRadians( - cartographic.longitude, - cartographic.latitude, - recenterHeight, - ), - cameraState: { - ...radioCameraState(camera), - height: recenterHeight, - heading: 0, - pitch: -Cesium.Math.PI_OVER_TWO, - roll: 0, - }, - }; -} - -function radioCameraNavigationIsCurrent(navigation) { - return Boolean( - navigation - && navigation.generation === _radioCameraNavigationGeneration - && _enabled - && radioCameraNavigationAllowed(_viewer) - ); -} - -function cancelActiveRadioCameraFlight() { - if (!_activeRadioCameraFlight || !radioCameraNavigationAllowed(_viewer)) return; - _activeRadioCameraFlight = null; - _viewer.camera.cancelFlight(); -} - -function invalidateRadioCameraNavigation() { - _radioCameraNavigationGeneration += 1; - cancelActiveRadioCameraFlight(); - _activeRadioCameraFlight = null; -} - -function radioCameraNavigationOwnsSelection(navigation, station) { - return Boolean( - navigation - && station - && navigation.generation === _radioCameraNavigationGeneration - && navigation.target?.id === station.id - ); -} - -function startRadioCameraFlight(navigation, options, onComplete) { - if (!radioCameraNavigationIsCurrent(navigation)) return false; - const token = ++_radioCameraFlightSequence; - _activeRadioCameraFlight = { generation: navigation.generation, token }; - const finish = (completed) => { - if (_activeRadioCameraFlight?.token === token) _activeRadioCameraFlight = null; - if (completed && radioCameraNavigationIsCurrent(navigation)) onComplete?.(); - }; - _viewer.camera.flyTo({ - ...options, - complete: () => finish(true), - cancel: () => finish(false), - }); - return true; -} - -function focusRadioNavigationTarget(navigation) { - if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target) return false; - const plan = radioStationCameraPlan(navigation.target, navigation.cameraState); - if (!plan) return false; - navigation.phase = 'focusing'; - return startRadioCameraFlight(navigation, { - destination: Cesium.Cartesian3.fromDegrees(plan.lon, plan.lat, plan.height), - orientation: { heading: plan.heading, pitch: plan.pitch, roll: plan.roll }, - duration: navigation.duration, - }, () => { - navigation.phase = 'settled'; - }); -} - -function beginRadioCameraNavigation(cameraState = null) { - const generation = ++_radioCameraNavigationGeneration; - if (!radioCameraNavigationAllowed(_viewer) || !_enabled) return null; - cancelActiveRadioCameraFlight(); - const recenterPlan = radioGlobeRecenterPlan(_viewer); - return { - generation, - phase: 'idle', - recentered: false, - recenterPlan, - cameraState: recenterPlan?.cameraState || cameraState || radioCameraState(), - target: null, - duration: 0.35, - }; -} - -function rotateRadioStationIntoView( - station, - duration = 0.35, - cameraState = null, - navigation = null, -) { - const activeNavigation = navigation || beginRadioCameraNavigation(cameraState); - if (!station || !radioCameraNavigationIsCurrent(activeNavigation)) return false; - activeNavigation.target = station; - activeNavigation.duration = duration; - if (activeNavigation.phase === 'recentering') return true; - if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight(); - if (activeNavigation.recenterPlan && !activeNavigation.recentered) { - activeNavigation.phase = 'recentering'; - const recenterDuration = Math.min(0.9, Math.max(0.65, duration)); - return startRadioCameraFlight(activeNavigation, { - destination: activeNavigation.recenterPlan.destination, - orientation: { - heading: activeNavigation.cameraState.heading, - pitch: activeNavigation.cameraState.pitch, - roll: activeNavigation.cameraState.roll, - }, - duration: recenterDuration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }, () => { - activeNavigation.recentered = true; - activeNavigation.phase = 'idle'; - focusRadioNavigationTarget(activeNavigation); - }); - } - return focusRadioNavigationTarget(activeNavigation); -} - -/** Preview a tuner station bracket and camera orientation without starting audio. */ -export function previewRadioTuningStation(id, { rotate = true } = {}) { - if (!radioPresentationAllowed() || !_tuningActive) return false; - const station = tuningResolutionStation(id); - const nextId = station?.id || null; - const changed = nextId !== _tuningPreviewId; - _tuningPreviewId = nextId; - _tuningStatic = !station; - if (changed) { - updateSelectionEntity(); - if (station && rotate) { - const cameraState = radioCameraState(); - _tuningCameraNavigation = beginRadioCameraNavigation(cameraState); - rotateRadioStationIntoView(station, 0.35, cameraState, _tuningCameraNavigation); - } - else if (rotate) { - _tuningCameraNavigation = null; - invalidateRadioCameraNavigation(); - } - } - syncTuningNoiseGain(); - emitState(); - return Boolean(station); -} - -function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) { - if (_tuningCameraNavigation) invalidateRadioCameraNavigation(); - _tuningActive = false; - _tuningStatic = false; - _tuningAwaitingStationId = null; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - _tuningCameraNavigation = null; - _cancelledTuningPresentationStation = restoredStation; - updateSelectionEntity(); - syncTuningNoiseGain(); - stopTuningNoiseSource(); - const suspended = _tuningNoiseContext?.suspend?.(); - if (suspended?.catch) void suspended.catch(() => {}); - if (emit) emitState(); -} - -/** Finish a tuning gesture and release its synthesized noise source. */ -export function endRadioTuning() { - if (!_tuningActive && !_tuningStatic && !_tuningAwaitingStationId - && !_tuningNoiseSource && !_cancelledTuningPresentationStation) return; - clearRadioTuningNoise(); -} - -/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */ -export function cancelRadioTuning() { - if (!_tuningActive) return false; - const restoredStation = tuningResolutionStation(_tuningStartStationId); - clearRadioTuningNoise({ restoredStation }); - return true; -} - -/** Commit the exact frozen drag resolution or report that it is unavailable. */ -export function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) { - const frozenStation = tuningResolutionStation(id); - const currentStation = _stationById.get(String(id)) || null; - const generation = _tuningResolutionSnapshot.generation; - if (!radioPresentationAllowed() || !_tuningActive || !frozenStation) { - endRadioTuning(); - return Object.freeze({ ok: false, reason: 'not-tuning', stationId: String(id || ''), generation }); - } - if (!radioStationResolutionMatches(frozenStation, currentStation)) { - const stationId = frozenStation.id; - // A failed exact release owns the visible outcome. Do not let the previous - // selection—or changed metadata under the same ID—reappear between the - // frozen preview and the unavailable state. - _selectedId = null; - _selectionGeneration += 1; - clearRadioTuningNoise({ emit: false }); - _tuningUnavailableStationId = stationId; - emitState(); - return Object.freeze({ ok: false, reason: 'station-unavailable', stationId, generation }); - } - _tuningActive = false; - _tuningStatic = true; - _tuningAwaitingStationId = frozenStation.id; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningUnavailableStationId = null; - const cameraNavigation = _tuningCameraNavigation; - _tuningCameraNavigation = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - // An exact tuner release owns its frozen target. A fallback armed by an - // earlier non-playing selection must never retarget this gesture if the - // broadcaster fails after release. - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - syncTuningNoiseGain(); - emitState(); - const selected = selectRadioStation(frozenStation.id, { - autoplay: true, - focus: false, - origin, - cameraNavigation, - }); - return Object.freeze({ - ok: selected, - reason: selected ? null : 'station-unavailable', - stationId: frozenStation.id, - generation, - }); -} - -function tryRadioFallback( - failedId, - origin = _playFallbackOrigin || 'programmatic', - attemptId = _playFallbackAttemptId, -) { - const fallbackId = _playFallbackId; - const fallbackStation = fallbackId ? _stationById.get(fallbackId) : null; - const fallbackFocusPolicy = _playFallbackFocus; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - if (!fallbackStation || fallbackId === failedId || _selectedId !== failedId) { - return false; - } - const fallbackFocusResult = typeof fallbackFocusPolicy === 'function' - ? fallbackFocusPolicy(fallbackStation) - : fallbackFocusPolicy; - const fallbackCameraNavigation = fallbackFocusResult - && typeof fallbackFocusResult === 'object' - ? fallbackFocusResult - : null; - const fallbackFocus = fallbackCameraNavigation ? false : Boolean(fallbackFocusResult); - selectRadioStation(fallbackId, { - autoplay: true, - focus: fallbackFocus, - origin: origin || 'programmatic', - attemptId, - cameraNavigation: fallbackCameraNavigation, - }); - return true; -} - -function recordDirectoryClick(id) { - fetch(`/api/radio/click/${encodeURIComponent(id)}`, { method: 'POST' }).catch(() => {}); -} - -/** Play the selected broadcaster stream after an explicit user action. */ -export async function playSelectedRadio({ origin = 'programmatic', attemptId = null } = {}) { - const station = selectedStation(); - if (!radioPresentationAllowed() || !station?.streamUrl) return false; - if (_tuningActive) endRadioTuning(); - // A media event carries no reliable attempt identity. Give every explicit - // play/resume/replacement its own element so queued events from the retired - // attempt remain bound to the discarded element and cannot mutate this one. - installAudio({ replace: true }); - if (!_audio) return false; - - const generation = ++_playGeneration; - const ownedAttemptId = attemptId || `radio-play-${++_playAttemptSequence}`; - _activePlaybackAttempt = { - id: ownedAttemptId, - origin, - stationId: station.id, - streamUrl: station.streamUrl, - generation, - }; - if (_playFallbackId) { - _playFallbackOrigin = origin; - _playFallbackAttemptId = ownedAttemptId; - } - _audioError = null; - _audioState = 'loading'; - if (_audioStationId !== station.id || _audio.src !== station.streamUrl) { - _audio.pause(); - _audio.src = station.streamUrl; - _audioStationId = station.id; - } - emitState(); - - try { - const playback = _audio.play(); - if (playback?.then) await playback; - if ( - generation !== _playGeneration - || _audioStationId !== station.id - || _activePlaybackAttempt?.id !== ownedAttemptId - ) return false; - _audioState = 'playing'; - if (_tuningAwaitingStationId === station.id) clearRadioTuningNoise({ emit: false }); - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - recordDirectoryClick(station.id); - emitState(); - if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId); - return true; - } catch (error) { - if (generation !== _playGeneration || _activePlaybackAttempt?.id !== ownedAttemptId) return false; - if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false; - _audioState = 'error'; - _audioError = error?.name === 'NotAllowedError' - ? 'Playback requires a direct click or tap.' - : 'Broadcaster stream could not be started.'; - emitState(); - return false; - } -} - -/** - * Wait for confirmed Radio playback while voice owns a hard mute. - * A fallback station may replace the first stream during this wait, so the - * state subscription—not the first play() promise—is authoritative. - */ -export function confirmRadioPlayback({ - startPlayback, - subscribe, - getState, - timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS, -} = {}) { - if (typeof startPlayback !== 'function' || typeof subscribe !== 'function' || typeof getState !== 'function') { - return Promise.resolve(false); - } - return new Promise((resolve) => { - let settled = false; - let started = false; - let unsubscribe = () => {}; - let timer = null; - const finish = (ok) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - unsubscribe(); - resolve(Boolean(ok)); - }; - const inspect = (state) => { - if (!started || !state) return; - if (!state.voiceDucked && ['loading', 'buffering', 'playing'].includes(state.audioState)) { - finish(false); - return; - } - if (state.audioState === 'playing' && state.playingStationId && state.voiceDucked) { - finish(true); - } else if (state.audioState === 'error') { - finish(false); - } - }; - unsubscribe = subscribe(inspect); - started = true; - timer = setTimeout(() => finish(false), Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS)); - Promise.resolve() - .then(startPlayback) - .then((playStarted) => { - const state = getState(); - inspect(state); - if (!playStarted && !['loading', 'buffering', 'playing'].includes(state?.audioState)) finish(false); - }) - .catch(() => finish(false)); - }); -} - -/** Start and verify a prepared station without ever making it audible under voice. */ -export function playPreparedRadioForVoice(options = {}) { - if (!_voiceDucked) return Promise.resolve(false); - return confirmRadioPlayback({ - startPlayback: () => playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }), - subscribe: subscribeToRadio, - getState: getRadioUIState, - timeoutMs: options.timeoutMs, - }); -} - -/** Stop the shared stream and release its network resource. */ -export function stopRadioPlayback({ origin = 'programmatic', attemptId = null } = {}) { - if (attemptId && _activePlaybackAttempt?.id !== attemptId) return false; - const stoppedAttemptId = _activePlaybackAttempt?.id || null; - endRadioTuning(); - _playGeneration += 1; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - _activePlaybackAttempt = null; - if (_audio) { - _audio.pause(); - _audio.removeAttribute('src'); - _audio.load(); - } - _audioStationId = null; - _audioState = 'stopped'; - _audioError = null; - emitState(); - if (origin === 'user' || origin === 'voice') { - emitPlaybackControl('stop', origin, stoppedAttemptId); - } - return true; -} - -/** Pause or resume the selected stream. Resuming is still click initiated. */ -export function toggleRadioPlayback({ origin = 'programmatic' } = {}) { - if (['loading', 'playing', 'buffering'].includes(_audioState)) { - return Promise.resolve(pauseRadioPlayback({ origin })); - } - if (!radioPresentationAllowed()) return Promise.resolve(false); - if (!_selectedId) { - const ranked = rankedVisibleStations(); - if (!ranked.length) return Promise.resolve(false); - _playFallbackId = ranked[1]?.id || null; - _playFallbackFocus = false; - selectRadioStation(ranked[0].id, { autoplay: false, focus: false }); - } - return playSelectedRadio({ origin }); -} - -/** Pause Radio without toggling a stopped or already-paused stream back on. */ -export function pauseRadioPlayback({ origin = 'programmatic' } = {}) { - if (!['loading', 'playing', 'buffering'].includes(_audioState)) return false; - const pausedAttemptId = _activePlaybackAttempt?.id || null; - if (_tuningAwaitingStationId && !_tuningActive) endRadioTuning(); - _playGeneration += 1; - _playFallbackId = null; - _playFallbackFocus = null; - _playFallbackOrigin = 'programmatic'; - _playFallbackAttemptId = null; - _activePlaybackAttempt = null; - _audio?.pause(); - _audioState = 'paused'; - emitState(); - if (origin === 'user' || origin === 'voice') { - emitPlaybackControl('pause', origin, pausedAttemptId); - } - return true; -} - -/** Set shared audio volume, clamped to [0, 1]. */ -export function setRadioVolume(value) { - if (!radioPresentationAllowed()) return false; - const volume = clampRadioVolume(value); - _userVolume = volume; - installAudio(); - if (!_voiceDucked) { - cancelRadioVolumeTransition(); - _voiceRestoring = false; - if (_audio) _audio.volume = volume; - } - syncTuningNoiseGain(); - emitState(); - return true; -} - -/** Durable Radio preferences; this surface never creates or plays audio. */ -export function setRadioParams(params = {}) { - const nextFilter = Object.hasOwn(params, 'filter') - ? normalizeRadioFilter(params.filter) - : null; - if (Object.hasOwn(params, 'filter') && !nextFilter) return false; - const numericVolume = Object.hasOwn(params, 'volume') ? Number(params.volume) : null; - if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume)) return false; - const nextVolume = numericVolume === null ? null : clampRadioVolume(numericVolume); - - let changed = false; - let filterChanged = false; - let clearedCancelledPresentation = false; - if (nextFilter !== null) { - filterChanged = nextFilter !== _filter; - if (filterChanged && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning(); - clearedCancelledPresentation = Boolean(_cancelledTuningPresentationStation); - _cancelledTuningPresentationStation = null; - _filter = nextFilter; - if (clearedCancelledPresentation) updateSelectionEntity(); - if (filterChanged) resetRadioClusterOverlayIdentities(); - changed ||= filterChanged || clearedCancelledPresentation; - } - if (nextVolume !== null && nextVolume !== _userVolume) { - _userVolume = nextVolume; - if (_audio && !_voiceDucked) { - cancelRadioVolumeTransition(); - _voiceRestoring = false; - _audio.volume = nextVolume; - } - syncTuningNoiseGain(); - changed = true; - } - if (changed && radioPresentationAllowed()) { - updateRenderVisibility(); - if (filterChanged && _dataSource?.clustering?.enabled) { - const clusterPoints = _dataSource.clustering.clusterPoints; - _dataSource.clustering.clusterPoints = !clusterPoints; - _dataSource.clustering.clusterPoints = clusterPoints; - _viewer?.scene?.requestRender(); - } - scheduleRadioOverlayPublish(); - } - emitState(); - return true; -} - -export function getRadioParams() { - return { filter: _filter, volume: _userVolume }; -} - -/** - * Mute Radio during a live voice turn, then gently restore the user-owned - * volume after voice returns to standby. Repeated state sync is idempotent. - */ -export function setRadioVoiceDucking(ducked, { - restoreDelayMs = VOICE_RESTORE_DELAY_MS, - restoreDurationMs = VOICE_RESTORE_DURATION_MS, -} = {}) { - const shouldDuck = Boolean(ducked); - if (shouldDuck === _voiceDucked && (shouldDuck || _voiceRestoring)) return; - if (!shouldDuck && !_voiceDucked && !_voiceRestoring - && (!_audio || Math.abs(_audio.volume - _userVolume) < 0.001)) return; - - cancelRadioVolumeTransition(); - _voiceDucked = shouldDuck; - _voiceRestoring = false; - - if (shouldDuck) { - if (_audio) _audio.volume = 0; - syncTuningNoiseGain(); - emitState(); - return; - } - if (!_audio) { - syncTuningNoiseGain(); - _voiceRestoring = false; - emitState(); - return; - } - - const generation = _volumeTransitionGeneration; - syncTuningNoiseGain(); - _voiceRestoring = true; - emitState(); - const beginRestore = () => { - _voiceRestoreTimer = null; - if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return; - const startedAt = volumeClock(); - const initialVolume = _audio.volume; - const duration = Math.max(0, Number(restoreDurationMs) || 0); - const step = (now) => { - if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return; - const progress = duration === 0 ? 1 : Math.min(1, Math.max(0, (now - startedAt) / duration)); - const eased = progress * progress * (3 - 2 * progress); - _audio.volume = clampRadioVolume(initialVolume + (_userVolume - initialVolume) * eased); - if (progress < 1) { - _volumeFadeFrame = scheduleVolumeFrame(step); - return; - } - _volumeFadeFrame = null; - _voiceRestoring = false; - emitState(); - }; - _volumeFadeFrame = scheduleVolumeFrame(step); - }; - const delay = Math.max(0, Number(restoreDelayMs) || 0); - if (delay > 0) _voiceRestoreTimer = setTimeout(beginRestore, delay); - else beginRestore(); -} - -function updateSelectionEntity() { - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - const station = selectedPresentationStation(); - if (!radioPresentationAllowed() || !_viewer || !station) { - scheduleRadioOverlayPublish(); - return; - } - const selectionColor = radioCategoryColor(radioStationCategoryId(station)); - const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(radioSelectionBracketSvg(selectionColor))}`; - - _selectedEntity = _viewer.entities.add({ - id: `${RADIO_PREFIX}selected:${station.id}`, - position: markerPosition(station, SELECTED_LIFT_M), - point: { - pixelSize: 14, - color: Cesium.Color.fromCssColorString(selectionColor), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - billboard: { - image: bracketImage, - width: 40, - height: 40, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72), - }, - }); - scheduleRadioOverlayPublish(); -} - -function clusterPointCollection() { - return _dataSource?.clustering?._clusterPointCollection || null; -} - -function publishRadioOverlayEntries() { - _overlayPublishTimer = null; - if (!radioPresentationAllowed()) { - resetRadioClusterOverlayIdentities(); - _overlayDiagnostics = emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - return; - } - - const entries = []; - const station = selectedPresentationStation(); - if (station) { - const selectedEntry = createRadioSelectedOverlayEntry( - station, - markerPosition(station, SELECTED_LIFT_M), - ); - if (selectedEntry) entries.push(selectedEntry); - } - - const clusterCandidates = []; - const clusteredStationIds = new Set(); - const points = clusterPointCollection(); - for (let index = 0; index < (points?.length || 0); index += 1) { - const point = points.get(index); - if (!point?.show || !point.position || !Array.isArray(point.id) || point.id.length < 3) continue; - const stationIds = point.id - .map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length)) - .filter((id) => { - const station = _stationById.get(id); - return station && stationMatchesRadioCategory(station, _filter); - }) - .sort(); - if (stationIds.length < 3) continue; - for (const stationId of stationIds) clusteredStationIds.add(stationId); - const clusteredStations = stationIds.map((id) => _stationById.get(id)); - const category = radioClusterCategoryId(clusteredStations, _filter); - clusterCandidates.push({ - id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`, - stationIds, - point, - text: radioClusterBadgeText(category, stationIds.length), - accent: radioCategoryColor(category), - stationCount: stationIds.length, - }); - } - const selectedClusterCandidates = selectRadioClusterCandidates(clusterCandidates); - _clusterOverlayIdentities = reconcileRadioClusterCandidates( - selectedClusterCandidates, - _clusterOverlayIdentities, - () => `stable:${++_clusterOverlayIdentitySequence}`, - ); - for (const candidate of _clusterOverlayIdentities) { - const clusterEntry = createRadioClusterOverlayEntry({ - ...candidate, - position: () => candidate.point.position, - }); - if (clusterEntry) entries.push(clusterEntry); - } - - const cameraPosition = _viewer?.camera?.positionWC; - const singletonAllowance = Math.min( - radioSingletonLabelLimit(_viewer?.camera?.positionCartographic?.height), - Math.max(0, RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length), - ); - const singletonCandidates = selectRadioSingletonCandidates( - [..._renderById.values()] - .filter((record) => ( - record.entity?.show - && record.station?.id !== station?.id - && !clusteredStationIds.has(record.station?.id) - )) - .map((record) => ({ - ...record, - distanceM: cameraPosition - ? Cesium.Cartesian3.distance(cameraPosition, record.position) - : Number.POSITIVE_INFINITY, - })), - singletonAllowance, - ); - for (let index = 0; index < singletonCandidates.length; index += 1) { - const candidate = singletonCandidates[index]; - const singletonEntry = createRadioSingletonOverlayEntry({ - station: candidate.station, - position: candidate.position, - // Cluster priorities begin at three. Keep clusters authoritative while - // retaining nearest-first singleton ordering inside the ambient lane. - priority: 2 - (index / Math.max(1, singletonCandidates.length + 1)), - }); - if (singletonEntry) entries.push(singletonEntry); - } - - setOverlayEntries(RADIO_OVERLAY_SOURCE_ID, entries, RADIO_OVERLAY_SOURCE_OPTIONS); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true); - _overlayDiagnostics = { - entryCount: entries.length, - selectedCount: entries.filter((entry) => entry.selected).length, - singletonTexts: entries.filter((entry) => entry.id.startsWith('station:')).map((entry) => entry.title), - singletonIds: entries.filter((entry) => entry.id.startsWith('station:')).map((entry) => entry.id), - clusterTexts: entries.filter((entry) => entry.id.startsWith('cluster:')).map((entry) => entry.title), - clusterIds: entries.filter((entry) => entry.id.startsWith('cluster:')).map((entry) => entry.id), - clusterMemberships: _clusterOverlayIdentities.map((candidate) => ({ - membershipId: candidate.membershipId, - entryId: `cluster:${candidate.id}`, - })), - }; -} - -function scheduleRadioOverlayPublish() { - if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer); - const sessionGeneration = _sessionGeneration; - _overlayPublishTimer = setTimeout(() => { - if (sessionGeneration === _sessionGeneration) publishRadioOverlayEntries(); - }, 0); -} - -function focusStation(station) { - _radioCameraNavigationGeneration += 1; - if (!station || !radioCameraNavigationAllowed(_viewer)) return false; - cancelActiveRadioCameraFlight(); - _viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(station.lon, station.lat, 85_000), - duration: 1.1, - }); - return true; -} - -/** Select a station. Playback occurs only when autoplay is explicitly true. */ -export function selectRadioStation(id, { - autoplay = false, - focus = false, - origin = 'programmatic', - attemptId = null, - cameraNavigation = null, -} = {}) { - if (!radioPresentationAllowed()) return false; - const station = _stationById.get(String(id)); - if (!station) return false; - _tuningUnavailableStationId = null; - if (!radioCameraNavigationOwnsSelection(cameraNavigation, station)) { - invalidateRadioCameraNavigation(); - } - if (_tuningActive || (_tuningAwaitingStationId && _tuningAwaitingStationId !== station.id)) endRadioTuning(); - _cancelledTuningPresentationStation = null; - _selectedId = station.id; - const generation = ++_selectionGeneration; - const sessionGeneration = _sessionGeneration; - updateSelectionEntity(); - warmGroundFloor([{ lat: station.lat, lon: station.lon }]); - if (_selectionTimer) clearTimeout(_selectionTimer); - _selectionTimer = setTimeout(() => { - _selectionTimer = null; - if ( - sessionGeneration === _sessionGeneration - && generation === _selectionGeneration - && _selectedId === station.id - ) updateSelectionEntity(); - }, 1300); - if (focus) focusStation(station); - emitState(); - if (autoplay) void playSelectedRadio({ origin, attemptId }); - return true; -} - -/** Select the previous or next station and optionally retain a UI-owned band order. */ -export function cycleRadioStation(direction = 1, { - rotate = false, - stationIds = null, - autoplay = true, - origin = 'programmatic', -} = {}) { - if (!radioPresentationAllowed()) return false; - const ranked = Array.isArray(stationIds) && stationIds.length - ? stationIds - .slice(0, RADIO_TUNER_DIRECTORY_LIMIT) - .map((id) => _stationById.get(String(id))) - .filter((station) => station && stationMatchesRadioCategory(station, _filter)) - : rankedVisibleStations(); - if (!ranked.length) return false; - const current = ranked.findIndex((station) => station.id === _selectedId); - const nextIndex = current < 0 - ? 0 - : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; - _playFallbackId = ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; - const rotationCameraState = rotate ? radioCameraState() : null; - const rotationNavigation = rotate ? beginRadioCameraNavigation(rotationCameraState) : null; - _playFallbackFocus = rotate - ? (fallbackStation) => { - rotateRadioStationIntoView(fallbackStation, 0.65, rotationCameraState, rotationNavigation); - return rotationNavigation; - } - : false; - const station = ranked[nextIndex]; - if (rotate) rotateRadioStationIntoView(station, 0.65, rotationCameraState, rotationNavigation); - return selectRadioStation(station.id, { - autoplay, - focus: false, - origin, - cameraNavigation: rotationNavigation, - }); -} - -/** Select and optionally play the best station for a location/category request. */ -export function selectRequestedRadioStation(criteria = {}, { autoplay = true, origin = 'programmatic' } = {}) { - if (!radioPresentationAllowed()) return null; - const requestedCategory = String(criteria.categoryId || 'all'); - const categoryId = _categories.some((category) => category.id === requestedCategory) - ? requestedCategory - : 'all'; - setRadioFilter(categoryId); - const ranked = rankRadioStationsForRequest(_stations, { - ...criteria, - categoryId, - anchor: criteria.anchor || viewportRadioAnchor(), - }); - if (!ranked.length) return null; - _playFallbackId = ranked[1]?.id || null; - _playFallbackFocus = false; - selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); - return ranked[0]; -} - -function updateRenderVisibility({ force = true } = {}) { - if (!_viewer || !_dataSource) return; - const cameraPosition = _viewer.camera?.positionWC; - if (!force && !radioCameraPositionChanged(_lastHorizonCameraPosition, cameraPosition)) return; - _lastHorizonCameraPosition = cameraPosition - ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z } - : null; - _horizonScanCount += 1; - const occluder = horizonOccluder(_viewer.camera); - let visibilityChanged = false; - for (const [id, record] of _renderById) { - const matches = stationMatchesRadioCategory(record.station, _filter); - const visible = matches && occluder.isPointVisible(record.position); - if (record.entity.show !== visible) visibilityChanged = true; - record.entity.show = visible; - } - if (_selectedEntity) { - const position = _selectedEntity.position?.getValue?.(Cesium.JulianDate.now()); - const selectedVisible = !position || occluder.isPointVisible(position); - if (_selectedEntity.show !== selectedVisible) visibilityChanged = true; - _selectedEntity.show = selectedVisible; - } - scheduleRadioOverlayPublish(); - // The horizon timer can commit AFTER the camera settles and the governor - // parks the scene — a changed show flag needs one frame. (perf wave 2 fix) - if (visibilityChanged) governorRequestRender('radio-horizon'); -} - -/** Change marker/list category without interrupting an active station. */ -export function setRadioFilter(categoryId) { - if (!radioPresentationAllowed()) return false; - const valid = _categories.some((category) => category.id === categoryId); - const nextFilter = valid ? categoryId : 'all'; - const changed = nextFilter !== _filter; - if (changed && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning(); - const clearsCancelledPresentation = Boolean(_cancelledTuningPresentationStation); - _cancelledTuningPresentationStation = null; - _filter = nextFilter; - if (clearsCancelledPresentation) updateSelectionEntity(); - if (changed) resetRadioClusterOverlayIdentities(); - updateRenderVisibility(); - if (changed && _dataSource?.clustering?.enabled) { - // Entity visibility changes do not invalidate Cesium's existing cluster - // primitives. Toggle a public clustering input twice to mark the current - // cluster set dirty without changing its effective configuration. - const clusterPoints = _dataSource.clustering.clusterPoints; - _dataSource.clustering.clusterPoints = !clusterPoints; - _dataSource.clustering.clusterPoints = clusterPoints; - _viewer?.scene?.requestRender(); - } - scheduleRadioOverlayPublish(); - emitState(); - return true; -} - -/** Retain refresh identities only while every represented station still exists. */ -export function retainRadioClusterIdentitiesForStations(previous, stations) { - const stationIds = new Set((stations || []).map((station) => String(station?.id || '')).filter(Boolean)); - return (previous || []).filter((candidate) => ( - Array.isArray(candidate?.stationIds) - && candidate.stationIds.length > 0 - && candidate.stationIds.every((stationId) => stationIds.has(String(stationId))) - )); -} - -function reconcileStations(stations) { - if (!_tuningActive) _cancelledTuningPresentationStation = null; - _clusterOverlayIdentities = retainRadioClusterIdentitiesForStations( - _clusterOverlayIdentities, - stations, - ); - _stations = Object.freeze([...stations]); - _stationById = new Map(stations.map((station) => [station.id, station])); - _categories = Object.freeze(buildRadioCategories(stations).map((category) => Object.freeze(category))); - if (_filter === DEFAULT_RADIO_FILTER && !filterRadioStations(stations, DEFAULT_RADIO_FILTER).length) { - _filter = 'all'; - } - if (_selectedId && !_stationById.has(_selectedId)) { - if (_audioStationId === _selectedId) stopRadioPlayback(); - _selectedId = null; - } - - if (_dataSource) _dataSource.entities.removeAll(); - _renderById.clear(); - for (const station of stations) { - const position = markerPosition(station); - const markerColor = radioCategoryColor(radioStationCategoryId(station)); - const entity = _dataSource.entities.add({ - id: `${RADIO_PREFIX}${station.id}`, - position, - point: { - pixelSize: 13, - color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86), - outlineColor: Cesium.Color.fromCssColorString('#071b25'), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(100_000, 1.15, 12_000_000, 1), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - }); - _renderById.set(station.id, { station, entity, position }); - } - updateSelectionEntity(); - updateRenderVisibility(); -} - -function installClusterStyling() { - if (!_dataSource || _removeClusterListener) return; - const clustering = _dataSource.clustering; - clustering.enabled = true; - clustering.pixelRange = 42; - clustering.minimumClusterSize = 3; - clustering.clusterPoints = true; - clustering.clusterLabels = false; - clustering.clusterBillboards = false; - _removeClusterListener = clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { - const clusteredStations = []; - for (const entity of clusteredEntities) { - const stationId = String(entity.id || '').slice(RADIO_PREFIX.length); - const station = _renderById.get(stationId)?.station; - if (station) clusteredStations.push(station); - } - const clusterCategory = radioClusterCategoryId(clusteredStations, _filter); - const clusterColor = Cesium.Color.fromCssColorString(radioCategoryColor(clusterCategory)); - // Cesium assigns the entity-id array only to the generated cluster label. - // Mirror it to the visible point so clicking either part of the callout - // resolves the first station and remains a direct playback gesture. - cluster.point.id = clusteredEntities; - cluster.billboard.id = clusteredEntities; - cluster.label.show = false; - cluster.label.text = ''; - cluster.point.show = true; - cluster.point.pixelSize = Math.min(26, 12 + Math.log2(clusteredEntities.length) * 1.6); - cluster.point.color = clusterColor.withAlpha(0.9); - cluster.point.outlineColor = Cesium.Color.BLACK; - cluster.point.outlineWidth = 2; - cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY; - cluster.point.distanceDisplayCondition = new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ); - scheduleRadioOverlayPublish(); - }); -} - -function pickedRadioStationAt(position) { - const scene = _viewer?.scene; - if (!scene || !position) return null; - - const stationFromPick = (picked) => { - const stationId = radioStationIdFromPick(picked); - return stationId && _stationById.has(stationId) ? stationId : null; - }; - const primaryPick = scene.pick(position); - const primaryStationId = stationFromPick(primaryPick); - if (primaryStationId) return primaryStationId; - const primaryId = resolvePickId(primaryPick); - if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; - - if (typeof scene.drillPick === 'function') { - const drilled = scene.drillPick(position, 16) || []; - for (const picked of drilled) { - const stationId = stationFromPick(picked); - if (stationId) return stationId; - } - } - - for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { - const offsetPosition = new Cesium.Cartesian2(position.x + offsetX, position.y + offsetY); - const picked = scene.pick(offsetPosition); - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; - const stationId = stationFromPick(picked); - if (stationId) return stationId; - } - return null; -} - -function radioPresentationAllowed() { - if (!_managerLifecyclePresentation) return _enabled; - return _enabled - && _managerLifecyclePresentation.lifecycleState === 'enabled' - && _managerLifecyclePresentation.enabled - && !_managerLifecyclePresentation.uncertain; -} - -function syncRadioLifecyclePresentation() { - const visible = radioPresentationAllowed(); - if (_dataSource) _dataSource.show = visible; - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); - if (visible) { - installInteraction(); - updateSelectionEntity(); - updateRenderVisibility(); - scheduleRadioOverlayPublish(); - } else { - removeInteraction(); - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - } -} - -function installInteraction() { - if (!_viewer || _clickHandler) return; - registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); - _clickHandler = new Cesium.ScreenSpaceEventHandler(_viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!radioPresentationAllowed()) return; - const stationId = pickedRadioStationAt(click.position); - if (!stationId) return; - _playFallbackId = null; - _playFallbackFocus = null; - selectRadioStation(stationId, { autoplay: true, origin: 'user' }); - if (typeof document !== 'undefined') { - document.dispatchEvent(new CustomEvent('gev:radio-selected', { detail: { stationId } })); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - // Polling remains bounded during flights, but a stationary camera no longer - // rewrites every station's visibility four times per second while voice and - // audio processing share the main thread. - _horizonTimer = setInterval(() => updateRenderVisibility({ force: false }), HORIZON_TICK_MS); -} - -function removeInteraction() { - unregisterPickOwner('radio'); - _clickHandler?.destroy(); - _clickHandler = null; - if (_horizonTimer) clearInterval(_horizonTimer); - _horizonTimer = null; -} - -/** Radio layer lifecycle implementation. */ -export const radioLayer = { - id: 'radio', - name: 'Radio', - icon: '◉', - source: 'Radio Browser', - updateInterval: 45 * 60 * 1000, - - /** Initialize the Cesium data source and the single audio element. */ - init(viewer) { - _sessionGeneration += 1; - _viewer = viewer; - resetRadioClusterOverlayIdentities(); - installAudio(); - if (!_dataSource) { - _dataSource = new Cesium.CustomDataSource('Radio stations'); - viewer.dataSources.add(_dataSource); - installClusterStyling(); - } - _dataSource.show = false; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - }, - - /** Show stations. Enabling or preset restoration never starts audio. */ - enable() { - _enabled = true; - syncRadioLifecyclePresentation(); - emitState(); - }, - - /** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */ - setLifecyclePresentation({ lifecycleState = null, enabled = false, uncertain = false } = {}) { - const settledState = enabled ? 'enabled' : 'disabled'; - const normalizedState = ['enabling', 'enabled', 'disabling', 'disabled'].includes(lifecycleState) - ? lifecycleState - : settledState; - _managerLifecyclePresentation = { - lifecycleState: normalizedState, - enabled: Boolean(enabled), - uncertain: Boolean(uncertain), - }; - syncRadioLifecyclePresentation(); - emitState(); - }, - - /** Hide the layer and stop playback without forgetting the selected station. */ - disable() { - _sessionGeneration += 1; - _enabled = false; - invalidateRadioCameraNavigation(); - _requestGeneration += 1; - _abortController?.abort(); - _abortController = null; - _loading = false; - _selectionGeneration += 1; - if (_selectionTimer) clearTimeout(_selectionTimer); - _selectionTimer = null; - removeInteraction(); - endRadioTuning(); - _cancelledTuningPresentationStation = null; - _tuningUnavailableStationId = null; - stopRadioPlayback({ origin: 'layer-disable' }); - if (_dataSource) _dataSource.show = false; - if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity); - _selectedEntity = null; - if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer); - _overlayPublishTimer = null; - resetRadioClusterOverlayIdentities(); - _overlayDiagnostics = emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); - emitState(); - }, - - /** Refresh directory metadata through the hardened same-origin broker. */ - async update() { - if (!_enabled) return; - const generation = ++_requestGeneration; - const sessionGeneration = _sessionGeneration; - _abortController?.abort(); - _abortController = new AbortController(); - _loading = true; - _error = null; - emitState(); - try { - const response = await fetch(DIRECTORY_ENDPOINT, { signal: _abortController.signal }); - if (!response.ok) throw new Error(`Radio directory returned ${response.status}`); - const body = await response.json(); - if (!radioRequestIsCurrent( - generation, - _requestGeneration, - _enabled, - sessionGeneration, - _sessionGeneration, - )) return; - if (!Array.isArray(body?.stations)) throw new Error('Radio directory response was malformed'); - const updatedAt = typeof body.updatedAt === 'string' && Number.isFinite(Date.parse(body.updatedAt)) - ? body.updatedAt - : null; - const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN; - if ( - !updatedAt - || updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS - || updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS - || typeof body.stale !== 'boolean' - || typeof body.degraded !== 'boolean' - ) throw new Error('Radio directory freshness metadata was malformed'); - const rows = body.stations; - const acceptedRows = rows.filter(isValidRadioDirectoryStation); - if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error('Radio directory response contained malformed stations'); - } - const acceptedGeneration = body.acceptedGeneration; - if ( - acceptedGeneration !== null - && (!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1) - ) throw new Error('Radio directory generation metadata was malformed'); - if (!body.stale && !body.degraded && acceptedGeneration === null) { - throw new Error('Radio directory omitted its accepted generation'); - } - const catalogInstance = body.catalogInstance; - if (!body.stale && !body.degraded && (typeof catalogInstance !== 'string' || !catalogInstance)) { - throw new Error('Radio directory omitted its catalog instance'); - } - const preservingWarmCatalog = _stations.length > 0 && (body.stale || body.degraded); - // Generations are only comparable within one producer instance. A new - // instance token (server restart, different proxy process) starts a fresh - // sequence: never a repeat and never a regression. - const sameCatalogInstance = _acceptedCatalogSnapshot?.instance === catalogInstance; - const currentAcceptedGeneration = sameCatalogInstance - ? _acceptedCatalogSnapshot?.generation - : null; - if ( - !body.stale - && !body.degraded - && Number.isSafeInteger(currentAcceptedGeneration) - && acceptedGeneration < currentAcceptedGeneration - ) throw new Error('Radio directory generation regressed'); - const repeatingAcceptedGeneration = ( - !body.stale - && !body.degraded - && Number.isSafeInteger(currentAcceptedGeneration) - && acceptedGeneration === currentAcceptedGeneration - ); - if (!preservingWarmCatalog) { - const immutableRows = acceptedRows.map(freezeRadioStation); - if (repeatingAcceptedGeneration) { - _updatedAt = _acceptedCatalogSnapshot.updatedAt; - if (!_tuningActive && _cancelledTuningPresentationStation) { - _cancelledTuningPresentationStation = null; - updateSelectionEntity(); - } - } else if (!body.stale && !body.degraded) { - const acceptedSnapshot = createAcceptedCatalogSnapshot( - catalogInstance, - acceptedGeneration, - updatedAt, - immutableRows, - ); - if (!acceptedSnapshot) throw new Error('Radio directory generation metadata was malformed'); - _acceptedCatalogSnapshot = acceptedSnapshot; - reconcileStations(acceptedSnapshot.stations); - } else { - reconcileStations(immutableRows); - } - if (!repeatingAcceptedGeneration) _updatedAt = updatedAt; - } - _degraded = body.degraded; - _stale = body.stale; - _error = preservingWarmCatalog - ? 'Directory refresh degraded; showing the previous station catalog.' - : (_degraded ? 'Radio directory coverage is degraded.' : null); - } catch (error) { - if (error?.name === 'AbortError' || !radioRequestIsCurrent( - generation, - _requestGeneration, - _enabled, - sessionGeneration, - _sessionGeneration, - )) return; - _error = _stations.length - ? 'Directory refresh failed; showing the previous station catalog.' - : 'Radio directory is temporarily unavailable.'; - _stale = _stations.length > 0; - _degraded = _stations.length > 0; - } finally { - if (generation === _requestGeneration && sessionGeneration === _sessionGeneration) { - _loading = false; - _abortController = null; - emitState(); - } - } - }, - - /** Release rendering, event, request, and playback resources. */ - destroy() { - this.disable(); - cancelRadioVolumeTransition(); - _voiceDucked = false; - _voiceRestoring = false; - endRadioTuning(); - const closedTuningNoise = _tuningNoiseContext?.close?.(); - if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {}); - _tuningNoiseContext = null; - if (_audio) _audio.volume = DEFAULT_RADIO_VOLUME; - _audio = null; - _userVolume = DEFAULT_RADIO_VOLUME; - _removeClusterListener?.(); - _removeClusterListener = null; - if (_dataSource && _viewer) _viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - _stations = []; - _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _stationById.clear(); - _categories = Object.freeze([]); - _filter = DEFAULT_RADIO_FILTER; - _renderById.clear(); - resetRadioClusterOverlayIdentities(); - _lastHorizonCameraPosition = null; - _horizonScanCount = 0; - _selectedId = null; - _tuningPreviewId = null; - _tuningStartStationId = null; - _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; - _tuningStationById = new Map(); - _tuningUnavailableStationId = null; - _cancelledTuningPresentationStation = null; - _loading = false; - _stale = false; - _degraded = false; - _error = null; - _updatedAt = null; - _managerLifecyclePresentation = null; - _viewer = null; - emitState(); - _listeners.clear(); - _playbackControlListeners.clear(); - }, - - /** Layer statistics for HUD/debug surfaces. */ - getStats() { - return { - count: _stations.length, - filtered: visibleStations().length, - selected: _selectedId, - playing: _audioStationId, - stale: _stale, - degraded: _degraded, - loading: _loading, - error: _error, - lastUpdate: _updatedAt ? Date.parse(_updatedAt) : null, - horizonScans: _horizonScanCount, - overlayEntries: _overlayDiagnostics.entryCount, - }; - }, - - subscribe: subscribeToRadio, - subscribePlaybackControls: subscribeToRadioPlaybackControls, - getAcceptedCatalogSnapshot: getRadioAcceptedCatalogSnapshot, - getUIState: getRadioUIState, - getParams: getRadioParams, - setParams: setRadioParams, - getOverlayDiagnostics: () => ({ - ..._overlayDiagnostics, - singletonTexts: [..._overlayDiagnostics.singletonTexts], - singletonIds: [..._overlayDiagnostics.singletonIds], - clusterTexts: [..._overlayDiagnostics.clusterTexts], - clusterIds: [..._overlayDiagnostics.clusterIds], - clusterMemberships: _overlayDiagnostics.clusterMemberships.map((entry) => ({ ...entry })), - }), - getTunerStations: getRadioTunerStations, - beginTuning: beginRadioTuning, - setTuningStatic: setRadioTuningStatic, - previewTuningStation: previewRadioTuningStation, - commitTuningStation: commitRadioTuningStation, - cancelTuning: cancelRadioTuning, - endTuning: endRadioTuning, - setFilter: setRadioFilter, - selectStation: selectRadioStation, - selectRequestedStation: selectRequestedRadioStation, - cycleStation: cycleRadioStation, - togglePlayback: toggleRadioPlayback, - play: playSelectedRadio, - playForVoice: playPreparedRadioForVoice, - pause: pauseRadioPlayback, - stopPlayback: stopRadioPlayback, - setVolume: setRadioVolume, - setVoiceDucked: setRadioVoiceDucking, -}; - -export default radioLayer; +export const radioGlobeLabel = layer.radioGlobeLabel; +export const createRadioSelectedOverlayEntry = + layer.createRadioSelectedOverlayEntry; +export const createRadioClusterOverlayEntry = + layer.createRadioClusterOverlayEntry; +export const createRadioSingletonOverlayEntry = + layer.createRadioSingletonOverlayEntry; +export const radioSingletonLabelLimit = layer.radioSingletonLabelLimit; +export const selectRadioSingletonCandidates = + layer.selectRadioSingletonCandidates; +export const selectRadioClusterCandidates = layer.selectRadioClusterCandidates; +export const reconcileRadioClusterCandidates = + layer.reconcileRadioClusterCandidates; +export const radioCameraPositionChanged = layer.radioCameraPositionChanged; +export const normalizeRadioTag = layer.normalizeRadioTag; +export const stationMatchesRadioCategory = layer.stationMatchesRadioCategory; +export const radioCategoryColor = layer.radioCategoryColor; +export const radioClusterBadgeText = layer.radioClusterBadgeText; +export const radioClusterCategoryId = layer.radioClusterCategoryId; +export const radioSelectionBracketSvg = layer.radioSelectionBracketSvg; +export const radioStationCategoryId = layer.radioStationCategoryId; +export const buildRadioCategories = layer.buildRadioCategories; +export const filterRadioStations = layer.filterRadioStations; +export const isEnglishRadioStation = layer.isEnglishRadioStation; +export const rankRadioStationsForViewport = layer.rankRadioStationsForViewport; +export const rankRadioStationsForRequest = layer.rankRadioStationsForRequest; +export const radioViewIsGlobal = layer.radioViewIsGlobal; +export const radioRequestIsCurrent = layer.radioRequestIsCurrent; +export const radioStationIdFromPick = layer.radioStationIdFromPick; +export const radioStationCameraPlan = layer.radioStationCameraPlan; +export const radioGlobeNeedsRecentering = layer.radioGlobeNeedsRecentering; +export const radioGlobeRecenterHeight = layer.radioGlobeRecenterHeight; +export const buildRadioTunerBand = layer.buildRadioTunerBand; +export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay; +export const getRadioAcceptedCatalogSnapshot = + layer.getRadioAcceptedCatalogSnapshot; +export const radioStationResolutionMatches = + layer.radioStationResolutionMatches; +export const getRadioUIState = layer.getRadioUIState; +export const subscribeToRadio = layer.subscribeToRadio; +export const subscribeToRadioPlaybackControls = + layer.subscribeToRadioPlaybackControls; +export const getRadioTunerStations = layer.getRadioTunerStations; +export const beginRadioTuning = layer.beginRadioTuning; +export const setRadioTuningStatic = layer.setRadioTuningStatic; +export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed; +export const previewRadioTuningStation = layer.previewRadioTuningStation; +export const endRadioTuning = layer.endRadioTuning; +export const cancelRadioTuning = layer.cancelRadioTuning; +export const commitRadioTuningStation = layer.commitRadioTuningStation; +export const playSelectedRadio = layer.playSelectedRadio; +export const confirmRadioPlayback = layer.confirmRadioPlayback; +export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice; +export const stopRadioPlayback = layer.stopRadioPlayback; +export const toggleRadioPlayback = layer.toggleRadioPlayback; +export const pauseRadioPlayback = layer.pauseRadioPlayback; +export const setRadioVolume = layer.setRadioVolume; +export const setRadioParams = layer.setRadioParams; +export const getRadioParams = layer.getRadioParams; +export const setRadioVoiceDucking = layer.setRadioVoiceDucking; +export const selectRadioStation = layer.selectRadioStation; +export const cycleRadioStation = layer.cycleRadioStation; +export const selectRequestedRadioStation = layer.selectRequestedRadioStation; +export const setRadioFilter = layer.setRadioFilter; +export const retainRadioClusterIdentitiesForStations = + layer.retainRadioClusterIdentitiesForStations; +export { + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, + GLOBAL_RADIO_ALTITUDE_M, +} from '../layers/radio/index.js'; +export default layer; + +export const radioLayer = layer; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from '../ui/radioTunerModel.js'; diff --git a/src/data/radioCountry.test.mjs b/src/data/radioCountry.test.mjs index 3270b46dc..aafe13a07 100644 --- a/src/data/radioCountry.test.mjs +++ b/src/data/radioCountry.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { normalizeRadioCountryInput } from './radioCountry.js'; +import { normalizeRadioCountryInput } from '../layers/radio/index.js'; test('Radio country normalization maps ISO codes and bounded common names', () => { for (const [input, code] of [ diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index c52b9d20d..c63b5e7a9 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,3700 +1,82 @@ -import * as Cesium from 'cesium'; import { - findSatelliteOrbitTrackInTle, - getSatelliteOrbitTrack, - orbitFrameModelMatrix, -} from './satellites.js'; -import { getKeyholeGeometry } from '../celestialRing.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; + createRocketLaunchesLayer, + createLaunchSource, +} from '../layers/launches/index.js'; +import * as satellites from './satellites.js'; +import * as geometry from '../celestialRing.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as render from '../renderGovernor.js'; -const WINDOW_DAYS = 30; -const API_URL = '/api/launches'; - -export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = 'rocket-mission-selected'; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 12, - collisionCapacity: 0, - moving: true, - solveIntervalMs: 0, +const layer = createRocketLaunchesLayer({ + source: createLaunchSource(), + services: { satellites, geometry, overlays, render }, }); - -const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, - moving: false, -}); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); - -let _dataSource = null; -let _count = 0; -let _lastUpdate = null; -let _lastError = null; -let _orbitMatches = 0; -let _clickHandler = null; -let _moveHandler = null; -let _viewer = null; -let _declutterHandler = null; -let _dataManager = null; -let _retryTimer = null; -let _postTleRetryCount = 0; -let _updatePromise = null; -let _updatePromiseToken = 0; -let _updateDirty = false; -let _lifecycleToken = 0; -let _enabled = false; -let _selectedLaunchId = null; -let _explicitSelection = false; -let _launches = []; -let _missionPanel = null; -let _missionRoster = null; -let _missionRosterHoverTimer = null; -let _hoveredRosterLaunchId = null; -let _missionRosterPreviewOwnership = null; -let _missionHoverReticleImage = null; -let _replayVehicleOverlay = null; -let _replayVehicleOverlayText = ''; -const _animationStarts = new Map(); -const _satelliteTelemetry = new Map(); -let _lastPanelTelemetryMs = 0; -let _lastMissionRingRotationMs = 0; -let _activeTleText = null; -let _activeTlePromise = null; -let _activeTlePromiseToken = 0; -let _renderedTleText = null; -let _focusAfterActiveLookup = false; -let _satelliteStateBeforeMission = null; -let _satelliteActivationPromise = null; -let _replayCameraRemover = null; -let _replayCameraLaunchId = null; -let _replayCameraToken = 0; -let _replayPaused = false; -let _replayPausedAtMs = null; -let _replaySpeed = 1; -const _replayTracks = new Map(); -const _missionOverlayRecords = new Map(); -let _missionOverlayHost = DEFAULT_OVERLAY_HOST; -let _selectedMissionOverlayTimeText = null; -let _missionZoomAnchorRemover = null; -let _missionZoomAnchorId = null; -let _launchPadZonePrimitive = null; -let _launchPadZoneRemover = null; -let _launchPadZoneLaunchId = null; -const _missionOrbitPrimitives = new Map(); - -const REPLAY_ASCENT_FALLBACK_SEC = 12; -const REPLAY_ASCENT_MIN_SEC = 8; -const REPLAY_ASCENT_MAX_SEC = 36; -const REPLAY_ORBIT_DURATION_SEC = 28; -const REPLAY_COUNTDOWN_DURATION_SEC = 10; -const REPLAY_TILE_SETTLE_DELAY_SEC = 5; -const REPLAY_INITIAL_RANGE_M = 3500; -const REPLAY_LOCAL_MAX_RANGE_M = 900000; -const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; -const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; -const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; -const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; -const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); -const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); -const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; -const REPLAY_SPEED_MIN = 0.25; -const REPLAY_SPEED_MAX = 4; -const REPLAY_SPEED_STEP = 0.25; -const MAX_POST_TLE_RETRIES = 1; -const POST_TLE_RETRY_DELAY_MS = 1500; -const _declutterTime = new Cesium.JulianDate(); -const _declutterOccluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - new Cesium.Cartesian3(), -); -const _missionRingDate = new Date(0); -const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; -const PROJECTED_ASCENT_ROTATION_SEC = 600; -const STAGE_REENTRY_ALTITUDE_M = 100000; -const MISSION_CLOSE_VIEW_RANGE_M = 180000; -const MISSION_GLOBE_VIEW_RANGE_M = 5000000; -const MISSION_FOCUS_RANGE_M = 12000; -const SATELLITE_STANDALONE_DEFAULTS = { - catalog: 'core', - showPoints: true, - showOrbits: true, -}; - -/** - * Derive the temporary Satellite display mode required by Space Missions. - * @param {object|null} currentParams Complete pre-mission Satellite parameters. - * @returns {object} Temporary mission-specific Satellite parameters. - */ -export function satelliteParamsForSpaceMissions(currentParams) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(currentParams || {}), - catalog: 'dense', - showPoints: false, - showOrbits: false, - }; -} - -/** - * Resolve the complete Satellite parameter set restored after mission mode. - * @param {object|null} snapshot Complete pre-mission Satellite parameters. - * @returns {object} Standalone Satellite parameters. - */ -export function satelliteParamsAfterSpaceMissions(snapshot) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(snapshot || {}), - }; -} - -/** - * Failed launch records may retain a planned orbit in Launch Library, but - * must not be represented as a live or estimated payload in orbit. - * @param {string|null} status Normalized Launch Library status name. - * @returns {boolean} Whether orbital visualization is allowed. - */ -export function launchStatusAllowsOrbit(status) { - return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test(String(status || '')); -} - -/** - * Describe only the mission paths that the selected record can actually show. - * Launch Library may retain a target orbit after a failure, but that target is - * not evidence of orbital insertion and cannot support a reconstructed replay. - * @param {object|null} launch Normalized launch record. - * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. - * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} - */ -export function missionPathPresentation(launch, replayAvailable = false) { - const orbitName = launch?.orbit?.name || (typeof launch?.orbit === 'string' ? launch.orbit : null); - const orbitAllowed = launchStatusAllowsOrbit(launch?.status); - const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) - ? launch.trajectory.filter((point) => ( - Number.isFinite(Number(point?.latitude)) - && Number.isFinite(Number(point?.longitude)) - )).length - : 0; - return { - orbit: orbitName ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` : null, - ascent: suppliedTrajectoryPoints > 1 - ? 'SUPPLIED TRAJECTORY POINTS' - : replayAvailable ? 'RECONSTRUCTED ESTIMATE' : 'UNAVAILABLE', - replayAvailable: Boolean(replayAvailable), - }; -} - -/** - * Decide whether one bounded rebuild is needed after the active TLE lookup. - * @param {object} input Retry state. - * @param {boolean} input.enabled Whether Space Missions is active. - * @param {number} input.retryCount Number of post-TLE retries already used. - * @param {string|null} input.activeTleText Resolved active TLE catalog. - * @param {string|null} input.renderedTleText TLE catalog used for the last build. - * @returns {boolean} Whether to schedule a refresh. - */ -export function shouldRetryAfterActiveTle({ - enabled, - retryCount, - activeTleText, - renderedTleText, -}) { - return Boolean( - enabled - && activeTleText - && activeTleText !== renderedTleText - && retryCount < MAX_POST_TLE_RETRIES, - ); -} - -/** - * Release aircraft follow state through each owning flight layer before a - * mission replay takes over the camera. - * @param {object|null} dataManager DataLayerManager instance. - * @returns {number} Number of owner APIs invoked. - */ -export function releaseAircraftTracking(dataManager) { - let released = 0; - for (const layerId of ['flights', 'military']) { - const module = dataManager?.layers?.get(layerId)?.module; - if (typeof module?.stopTracking !== 'function') continue; - module.stopTracking(); - released++; - } - return released; -} -const MISSION_ORBIT_PATTERN_GROUPS = 4; -const MISSION_ORBIT_DASHES_PER_GROUP = 100; -export const LAUNCH_PAD_ZONE_RADIUS_M = 500; -const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; -const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; - -const TRAJECTORY_STAGE_COLORS = [ - '#ff9f43', '#ff66c4', '#a78bfa', '#7bed9f', '#ffd166', '#60a5fa', -]; - -let _missionOrbitPatternRegistered = false; -const _pathDistanceCache = new WeakMap(); - -/** - * Register the selected-orbit tactical material once. Each group begins with - * one compact round dot followed by one hundred short dashes. - */ -function ensureMissionOrbitPatternRegistered() { - if (_missionOrbitPatternRegistered) return; - new Cesium.Material({ - fabric: { - type: 'GevMissionOrbitTactical', - uniforms: { - color: Cesium.Color.CYAN, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - float groupPosition = fract(materialInput.st.s * groupCount); - float markPosition = groupPosition * (dashCount + 1.0); - float markIndex = floor(markPosition); - float localPosition = fract(markPosition); - float centerDistance = abs(localPosition - 0.5); - float edge = max(fwidth(localPosition) * 1.35, 0.012); - float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); - float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); - float dash = dashAlong * dashAcross; - float dotAlong = (localPosition - 0.5) / 0.32; - float dotAcross = (materialInput.st.t - 0.5) / 0.5; - float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); - float isDot = 1.0 - step(0.5, markIndex); - float visible = mix(dash, dot, isDot); - material.diffuse = color.rgb; - material.emission = color.rgb * mix(0.07, 0.65, isDot); - material.alpha = color.a * visible * mix(0.58, 1.0, isDot); - return material; - }`, - }, - }); - _missionOrbitPatternRegistered = true; -} - -function createMissionOrbitPatternMaterial(color) { - ensureMissionOrbitPatternRegistered(); - return Cesium.Material.fromType('GevMissionOrbitTactical', { - color, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }); -} - -function missionOrbitPrimitiveVisible(launchId) { - return Boolean( - _enabled - && _dataSource?.show - && (!_selectedLaunchId || _selectedLaunchId === launchId), - ); -} - -function syncMissionOrbitPrimitiveVisibility() { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (path.primitive) path.primitive.show = missionOrbitPrimitiveVisible(launchId); - } -} - -function removeMissionOrbitPrimitives() { - for (const path of _missionOrbitPrimitives.values()) { - if (path.primitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(path.primitive); - } - } - _missionOrbitPrimitives.clear(); -} - -function updateMissionOrbitPrimitiveFrames(nowDate) { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - if (path.labelBakePosition && path.labelPosition) { - Cesium.Matrix4.multiplyByPoint( - path.primitive.modelMatrix, - path.labelBakePosition, - path.labelPosition, - ); - } - } -} - -function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { - if (!_viewer || !satelliteTrack || !Number.isFinite(satelliteTrack.gmstAtBake)) return false; - const collection = new Cesium.PolylineCollection(); - collection.add({ - positions: orbitPath, - width: 3, - material: createMissionOrbitPatternMaterial( - Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), - ), - }); - collection.show = missionOrbitPrimitiveVisible(launch.id); - orbitFrameModelMatrix(satelliteTrack.gmstAtBake, new Date(), collection.modelMatrix); - _viewer.scene.primitives.add(collection); - _missionOrbitPrimitives.set(launch.id, { - primitive: collection, - gmstAtBake: satelliteTrack.gmstAtBake, - }); - return true; -} - -function MissionOrbitPatternMaterialProperty(color) { - ensureMissionOrbitPatternRegistered(); - this._color = color; - this._definitionChanged = new Cesium.Event(); -} - -Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { - isConstant: { get() { return true; } }, - definitionChanged: { get() { return this._definitionChanged; } }, -}); - -MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { - return 'GevMissionOrbitTactical'; -}; - -MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(time, result) { - if (!Cesium.defined(result)) result = {}; - result.color = this._color; - result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; - result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; - return result; -}; - -MissionOrbitPatternMaterialProperty.prototype.equals = function equals(other) { - return this === other; -}; - -/** - * Decide whether the selected launch-pad zone belongs in the current view. - * Both altitude and direct camera range are bounded so an oblique close-up can - * show the effect without leaking it into regional or globe views. - * @param {object} input Visibility inputs. - * @param {boolean} input.layerActive Whether Space Missions is active. - * @param {string|null} input.selectedLaunchId Selected mission identifier. - * @param {string} input.launchId Candidate mission identifier. - * @param {number} input.cameraHeightM Camera height above the ellipsoid. - * @param {number} input.cameraDistanceM Direct camera range to the launch pad. - * @returns {boolean} - */ -export function launchPadZoneVisible({ - layerActive, - selectedLaunchId, - launchId, - cameraHeightM, - cameraDistanceM, -}) { - return Boolean( - layerActive - && selectedLaunchId - && selectedLaunchId === launchId - && Number.isFinite(cameraHeightM) - && cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M - && Number.isFinite(cameraDistanceM) - && cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, - ); -} - -/** - * Resolve whether a surface mission anchor is safely on the camera-facing - * side of Earth. The small positive limb margin prevents labels anchored just - * beyond the horizon from leaking through the hidden Cesium globe. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {number} [limbMargin] Additional normalized horizon clearance. - * @returns {boolean} Whether the marker belongs on the visible hemisphere. - */ -export function missionAnchorHorizonVisible(cameraPosition, markerPosition, limbMargin = 0.012) { - if (!cameraPosition || !markerPosition) return false; - const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); - if (!Number.isFinite(cameraMagnitude) || cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius) { - return false; - } - const cameraDirection = Cesium.Cartesian3.normalize( - cameraPosition, - new Cesium.Cartesian3(), - ); - const markerDirection = Cesium.Cartesian3.normalize( - markerPosition, - new Cesium.Cartesian3(), - ); - const limbThreshold = Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; - return Cesium.Cartesian3.dot(cameraDirection, markerDirection) - > limbThreshold + limbMargin; -} - -/** - * Resolve launch-anchor visibility for overview and selected-mission views. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {string} markerId Mission represented by this anchor. - * @param {string|null} selectedLaunchId Explicitly selected mission. - * @returns {boolean} Whether the launch anchor should render. - */ -export function missionAnchorVisible( - cameraPosition, - markerPosition, - markerId, - selectedLaunchId = null, -) { - if (selectedLaunchId && markerId !== selectedLaunchId) return false; - return missionAnchorHorizonVisible(cameraPosition, markerPosition); -} - -function focusFullGlobe(viewer, duration = 2.4) { - const canvas = viewer?.scene?.canvas; - const height = canvas?.clientHeight || canvas?.height; - const width = canvas?.clientWidth || canvas?.width; - const cartographic = viewer?.camera?.positionCartographic; - const fovy = viewer?.camera?.frustum?.fovy; - if (!(width > 0) || !(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) return; - const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; - const keyholeRadius = getKeyholeGeometry(width, height).radius; - const targetScreenRadius = keyholeRadius * 0.61; - const angularRadius = Math.atan((targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)); - const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); - const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, altitude), - orientation: { heading: viewer.camera.heading, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - -function shortMissionLabel(name, maxLength = 24) { - const text = String(name || 'Unnamed mission').replace(/\s+/g, ' ').trim().split(' | ')[0]; - const compact = text.split(' — ')[0].trim(); - return compact.length > maxLength ? `${compact.slice(0, maxLength - 1).trimEnd()}…` : compact; -} - -/** - * Reduce generic launch-complex names to their identifying pad or area suffix. - * @param {string|null} launchSite Launch Library pad name. - * @returns {string|null} Compact launch-site identifier. - */ -export function compactLaunchSiteName(launchSite) { - const text = String(launchSite || '').replace(/\s+/g, ' ').trim(); - if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; - const genericPrefix = /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; - const compact = text.replace(genericPrefix, '').trim(); - return compact || null; -} - -/** - * Build the source-owned launch-site marker presentation. Overview markers - * compete in the bounded ambient-label domain; the selected mission moves to - * the protected selected lane and gains the former launch-site detail line. - * @param {object} launch Normalized Launch Library mission. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. - * @param {boolean} [selected=false] Whether the mission owns the selected view. - * @returns {object} - */ -export function createRocketMissionMarkerOverlayEntry(launch, position, selected = false) { - const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); - const siteName = compactLaunchSiteName(launch?.launchSite); - const launchTimeMs = Date.parse(launch?.launchTime); - const details = selected - ? [siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'] - : []; - return { - id: `launch:${launch?.id}`, - position, - variant: 'label', - title: mission, - details, - accent: '#22e6e6', - priority: selected - ? Number.MAX_SAFE_INTEGER - : Number.isFinite(launchTimeMs) ? Math.floor(launchTimeMs / 1000) : 0, - selected, - protected: selected, - paintLane: selected ? 'selected' : 'ambient-label', - collisionGroup: 'ambient-label', - interactive: false, - distanceScale: { - near: 1000, - nearValue: 1.08, - far: 20_000_000, - farValue: 0.78, - }, - gapPx: 12, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** - * Build one protected label belonging to the selected mission's trajectory, - * live payload position, or orbit. Copy is already source-formatted by the - * caller; newline semantics become explicit host detail rows. - * @param {object} input - * @param {string} input.id Stable mission-element identity. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. - * @param {string} input.text Former native-label text, including newlines. - * @param {string} input.accent Source-owned label color. - * @param {number} [input.priority=0] Protected placement order. - * @param {number} [input.gapPx=8] Anchor-to-label gap. - * @returns {object} - */ -export function createRocketMissionElementOverlayEntry({ - id, - position, - text, - accent, - priority = 0, - gapPx = 8, -}) { - const [title, ...details] = String(text || '').split('\n'); - return { - id: String(id), - position, - variant: 'label', - title, - details, - accent, - priority, - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-label', - interactive: false, - gapPx, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** Keep the newest ambient mission markers with stable identity tie-breaking. */ -export function selectRocketMissionMarkerOverlayCohort( - entries, - limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, -) { - const cap = Math.max(0, Math.min( - ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - Math.floor(Number(limit) || 0), - )); - if (!Array.isArray(entries) || cap === 0) return []; - return entries.slice().sort((a, b) => ( - b.priority - a.priority || String(a.id).localeCompare(String(b.id)) - )).slice(0, cap); -} - -function missionHoverReticleImage() { - if (_missionHoverReticleImage) return _missionHoverReticleImage; - const canvas = document.createElement('canvas'); - const size = 48; - const inset = 6; - const arm = 12; - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); - context.strokeStyle = '#22e6e6'; - context.lineWidth = 2; - context.lineCap = 'square'; - context.shadowColor = 'rgba(34, 230, 230, .72)'; - context.shadowBlur = 5; - context.beginPath(); - context.moveTo(inset, inset + arm); - context.lineTo(inset, inset); - context.lineTo(inset + arm, inset); - context.moveTo(size - inset - arm, inset); - context.lineTo(size - inset, inset); - context.lineTo(size - inset, inset + arm); - context.moveTo(inset, size - inset - arm); - context.lineTo(inset, size - inset); - context.lineTo(inset + arm, size - inset); - context.moveTo(size - inset - arm, size - inset); - context.lineTo(size - inset, size - inset); - context.lineTo(size - inset, size - inset - arm); - context.stroke(); - _missionHoverReticleImage = canvas; - return canvas; -} - -/** - * Resolve the one permitted screen-space replay overlay state. - * @param {object} input Overlay state. - * @param {boolean} input.replayActive Whether replay owns the camera. - * @param {boolean} input.ascending Whether the replay marker is on ascent. - * @param {boolean} input.countdownActive Whether the T-minus hold is active. - * @returns {'countdown'|'ascent'|'orbit'|null} - */ -export function replayOverlayMode({ - replayActive, - ascending, - countdownActive, - preCountdownActive = false, -}) { - if (!replayActive) return null; - if (preCountdownActive) return null; - if (countdownActive) return 'countdown'; - return ascending ? 'ascent' : 'orbit'; -} - -/** - * Rotate an upright screen-space rocket so its nose follows a projected path. - * @param {{x: number, y: number}} from Current screen point. - * @param {{x: number, y: number}} to Forward screen point. - * @returns {number} Clockwise CSS rotation in radians. - */ -export function replayVehicleScreenRotation(from, to) { - const dx = Number(to?.x) - Number(from?.x); - const dy = Number(to?.y) - Number(from?.y); - if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 0.01) return 0; - return Math.atan2(dx, -dy); -} - -/** - * Reduce small screen-space reprojection jitter without allowing the marker - * to lag behind a camera jump or a phase transition. - * @param {{x:number,y:number}|null} previous Previous rendered position. - * @param {{x:number,y:number}} next Current path projection. - * @param {number} [alpha] Interpolation amount. - * @param {number} [snapDistance] Maximum distance to smooth. - * @returns {{x:number,y:number}} - */ -export function smoothReplayWindowPosition( - previous, - next, - alpha = 0.55, - snapDistance = 24, -) { - if (!previous || !next) return next; - const distance = Math.hypot(next.x - previous.x, next.y - previous.y); - if (!Number.isFinite(distance) || distance > snapDistance) return next; - const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); - return { - x: Cesium.Math.lerp(previous.x, next.x, amount), - y: Cesium.Math.lerp(previous.y, next.y, amount), - }; -} - -function createReplayVehicleOverlay() { - if (_replayVehicleOverlay || typeof document === 'undefined') return; - const host = document.getElementById('cesiumContainer') || document.body; - _replayVehicleOverlay = document.createElement('div'); - _replayVehicleOverlay.className = 'mission-replay-vehicle-overlay'; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.setAttribute('aria-hidden', 'true'); - _replayVehicleOverlay.innerHTML = ` - - - - - - - - - - - - - - - - - - - `; - host.appendChild(_replayVehicleOverlay); -} - -function hideReplayVehicleOverlay() { - if (!_replayVehicleOverlay) return; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.classList.remove('is-thrusting', 'is-paused'); -} - -function destroyReplayVehicleOverlay() { - _replayVehicleOverlay?.remove(); - _replayVehicleOverlay = null; - _replayVehicleOverlayText = ''; -} - -/** - * Score the amount of useful mission context available for roster triage. - * @param {object} launch Normalized launch record. - * @returns {number} Completeness score. - */ -export function missionDataCompleteness(launch = {}) { - let score = 0; - const present = (value) => value !== null && value !== undefined && value !== ''; - score += present(launch.provider) ? 1 : 0; - score += present(launch.mission) ? 2 : 0; - score += present(launch.missionName) ? 1 : 0; - score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; - score += Array.isArray(launch.payloads) ? Math.min(launch.payloads.length, 5) * 2 : 0; - score += Array.isArray(launch.recoveryStages) ? Math.min(launch.recoveryStages.length, 4) * 2 : 0; - score += Array.isArray(launch.trajectory) ? Math.min(launch.trajectory.length, 12) : 0; - score += Array.isArray(launch.timeline) ? Math.min(launch.timeline.length, 6) : 0; - return score; -} - -/** - * Build a data-rich roster while preserving the source-array index - * used by mission selection and Previous/Next navigation. - * @param {Array} launches Normalized launch records. - * @returns {Array<{launch: object, index: number}>} - */ -export function missionRosterEntries(launches) { - return (launches || []) - .map((launch, index) => ({ launch, index })) - .sort((a, b) => { - const completeness = missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); - if (completeness !== 0) return completeness; - const aTime = Date.parse(a.launch?.launchTime); - const bTime = Date.parse(b.launch?.launchTime); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime; - return b.index - a.index; - }); -} - -/** - * Preserve the user's globe scale for roster previews while avoiding an - * accidental surface-level fly-to when the list is opened from a close view. - * @param {number} cameraHeight Current camera height above the ellipsoid. - * @returns {number} Preview range in metres. - */ -export function missionHoverPreviewRange(cameraHeight) { - const height = Number(cameraHeight); - return Math.max( - MISSION_CLOSE_VIEW_RANGE_M, - Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, - ); -} - -/** - * Coordinate pointer and keyboard ownership of the temporary roster preview. - * The most recent input owns the preview until it leaves, then any remaining - * input resumes ownership. This lets keyboard focus take over from a pointer - * resting on another row without losing that pointer preview on blur. - * @param {{preview: (index: number) => void, clear: () => void}} handlers - * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, - * reset: () => void}} - */ -export function createMissionRosterPreviewOwnership({ preview, clear }) { - let pointerIndex = null; - let focusIndex = null; - let latestOwner = null; - let activeIndex = null; - - const sync = () => { - const nextIndex = latestOwner === 'pointer' - ? pointerIndex ?? focusIndex - : focusIndex ?? pointerIndex; - if (nextIndex === activeIndex) return; - activeIndex = nextIndex; - if (Number.isInteger(nextIndex)) preview(nextIndex); - else clear(); - }; - - return { - pointerEnter(index) { - pointerIndex = index; - latestOwner = 'pointer'; - sync(); - }, - pointerLeave() { - pointerIndex = null; - if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus'; - sync(); - }, - focus(index) { - focusIndex = index; - latestOwner = 'focus'; - sync(); - }, - blur(nextIndex = null) { - focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; - if (latestOwner === 'focus') latestOwner = focusIndex === null - ? (pointerIndex === null ? null : 'pointer') - : 'focus'; - sync(); - }, - reset() { - pointerIndex = null; - focusIndex = null; - latestOwner = null; - activeIndex = null; - clear(); - }, - }; -} - -/** - * Bind native keyboard focus events for one rendered roster row. - * @param {Element} button Mission roster button. - * @param {number} index Source-array mission index. - * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership - * @param {Element} roster Roster root used to reject focus targets outside the list. - */ -export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) { - button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); - button.addEventListener('mouseleave', () => ownership.pointerLeave()); - button.addEventListener('focus', () => ownership.focus(index)); - button.addEventListener('blur', (event) => { - const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]'); - const nextIndex = nextButton && roster.contains(nextButton) - ? Number(nextButton.dataset.missionRosterIndex) - : null; - ownership.blur(nextIndex); - }); -} - -/** Capture the exact mission identity owned by keyboard focus before a refresh. */ -export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) { - const button = activeElement?.closest?.('[data-mission-roster-id]'); - if (!button || !list?.contains(button)) return null; - const launchId = button.dataset.missionRosterId; - return launchId ? { launchId } : null; -} - -/** Restore focus by mission identity, or continue after the list if it departed. */ -export function restoreMissionRosterFocus(list, snapshot, continuation) { - if (!snapshot?.launchId) return 'none'; - const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || []) - .find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId); - if (button) { - button.focus({ preventScroll: true }); - return 'restored'; - } - if (continuation?.focus) { - continuation.focus({ preventScroll: true }); - return 'continued'; - } - return 'none'; -} - -/** Resolve a pending preview against the current refresh, never a stale object. */ -export function resolveMissionRosterPreviewLaunch(launches, launchId) { - return (launches || []).find((candidate) => candidate.id === launchId) || null; -} - -/** - * Format a mission epoch for compact on-globe replay labels. - * @param {string|Date|null} launchTime ISO-8601 mission time or Date. - * @returns {string} UTC timestamp or a clear unavailable state. - */ -export function formatMissionEventTime(launchTime) { - const date = new Date(launchTime); - if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; - return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; -} - -/** - * Parse the ISO-8601 durations supplied by Launch Library timeline events. - * @param {string|null} value ISO duration such as PT8M40S or -PT35M. - * @returns {number|null} Signed duration in seconds. - */ -export function parseMissionDurationSeconds(value) { - const match = String(value || '').trim().match( - /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, - ); - if (!match) return null; - const seconds = (Number(match[2] || 0) * 86400) - + (Number(match[3] || 0) * 3600) - + (Number(match[4] || 0) * 60) - + Number(match[5] || 0); - return match[1] ? -seconds : seconds; -} - -function orbitInsertionOffsetSeconds(launch) { - const events = (launch.timeline || []).filter((event) => Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0); - if (!events.length) return null; - const deployment = events.filter((event) => /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test(event.name)); - if (deployment.length) return Math.max(...deployment.map((event) => event.offsetSeconds)); - const engineCutoff = events.filter((event) => /seco|second engine cutoff/i.test(event.name)); - if (engineCutoff.length) return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); - return Math.max(...events.map((event) => event.offsetSeconds)); -} - -function estimatedOrbitPeriodSeconds(orbitPath) { - if (!orbitPath?.length) return 5400; - const meanRadius = orbitPath.reduce((total, point) => total + Cesium.Cartesian3.magnitude(point), 0) / orbitPath.length; - return 2 * Math.PI * Math.sqrt((meanRadius ** 3) / 3.986004418e14); -} - -/** - * Derive a compressed but mission-specific ascent replay duration. - * Launch Library timelines are authoritative when they expose insertion, - * SECO, or separation timing. Sparse records fall back to the reconstructed - * path length and a conservative ascent velocity estimate. - * @param {object} launch Normalized launch record. - * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. - * @returns {number} Replay duration in seconds. - */ -export function replayAscentDurationSeconds(launch, ascentPath = []) { - const disclosedSeconds = orbitInsertionOffsetSeconds(launch); - let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; - if (!realAscentSeconds && ascentPath.length > 1) { - const pathLength = ascentPath.slice(1).reduce( - (total, point, index) => total + Cesium.Cartesian3.distance(ascentPath[index], point), - 0, - ); - realAscentSeconds = Math.max(180, pathLength / 9000); - } - if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; - return Cesium.Math.clamp( - REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), - REPLAY_ASCENT_MIN_SEC, - REPLAY_ASCENT_MAX_SEC, - ); -} - -/** - * Clamp and snap a replay speed multiplier to the supported slider range. - * @param {number|string} value Requested playback multiplier. - * @returns {number} Supported multiplier between 0.25x and 4x. - */ -export function normalizeReplaySpeed(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 1; - const clamped = Cesium.Math.clamp(numeric, REPLAY_SPEED_MIN, REPLAY_SPEED_MAX); - return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; -} - -/** - * Preserve replay elapsed time when resuming after a pause. - * @param {number} startedAt Original replay start epoch in milliseconds. - * @param {number} pausedAt Pause epoch in milliseconds. - * @param {number} resumedAt Resume epoch in milliseconds. - * @returns {number} Shifted start epoch. - */ -export function replayStartAfterPause(startedAt, pausedAt, resumedAt) { - if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) return startedAt; - return startedAt + Math.max(0, resumedAt - pausedAt); -} - -export function replayState( - launch, - startedAt, - ascentDurationSec, - orbitDurationSec, - orbitPeriodSec, - speed = 1, - nowMs = Date.now(), - preCountdownDurationSec = 0, - loop = true, -) { - const animationDurationSec = ascentDurationSec + orbitDurationSec; - const realSecondsSinceStart = (nowMs - startedAt) / 1000; - const preCountdownDuration = Math.max(0, Number(preCountdownDurationSec) || 0); - const preCountdownActive = preCountdownDuration > 0 - && realSecondsSinceStart < -preCountdownDuration; - const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; - const countdownSeconds = countdownActive ? Math.ceil(-realSecondsSinceStart) : 0; - const elapsedSinceStart = Math.max(0, realSecondsSinceStart * normalizeReplaySpeed(speed)); - const elapsed = loop - ? elapsedSinceStart % animationDurationSec - : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); - const insertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const ascending = elapsed < ascentDurationSec; - const phaseProgress = ascending - ? elapsed / ascentDurationSec - : (elapsed - ascentDurationSec) / orbitDurationSec; - const missionOffsetSec = insertionOffsetSec === null - ? null - : ascending - ? phaseProgress * insertionOffsetSec - : insertionOffsetSec + phaseProgress * orbitPeriodSec; - const launchEpoch = Date.parse(launch.launchTime); - const eventTime = Number.isFinite(launchEpoch) && missionOffsetSec !== null - ? new Date(launchEpoch + missionOffsetSec * 1000) - : null; - return { - ascending, - phaseProgress, - eventTime, - elapsedSinceStart, - countdownActive, - preCountdownActive, - countdownSeconds, - }; -} - -export function approximateOrbitPath(launch) { - if (!launch.orbit?.name || !Number.isFinite(launch.lat) || !Number.isFinite(launch.lon)) return null; - const orbitName = launch.orbit.name.toLowerCase(); - const altitude = orbitName.includes('geostationary') || orbitName.includes('transfer') ? 35786000 - : orbitName.includes('medium') ? 20200000 : 550000; - const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; - const longitude = Cesium.Math.toRadians(launch.lon); - const latitude = Cesium.Math.toRadians(launch.lat); - const up = new Cesium.Cartesian3( - Math.cos(latitude) * Math.cos(longitude), - Math.cos(latitude) * Math.sin(longitude), - Math.sin(latitude), - ); - const east = new Cesium.Cartesian3( - -Math.sin(longitude), - Math.cos(longitude), - 0, - ); - const north = new Cesium.Cartesian3( - -Math.sin(latitude) * Math.cos(longitude), - -Math.sin(latitude) * Math.sin(longitude), - Math.cos(latitude), - ); - const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); - const isWesternNorthAmerica = launch.lat > 20 && launch.lat < 60 - && launch.lon > -140 && launch.lon < -105; - const launchAzimuthDeg = isPolar - ? (launch.lat >= 0 ? 180 : 0) - : isWesternNorthAmerica ? 190 : 90; - const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); - const forward = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(launchAzimuth), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(launchAzimuth), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - // A reconstructed orbit is an estimate, not a claim that the vehicle was - // inserted directly above the pad. Offset the orbital plane downrange by a - // small launch-to-insertion arc so a top-down view does not draw the entire - // orbit on top of the launch site. The ascent still joins this ring at its - // propagated insertion reference. - const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); - const orbitAnchor = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(up, Math.cos(insertionArc), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(forward, Math.sin(insertionArc), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const planeNormal = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const crossTrack = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(planeNormal, orbitAnchor, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return Array.from({ length: 97 }, (_, index) => { - const angle = (index / 96) * Math.PI * 2; - return new Cesium.Cartesian3( - radius * (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), - radius * (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), - radius * (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), - ); - }); -} - -function entityLaunchId(entity) { - if (!entity?.id || typeof entity.id !== 'string') return null; - const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); - return match?.[1] || null; -} - -function clearMissionOverlaySources() { - _selectedMissionOverlayTimeText = null; - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); -} - -function syncMissionOverlayEntries() { - if (!_enabled || !_dataSource?.show) { - clearMissionOverlaySources(); - return; - } - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (selectedRecord) { - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - const entries = selectedRecord.elementEntryFactories.map((createEntry) => createEntry()); - if (_replayCameraLaunchId !== selectedRecord.launch.id) { - entries.unshift(createRocketMissionMarkerOverlayEntry( - selectedRecord.launch, - selectedRecord.anchorPosition, - true, - )); - } - _missionOverlayHost.setEntries( - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, true); - _selectedMissionOverlayTimeText = selectedRecord.liveEventTime - ? formatMissionEventTime(selectedRecord.liveEventTime()) - : null; - return; - } - - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); - _selectedMissionOverlayTimeText = null; - const entries = selectRocketMissionMarkerOverlayCohort( - Array.from(_missionOverlayRecords.values(), (record) => { - const entry = createRocketMissionMarkerOverlayEntry( - record.launch, - record.anchorPosition, - ); - if (record.launch.id === _hoveredRosterLaunchId) { - entry.pinned = true; - entry.priority = Number.MAX_SAFE_INTEGER - 1; - } - return entry; - }), - ); - _missionOverlayHost.setEntries( - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, true); -} - -function refreshSelectedMissionOverlayText() { - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (!selectedRecord?.liveEventTime) return; - const nextText = formatMissionEventTime(selectedRecord.liveEventTime()); - if (nextText !== _selectedMissionOverlayTimeText) syncMissionOverlayEntries(); -} - -function setSelectedMission(launchId, isolate = true) { - if (launchId) clearMissionRosterHover(); - if (_replayCameraLaunchId && _replayCameraLaunchId !== launchId) stopMissionReplay(); - if (_missionZoomAnchorId && _missionZoomAnchorId !== launchId) stopMissionZoomAnchor(); - _selectedLaunchId = launchId; - _explicitSelection = Boolean(launchId && isolate); - if (launchId) _animationStarts.set(launchId, Date.now()); - if (!launchId && _viewer) { - stopMissionZoomAnchor(); - _viewer.selectedEntity = undefined; - } - if (!_dataSource) return; - for (const entity of _dataSource.entities.values) { - const relatedId = entityLaunchId(entity); - entity.show = launchId - ? relatedId === launchId - : entity.id.startsWith('rocket-launch:'); - } - syncMissionOrbitPrimitiveVisibility(); - syncMissionOverlayEntries(); - renderMissionPanel(); -} - -function hideLaunchPadZone() { - if (_launchPadZonePrimitive) _launchPadZonePrimitive.show = false; -} - -function removeLaunchPadZonePrimitive() { - if (_launchPadZonePrimitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(_launchPadZonePrimitive); - } - _launchPadZonePrimitive = null; - _launchPadZoneLaunchId = null; -} - -function createLaunchPadZonePrimitive(launch) { - removeLaunchPadZonePrimitive(); - const material = new Cesium.Material({ - fabric: { - type: 'GevLaunchPadZone', - uniforms: { - color: Cesium.Color.fromCssColorString('#22e6e6'), - fillAlpha: 0.105, - rimAlpha: 0.72, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; - float radius = length(centered); - float inside = 1.0 - smoothstep(0.985, 1.0, radius); - float rim = smoothstep(0.952, 0.985, radius) * inside; - material.diffuse = color.rgb; - material.emission = color.rgb * rim * 0.35; - material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); - return material; - }`, - }, - }); - const geometry = new Cesium.EllipseGeometry({ - center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), - semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - granularity: Cesium.Math.toRadians(0.08), - vertexFormat: Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, - }); - _launchPadZonePrimitive = _viewer.scene.primitives.add(new Cesium.GroundPrimitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry, - id: `rocket-pad-zone:${launch.id}`, - }), - appearance: new Cesium.MaterialAppearance({ - material, - translucent: true, - closed: false, - faceForward: true, - flat: true, - // Keep the zone classified onto the photoreal surface, but bias only its - // rasterized depth toward the camera. This avoids coplanar fragments - // being intermittently buried by the launch-pad mesh at oblique angles - // without adding a world-space height that would make the ring float. - renderState: { - depthTest: { - enabled: true, - }, - depthMask: false, - polygonOffset: { - enabled: true, - factor: -1, - units: -4, - }, - blending: Cesium.BlendingState.ALPHA_BLEND, - }, - }), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - show: true, - })); - _launchPadZoneLaunchId = launch.id; -} - -function initLaunchPadZonePrimitive() { - if (!_viewer || _launchPadZoneRemover) return; - _launchPadZoneRemover = _viewer.scene.preRender.addEventListener(() => { - if (!_enabled || !_dataSource?.show) { - hideLaunchPadZone(); - return; - } - const nowMs = Date.now(); - if (nowMs - _lastMissionRingRotationMs >= 1000) { - _missionRingDate.setTime(nowMs); - updateMissionOrbitPrimitiveFrames(_missionRingDate); - _lastMissionRingRotationMs = nowMs; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const camera = _viewer?.camera; - if (!launch || !camera) { - hideLaunchPadZone(); - return; - } - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const visible = launchPadZoneVisible({ - layerActive: Boolean(_dataSource?.show), - selectedLaunchId: _selectedLaunchId, - launchId: launch.id, - cameraHeightM: camera.positionCartographic?.height, - cameraDistanceM: Cesium.Cartesian3.distance(camera.positionWC, launchPosition), - }); - if (!visible) { - hideLaunchPadZone(); - return; - } - if (_launchPadZoneLaunchId !== launch.id) createLaunchPadZonePrimitive(launch); - _launchPadZonePrimitive.show = true; - }); -} - -function destroyLaunchPadZonePrimitive() { - if (_launchPadZoneRemover) _launchPadZoneRemover(); - _launchPadZoneRemover = null; - removeLaunchPadZonePrimitive(); -} - -export function samplePath(path, progress, result) { - if (!path?.length) return undefined; - // Degenerate returns clone into `result` when provided — handing back a - // path vertex would let an in-place caller mutate the path geometry. - if (path.length === 1) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - let distances = _pathDistanceCache.get(path); - if (!distances) { - distances = new Float64Array(path.length); - for (let index = 1; index < path.length; index++) { - distances[index] = distances[index - 1] - + Cesium.Cartesian3.distance(path[index - 1], path[index]); - } - _pathDistanceCache.set(path, distances); - } - const totalDistance = distances.at(-1); - if (!(totalDistance > 0)) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; - let low = 1; - let high = distances.length - 1; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (distances[middle] < targetDistance) low = middle + 1; - else high = middle; - } - const index = Math.max(0, low - 1); - const segmentDistance = distances[index + 1] - distances[index]; - const segmentProgress = segmentDistance > 0 - ? (targetDistance - distances[index]) / segmentDistance - : 0; - return Cesium.Cartesian3.lerp( - path[index], - path[index + 1], - segmentProgress, - result || new Cesium.Cartesian3(), - ); -} - -/** - * Resolve a continuously advancing orbit fraction from a wall-clock epoch. - * Fractional seconds prevent the selected satellite marker from jumping at - * each second boundary and forcing a one-frame photoreal globe LOD pulse. - * @param {number} nowMs Wall-clock epoch in milliseconds. - * @param {number} periodSec Orbital period in seconds. - * @returns {number} Normalized progress in the range [0, 1). - */ -export function orbitProgressAtTime(nowMs, periodSec) { - const period = Math.max(1, Number(periodSec) || 1); - return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; -} - -/** - * Construct one continuous estimated climb from the pad to orbit insertion. - * Horizontal movement begins much more slowly than altitude gain, preserving - * the near-vertical launch appearance without a hard corner at 120 km. - * @param {Cesium.Cartesian3} launchPosition Launch-pad position. - * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. - * @param {number} [samples] Number of curve intervals. - * @returns {Cesium.Cartesian3[]} - */ -export function reconstructedAscentPath(launchPosition, insertionPosition, samples = 512) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const origin = ellipsoid.cartesianToCartographic(launchPosition); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!origin || !insertion) return [launchPosition, insertionPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(origin.longitude, origin.latitude), - new Cesium.Cartographic(insertion.longitude, insertion.latitude), - ellipsoid, - ); - return Array.from({ length: samples + 1 }, (_, index) => { - const progress = index / samples; - if (index === 0) return launchPosition; - if (index === samples) return insertionPosition; - const horizontalProgress = progress ** 4; - const cartographic = geodesic.interpolateUsingFraction( - horizontalProgress, - new Cesium.Cartographic(), - ); - // Approximate the inertial eastward lead accumulated during a ten-minute - // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the - // upper climb, and returns to the fixed insertion endpoint. - const rotationalLead = EARTH_ROTATION_RAD_PER_SEC - * PROJECTED_ASCENT_ROTATION_SEC - * Math.sin(Math.PI * progress ** 3); - cartographic.longitude = Cesium.Math.negativePiToPi( - cartographic.longitude + rotationalLead, - ); - cartographic.height = Cesium.Math.lerp( - Math.max(0, origin.height), - Math.max(0, insertion.height), - Math.sin(progress * Cesium.Math.PI_OVER_TWO), - ); - return ellipsoid.cartographicToCartesian(cartographic); - }); -} - -function nearestOrbitIndex(orbitPath, referencePosition) { - if (!orbitPath?.length || !referencePosition) return 0; - const referenceDirection = Cesium.Cartesian3.normalize(referencePosition, new Cesium.Cartesian3()); - let bestIndex = 0; - let bestDot = -Number.MAX_VALUE; - orbitPath.forEach((candidate, index) => { - const direction = Cesium.Cartesian3.normalize(candidate, new Cesium.Cartesian3()); - const dot = Cesium.Cartesian3.dot(referenceDirection, direction); - if (dot > bestDot) { - bestDot = dot; - bestIndex = index; - } - }); - return bestIndex; -} - -function orbitPathFromInsertion(orbitPath, insertionIndex) { - if (!orbitPath?.length) return []; - const first = orbitPath[0]; - const last = orbitPath.at(-1); - const isClosed = orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; - const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); - if (!core.length) return orbitPath.slice(); - const index = Cesium.Math.mod(insertionIndex, core.length); - const rotated = [...core.slice(index), ...core.slice(0, index)]; - rotated.push(rotated[0]); - return rotated; -} - -function surfaceSafeSegment(startPosition, endPosition) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = ellipsoid.cartesianToCartographic(startPosition); - const end = ellipsoid.cartesianToCartographic(endPosition); - if (!start || !end) return [startPosition, endPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(start.longitude, start.latitude), - new Cesium.Cartographic(end.longitude, end.latitude), - ellipsoid, - ); - // Replay advances in real time across this path. Keep the geometry - // surface-safe, but give the animated marker and chase camera enough - // samples that they do not visibly pause at long segment boundaries. - const steps = Cesium.Math.clamp(Math.ceil(geodesic.surfaceDistance / 75000), 2, 256); - const positions = []; - for (let index = 0; index <= steps; index++) { - const fraction = index / steps; - const eased = fraction * fraction * (3 - 2 * fraction); - const cartographic = geodesic.interpolateUsingFraction(fraction, new Cesium.Cartographic()); - cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); - positions.push(ellipsoid.cartographicToCartesian(cartographic)); - } - positions[0] = startPosition; - positions[positions.length - 1] = endPosition; - return positions; -} - -function surfaceSafePath(controlPositions) { - if (!controlPositions?.length) return []; - if (controlPositions.length === 1) return controlPositions.slice(); - const path = []; - for (let index = 0; index < controlPositions.length - 1; index++) { - const segment = surfaceSafeSegment(controlPositions[index], controlPositions[index + 1]); - path.push(...(index === 0 ? segment : segment.slice(1))); - } - return path; -} - -function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { - if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) return ascentPath; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const transferEnd = orbitPath[insertionIndex]; - const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; - const orbitTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(nextOrbit, transferEnd, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.equalsEpsilon(orbitTangent, Cesium.Cartesian3.ZERO, 1e-8)) return ascentPath; - - // Replace a substantial final section with one cubic Bézier transition. - // Matching both endpoint tangents avoids the short corrective hook produced - // by locally pulling only the last few ascent samples toward the orbit. - const blendCount = Math.min(52, ascentPath.length - 2); - const blendStart = ascentPath.length - 1 - blendCount; - const start = ascentPath[blendStart]; - const previous = ascentPath[Math.max(0, blendStart - 1)]; - const altitudeEnvelope = ascentPath.slice(blendStart).map((position) => Math.max( - 0, - ellipsoid.cartesianToCartographic(position)?.height || 0, - )); - const ascentTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const chordLength = Math.max(Cesium.Cartesian3.distance(start, transferEnd), 1000); - const handleLength = chordLength * 0.28; - const controlA = Cesium.Cartesian3.add( - start, - Cesium.Cartesian3.multiplyByScalar(ascentTangent, handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const controlB = Cesium.Cartesian3.add( - transferEnd, - Cesium.Cartesian3.multiplyByScalar(orbitTangent, -handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - for (let index = 0; index <= blendCount; index++) { - const progress = index / blendCount; - const inverse = 1 - progress; - const point = new Cesium.Cartesian3(); - Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlA, 3 * inverse ** 2 * progress, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlB, 3 * inverse * progress ** 2, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(transferEnd, progress ** 3, new Cesium.Cartesian3()), - point, - ); - // A Cartesian Bézier is a chord in world space and can pass through the - // ellipsoid when its orbit-tangent handle is long. Preserve the original - // climb's smooth altitude envelope while retaining the Bézier's horizontal - // curvature and tangent-aligned insertion. - const cartographic = ellipsoid.cartesianToCartographic(point); - const minimumHeight = altitudeEnvelope[index] ?? 0; - if (cartographic && cartographic.height < minimumHeight) { - cartographic.height = minimumHeight; - ascentPath[blendStart + index] = ellipsoid.cartographicToCartesian(cartographic); - } else { - ascentPath[blendStart + index] = point; - } - } - ascentPath[ascentPath.length - 1] = transferEnd; - return ascentPath; -} - -/** - * Build a globe-safe ascent ending at the nearest point on the selected orbit. - * The returned orbit is rotated to begin at that same insertion point so the - * animated marker cannot jump between ascent and orbital phases. - * @param {Cesium.Cartesian3} launchPosition Launch-site position. - * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. - * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. - * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. - * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} - */ -export function buildMissionPaths( - launchPosition, - trajectoryPositions, - orbitPath, - insertionReference = null, -) { - const suppliedTrajectory = trajectoryPositions || []; - const controls = [launchPosition, ...suppliedTrajectory]; - const insertionIndex = nearestOrbitIndex( - orbitPath, - insertionReference || controls.at(-1), - ); - const transferEnd = orbitPath[insertionIndex]; - const ascentPath = suppliedTrajectory.length - ? surfaceSafePath([...controls, transferEnd]) - : reconstructedAscentPath(launchPosition, transferEnd); - blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); - return { - ascentPath, - animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), - insertionIndex, - }; -} - -export function cameraHeadingForPath(path, progress, fallback = Math.PI) { - if (!path?.length) return fallback; - const current = samplePath(path, progress); - const currentCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); - if (!currentCartographic) return fallback; - for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { - const next = samplePath(path, Math.min(1, progress + step)); - const nextCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); - if (!nextCartographic) continue; - const geodesic = new Cesium.EllipsoidGeodesic(currentCartographic, nextCartographic); - if (geodesic.surfaceDistance > 10 && Number.isFinite(geodesic.startHeading)) { - // HeadingPitchRange positions the camera opposite its heading vector. - // Passing the forward path heading therefore keeps the camera behind - // the vehicle, with the remaining ascent receding into the scene. - return Cesium.Math.zeroToTwoPi(geodesic.startHeading); - } - } - return fallback; -} - -/** - * Resolve the initial replay heading as a profile view of the path. - * @param {Cesium.Cartesian3[]} path Replay path. - * @returns {number} Heading perpendicular to the initial path direction. - */ -export function replayInitialCameraHeading(path) { - return Cesium.Math.zeroToTwoPi( - cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, - ); -} - -/** - * Keep ascent framing behind and slightly to one side of the vehicle, then - * widen that rear-quarter angle as the orbit becomes visible. - * @param {number} pathHeading Forward path bearing in radians. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {number} Cesium HeadingPitchRange heading. - */ -export function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - return Cesium.Math.zeroToTwoPi( - pathHeading + Cesium.Math.lerp( - REPLAY_ASCENT_CAMERA_OFFSET_RAD, - REPLAY_ORBIT_CAMERA_OFFSET_RAD, - blend, - ), - ); -} - -/** - * Limit replay-camera yaw changes so a path heading wrap or insertion turn - * cannot swing the chase view through the front of the vehicle. - * @param {number} previous Previous Cesium heading in radians. - * @param {number} desired Desired Cesium heading in radians. - * @param {number} [maxStepRad] Maximum angular change for one rendered frame. - * @returns {number} - */ -export function smoothReplayCameraHeading( - previous, - desired, - maxStepRad = Cesium.Math.toRadians(2), -) { - if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); - if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); - const delta = Cesium.Math.negativePiToPi(desired - previous); - const step = Cesium.Math.clamp(delta, -Math.abs(maxStepRad), Math.abs(maxStepRad)); - return Cesium.Math.zeroToTwoPi(previous + step); -} - -/** - * Blend from a global nadir view into an oblique local 3D view as the user - * approaches a selected launch site. - * @param {number} rangeM Camera distance from the launch-site anchor. - * @returns {number} Cesium camera pitch in radians. - */ -export function missionZoomPitch(rangeM) { - const range = Math.max(0, Number(rangeM) || 0); - const blend = Cesium.Math.clamp( - (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)) - / (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)), - 0, - 1, - ); - return Cesium.Math.lerp(Cesium.Math.toRadians(-42), -Cesium.Math.PI_OVER_TWO, blend); -} - -/** - * Resolve the replay camera offset for either close ascent tracking or the - * orbital globe pullback. - * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. - * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. - * @returns {{range: number, pitch: number}} - */ -export function replayCameraView(state, altitudeM) { - const altitude = Math.max(0, Number(altitudeM) || 0); - const localRange = Cesium.Math.clamp( - REPLAY_INITIAL_RANGE_M + altitude * 0.7, - REPLAY_INITIAL_RANGE_M, - REPLAY_LOCAL_MAX_RANGE_M, - ); - const rawContextBlend = Cesium.Math.clamp( - (altitude - 20000) - / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), - 0, - 1, - ); - const contextBlend = rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); - const contextRange = Cesium.Math.clamp( - 180000 + altitude * 3.8, - MISSION_CLOSE_VIEW_RANGE_M, - REPLAY_CONTEXT_MAX_RANGE_M, - ); - const ascentRange = Cesium.Math.lerp(localRange, contextRange, contextBlend); - const ascentPitch = Cesium.Math.lerp( - Cesium.Math.toRadians(-20), - Cesium.Math.toRadians(-34), - contextBlend, - ); - if (state?.ascending) { - return { range: ascentRange, pitch: ascentPitch }; - } - const rawBlend = Cesium.Math.clamp( - (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ); - const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); - return { - range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), - // Keep an oblique tactical view of the complete orbit rather than ending - // in a nadir view. This also prevents the camera from appearing to - // return toward the launch site after insertion. - pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), - }; -} - -/** - * Move the orbit-follow target from the vehicle toward its sub-satellite - * globe anchor, keeping Earth centered while the vehicle remains in frame. - * @param {Cesium.Cartesian3} position Animated orbital position. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Camera look-at target. - */ -export function replayOrbitGlobeAnchor(position, orbitBlend = 0) { - if (!position) return position; - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - if (blend <= 0) return Cesium.Cartesian3.clone(position); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - if (!cartographic) return Cesium.Cartesian3.clone(position); - const altitude = Math.max(0, cartographic.height || 0); - return Cesium.Cartesian3.fromRadians( - cartographic.longitude, - cartographic.latitude, - Cesium.Math.lerp(altitude, altitude * 0.1, blend), - ); -} - -/** - * Keep the orbital camera's look-at frame biased toward the moving vehicle. - * Blending completely to a whole-orbit bounding-sphere center can place the - * target at (or numerically close to) Earth's center. That frame is singular - * for a heading/pitch camera and lets compact-orbit vehicles leave the view as - * the camera rotates. Retaining a radial vehicle bias keeps the local frame - * stable while the wider camera range still contains Earth and the full orbit. - * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. - * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Stable camera look-at target. - */ -export function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { - if (!vehicleAnchor) return vehicleAnchor; - if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) - * REPLAY_ORBIT_FRAME_CENTER_BLEND; - return Cesium.Cartesian3.lerp( - vehicleAnchor, - frameCenter, - blend, - new Cesium.Cartesian3(), - ); -} - -/** - * Build a stable orbit-relative camera pose. The camera remains on one side - * of the orbital plane and uses the vehicle radial as its visual up axis. - * Consequently the forward orbit tangent always projects toward screen-left - * instead of changing direction when local compass headings wrap near a pole. - * @param {Cesium.Cartesian3} position Current vehicle position. - * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. - * @param {Cesium.Cartesian3} target Camera look-at target. - * @param {number} range Camera distance from the target. - * @param {number} pitch Camera elevation below the local horizon. - * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} - */ -export function replayOrbitCameraPose( - position, - tangentPosition, - target, - range, - pitch, -) { - if (!position || !tangentPosition || !target) return null; - const radial = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.clone(position), - new Cesium.Cartesian3(), - ); - const tangent = Cesium.Cartesian3.subtract( - tangentPosition, - position, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; - Cesium.Cartesian3.normalize(tangent, tangent); - const orbitNormal = Cesium.Cartesian3.cross( - radial, - tangent, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; - Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); - - const distance = Math.max(1, Number(range) || 1); - const elevation = Cesium.Math.clamp( - Math.abs(Number(pitch) || 0), - Cesium.Math.toRadians(5), - Cesium.Math.toRadians(80), - ); - const planeOffset = Cesium.Cartesian3.multiplyByScalar( - orbitNormal, - Math.cos(elevation) * distance, - new Cesium.Cartesian3(), - ); - const radialOffset = Cesium.Cartesian3.multiplyByScalar( - radial, - Math.sin(elevation) * distance, - new Cesium.Cartesian3(), - ); - const destination = Cesium.Cartesian3.add( - target, - planeOffset, - new Cesium.Cartesian3(), - ); - Cesium.Cartesian3.add(destination, radialOffset, destination); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const radialAlongView = Cesium.Cartesian3.multiplyByScalar( - direction, - Cesium.Cartesian3.dot(radial, direction), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(radial, radialAlongView, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return { destination, direction, up }; -} - -/** - * Build one conservative frame that contains both Earth and the complete - * selected orbit. High-apogee missions cannot be composed from the vehicle's - * instantaneous altitude alone because the opposite side of the orbit may - * extend much farther from the globe. - * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. - * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. - */ -export function replayOrbitFrameSphere(orbitPath = []) { - const earth = new Cesium.BoundingSphere( - Cesium.Cartesian3.ZERO, - Cesium.Ellipsoid.WGS84.maximumRadius, - ); - if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; - const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); - return Cesium.BoundingSphere.union(earth, orbit, new Cesium.BoundingSphere()); -} - -/** - * Ensure high-altitude missions frame both the globe and selected vehicle. - * @param {number} baseRange Range from the normal replay camera transition. - * @param {number} altitudeM Vehicle altitude above the ellipsoid. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. - * @returns {number} Camera range in metres. - */ -export function replayOrbitGlobeRange( - baseRange, - altitudeM, - orbitBlend = 0, - frameRadiusM = 0, -) { - const range = Math.max(0, Number(baseRange) || 0); - const altitude = Math.max(0, Number(altitudeM) || 0); - const frameRadius = Math.max(0, Number(frameRadiusM) || 0); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - const globeAndVehicleRange = Math.max( - range, - altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, - frameRadius * 3, - ); - return Cesium.Math.lerp(range, globeAndVehicleRange, blend); -} - -function stopMissionZoomAnchor() { - if (_missionZoomAnchorRemover) _missionZoomAnchorRemover(); - _missionZoomAnchorRemover = null; - _missionZoomAnchorId = null; - if (_viewer?.camera && !_replayCameraLaunchId) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - } -} - -function startMissionZoomAnchor(launch, initialRange) { - if (!_viewer || !launch || _replayCameraLaunchId) return; - stopMissionZoomAnchor(); - const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const range = Math.max(2000, Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M); - _missionZoomAnchorId = launch.id; - _viewer.camera.lookAt( - target, - new Cesium.HeadingPitchRange(0, missionZoomPitch(range), range), - ); -} - -function syncReplayButton() { - const button = _missionPanel?.querySelector('[data-mission-replay]'); - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - const speedControl = _missionPanel?.querySelector('.mission-replay-speed-control'); - if (!button) return; - const active = Boolean(_replayCameraLaunchId && _replayCameraLaunchId === _selectedLaunchId); - const replayAvailable = Boolean(_selectedLaunchId && _replayTracks.has(_selectedLaunchId)); - if (speedControl) speedControl.hidden = !replayAvailable; - button.hidden = active || !replayAvailable; - button.disabled = !replayAvailable; - button.textContent = 'REPLAY ASCENT'; - button.classList.remove('active'); - button.setAttribute('aria-pressed', String(active)); - button.title = 'Replay the estimated ascent with a following camera'; - if (transport) { - transport.hidden = !active; - transport.classList.toggle('is-paused', active && _replayPaused); - const toggleButton = transport.querySelector('[data-mission-replay-toggle]'); - if (toggleButton) { - toggleButton.disabled = !active; - toggleButton.textContent = _replayPaused ? '▶' : 'Ⅱ'; - toggleButton.setAttribute('aria-label', _replayPaused ? 'Resume replay' : 'Pause replay'); - toggleButton.title = _replayPaused ? 'Resume replay' : 'Pause replay'; - } - } -} - -function syncReplayCountdownButton(state) { - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - if (!transport || !_replayCameraLaunchId) return; - const phase = state.countdownActive - ? `T minus ${state.countdownSeconds}` - : state.preCountdownActive - ? 'Preparing launch site' - : state.elapsedSinceStart < 1 - ? 'Liftoff' - : state.ascending ? 'Ascent replay' : 'Orbit replay'; - transport.setAttribute('aria-label', `${phase}${_replayPaused ? ', paused' : ''}`); -} - -function syncReplaySpeedControl() { - const input = _missionPanel?.querySelector('[data-mission-replay-speed]'); - const output = _missionPanel?.querySelector('[data-mission-replay-speed-output]'); - if (input) { - input.value = String(_replaySpeed); - const progress = ((_replaySpeed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100; - input.style.setProperty('--replay-speed-progress', `${progress}%`); - } - if (output) output.textContent = `${_replaySpeed.toFixed(_replaySpeed % 1 ? 2 : 0)}×`; -} - -function setReplaySpeed(value) { - const nextSpeed = normalizeReplaySpeed(value); - const previousSpeed = _replaySpeed; - if (nextSpeed === previousSpeed) { - syncReplaySpeedControl(); - return; - } - const now = _replayPaused && Number.isFinite(_replayPausedAtMs) - ? _replayPausedAtMs - : Date.now(); - for (const [launchId, startedAt] of _animationStarts) { - if (!Number.isFinite(startedAt)) continue; - const elapsedMissionMs = (now - startedAt) * previousSpeed; - _animationStarts.set(launchId, now - elapsedMissionMs / nextSpeed); - } - _replaySpeed = nextSpeed; - syncReplaySpeedControl(); -} - -function replayClockNow(launchId) { - if ( - _replayPaused - && _replayCameraLaunchId === launchId - && Number.isFinite(_replayPausedAtMs) - ) { - return _replayPausedAtMs; - } - return Date.now(); -} - -function pauseMissionReplay() { - if (!_replayCameraLaunchId || _replayPaused) return false; - _replayPausedAtMs = Date.now(); - _replayPaused = true; - syncReplayButton(); - return true; -} - -function resumeMissionReplay() { - if (!_replayCameraLaunchId || !_replayPaused || !Number.isFinite(_replayPausedAtMs)) { - return false; - } - const resumedAt = Date.now(); - const startedAt = _animationStarts.get(_replayCameraLaunchId); - if (Number.isFinite(startedAt)) { - _animationStarts.set( - _replayCameraLaunchId, - replayStartAfterPause(startedAt, _replayPausedAtMs, resumedAt), - ); - } - _replayPaused = false; - _replayPausedAtMs = null; - syncReplayButton(); - return true; -} - -function stopMissionReplay() { - const stoppedLaunchId = _replayCameraLaunchId; - _replayCameraToken++; - if (_replayCameraRemover) _replayCameraRemover(); - _replayCameraRemover = null; - _viewer?.camera?.cancelFlight(); - if (_viewer?.camera) _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - _replayCameraLaunchId = null; - _replayPaused = false; - _replayPausedAtMs = null; - if (stoppedLaunchId) _animationStarts.set(stoppedLaunchId, Date.now()); - syncReplayButton(); - syncMissionOverlayEntries(); -} - -function startMissionReplay(launchId) { - const launch = _launches.find((item) => item.id === launchId); - const track = _replayTracks.get(launchId); - if (!_viewer || !launch || !track) return false; - if (_replayCameraLaunchId === launchId) { - stopMissionReplay(); - return false; - } - - stopMissionZoomAnchor(); - stopMissionReplay(); - _replayCameraLaunchId = launchId; - _replayPaused = false; - _replayPausedAtMs = null; - const token = ++_replayCameraToken; - const ascentDurationSec = track.ascentDurationSec; - // Start broadside to the ascent/orbit direction so the launch profile is - // visible. The chase heading then eases toward the path tangent after the - // camera is established. - const initialHeading = replayInitialCameraHeading(track.ascentPath); - track.lastCameraHeading = initialHeading; - syncReplayButton(); - syncMissionOverlayEntries(); - releaseAircraftTracking(_dataManager); - _viewer.trackedEntity = undefined; - _viewer.camera.cancelFlight(); - _animationStarts.set( - launchId, - Date.now() + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, - ); - let cameraReady = false; - let lastCameraUpdateMs = null; - - // Move the chase camera before scene traversal. Mutating it in preRender - // makes Cesium discover a new view after 3D-tile refinement, which can cause - // a self-sustaining refinement loop and visible stutter during slow replay. - _replayCameraRemover = _viewer.scene.preUpdate.addEventListener(() => { - if (token !== _replayCameraToken || _replayCameraLaunchId !== launchId) return; - const state = track.beginReplayFrame(); - syncReplayCountdownButton(state); - if (!cameraReady) return; - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - const position = samplePath(path, state.phaseProgress); - if (!position) return; - const pathHeading = cameraHeadingForPath( - path, - state.phaseProgress, - track.lastCameraHeading, - ); - const orbitBlend = !state.ascending - ? Cesium.Math.clamp( - (Number(state.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ) - : 0; - const desiredHeading = replayChaseCameraHeading(pathHeading, orbitBlend); - if (state.ascending) track.orbitCameraWorldFrame = false; - const nowMs = performance.now(); - const frameDurationMs = Number.isFinite(lastCameraUpdateMs) - ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) - : 1000 / 60; - lastCameraUpdateMs = nowMs; - track.lastCameraHeading = smoothReplayCameraHeading( - track.lastCameraHeading, - desiredHeading, - Cesium.Math.toRadians(2) * frameDurationMs / (1000 / 60), - ); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - const altitude = Math.max(0, cartographic?.height || 0); - const cameraView = replayCameraView(state, altitude); - const defaultOrbitTarget = !state.ascending - ? replayOrbitGlobeAnchor(position, orbitBlend) - : position; - const cameraTarget = !state.ascending - ? replayOrbitCameraTarget( - defaultOrbitTarget, - track.orbitFrameSphere?.center, - orbitBlend, - ) - : defaultOrbitTarget; - const cameraRange = !state.ascending - ? replayOrbitGlobeRange( - cameraView.range, - altitude, - orbitBlend, - track.orbitFrameSphere?.radius, - ) - : cameraView.range; - if (state.ascending) { - _viewer.camera.lookAt( - cameraTarget, - new Cesium.HeadingPitchRange( - track.lastCameraHeading, - cameraView.pitch, - cameraRange, - ), - ); - } else { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const tangentProgress = Math.min(1, state.phaseProgress + tangentStep); - const tangentPosition = samplePath( - path, - tangentProgress > state.phaseProgress - ? tangentProgress - : Math.max(0, state.phaseProgress - tangentStep), - ); - const orbitPose = replayOrbitCameraPose( - position, - tangentPosition, - cameraTarget, - cameraRange, - cameraView.pitch, - ); - if (orbitPose) { - if (!track.orbitCameraWorldFrame) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - track.orbitCameraWorldFrame = true; - } - _viewer.camera.setView({ - destination: orbitPose.destination, - orientation: { - direction: orbitPose.direction, - up: orbitPose.up, - }, - }); - } - } - if (state.elapsedSinceStart >= ascentDurationSec - + REPLAY_ORBIT_DURATION_SEC) { - stopMissionReplay(); - } - }); - - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(track.ascentPath[0], 0), - { - offset: new Cesium.HeadingPitchRange( - initialHeading, - Cesium.Math.toRadians(-20), - REPLAY_INITIAL_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - cameraReady = true; - }, - cancel: () => { - if (token === _replayCameraToken && _replayCameraLaunchId === launchId) stopMissionReplay(); - }, - }, - ); - return true; -} - -function finiteCoordinate(value) { - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function normalizePayloadFlights(launch) { - const flights = launch.rocket?.payloads || launch.payloads || launch.mission?.payloads || []; - if (!Array.isArray(flights)) return []; - return flights.map((flight, index) => { - const payload = flight.payload || flight; - return { - id: String(flight.id || payload.id || `payload-${index}`), - name: payload.name || flight.name || 'Unnamed payload', - type: payload.type?.name || flight.type?.name || null, - manufacturer: payload.manufacturer?.name || null, - operator: payload.operator?.name || null, - destination: flight.destination || payload.destination || null, - amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1, - massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== '')) - && Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0 - ? Number(payload.mass) : null, - }; - }); -} - -function normalizeLanding(stage, fallbackName, category, index) { - const landing = stage?.landing; - const location = landing?.landing_location || {}; - const launcher = stage?.launcher || stage?.spacecraft || {}; - const serial = launcher.serial_number || stage?.serial_number || null; - const stageType = stage?.type?.name || stage?.type || category; - const attempted = landing?.attempt === true; - const success = landing?.success; - const recoveryType = landing?.type?.name || null; - const launcherStatus = launcher.status?.name || null; - const status = success === true ? 'RECOVERED' - : success === false ? 'LOST' - : attempted ? 'RECOVERY ATTEMPT' - : recoveryType ? recoveryType.toUpperCase() - : launcherStatus ? launcherStatus.toUpperCase() - : 'NO RECOVERY DATA'; - return { - id: String(stage?.id || landing?.id || `${category}-${index}`), - category, - name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, - serial, - reused: stage?.reused === true, - flightNumber: finiteCoordinate(stage?.launcher_flight_number), - status, - attempted, - success: success === true ? true : success === false ? false : null, - recoveryType, - destination: location.name || landing?.destination || landing?.type?.name || null, - description: landing?.description || null, - downrangeKm: finiteCoordinate(landing?.downrange_distance), - lat: finiteCoordinate(location.latitude ?? landing?.latitude), - lon: finiteCoordinate(location.longitude ?? landing?.longitude), - }; -} - -function normalizeRecoveryStages(launch, payloads) { - const rocket = launch.rocket || {}; - const launcherStages = Array.isArray(rocket.launcher_stage) ? rocket.launcher_stage : []; - const spacecraftStages = Array.isArray(rocket.spacecraft_stage) ? rocket.spacecraft_stage : []; - const stages = [ - ...launcherStages.map((stage, index) => normalizeLanding(stage, `Launcher stage ${index + 1}`, 'LAUNCHER', index)), - ...spacecraftStages.map((stage, index) => normalizeLanding(stage, `Spacecraft stage ${index + 1}`, 'SPACECRAFT', index)), - ]; - const payloadFlights = rocket.payloads || launch.payloads || []; - if (Array.isArray(payloadFlights)) { - payloadFlights.forEach((flight, index) => { - if (!flight?.landing) return; - stages.push(normalizeLanding( - { ...flight, type: payloads[index]?.type || 'Payload', serial_number: payloads[index]?.name }, - payloads[index]?.name || `Payload ${index + 1}`, - 'PAYLOAD', - index, - )); - }); - } - return stages; -} - -function landingEndpoint(stage, launch, insertionPosition) { - if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { - return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; - } - const recoveryIdentity = `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); - if (/return to launch site|rtls|launch site|landing zone/.test(recoveryIdentity)) { - return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; - } - if (!(stage.downrangeKm > 0) || !insertionPosition) return null; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!insertion) return null; - const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); - const bearing = geodesic.startHeading; - const angularDistance = (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; - const lat1 = start.latitude; - const lon1 = start.longitude; - const lat = Math.asin( - Math.sin(lat1) * Math.cos(angularDistance) - + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), - ); - const lon = lon1 + Math.atan2( - Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), - Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), - ); - return { - lat: Cesium.Math.toDegrees(lat), - lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), - accuracy: 'EST. DOWNRANGE', - }; -} - -function stageReentryRecoveryPath(ascentPath, endpoint, stageIndex, stageCount) { - if (!ascentPath?.length || !endpoint) return []; - const progress = Cesium.Math.clamp(0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, 0.28, 0.68); - const separation = samplePath(ascentPath, progress); - const destination = Cesium.Cartesian3.fromDegrees(endpoint.lon, endpoint.lat, 12); - return surfaceSafeSegment(separation, destination); -} - -function atmosphericReentryIndex(path) { - if (!path?.length) return 0; - const ellipsoid = Cesium.Ellipsoid.WGS84; - for (let index = 1; index < path.length; index++) { - const previousHeight = ellipsoid.cartesianToCartographic(path[index - 1])?.height; - const height = ellipsoid.cartesianToCartographic(path[index])?.height; - if ( - Number.isFinite(previousHeight) - && Number.isFinite(height) - && previousHeight > STAGE_REENTRY_ALTITUDE_M - && height <= STAGE_REENTRY_ALTITUDE_M - ) { - return index; - } - } - return Math.min(Math.max(Math.round(path.length * 0.55), 0), path.length - 1); -} - -function missionTableRows(items, columns, emptyText) { - if (!items.length) return `${emptyText}`; - return items.map((item) => item).join(''); -} - -function setMissionPanelField(selector, value, title = '') { - const output = _missionPanel?.querySelector(selector); - if (!output) return; - const row = output.closest('[data-mission-field]'); - const available = value !== null && value !== undefined && String(value).trim() !== ''; - if (row) row.hidden = !available; - if (!available) { - output.textContent = ''; - output.removeAttribute('title'); - return; - } - output.textContent = value; - if (title) output.title = title; - else output.removeAttribute('title'); -} - -function renderMissionPanel() { - if (!_missionPanel) return; - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const index = launch ? _launches.indexOf(launch) : -1; - _missionPanel.hidden = !launch; - if (!launch) return; - _missionPanel.querySelector('[data-mission-title]').textContent = shortMissionLabel(launch.name, 32).toUpperCase(); - setMissionPanelField('[data-mission-provider]', launch.provider); - setMissionPanelField('[data-mission-status]', launch.status); - setMissionPanelField( - '[data-mission-site]', - launch.launchSite && launch.launchSite !== 'Unknown launch site' ? launch.launchSite : null, - ); - setMissionPanelField('[data-mission-time]', launch.launchTime); - const pathPresentation = missionPathPresentation(launch, _replayTracks.has(launch.id)); - setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); - _missionPanel.querySelector('[data-mission-ascent-source]').textContent = pathPresentation.ascent; - const payloadRows = launch.payloads.length - ? launch.payloads.slice(0, 5).map((payload) => { - const detail = [ - payload.manufacturer, - payload.operator && payload.operator !== payload.manufacturer ? payload.operator : null, - Number.isFinite(payload.massKg) ? `${payload.massKg.toLocaleString()} KG` : null, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; - }) - : []; - if (launch.payloads.length > 5) { - payloadRows.push(`+${launch.payloads.length - 5} additional payload records`); - } - _missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows( - payloadRows, - 3, - 'PAYLOAD DATA UNAVAILABLE', - ); - const stageRows = launch.recoveryStages.map((stage) => { - const endpoint = stage.endpoint; - const destination = stage.destination || (endpoint?.accuracy === 'PAD / RTLS' ? launch.launchSite : 'UNAVAILABLE'); - const position = endpoint - ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` - : stage.downrangeKm > 0 ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` : 'POSITION UNAVAILABLE'; - const stageDetail = [ - Number.isFinite(stage.flightNumber) ? `FLIGHT ${stage.flightNumber}` : null, - stage.reused ? 'REUSED' : null, - stage.recoveryType, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; - }); - _missionPanel.querySelector('[data-mission-stages]').innerHTML = missionTableRows( - stageRows, - 3, - 'NO STAGE RE-ENTRY / RECOVERY DATA', - ); - const stageSection = _missionPanel.querySelector('[data-mission-stages-section]'); - if (stageSection) stageSection.hidden = stageRows.length === 0; - updateMissionTelemetry(true); - _missionPanel.querySelector('[data-mission-index]').textContent = `${index + 1} / ${_launches.length}`; - _missionPanel.querySelector('[data-mission-prev]').disabled = index <= 0; - _missionPanel.querySelector('[data-mission-next]').disabled = index < 0 || index >= _launches.length - 1; - syncReplayButton(); - const panelScroller = _missionPanel.closest('.global-context-panel-inner'); - if (panelScroller) panelScroller.scrollTop = 0; -} - -function renderMissionRoster() { - if (!_missionRoster) return; - const list = _missionRoster.querySelector('[data-mission-roster-list]'); - const count = _missionRoster.querySelector('[data-mission-roster-count]'); - if (count) count.textContent = `${_launches.length} / 30D`; - if (!list) return; - const focusSnapshot = captureMissionRosterFocus(list); - _missionRosterPreviewOwnership?.reset(); - const entries = missionRosterEntries(_launches); - if (!entries.length) { - list.innerHTML = 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'; - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); - return; - } - list.innerHTML = entries.map(({ launch, index }) => { - const color = missionMarkerColor(launch).toCssColorString(); - const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; - const provider = launch.provider || 'UNSPECIFIED OPERATOR'; - const label = shortMissionLabel(launch.name, 27).toUpperCase(); - return `${escapeMissionText(label)}${escapeMissionText(provider)} · ${escapeMissionText(date)}›`; - }).join(''); - list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { - const index = Number(button.dataset.missionRosterIndex); - bindMissionRosterItemKeyboardPreview( - button, - index, - _missionRosterPreviewOwnership, - _missionRoster, - ); - }); - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); -} - -function escapeMissionText(value) { - return String(value ?? '').replace(/[&<>"']/g, (character) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - })[character]); -} - -function updateMissionTelemetry(force = false) { - if (!_missionPanel || !_selectedLaunchId || !_dataSource) return; - const now = performance.now(); - if (!force && now - _lastPanelTelemetryMs < 250) return; - _lastPanelTelemetryMs = now; - const satellite = _dataSource.entities.getById(`rocket-satellite:${_selectedLaunchId}`); - const position = satellite?.position?.getValue(Cesium.JulianDate.now(_declutterTime)); - const altitudeM = position ? Cesium.Cartographic.fromCartesian(position)?.height : null; - setMissionPanelField( - '[data-mission-distance]', - Number.isFinite(altitudeM) - ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` - : null, - ); - const speedMps = _satelliteTelemetry.get(_selectedLaunchId)?.speedMps; - setMissionPanelField( - '[data-mission-speed]', - Number.isFinite(speedMps) - ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` - : null, - Number.isFinite(speedMps) - ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` - : '', - ); -} - -function selectMissionAt(index) { - const launch = _launches[index]; - if (!launch) return; - setSelectedMission(launch.id, true); - focusMission(launch); -} - -function clearMissionRosterPreviewState() { - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _missionRosterHoverTimer = null; - const changed = _hoveredRosterLaunchId !== null; - _hoveredRosterLaunchId = null; - if (changed) syncMissionOverlayEntries(); -} - -function clearMissionRosterHover() { - if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset(); - else clearMissionRosterPreviewState(); -} - -function previewMissionFromRoster(launch) { - if (!_viewer || !launch || _selectedLaunchId) return; - const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height); - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(position, 0), - { - offset: new Cesium.HeadingPitchRange(0, -Cesium.Math.PI_OVER_TWO, range), - duration: 0.8, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }, - ); -} - -function scheduleMissionRosterPreview(index) { - const launch = _launches[index]; - if (!launch || _selectedLaunchId) return; - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _hoveredRosterLaunchId = launch.id; - syncMissionOverlayEntries(); - _missionRosterHoverTimer = setTimeout(() => { - _missionRosterHoverTimer = null; - if (_hoveredRosterLaunchId !== launch.id) return; - const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id); - if (currentLaunch) previewMissionFromRoster(currentLaunch); - }, 140); -} - -function focusMission(launch) { - if (!_viewer || !launch) return; - stopMissionZoomAnchor(); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - const orbitEntity = _dataSource.entities.getById(`rocket-orbit:${launch.id}`); - const orbitPositions = orbitEntity?.polyline?.positions?.getValue(Cesium.JulianDate.now()); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - let range = 18000000; - if (orbitPositions?.length > 1) { - const canvas = _viewer.scene.canvas; - const fovy = _viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); - const aspect = Math.max(0.1, (canvas.clientWidth || canvas.width) / Math.max(1, canvas.clientHeight || canvas.height)); - const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); - const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; - const orbitExtent = orbitPositions.reduce( - (largest, point) => Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), - 0, - ); - range = Math.max( - orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), - 18000000, - ); - } - const targetNormal = Cesium.Cartesian3.normalize(launchPosition, new Cesium.Cartesian3()); - const destination = Cesium.Cartesian3.add( - launchPosition, - Cesium.Cartesian3.multiplyByScalar(targetNormal, range, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const direction = Cesium.Cartesian3.negate(targetNormal, new Cesium.Cartesian3()); - let right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3()); - if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { - right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Y, right); - } - Cesium.Cartesian3.normalize(right, right); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - _viewer.camera.flyTo({ - destination, - orientation: { direction, up }, - duration: 1.1, - complete: () => { - if (_selectedLaunchId === launch.id) startMissionZoomAnchor(launch, range); - }, - }); -} - -function focusLaunchSite(launch) { - if (!_viewer || !launch) return; - stopMissionReplay(); - stopMissionZoomAnchor(); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(launchPosition, 0), - { - offset: new Cesium.HeadingPitchRange( - 0, - Cesium.Math.toRadians(-42), - MISSION_FOCUS_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - if (_selectedLaunchId === launch.id) { - startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); - } - }, - }, - ); -} - -function createMissionPanel() { - if (_missionPanel || typeof document === 'undefined') return; - const host = document.getElementById('space-mission-panel-host') - || document.getElementById('right-context-rail'); - if (!host) return; - _missionRoster = document.getElementById('space-mission-roster'); - if (_missionRoster) { - _missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({ - preview: scheduleMissionRosterPreview, - clear: clearMissionRosterPreviewState, - }); - _missionRoster.onclick = (event) => { - const button = event.target instanceof Element - ? event.target.closest('[data-mission-roster-index]') - : null; - if (!button) return; - selectMissionAt(Number(button.dataset.missionRosterIndex)); - }; - } - _missionPanel = document.createElement('aside'); - _missionPanel.id = 'space-mission-panel'; - _missionPanel.className = 'context-space-mission-detail'; - _missionPanel.setAttribute('aria-label', 'Selected Space Mission'); - _missionPanel.innerHTML = `SELECTED SPACE MISSION×MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED · PAYLOADNAMETYPEDESTINATIONSTAGE / RE-ENTRY / RECOVERYSTAGESTATUSFINAL POSITIONREPLAY SPEED1×0.25×1×4×FOCUSREPLAY ASCENT‹ PREV—NEXT ›SHOW ALL / DESELECT`; - _missionPanel.querySelector('.mission-action-row').insertAdjacentHTML( - 'beforeend', - ` - Ⅱ - × - `, - ); - host.appendChild(_missionPanel); - _missionPanel.querySelector('[data-mission-prev]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) - 1)); - _missionPanel.querySelector('[data-mission-next]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) + 1)); - _missionPanel.querySelector('[data-mission-close]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-show-all]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-focus]').addEventListener('click', () => { - const launch = _launches.find((item) => item.id === _selectedLaunchId); - if (launch) focusLaunchSite(launch); - }); - _missionPanel.querySelector('[data-mission-replay]').addEventListener('click', () => { - if (_selectedLaunchId) startMissionReplay(_selectedLaunchId); - }); - _missionPanel.querySelector('[data-mission-replay-toggle]').addEventListener('click', () => { - if (_replayPaused) resumeMissionReplay(); - else pauseMissionReplay(); - }); - _missionPanel.querySelector('[data-mission-replay-cancel]').addEventListener('click', stopMissionReplay); - _missionPanel.querySelector('[data-mission-replay-speed]').addEventListener('input', (event) => { - setReplaySpeed(event.currentTarget.value); - }); - syncReplaySpeedControl(); -} - -function updateReplayVehicleOverlay(occluder) { - if (!_viewer || !_replayVehicleOverlay || !_selectedLaunchId) { - hideReplayVehicleOverlay(); - return null; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const track = _replayTracks.get(_selectedLaunchId); - if (!launch || !track) { - hideReplayVehicleOverlay(); - return null; - } - const replayActive = _replayCameraLaunchId === launch.id; - const state = track.getReplayState(); - const mode = replayOverlayMode({ - replayActive, - ascending: state.ascending, - countdownActive: state.countdownActive, - preCountdownActive: state.preCountdownActive, - }); - if (!mode) { - track.lastOverlayWindowPosition = null; - track.lastOverlayMode = null; - hideReplayVehicleOverlay(); - return null; - } - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - // Do not call scene.sampleHeight() from this post-render path. A selected - // mission can remain visible from globe range, and a periodic remote height - // probe forces Google Photorealistic 3D Tiles to reconsider its refinement - // set even though the camera is stationary. That presented as a one-second - // shrink/expand pulse across the globe. Replay follows its already-built, - // surface-safe Cartesian path and therefore needs no runtime pad sampling. - const position = samplePath(path, state.phaseProgress); - if (!position || !occluder.isPointVisible(position)) { - hideReplayVehicleOverlay(); - return null; - } - const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( - _viewer.scene, - position, - ); - if (!windowPosition) { - hideReplayVehicleOverlay(); - return null; - } - const canvas = _viewer.scene.canvas; - if ( - windowPosition.x < -80 - || windowPosition.y < -100 - || windowPosition.x > canvas.clientWidth + 80 - || windowPosition.y > canvas.clientHeight + 100 - ) { - hideReplayVehicleOverlay(); - return null; - } - // The replay camera already targets this same frame-cached world position. - // Applying a second temporal filter here made the DOM vehicle trail the - // camera until the snap threshold was crossed, producing a repeating - // forward/back jump. Keep smoothing only for the non-tracked close-up pad - // marker, where the user can move the camera independently. - const renderedWindowPosition = !replayActive && track.lastOverlayMode === mode - ? smoothReplayWindowPosition(track.lastOverlayWindowPosition, windowPosition) - : windowPosition; - track.lastOverlayWindowPosition = renderedWindowPosition; - track.lastOverlayMode = mode; - _replayVehicleOverlay.hidden = false; - _replayVehicleOverlay.dataset.mode = mode; - _replayVehicleOverlay.classList.toggle( - 'is-thrusting', - replayActive && state.ascending && !state.countdownActive, - ); - _replayVehicleOverlay.classList.toggle('is-paused', replayActive && _replayPaused); - _replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; - let vehicleRotation = 0; - if (replayActive && !state.countdownActive) { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); - const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); - const useForward = forwardProgress > state.phaseProgress; - const tangentPosition = samplePath( - path, - useForward ? forwardProgress : backwardProgress, - ); - const tangentWindowPosition = tangentPosition - ? Cesium.SceneTransforms.worldToWindowCoordinates(_viewer.scene, tangentPosition) - : null; - if (tangentWindowPosition) { - const screenDelta = Math.hypot( - tangentWindowPosition.x - windowPosition.x, - tangentWindowPosition.y - windowPosition.y, - ); - // When the path points almost directly into/out of the camera, its - // screen projection has no trustworthy direction. Keep the last valid - // path-facing pose instead of snapping the rocket to a camera-facing - // upright orientation. - if (screenDelta >= 2) { - vehicleRotation = useForward - ? replayVehicleScreenRotation(windowPosition, tangentWindowPosition) - : replayVehicleScreenRotation(tangentWindowPosition, windowPosition); - track.lastVehicleRotation = vehicleRotation; - } else if (Number.isFinite(track.lastVehicleRotation)) { - vehicleRotation = track.lastVehicleRotation; - } - } - } - _replayVehicleOverlay.style.setProperty( - '--mission-replay-rotation', - `${Cesium.Math.toDegrees(vehicleRotation)}deg`, - ); - - const mission = shortMissionLabel(launch.name, 22).toUpperCase(); - const siteName = compactLaunchSiteName(launch.launchSite); - const siteCallout = siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'; - let title = mission; - let detail = siteCallout; - if (mode === 'countdown') { - title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; - detail = `LAUNCH STANDBY\n${siteCallout}`; - } else if (mode === 'ascent') { - title = state.elapsedSinceStart < 1 - ? `LIFTOFF · ${mission}` - : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'recovery') { - title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'orbit') { - title = `ORBIT REPLAY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } - if (_replayPaused) title = `PAUSED · ${title}`; - const nextText = `${title}\n${detail}`; - if (nextText !== _replayVehicleOverlayText) { - _replayVehicleOverlayText = nextText; - _replayVehicleOverlay.querySelector('[data-replay-overlay-title]').textContent = title; - _replayVehicleOverlay.querySelector('[data-replay-overlay-detail]').textContent = detail; - } - return mode; -} - -function setGraphicVisibility(graphic, visible, time) { - if (!graphic) return; - const next = Boolean(visible); - const current = graphic.show?.getValue?.(time); - if (current !== next) graphic.show = next; -} - -function updateMissionFrame() { - if (!_enabled || !_viewer || !_dataSource?.show) { - hideReplayVehicleOverlay(); - return; - } - updateMissionTelemetry(); - const time = Cesium.JulianDate.now(_declutterTime); - _declutterOccluder.cameraPosition = _viewer.scene.camera.positionWC; - updateReplayVehicleOverlay(_declutterOccluder); - refreshSelectedMissionOverlayText(); - for (const entity of _dataSource.entities.values) { - if (!entity.show) continue; - if (!entity.position) continue; - const id = entityLaunchId(entity); - if (!id) continue; - const position = entity.position.getValue(time); - let horizonVisible = Boolean(position && _declutterOccluder.isPointVisible(position)); - const launchAnchor = entity.id.startsWith('rocket-launch:'); - if (launchAnchor) { - horizonVisible = missionAnchorVisible( - _viewer.scene.camera.positionWC, - position, - id, - _selectedLaunchId, - ); - // Keep entity.show reserved for mission-selection isolation. Horizon - // state is applied to the individual graphics below so a rear-side - // anchor remains eligible for reevaluation as the camera moves. - } - if (entity.point) { - setGraphicVisibility(entity.point, horizonVisible, time); - } - if (launchAnchor && entity.billboard) { - setGraphicVisibility( - entity.billboard, - horizonVisible && id === _hoveredRosterLaunchId, - time, - ); - } - } -} - -/** - * Select a stable marker color from the mission operator and payload name. - * Labels stay cyan so the layer remains visually coherent. - * @param {object} launch Normalized launch record. - * @returns {Cesium.Color} - */ -export function missionMarkerColor(launch) { - const identity = `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); - if (/nasa|national aeronautics/.test(identity)) return Cesium.Color.fromCssColorString('#ff9f43'); - if (/starlink|spacex|space exploration/.test(identity)) return Cesium.Color.fromCssColorString('#4cc9f0'); - if (/rocket lab/.test(identity)) return Cesium.Color.fromCssColorString('#7bed9f'); - if (/isro|indian space/.test(identity)) return Cesium.Color.fromCssColorString('#ff66c4'); - if (/cnsa|china national|long march/.test(identity)) return Cesium.Color.fromCssColorString('#ffd166'); - if (/blue origin/.test(identity)) return Cesium.Color.fromCssColorString('#a78bfa'); - if (/ula|united launch alliance/.test(identity)) return Cesium.Color.fromCssColorString('#f97316'); - if (/arianespace|esa|european space/.test(identity)) return Cesium.Color.fromCssColorString('#60a5fa'); - if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); - return Cesium.Color.fromCssColorString('#22e6e6'); -} - -/** - * Normalize a Launch Library 2 response into records suitable for the layer. - * Trajectory points are retained only when the upstream explicitly supplies - * them; orbital tracks must not be reconstructed from launch metadata. - * @param {object} payload Launch Library 2-compatible response. - * @param {Date} [now] Reference time used for the rolling window. - * @returns {Array} - */ -export function normalizeRocketLaunches(payload, now = new Date()) { - const launches = Array.isArray(payload) ? payload : payload?.results; - if (!Array.isArray(launches)) return []; - const cutoff = now.getTime() - WINDOW_DAYS * 86400000; - return launches.map((launch) => { - const launchTime = launch.net || launch.window_start || launch.pad?.location?.name; - const date = Date.parse(launchTime); - const pad = launch.pad || {}; - const location = pad.location || {}; - const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates).split(',').map(Number); - const lat = Number.isFinite(Number(pad.latitude)) ? Number(pad.latitude) : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) ? Number(pad.longitude) : coordinateLon; - const payloads = normalizePayloadFlights(launch); - return { - id: String(launch.id || launch.slug || launch.name || `launch-${date}`), - name: launch.name || 'Unnamed launch', - status: launch.status?.name || 'Unknown', - launchTime: Number.isFinite(date) ? new Date(date).toISOString() : null, - launchSite: pad.name || location.name || 'Unknown launch site', - lat: Number.isFinite(lat) ? lat : null, - lon: Number.isFinite(lon) ? lon : null, - provider: launch.launch_service_provider?.name || null, - mission: launch.mission?.description || null, - missionName: launch.mission?.name || null, - satelliteQuery: launch.mission?.name || launch.name || null, - payloads, - recoveryStages: normalizeRecoveryStages(launch, payloads), - trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], - timeline: Array.isArray(launch.timeline) - ? launch.timeline.map((event) => ({ - name: event.type?.abbrev || event.type?.name || event.name || 'Mission event', - relativeTime: event.relative_time || event.relativeTime || null, - offsetSeconds: parseMissionDurationSeconds(event.relative_time || event.relativeTime), - })) - : [], - orbit: launch.mission?.orbit || launch.orbit || null, - source: 'Launch Library 2', - inWindow: Number.isFinite(date) && date >= cutoff && date <= now.getTime(), - }; - }).filter((launch) => launch.inWindow && launch.lat !== null && launch.lon !== null); -} - -function addLaunchEntity(launch, activeTleText = _activeTleText) { - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const overlayRecord = { - launch, - anchorPosition: position, - elementEntryFactories: [], - liveEventTime: null, - }; - _missionOverlayRecords.set(launch.id, overlayRecord); - const orbitAllowed = launchStatusAllowsOrbit(launch.status); - const coreTrack = orbitAllowed && launch.satelliteQuery - ? getSatelliteOrbitTrack(launch.satelliteQuery, { launchTime: launch.launchTime }) - : null; - const satelliteTrack = coreTrack || (orbitAllowed && activeTleText && launch.satelliteQuery - ? findSatelliteOrbitTrackInTle( - activeTleText, - launch.satelliteQuery, - { launchTime: launch.launchTime }, - ) - : null); - const orbitPath = !orbitAllowed - ? null - : satelliteTrack?.orbitPath?.length > 1 - ? satelliteTrack.orbitPath - : approximateOrbitPath(launch); - launch.recoveryStages.forEach((stage) => { - stage.endpoint = landingEndpoint(stage, launch, orbitPath?.[0] || null); - }); - if (satelliteTrack) _orbitMatches++; - const entity = _dataSource.entities.add({ - id: `rocket-launch:${launch.id}`, - position, - point: { - pixelSize: 5, - color: missionMarkerColor(launch), - outlineColor: missionMarkerColor(launch), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - billboard: { - image: missionHoverReticleImage(), - width: 24, - height: 24, - show: false, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - launchName: launch.name, - launchTime: launch.launchTime, - launchSite: launch.launchSite, - provider: launch.provider, - mission: launch.mission, - status: launch.status, - source: launch.source, - orbit: JSON.stringify(launch.orbit || {}), - satelliteName: satelliteTrack?.name || '', - noradId: satelliteTrack?.noradId || '', - }, - }); - const points = launch.trajectory - .filter((point) => Number.isFinite(Number(point.latitude)) && Number.isFinite(Number(point.longitude))) - .map((point) => ({ - stage: String(point.stage || point.stage_name || point.phase || point.stageName || 'trajectory'), - position: Cesium.Cartesian3.fromDegrees(Number(point.longitude), Number(point.latitude), Number(point.altitude || 0)), - })); - if (points.length > 1) { - const segments = []; - points.forEach((point) => { - const previous = segments.at(-1); - if (!previous || previous.stage !== point.stage) segments.push({ stage: point.stage, positions: [] }); - segments.at(-1).positions.push(point.position); - }); - segments.forEach((segment, index) => { - if (segment.positions.length < 2) return; - _dataSource.entities.add({ - id: `rocket-trajectory:${launch.id}:${index}`, - polyline: { - positions: surfaceSafePath(segment.positions), - width: 2, - material: Cesium.Color.fromCssColorString(TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length]).withAlpha(0.8), - clampToGround: false, - arcType: Cesium.ArcType.NONE, - }, - properties: { launchId: launch.id, stage: segment.stage, source: launch.source }, - }); - }); - } - if (orbitPath?.length > 1) { - const orbitCurrent = satelliteTrack?.current || { longitude: launch.lon, latitude: launch.lat, altitude: 0 }; - const orbitPeriodSec = satelliteTrack?.periodSec || estimatedOrbitPeriodSeconds(orbitPath); - const launchEpochMs = Date.parse(launch.launchTime); - const disclosedInsertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) - ? Math.max(0, disclosedInsertionOffsetSec) - : 600; - const insertionEpochMs = Number.isFinite(launchEpochMs) - ? launchEpochMs + insertionDurationSec * 1000 - : Number.NaN; - let insertionReference = points.at(-1)?.position || null; - if (!insertionReference && satelliteTrack?.positionAt && Number.isFinite(insertionEpochMs)) { - const propagatedInsertion = satelliteTrack.positionAt(new Date(insertionEpochMs)); - if (propagatedInsertion) { - insertionReference = Cesium.Cartesian3.fromDegrees( - propagatedInsertion.longitude, - propagatedInsertion.latitude, - propagatedInsertion.altitude, - ); - } - } - if (!insertionReference && !satelliteTrack) { - // A projected orbit has no authoritative historical phase. Start its - // plane over the launch site, advance only by the estimated powered - // ascent duration, and join the forward orbit tangent. Using the UTC - // epoch as phase sent some ascents toward the far side of Earth and - // forced a visible 180-degree corrective hook before insertion. - const insertionProgress = Cesium.Math.clamp( - insertionDurationSec / orbitPeriodSec, - 1 / 96, - 0.16, - ); - insertionReference = samplePath(orbitPath, insertionProgress); - } - const { ascentPath, animatedOrbitPath } = buildMissionPaths( - position, - points.map((point) => point.position), - orbitPath, - insertionReference, - ); - const ascentDurationSec = replayAscentDurationSeconds(launch, ascentPath); - // Cesium can evaluate CallbackProperty values multiple times while it - // traverses one frame. Camera tracking and the HTML replay overlay must - // use the exact same replay epoch as those callbacks; sampling Date.now() - // independently made the vehicle advance relative to the camera and then - // snap back on the next frame. - let replayStateForFrame = null; - const sampleReplayState = () => replayState( - launch, - _animationStarts.get(launch.id) || Date.now(), - ascentDurationSec, - REPLAY_ORBIT_DURATION_SEC, - orbitPeriodSec, - _replaySpeed, - replayClockNow(launch.id), - REPLAY_TILE_SETTLE_DELAY_SEC, - _replayCameraLaunchId !== launch.id, - ); - // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed - // one state there and retain it through postRender so every consumer in - // that traversal sees the same timestamp. scene.frameState.frameNumber is - // advanced between some of those phases, so it is not a reliable cache key. - const beginReplayFrame = () => { - replayStateForFrame = sampleReplayState(); - return replayStateForFrame; - }; - const getReplayState = () => { - if (!replayStateForFrame) { - replayStateForFrame = sampleReplayState(); - } - return replayStateForFrame; - }; - launch.recoveryStages.forEach((stage, stageIndex) => { - stage.endpoint = landingEndpoint(stage, launch, ascentPath.at(-1)); - const path = stageReentryRecoveryPath(ascentPath, stage.endpoint, stageIndex, launch.recoveryStages.length); - if (path.length < 2) return; - const reentryIndex = atmosphericReentryIndex(path); - const reentryPath = path.slice(reentryIndex); - const color = Cesium.Color.fromCssColorString( - TRAJECTORY_STAGE_COLORS[(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length], - ); - _dataSource.entities.add({ - id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, - polyline: { - positions: path, - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ - color: color.withAlpha(0.82), - dashLength: 12, - dashPattern: 0xAAAA, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'STAGE_REENTRY_RECOVERY', - accuracy: stage.endpoint.accuracy, - source: launch.source, - }, - }); - if (reentryPath.length > 1) { - _dataSource.entities.add({ - id: `rocket-reentry:${launch.id}:${stageIndex}`, - polyline: { - positions: reentryPath, - width: 2.5, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), - dashLength: 8, - dashPattern: 0xF0F0, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'ATMOSPHERIC_REENTRY', - altitudeM: STAGE_REENTRY_ALTITUDE_M, - source: 'Estimated 100 km atmospheric interface', - }, - }); - const reentryPosition = path[reentryIndex]; - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `reentry:${launch.id}:${stageIndex}`, - position: reentryPosition, - text: 'STAGE RE-ENTRY', - accent: '#ffd166', - priority: 700_000 - stageIndex, - gapPx: 8, - }) - )); - } - _dataSource.entities.add({ - id: `rocket-recovery-end:${launch.id}:${stageIndex}`, - position: path.at(-1), - point: { - pixelSize: 4, - color, - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'RECOVERY_ENDPOINT', - source: launch.source, - }, - }); - }); - _replayTracks.set(launch.id, { - ascentPath, - animatedOrbitPath, - orbitFrameSphere: replayOrbitFrameSphere(animatedOrbitPath), - ascentDurationSec, - beginReplayFrame, - getReplayState, - lastCameraHeading: Math.PI, - orbitCameraWorldFrame: false, - lastVehicleRotation: 0, - lastOverlayWindowPosition: null, - lastOverlayMode: null, - }); - let livePosition = Cesium.Cartesian3.fromDegrees( - orbitCurrent.longitude, - orbitCurrent.latitude, - orbitCurrent.altitude, - ); - const liveTime = new Date(); - const liveTelemetry = { speedMps: null }; - _satelliteTelemetry.set(launch.id, liveTelemetry); - let liveFrameNumber = -1; - const fallbackPeriodSec = estimatedOrbitPeriodSeconds(orbitPath); - const updateLiveState = () => { - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; - liveFrameNumber = frameNumber; - const nowMs = Date.now(); - liveTime.setTime(nowMs); - if (satelliteTrack) { - const propagated = satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; - if (propagated) { - Cesium.Cartesian3.fromDegrees( - propagated.longitude, - propagated.latitude, - propagated.altitude, - undefined, - livePosition, - ); - liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) - ? propagated.speedMps - : null; - } - } else { - // In-place write keeps one Cartesian3 for the mission's lifetime — - // the estimated branch previously reallocated per propagation. - livePosition = samplePath( - orbitPath, - orbitProgressAtTime(nowMs, fallbackPeriodSec), - livePosition, - ) || livePosition; - } - }; - _dataSource.entities.add({ - id: `rocket-satellite:${launch.id}`, - position: new Cesium.CallbackProperty(() => { - updateLiveState(); - return livePosition; - }, false), - point: { - pixelSize: 6, - color: Cesium.Color.fromCssColorString(satelliteTrack ? '#7bed9f' : '#ffd166'), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - noradId: satelliteTrack?.noradId || '', - phase: satelliteTrack ? 'CURRENT_SATELLITE_POSITION' : 'ESTIMATED_ORBIT_POSITION', - source: satelliteTrack ? 'Satellites layer' : 'Approximate mission orbit', - }, - }); - overlayRecord.liveEventTime = () => liveTime; - overlayRecord.elementEntryFactories.push(() => { - const name = satelliteTrack - ? shortMissionLabel(satelliteTrack.name, 22).toUpperCase() - : 'EST. ORBIT POSITION'; - return createRocketMissionElementOverlayEntry({ - id: `payload-position:${launch.id}`, - position: () => livePosition, - text: `${name}\n${formatMissionEventTime(liveTime)}`, - accent: satelliteTrack ? '#7bed9f' : '#ffd166', - priority: 900_000, - gapPx: 12, - }); - }); - // Live satellite rings use a primitive collection so the core Satellite - // GMST transform can rotate their baked ECEF geometry without rebuilding - // it. Estimated launch-site-relative rings remain ordinary entities. - const primitiveOrbitAdded = addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack); - if (!primitiveOrbitAdded) { - _dataSource.entities.add({ - id: `rocket-orbit:${launch.id}`, - polyline: { - positions: orbitPath, - // The 3 px canvas gives the periodic dot room to read as round; the - // shader masks the ten intervening dashes down to a thin center stroke. - width: 3, - material: new MissionOrbitPatternMaterialProperty( - Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), - ), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - satelliteName: '', - noradId: '', - source: 'Approximate mission orbit', - }, - }); - } - const orbitLabelBakePosition = ascentPath.at(-1); - let orbitLabelPosition = orbitLabelBakePosition; - const primitivePath = _missionOrbitPrimitives.get(launch.id); - if (primitivePath) { - primitivePath.labelBakePosition = orbitLabelBakePosition; - primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( - primitivePath.primitive.modelMatrix, - orbitLabelBakePosition, - new Cesium.Cartesian3(), - ); - orbitLabelPosition = () => primitivePath.labelPosition; - } - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `orbit:${launch.id}`, - position: orbitLabelPosition, - text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', - accent: satelliteTrack ? '#22e6e6' : '#c084fc', - priority: 800_000, - gapPx: 8, - }) - )); - const replayPosition = new Cesium.CallbackProperty(() => { - const state = getReplayState(); - return state.ascending - ? samplePath(ascentPath, state.phaseProgress) - : samplePath(animatedOrbitPath, state.phaseProgress); - }, false); - _dataSource.entities.add({ - id: `rocket-vehicle:${launch.id}`, - position: replayPosition, - properties: { launchId: launch.id, noradId: satelliteTrack?.noradId || '', phase: 'ASCENT_THEN_ORBIT', source: satelliteTrack ? 'Satellite trajectory animation' : 'Approximate trajectory animation' }, - }); - _dataSource.entities.add({ - id: `rocket-transfer:${launch.id}`, - polyline: { - positions: ascentPath, - width: 1.5, - // The animated vehicle uses Cartesian interpolation between these - // exact samples. Prevent Cesium from replacing each segment with a - // geodesic arc, which would visually separate the dot from the line. - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), - dashLength: 24, - dashPattern: 0xF0F0, - }), - }, - properties: { - launchId: launch.id, - phase: 'APPROXIMATE_TRANSFER', - source: satelliteTrack - ? 'Launch site to propagated insertion' - : 'Launch site to forward projected insertion', - }, - }); - } -} - -function clearPostTleRetry() { - if (_retryTimer) clearTimeout(_retryTimer); - _retryTimer = null; -} - -function schedulePostTleRetry(token) { - if (_retryTimer || token !== _lifecycleToken || !shouldRetryAfterActiveTle({ - enabled: _enabled, - retryCount: _postTleRetryCount, - activeTleText: _activeTleText, - renderedTleText: _renderedTleText, - })) return; - _postTleRetryCount++; - _retryTimer = setTimeout(() => { - _retryTimer = null; - if (_enabled && token === _lifecycleToken) requestMissionUpdate(); - }, POST_TLE_RETRY_DELAY_MS); -} - -function ensureActiveTleLookup(token) { - if (_activeTleText) return Promise.resolve(_activeTleText); - if (_activeTlePromise && _activeTlePromiseToken === token) return _activeTlePromise; - const request = fetch('/api/celestrak/active') - .then((activeResponse) => { - if (!activeResponse.ok) throw new Error(`HTTP ${activeResponse.status}`); - return activeResponse.text(); - }) - .then((text) => { - if (!_enabled || token !== _lifecycleToken) return null; - _activeTleText = text; - _focusAfterActiveLookup = Boolean(_selectedLaunchId); - schedulePostTleRetry(token); - return text; - }) - .catch((error) => { - if (_enabled && token === _lifecycleToken) { - console.warn('[Data:RocketLaunches] Active satellite lookup unavailable:', error.message); - } - return null; - }) - .finally(() => { - if (_activeTlePromise === request) { - _activeTlePromise = null; - _activeTlePromiseToken = 0; - } - }); - _activeTlePromise = request; - _activeTlePromiseToken = token; - return request; -} - -async function captureSatelliteDependency() { - if (!_dataManager || _satelliteStateBeforeMission) return; - _satelliteStateBeforeMission = { - // Effective visibility: a user enable still mid-activation is intent ON — - // capturing settled false would restore the user's enable away on exit. - enabled: _dataManager.isEffectivelyEnabled?.('satellites') - ?? _dataManager.isEnabled('satellites'), - params: satelliteParamsAfterSpaceMissions( - _dataManager.getLayerParams('satellites'), - ), - }; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsForSpaceMissions(_satelliteStateBeforeMission.params), - ); - const token = _lifecycleToken; - const activation = Promise.resolve(_dataManager.setEnabled('satellites', true)); - _satelliteActivationPromise = activation; - try { - const activated = await activation; - // Space Missions without its satellite dependency is a broken replay - // surface; fail the mission enable so the manager's fail-closed path and - // the Context rollback see an honest failure instead of a silent success. - if ( - token === _lifecycleToken - && _enabled - && (activated === false || !_dataManager.isEnabled('satellites')) - ) { - throw new Error('Space Missions requires the satellites layer, which failed to start'); - } - } finally { - if (token === _lifecycleToken && _satelliteActivationPromise === activation) { - _satelliteActivationPromise = null; - } - } -} - -async function restoreSatelliteDependency() { - const snapshot = _satelliteStateBeforeMission; - if (!snapshot || !_dataManager) return; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsAfterSpaceMissions(snapshot.params), - ); - const restored = await _dataManager.setEnabled('satellites', snapshot.enabled); - if ( - restored === false - || _dataManager.isEnabled('satellites') !== snapshot.enabled - ) { - throw new Error('Space Missions could not restore the satellites layer'); - } -} - -async function performMissionUpdate(token) { - try { - ensureActiveTleLookup(token); - const response = await fetch(API_URL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const launches = normalizeRocketLaunches(await response.json()); - if (!_enabled || token !== _lifecycleToken || !_dataSource) return; - const activeTleText = _activeTleText; - if (_replayCameraLaunchId) stopMissionReplay(); - _launches = launches; - removeMissionOrbitPrimitives(); - _dataSource.entities.removeAll(); - _missionOverlayRecords.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _orbitMatches = 0; - launches.forEach((launch) => addLaunchEntity(launch, activeTleText)); - _renderedTleText = activeTleText; - if (_renderedTleText === _activeTleText) clearPostTleRetry(); - _count = launches.length; - renderMissionRoster(); - if (!_selectedLaunchId || !launches.some((launch) => launch.id === _selectedLaunchId)) { - setSelectedMission(null, false); - } else { - setSelectedMission(_selectedLaunchId, true); - if (_focusAfterActiveLookup) { - focusMission(launches.find((launch) => launch.id === _selectedLaunchId)); - } - } - _focusAfterActiveLookup = false; - renderMissionPanel(); - _lastUpdate = Date.now(); - _lastError = null; - } catch (error) { - if (_enabled && token === _lifecycleToken) { - _lastError = error.message; - console.warn('[Data:RocketLaunches] Fetch error:', error); - } - } -} - -function requestMissionUpdate() { - if (!_enabled) return Promise.resolve(); - const token = _lifecycleToken; - _updateDirty = true; - if (_updatePromise && _updatePromiseToken === token) return _updatePromise; - let request; - request = (async () => { - while (_enabled && token === _lifecycleToken && _updateDirty) { - _updateDirty = false; - await performMissionUpdate(token); - } - })().finally(() => { - if (_updatePromise === request) { - _updatePromise = null; - _updatePromiseToken = 0; - } - }); - _updatePromise = request; - _updatePromiseToken = token; - return request; -} - -const rocketLaunchesLayer = { - id: 'rocket-launches', - name: 'Space Missions (30d)', - icon: '🚀', - source: 'Launch Library 2', - updateInterval: 300000, - - init(viewer) { - _viewer = viewer; - _enabled = false; - createMissionPanel(); - createReplayVehicleOverlay(); - _dataSource = new Cesium.CustomDataSource('rocket-launches'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); - _count = 0; - _lastUpdate = null; - _lastError = null; - _orbitMatches = 0; - clearMissionOverlaySources(); - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const entity = viewer.scene.drillPick(movement.position, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - const launchId = entityLaunchId(entity); - if (!launchId) return; - setSelectedMission(launchId); - focusMission(_launches.find((launch) => launch.id === launchId)); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - _moveHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _moveHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const missionEntity = viewer.scene.drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - // Apply horizon visibility before Cesium traverses and picks the scene. - // A postRender write is one frame late and can remain visibly stale when - // request-on-demand rendering stops after a globe camera move. - _declutterHandler = viewer.scene.preRender.addEventListener(updateMissionFrame); - initLaunchPadZonePrimitive(); - }, - - async enable() { - _enabled = true; - holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) - _lifecycleToken++; - _postTleRetryCount = 0; - try { - await this._enableBody(); - } catch (error) { - // Enable is a transaction: a failed dependency must not leave mission - // UI/datasource visible or the satellite snapshot retained (a retained - // snapshot makes the next enable skip dependency capture entirely). - try { - await this.disable(); - } catch (cleanupError) { - console.warn('[Missions] enable rollback failed:', cleanupError); - } - throw error; - } - }, - async _enableBody() { - _updateDirty = false; - if (_dataSource) _dataSource.show = true; - syncMissionOrbitPrimitiveVisibility(); - focusFullGlobe(_viewer); - document.getElementById('cockpit-context')?.setAttribute('hidden', ''); - if (_selectedLaunchId) setSelectedMission(_selectedLaunchId, _explicitSelection); - else setSelectedMission(null, false); - await captureSatelliteDependency(); - }, - async disable() { - _enabled = false; - releaseContinuousRender('rocket-launches'); - _lifecycleToken++; - _updateDirty = false; - clearPostTleRetry(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - if (_dataSource) _dataSource.show = false; - syncMissionOrbitPrimitiveVisibility(); - if (_viewer?.scene?.canvas) _viewer.scene.canvas.style.cursor = ''; - hideLaunchPadZone(); - hideReplayVehicleOverlay(); - _selectedLaunchId = null; - clearMissionOverlaySources(); - await restoreSatelliteDependency(); - renderMissionPanel(); - }, - - update() { - return requestMissionUpdate(); - }, - - /** Release only Space Mission camera ownership, preserving layer and selection state. */ - releaseCameraOwnership() { - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - }, - - async destroy(viewer) { - releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _lifecycleToken++; - _updateDirty = false; - await restoreSatelliteDependency(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - clearPostTleRetry(); - if (_clickHandler) _clickHandler.destroy(); - _clickHandler = null; - if (_moveHandler) _moveHandler.destroy(); - _moveHandler = null; - if (_declutterHandler) _declutterHandler(); - _declutterHandler = null; - destroyLaunchPadZonePrimitive(); - removeMissionOrbitPrimitives(); - clearMissionOverlaySources(); - destroyReplayVehicleOverlay(); - _launches = []; - _missionOverlayRecords.clear(); - _animationStarts.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _missionPanel?.remove(); - _missionPanel = null; - if (_missionRoster) { - _missionRoster.onclick = null; - } - _missionRosterPreviewOwnership = null; - _missionRoster = null; - _viewer = null; - if (_dataSource) viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - _count = 0; - _orbitMatches = 0; - _lastUpdate = null; - _activeTleText = null; - _activeTlePromise = null; - _activeTlePromiseToken = 0; - _renderedTleText = null; - _postTleRetryCount = 0; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager = null; - }, - - getStats() { return { count: _count, orbitMatches: _orbitMatches, lastUpdate: _lastUpdate, error: _lastError }; }, - attachDataManager(dataManager) { _dataManager = dataManager; }, -}; - -/** Test seam for real layer lifecycle coverage with a recording host. */ -export function _setRocketMissionOverlayHostForTest(host = null) { - _missionOverlayHost = host ? { ...DEFAULT_OVERLAY_HOST, ...host } : DEFAULT_OVERLAY_HOST; -} - -/** Test seam that exercises the real selection/deselection path. */ -export function _setSelectedRocketMissionForTest(launchId = null) { - setSelectedMission(launchId, Boolean(launchId)); -} - -export default rocketLaunchesLayer; +export const satelliteParamsForSpaceMissions = + layer.satelliteParamsForSpaceMissions; +export const satelliteParamsAfterSpaceMissions = + layer.satelliteParamsAfterSpaceMissions; +export const launchStatusAllowsOrbit = layer.launchStatusAllowsOrbit; +export const missionPathPresentation = layer.missionPathPresentation; +export const shouldRetryAfterActiveTle = layer.shouldRetryAfterActiveTle; +export const releaseAircraftTracking = layer.releaseAircraftTracking; +export const launchPadZoneVisible = layer.launchPadZoneVisible; +export const missionAnchorHorizonVisible = layer.missionAnchorHorizonVisible; +export const missionAnchorVisible = layer.missionAnchorVisible; +export const compactLaunchSiteName = layer.compactLaunchSiteName; +export const createRocketMissionMarkerOverlayEntry = + layer.createRocketMissionMarkerOverlayEntry; +export const createRocketMissionElementOverlayEntry = + layer.createRocketMissionElementOverlayEntry; +export const selectRocketMissionMarkerOverlayCohort = + layer.selectRocketMissionMarkerOverlayCohort; +export const replayOverlayMode = layer.replayOverlayMode; +export const replayVehicleScreenRotation = layer.replayVehicleScreenRotation; +export const smoothReplayWindowPosition = layer.smoothReplayWindowPosition; +export const missionDataCompleteness = layer.missionDataCompleteness; +export const missionRosterEntries = layer.missionRosterEntries; +export const missionHoverPreviewRange = layer.missionHoverPreviewRange; +export const createMissionRosterPreviewOwnership = + layer.createMissionRosterPreviewOwnership; +export const bindMissionRosterItemKeyboardPreview = + layer.bindMissionRosterItemKeyboardPreview; +export const captureMissionRosterFocus = layer.captureMissionRosterFocus; +export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus; +export const resolveMissionRosterPreviewLaunch = + layer.resolveMissionRosterPreviewLaunch; +export const formatMissionEventTime = layer.formatMissionEventTime; +export const parseMissionDurationSeconds = layer.parseMissionDurationSeconds; +export const replayAscentDurationSeconds = layer.replayAscentDurationSeconds; +export const normalizeReplaySpeed = layer.normalizeReplaySpeed; +export const replayStartAfterPause = layer.replayStartAfterPause; +export const replayState = layer.replayState; +export const approximateOrbitPath = layer.approximateOrbitPath; +export const samplePath = layer.samplePath; +export const orbitProgressAtTime = layer.orbitProgressAtTime; +export const reconstructedAscentPath = layer.reconstructedAscentPath; +export const buildMissionPaths = layer.buildMissionPaths; +export const cameraHeadingForPath = layer.cameraHeadingForPath; +export const replayInitialCameraHeading = layer.replayInitialCameraHeading; +export const replayChaseCameraHeading = layer.replayChaseCameraHeading; +export const smoothReplayCameraHeading = layer.smoothReplayCameraHeading; +export const missionZoomPitch = layer.missionZoomPitch; +export const replayCameraView = layer.replayCameraView; +export const replayOrbitGlobeAnchor = layer.replayOrbitGlobeAnchor; +export const replayOrbitCameraTarget = layer.replayOrbitCameraTarget; +export const replayOrbitCameraPose = layer.replayOrbitCameraPose; +export const replayOrbitFrameSphere = layer.replayOrbitFrameSphere; +export const replayOrbitGlobeRange = layer.replayOrbitGlobeRange; +export const missionMarkerColor = layer.missionMarkerColor; +export const normalizeRocketLaunches = layer.normalizeRocketLaunches; +export const _setRocketMissionOverlayHostForTest = + layer._setRocketMissionOverlayHostForTest; +export const _setSelectedRocketMissionForTest = + layer._setSelectedRocketMissionForTest; +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from '../layers/launches/index.js'; +export default layer; diff --git a/src/data/satellites.js b/src/data/satellites.js index 0fe8b6d02..ecad3df45 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,2397 +1,54 @@ -import * as Cesium from 'cesium'; -import { twoline2satrec, propagate, gstime, eciToGeodetic, degreesLong, degreesLat } from 'satellite.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { findNextIssPass } from './issPass.js'; import { - advanceSpriteFocus, - clearFocusTarget, - focusAlphaNeedsWrite, - focusNowMs, - focusPassIsNeeded, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, -} from './focusDeemphasis.js'; -import { refreshTrackedReadout } from './trackedReadout.js'; -import { - satelliteClassColor, - satelliteClassLabel, - satelliteClassLegend, - tallySatelliteClasses, -} from './satelliteClass.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { - clearTrackedSubjectContext, - getContextStore, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; + createSatellitesLayer, + createSatelliteSource, +} from '../layers/satellites/index.js'; +import * as picking from './pickRegistry.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as layerState from './layerState.js'; -/** - * Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation. - * - * Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS, - * Galileo, and the geosynchronous belt. Optional dense mode (setParams - * catalog:'dense') adds the Starlink shell as points-only extras. - * Renders positions via PointPrimitiveCollection, orbital paths as polylines. - * Click any satellite to track it with camera follow + orbital path. - * - * ISS gets special treatment: larger point, persistent host label, path shown by default. - */ - -const ISS_NORAD = 25544; -export const ISS_OVERLAY_SOURCE_ID = 'satellites-iss'; -export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 1, - collisionCapacity: 1, - moving: true, - solveIntervalMs: 125, +const layer = createSatellitesLayer({ + source: createSatelliteSource(), + services: { picking, focus, readout, overlays, context, render, layerState }, }); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); -let _overlayHost = DEFAULT_OVERLAY_HOST; -const ORBIT_PATH_STEPS = 180; // points per orbital path -const POSITION_UPDATE_MS = 1000; // re-propagate every 1s (SGP4 is smooth at this rate) -const RING_ROTATION_MS = 1000; // re-align baked orbit rings to current GMST every 1s - -/** - * CelesTrak groups loaded as the core catalog, in dedupe-priority order: - * a satellite that appears in multiple groups keeps the FIRST (most specific) - * tag. `path` is the upstream GROUP name forwarded by the /api/celestrak - * proxy; `tag` is the internal group key used for POINT_STYLES lookup. - * Note: CelesTrak's GLONASS group is named 'glo-ops' (not - * 'glonass-operational' — that name 404s upstream). - */ -const CATALOG_GROUPS = [ - { tag: 'stations', path: 'stations' }, - { tag: 'visual', path: 'visual' }, - { tag: 'gps-ops', path: 'gps-ops' }, - { tag: 'glonass', path: 'glo-ops' }, - { tag: 'galileo', path: 'galileo' }, - { tag: 'geo', path: 'geo' }, -]; - -// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as -// points-only extras — no labels, no detection-overlay participation, and a -// relaxed propagation budget (round-robin, ~1/5 of core cadence per sat). -const DENSE_GROUP_PATH = 'starlink'; -const DENSE_REFRESH_FRAMES = 300; // full dense pass spread over ~300 frames (~5s @ 60fps) -const DENSE_CREATE_CHUNK = 1500; // satrec builds per macro-task while loading - -/** - * Tracked-entity camera offset, east-north-up meters (Entity.viewFrom). - * Magnitude ≈ 726 km — the user-validated "slightly zoomed out" framing for - * LEO: far enough that per-frame satellite motion doesn't stutter the camera - * or smear the label, with +Z biasing the view down onto the satellite. - * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. - */ -const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3(-450000, -450000, 350000); -const HIGH_ORBIT_ALTITUDE_M = 2000000; -const TRACK_VIEW_FROM_HIGH_SCALE = 4; // ≈ 2900 km back for MEO/GEO - -/** - * Shared per-group point styling — single source of truth used by BOTH the - * creation site (update) and tracking restore (_clearTracking) so deselecting - * a satellite never loses the original palette (WS-D3). - * - * Colors come from `satelliteClass.js` so the dot, the class label on the - * card, and the legend swatch on the layer row can never disagree. Only - * pixelSize/outline live here — those encode per-group prominence, not class. - * Converted once at module load; per-point color is a plain primitive - * attribute, so classification costs nothing per frame. - */ -const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); - -const POINT_STYLES = { - // The ISS keeps its own long-standing red hero styling rather than the - // STATION class color: it is the object most users open this layer for, it - // carries a permanent name label, and its size/outline already set it apart. - // Its card still reads "STATION · ISS", so the class stays legible. - iss: { - pixelSize: 12, - color: Cesium.Color.fromCssColorString('#ff4444'), - outlineColor: POINT_OUTLINE, - outlineWidth: 2, - }, - stations: { - pixelSize: 8, - color: _classColor('stations'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - visual: { - pixelSize: 6, - color: _classColor('visual'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - // Nav constellations (GPS / GLONASS / Galileo) resolve to one shared NAV - // color; 6px so the MEO shells read as clearly as the old visual group did. - 'gps-ops': { - pixelSize: 6, - color: _classColor('gps-ops'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - glonass: { - pixelSize: 6, - color: _classColor('glonass'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - galileo: { - pixelSize: 6, - color: _classColor('galileo'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - geo: { - pixelSize: 5, - color: _classColor('geo'), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, - // Dense-mode extras (Starlink): dim, small, points-only. - dense: { - pixelSize: 3, - color: _classColor('dense').withAlpha(0.9), - outlineColor: POINT_OUTLINE, - outlineWidth: 0, - }, -}; - -/** - * Resolve the canonical point style for a satellite. - * @param {number} noradId NORAD catalog number. - * @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense'). - * @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }} - */ -function _pointStyleFor(noradId, group) { - if (noradId === ISS_NORAD) return POINT_STYLES.iss; - return POINT_STYLES[group] || POINT_STYLES.visual; -} - -// Satellite catalog: { noradId → { name, satrec, group } } -let _catalog = new Map(); -let _pointCollection = null; -let _points = new Map(); // noradId → point primitive -/** Stable lightweight records reused by the detection overlay between updates. */ -let _detectionObjects = new Map(); -// noradId → { primitive, gmstAtBake } -// primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF -// at gmstAtBake; the preRender tick re-aligns it to current GMST by updating -// its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1). -let _orbitPaths = new Map(); -let _count = 0; -let _lastUpdate = null; -/** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */ -let _lastError = null; -const _activeUpdateControllers = new Set(); -let _denseLoadController = null; - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); - _denseLoadController?.abort(); - _denseLoadController = null; -} -let _viewer = null; -let _preRenderListener = null; -let _lastPropagation = 0; -let _lastRingRotation = 0; -let _lastFocusUpdate = 0; -/** Points whose animated emphasis remains outside the 1.0 deadband. */ -let _activeFocusCount = 0; -const _scratchFocusScreen = new Cesium.Cartesian2(); -let _enabled = false; - -// Click-to-track state -let _trackedNorad = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - failedGroups: [], -}; -let _trackedEntity = null; -let _clickHandler = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; - -// Runtime params (DataLayerManager.setLayerParams path) -let _params = { catalog: 'core', showPoints: true, showOrbits: true }; // 'core' | 'dense' -let _denseIds = []; // norad ids of dense extras, round-robin order -let _denseCursor = 0; // next dense id to re-propagate -let _denseLoadToken = 0; // invalidates in-flight dense loads on mode flip/reload -let _denseLoadPromise = null; - -/** - * Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'. - * The catalog param flips synchronously but the Starlink shell arrives over - * seconds and can fail outright (CelesTrak 502s this feed regularly), so the - * row chip reports THIS, not the param. An active chip must mean dense points - * are actually on screen. - */ -let _denseStatus = 'idle'; -/** @type {string|null} Why the last dense load failed, for the chip tooltip. */ -let _denseError = null; -/** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */ -let _catalogRevision = 0; -let _classTallyCache = { revision: -1, counts: null }; -/** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */ -let _rowControlsListener = null; - -/** Tell the manager to re-render this layer's row (chip state / legend counts). */ -function _notifyRowControls() { - try { - _rowControlsListener?.(); - } catch (error) { - console.warn('[Data:Satellites] row-controls listener failed:', error); - } -} - -/** - * Per-class tally for the row legend, cached against the catalog revision. - * Without the cache this scans ~10.7k entries in dense mode on every panel - * refresh, and any layer polling on the 1s stats timer refreshes the panel. - * @returns {Record} Class key → count. - */ -function _classTally() { - if (_classTallyCache.counts && _classTallyCache.revision === _catalogRevision) { - return _classTallyCache.counts; - } - const entries = []; - // Pass the ISS flag, not just the group: during a stations-feed outage the - // ISS is ingested as `visual`, and the legend must file it exactly where its - // card does (STATION) rather than letting STATION vanish from the legend. - for (const [noradId, sat] of _catalog) { - entries.push({ group: sat.group, isIss: noradId === ISS_NORAD }); - } - const counts = tallySatelliteClasses(entries); - _classTallyCache = { revision: _catalogRevision, counts }; - return counts; -} - -/** - * Satellite preferences may be restored while the layer is disabled (for - * example, when Space Missions releases its dependency). Preferences must not - * revive render primitives until the layer is explicitly enabled again. - * @param {boolean} layerEnabled Whether the Satellite layer is enabled. - * @param {boolean} requestedVisible Whether the current presentation requests visibility. - * @returns {boolean} Whether a primitive should be visible now. - */ -export function satelliteVisualsVisible(layerEnabled, requestedVisible) { - return Boolean(layerEnabled) && Boolean(requestedVisible); -} - -/** - * Decide whether a valid catalog request represents a real mode transition. - * Reapplying an already-active mode must not restart the dense TLE load. - * @param {'core'|'dense'} currentCatalog Current catalog mode. - * @param {string|undefined} requestedCatalog Requested catalog mode. - * @returns {boolean} Whether the catalog mode should change. - */ -export function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) { - return (requestedCatalog === 'core' || requestedCatalog === 'dense') - && requestedCatalog !== currentCatalog; -} - -/** Build the persistent ISS ambient label from the cached point position. */ -export function createIssOverlayEntry(position) { - return { - id: String(ISS_NORAD), - position, - variant: 'label', - title: 'ISS', - accent: '#ff4444', - priority: 1000, - collisionGroup: 'ambient-label', - paintLane: 'ambient-label', - interactive: false, - distanceScale: { - near: 1_000_000, - nearValue: 1, - far: 30_000_000, - farValue: 0.4, - }, - gapPx: 14, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** Cached ISS point only; never performs a fresh SGP4 propagation. */ -function _issDisplayCached() { - return _points.get(ISS_NORAD)?.position || null; -} - -/** - * Physically DOCKED vehicles are separate real tracks sharing one position: the - * station and everything berthed to it sit within metres of each other. Their - * ambient labels therefore stack underneath the tracked card, which is what the - * owner saw. This radius is deliberately tight — it must catch a docked stack - * and nothing else, so an unrelated satellite in a similar orbit is never - * suppressed. Formation-flying pairs are km apart; a docked stack is ~100 m. - */ -const DOCKED_COMPANION_RADIUS_M = 2000; -/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */ -const DOCKED_SCAN_INTERVAL_MS = 1000; -/** @type {Set} NORAD ids co-located with the tracked satellite. */ -let _dockedCompanions = new Set(); -let _lastDockedScanMs = Number.NEGATIVE_INFINITY; - -/** - * Rebuild the docked-companion set for the tracked satellite. - * @returns {boolean} true when membership changed (callers resync presentation). - */ -function _refreshDockedCompanions(nowMs) { - const trackedPosition = _trackedNorad === null ? null : _trackedDisplayCached(); - if (!trackedPosition) { - if (_dockedCompanions.size === 0) return false; - _dockedCompanions = new Set(); - return true; - } - if (nowMs - _lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS) return false; - _lastDockedScanMs = nowMs; - const radiusSq = DOCKED_COMPANION_RADIUS_M * DOCKED_COMPANION_RADIUS_M; - let changed = false; - let found = 0; - const next = new Set(); - for (const [noradId, point] of _points) { - if (noradId === _trackedNorad || !point?.position) continue; - const dx = point.position.x - trackedPosition.x; - const dy = point.position.y - trackedPosition.y; - const dz = point.position.z - trackedPosition.z; - if (dx * dx + dy * dy + dz * dz > radiusSq) continue; - next.add(noradId); - found++; - if (!_dockedCompanions.has(noradId)) changed = true; - } - if (found !== _dockedCompanions.size) changed = true; - if (changed) _dockedCompanions = next; - return changed; -} - -/** Names of the docked companions, stable-sorted so the card text never churns. */ -function _dockedCompanionNames() { - const names = []; - for (const noradId of _dockedCompanions) { - names.push(_catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`); - } - return names.sort(); -} - -/** Keep persistent ISS text mutually exclusive with the tracked host card. */ -function _syncIssOverlay() { - // Hidden when ISS is the tracked subject, and equally when ISS is DOCKED to - // whatever is tracked: its ambient label would otherwise sit underneath the - // tracked card at the same position. - const visible = _enabled && _params.showOrbits && _trackedNorad !== ISS_NORAD - && !_dockedCompanions.has(ISS_NORAD) - && _catalog.has(ISS_NORAD) && _issDisplayCached(); - if (!visible) { - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - return; - } - _overlayHost.setEntries( - ISS_OVERLAY_SOURCE_ID, - [createIssOverlayEntry(_issDisplayCached)], - ISS_OVERLAY_SOURCE_OPTIONS, - ); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true); -} - -// Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and -// getTrackedInfo all read one SGP4 sample per rendered frame, keyed on -// scene.frameState.frameNumber, so they never diverge in epoch. -let _trackedFrameNumber = -1; -let _trackedFrameGeo = null; // { longitude, latitude, altitude } or null -const _trackedFrameCartesian = new Cesium.Cartesian3(); -/** Optional deterministic clock used only by the production-frame test seam. */ -let _trackedFrameNowForTest = null; - -// Scratch variables -const _scratchCartesian = new Cesium.Cartesian3(); -const _scratchRingRotation = new Cesium.Matrix3(); - -/** - * Build the rigid ECEF transform that keeps an orbit path baked at one GMST - * aligned with live SGP4 positions propagated at another epoch. - * @param {number} gmstAtBake GMST used when the path positions were baked. - * @param {Date} nowDate Epoch whose rotating-Earth frame should be displayed. - * @param {Cesium.Matrix4} [result] Optional matrix to update in place. - * @returns {Cesium.Matrix4} Z-rotation from bake-time ECEF to current ECEF. - */ -export function orbitFrameModelMatrix( - gmstAtBake, - nowDate, - result = new Cesium.Matrix4(), -) { - const deltaGmst = gstime(nowDate) - gmstAtBake; - const rotation = Cesium.Matrix3.fromRotationZ(-deltaGmst, _scratchRingRotation); - return Cesium.Matrix4.fromRotationTranslation( - rotation, - Cesium.Cartesian3.ZERO, - result, - ); -} - -/** - * Parse TLE text into array of { name, line1, line2 } objects. - */ -function parseTLE(text) { - const lines = text.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0); - const result = []; - for (let i = 0; i < lines.length - 2; i += 3) { - const name = lines[i]; - const line1 = lines[i + 1]; - const line2 = lines[i + 2]; - if (line1.startsWith('1 ') && line2.startsWith('2 ')) { - result.push({ name, line1, line2 }); - } - } - return result; -} - -/** - * Propagate satellite position at a given JS Date. - * Returns geodetic position plus inertial speed from the same SGP4 propagation - * epoch, or null on error. - */ -function propagatePosition(satrec, date) { - try { - const posVel = propagate(satrec, date); - if (!posVel.position || typeof posVel.position === 'boolean') return null; - - const gmst = gstime(date); - const geo = eciToGeodetic(posVel.position, gmst); - const velocity = posVel.velocity && typeof posVel.velocity !== 'boolean' - ? posVel.velocity - : null; - const speedMps = velocity - ? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000 - : null; - - return { - longitude: degreesLong(geo.longitude), - latitude: degreesLat(geo.latitude), - altitude: geo.height * 1000, // km → meters - speedMps: Number.isFinite(speedMps) ? speedMps : null, - }; - } catch { - return null; - } -} - -function orbitalPeriodSeconds(satrec) { - const meanMotion = satrec.no * (1440 / (2 * Math.PI)); - return 86400 / Math.max(meanMotion, 0.1); -} - -/** - * Compute full orbital path as array of Cartesian3 positions. - * Steps around one full orbit based on the satellite's mean motion. - */ -function computeOrbitPath(satrec, referenceDate) { - const periodSec = orbitalPeriodSeconds(satrec); - const stepSec = periodSec / ORBIT_PATH_STEPS; - - const positions = []; - const baseTime = referenceDate.getTime(); - // Fix GMST to reference time so the orbital ring closes. - // Without this, Earth rotation during the orbit period (~24° for LEO) - // shifts the end point west of the start, leaving a visible gap. - const fixedGmst = gstime(referenceDate); - - for (let i = 0; i <= ORBIT_PATH_STEPS; i++) { - const t = new Date(baseTime + i * stepSec * 1000); - try { - const posVel = propagate(satrec, t); - if (!posVel.position || typeof posVel.position === 'boolean') continue; - const geo = eciToGeodetic(posVel.position, fixedGmst); - positions.push(Cesium.Cartesian3.fromDegrees( - degreesLong(geo.longitude), - degreesLat(geo.latitude), - geo.height * 1000 - )); - } catch { continue; } - } - - return positions; -} - -/** - * Show orbital path for a satellite. - * - * RING FLICKER FIX: this is a one-instance Cesium.Primitive built - * synchronously ONCE (asynchronous: false), then re-aligned to current GMST - * each tick via its modelMatrix. The previous Entity-polyline approach - * rebuilt geometry ASYNCHRONOUSLY on every positions assignment, which made - * both the selected ring and the ISS ring blink once per second while the - * rebuild was in flight. A rigid Z-rotation needs no rebuild at all. - * - * Why not a CallbackProperty entity (dynamic mode)? Verified in Cesium - * 1.138 source: the dynamic polyline updater renders through a shared - * PolylineCollection and applies only the fill material — - * `depthFailMaterial` is silently dropped, losing the dimmed behind-Earth - * segment. The Primitive keeps it via depthFailAppearance + the per-instance - * depthFailColor attribute (same mechanism the entity STATIC batch uses): - * bright where above the horizon, dimmed where behind the globe. - */ -function _showOrbitPath(noradId, color) { - if (_orbitPaths.has(noradId)) return; // already showing - - const sat = _catalog.get(noradId); - if (!sat || !_viewer) return; - - const bakeDate = new Date(); - const basePositions = computeOrbitPath(sat.satrec, bakeDate); - if (basePositions.length < 2) return; - - const pathColor = color || Cesium.Color.CYAN; - - const primitive = new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry({ - positions: basePositions, - width: noradId === ISS_NORAD ? 2.5 : 2.0, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: Cesium.ColorGeometryInstanceAttribute.fromColor(pathColor.withAlpha(0.6)), - depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(pathColor.withAlpha(0.35)), - }, - }), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: false, // build this frame — no async-rebuild blink window - allowPicking: false, // ring clicks fall through to satellites/deselect - }); - _viewer.scene.primitives.add(primitive); - - _orbitPaths.set(noradId, { - primitive, - // Same Date object as computeOrbitPath's internal fixedGmst → identical - // GMST value (gstime is pure), so delta starts at exactly 0 and the - // initial identity modelMatrix is correct until the first ring tick. - gmstAtBake: gstime(bakeDate), - }); -} - -/** - * Rotate every baked orbit ring from its bake-time ECEF snapshot to current - * GMST (WS-D1). The baked ring is an inertial-frame snapshot frozen at - * gmstAtBake; the live dot lives in true rotating-frame ECEF, so without this - * the dot slides west off the ring at Earth-rotation rate (~0.25°/min). - * - * Sign derivation: a point fixed in inertial space keeps its right ascension - * α, so its ECEF longitude λ = α − gmst DECREASES by ΔGMST as time advances - * (it drifts WEST). Cesium.Matrix3.fromRotationZ(θ) rotates +X toward +Y, - * i.e. INCREASES longitude by θ — so we rotate the baked points by −ΔGMST. - * Mental check: ISS baked over Austin at t0; 10 min later Austin has rotated - * east under the (inertial) ring, so the ring must sit further WEST in ECEF. - * - * Wraparound: gstime returns radians in [0, 2π), so the raw difference can be - * off from the continuous ΔGMST — but only by exact multiples of 2π, which a - * rotation cannot distinguish. Tiny negative deltas right after bake are - * likewise harmless. No unwrapping needed. - * - * No SGP4 here — the rotation is applied as the ring primitive's modelMatrix - * (exact compensation; WGS84 is rotationally symmetric about Z, so rotating - * the baked curve rigidly equals rebaking from rotated points). modelMatrix - * updates are synchronous uniforms — no geometry rebuild, no flicker. Post- - * creation modelMatrix changes are supported for one-instance primitives in - * 3D mode, which these rings are. In-place mutation is safe: Primitive.update - * diffs modelMatrix against an internal clone. - * @param {Date} nowDate Epoch used for current GMST. - */ -function _updateOrbitPathRotations(nowDate) { - if (_orbitPaths.size === 0) return; - - for (const path of _orbitPaths.values()) { - if (!path.primitive) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - } -} - -/** - * Remove orbital path for a satellite. - */ -function _hideOrbitPath(noradId) { - const path = _orbitPaths.get(noradId); - if (path) { - if (_viewer) _viewer.scene.primitives.remove(path.primitive); // remove() destroys - _orbitPaths.delete(noradId); - } -} - -function _normalizeTrackedNorad(candidate) { - const numeric = Number(candidate); - if (!Number.isFinite(numeric)) return null; - const rounded = Math.trunc(numeric); - return rounded > 0 ? rounded : null; -} - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - -function _applyPendingTrackingRestore() { - const pending = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration || !_enabled) return false; - if (!_viewer || !_catalog.has(pending.id) || !_points.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackSatellite(pending.id, { origin: pending.origin }); - return _trackedNorad === pending.id; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _pendingTrackingRestore = null; -} - -/** - * Stop tracking the currently followed satellite. - * @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed - * the follow-camera (viewer.trackedEntityChanged), tear down our own state - * but do NOT clear viewer.trackedEntity — the new owner controls it now, - * and clearing it would yank the camera off their target (mirror of flights). - */ -function _clearTracking(skipViewerUntrack = false, { origin = 'programmatic' } = {}) { - // Untracking dissolves the cluster: every companion returns to its own - // ambient label on the next collection. - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - if (!_trackedNorad) { - clearFocusTarget('satellites'); - _syncIssOverlay(); - return; - } - const clearedNorad = _trackedNorad; - clearFocusTarget('satellites', _trackedNorad); - - const lastPos = _points.get(_trackedNorad); - - // Re-show the primitive (hidden while the tracked entity rendered the dot) - // and restore the original group palette from the shared style table (WS-D3) - if (lastPos) { - const style = _pointStyleFor(_trackedNorad, _catalog.get(_trackedNorad)?.group); - lastPos.show = true; - lastPos.pixelSize = style.pixelSize; - lastPos.color = style.color; - lastPos.outlineColor = style.outlineColor; - lastPos.outlineWidth = style.outlineWidth; - lastPos.disableDepthTestDistance = 0; - } - - // Invalidate the per-frame tracked-position cache (WS-D2) - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - - // Remove tracked entity and orbit path (unless ISS — keep its path) - if (_trackedNorad !== ISS_NORAD) { - _hideOrbitPath(_trackedNorad); - } - if (_viewer && !skipViewerUntrack) _viewer.trackedEntity = undefined; - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _trackedNorad = null; - _syncIssOverlay(); - clearTrackedSubjectContext('satellites'); - _contextRefreshedAtMs = 0; - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'satellites', id: clearedNorad, origin, - }); -} - -/** - * Get the tracked satellite's geodetic position, propagated at most once per - * rendered frame (WS-D2). All tracked-satellite consumers (entity position - * callback → camera, host model, point primitive, getTrackedInfo) share this - * single `new Date()` epoch per frame, so they can never diverge by the old - * 200ms throttle. The matching ECEF position is left in - * `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap. - * @returns {{ longitude: number, latitude: number, altitude: number }|null} - */ -function _getTrackedFramePosition() { - if (_trackedNorad === null) return null; - const sat = _catalog.get(_trackedNorad); - if (!sat) return null; - - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber === -1 || frameNumber !== _trackedFrameNumber || _trackedFrameGeo === null) { - const pos = propagatePosition( - sat.satrec, - _trackedFrameNowForTest ? new Date(_trackedFrameNowForTest()) : new Date(), - ); - if (!pos) return _trackedFrameGeo; // propagation hiccup — keep last good sample - _trackedFrameGeo = pos; - Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude, undefined, _trackedFrameCartesian); - _trackedFrameNumber = frameNumber; - // Throttled inside; membership changes are rare, so resync the ISS ambient - // gate only when the cluster actually changed. - const clusterChanged = _refreshDockedCompanions( - _trackedFrameNowForTest ? _trackedFrameNowForTest() : Date.now(), - ); - _updateTrackedSatelliteLabelModel(); - // The card and the context slot describe the same satellite — keep them - // together so voice never narrates a fix the card has already replaced. - _refreshTrackedSubjectContext(); - if (clusterChanged) _syncIssOverlay(); - const name = sat.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${_trackedNorad}`; - const labelWidthPx = Math.max(name.length, altitudeText.length) * 7.8 + 20; - const labelHeightPx = 2 * 13 + 12; - const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides - // Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all - // share one epoch, avoiding a second propagation phase and its old jitter. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'satellites', - id: _trackedNorad, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _trackedFrameCartesian, - widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)), - // Union of the 14 px point and the two-line label shifted 18 px upward. - heightPx: trackedPointDiameterPx + 18 + labelHeightPx, - }); - } - return _trackedFrameGeo; -} - -/** Cached ECEF display point only; never propagates a fresh SGP4 sample. */ -function _trackedDisplayCached() { - return _trackedFrameGeo ? _trackedFrameCartesian : null; -} - -/** Update the explicit model only when the rounded altitude line changes. */ -/** Epoch of the last shared-context refresh for the tracked satellite. */ -let _contextRefreshedAtMs = 0; -/** - * Shared-context refresh interval. The tracked satellite is re-propagated - * every rendered frame; the voice context only needs to be current to about - * the propagation cadence, so this refreshes on the same 1 s beat instead of - * allocating a record 60 times a second. - */ -const CONTEXT_REFRESH_INTERVAL_MS = 1000; - -/** - * Describe the tracked satellite for the shared context slot the voice tools - * read. Values come from the live per-frame propagation, not a selection-time - * snapshot, so a long follow never narrates a position the satellite has left. - * @param {number} noradId Catalog identity. - * @param {{latitude: number, longitude: number, altitude: number}|null} [position] - * Explicit position; defaults to the current per-frame propagation. - * @returns {object|null} Context metadata, or null when the satellite is gone. - */ -function _contextSubjectMetadata(noradId, position = null) { - const sat = _catalog.get(noradId); - if (!sat) return null; - const pos = position || _getTrackedFramePosition(); - if (!pos) return null; - const name = sat.name?.trim() || `SAT-${noradId}`; - const altitudeKm = Number.isFinite(pos.altitude) ? Math.round(pos.altitude / 1000) : null; - return { - id: String(noradId), - layerId: 'satellites', - layerName: 'Satellites', - source: 'CelesTrak', - label: name, - latitude: pos.latitude, - longitude: pos.longitude, - // Flat text only: the voice payload compacts properties through a string - // cleaner that drops nested objects. - properties: { - name, - operator: '', - noradId: String(noradId), - class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }), - altitude: altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`, - }, - }; -} - -/** - * Reconcile the published subject with a freshly rebuilt catalog. - * - * A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the - * catalog, so the tracked satellite's entry is a NEW object with a new satrec. - * A surviving subject is simply re-resolved against it. A subject that is GONE - * must release the slot: the per-frame refresh cannot do this itself, because - * `_getTrackedFramePosition` returns early once the satellite has no catalog - * entry — so without this the record would linger and voice would narrate a - * satellite the catalog no longer carries, frozen at its last position. - * - * Releasing is gated on PROOF. The subject is preserved unless it is absent - * from a catalog that is both complete (`accepted`, no failed CelesTrak group) - * and applicable (dense settled, when dense is the requested catalog). A - * partial refresh, a failed dense load, or an empty catalog is unproven - * absence, and unproven absence is not absence — the same honesty rule the - * tracking-restore path already applies. - * @returns {Promise} Resolves once the applicable catalog has settled. - */ -async function _reconcileTrackedSubjectContext() { - const subjectAtStart = _trackedNorad; - if (subjectAtStart === null) return; - // Dense extras land AFTER the core rebuild resolves. Deciding before they - // settle called a dense subject missing and deleted its record; the record - // then stayed gone, because a refresh can update an existing record but - // cannot recreate one. - const denseSettlement = _params.catalog === 'dense' ? _denseLoadPromise : null; - if (denseSettlement) { - try { - await denseSettlement; - } catch { - // A failed dense load proves nothing about the subject; fall through and - // let the outcome check below preserve it. - } - } - // The operator may have moved on while we waited. - if (_trackedNorad !== subjectAtStart) return; - - const metadata = _contextSubjectMetadata(subjectAtStart); - if (metadata) { - const store = getContextStore(); - const key = String(subjectAtStart); - if (store.entities.has(key)) { - refreshTrackedSubjectContext(metadata); - } else if (!store.selectedEntityId) { - // The record was dropped while the subject was briefly unresolvable. - // Restore it — but only into an EMPTY slot: a satellite reappearing must - // never yank the subject away from something the operator selected since. - selectTrackedSubjectContext(metadata); - } - _contextRefreshedAtMs = Date.now(); - return; - } - - // Absence only counts when EVERY catalog that could carry the subject - // actually loaded. Unproven absence is not absence — the same honesty rule - // the tracking-restore path applies. - // - // Deliberately NOT scoped to the group the subject was last seen in: - // CelesTrak reclassifies satellites between groups, so a subject missing - // from its old group may simply have moved to one that failed this refresh. - // Believing the old group alone would drop it. Any failed or empty group is - // therefore a reason to wait — and `accepted` already means every group - // returned entries. - if (_catalog.size === 0) return; - if (_lastTrackingRefreshOutcome?.status !== 'accepted') return; - // Dense is a potential carrier too whenever it was REQUESTED — and the - // request is read from `denseSettlement`, captured before the await, not - // from `_params.catalog` now. A failed dense load reverts the mode to 'core' - // as part of settling (an ACTIVE chip over an empty sky would be a lie), so - // by the time we get here the intent that made dense a carrier has been - // erased. Re-reading it would skip this guard on exactly the runs that need - // it and delete a subject the dense catalog might have carried. - if (denseSettlement && _denseStatus !== 'ready') return; - clearTrackedSubjectContext('satellites'); - _contextRefreshedAtMs = 0; -} - -/** - * Keep the shared context slot current with the tracked satellite, on the - * propagation beat rather than the frame clock. - * @returns {void} - */ -function _refreshTrackedSubjectContext() { - if (_trackedNorad === null) return; - const now = Date.now(); - if (now - _contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS) return; - _contextRefreshedAtMs = now; - refreshTrackedSubjectContext(_contextSubjectMetadata(_trackedNorad)); -} - -function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) { - if (!_trackedEntity || _trackedNorad === null) return; - const sat = _catalog.get(_trackedNorad); - const title = sat?.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeM = _trackedFrameGeo?.altitude ?? fallbackAltitudeM; - const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${_trackedNorad}`; - // Class leads the detail block: it is what tells the operator WHAT they are - // looking at, and it stays readable under the IR styles that flatten the - // dot colors to a single channel (the card is painted above post-FX). - const details = [satelliteClassLabel(sat?.group, { isIss: _trackedNorad === ISS_NORAD }), detail]; - // Docked companions are consolidated onto the tracked card as SECONDARY info - // instead of competing with it as separate ambient labels. Identities are - // preserved: the catalog is untouched and every companion returns to its own - // label the moment the cluster is no longer tracked. - const companions = _dockedCompanionNames(); - if (companions.length > 0) { - const extra = companions.length - 1; - details.push(`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`); - } - const current = _trackedEntity.gevLabelModel; - // Compare the WHOLE detail array: comparing only `details[0]` swallowed any - // change confined to the companions line, so the card would never republish. - const unchanged = current?.title === title - && current?.details?.length === details.length - && details.every((line, index) => current.details[index] === line); - if (unchanged) return; - _trackedEntity.gevLabelModel = { title, details, accent: '#ffd84d' }; - refreshTrackedReadout(_trackedEntity); -} - -function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); - - const point = _points.get(noradId); - const sat = _catalog.get(noradId); - if (!point || !sat) return; - - _trackedNorad = noradId; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _syncIssOverlay(); - - // Hide the primitive — the tracked ENTITY renders the dot below. The - // entity must own a point graphic so the Viewer's tracking camera can - // resolve a bounding sphere and engage viewFrom (a label-only entity - // left the camera stranded; this mirrors the proven flights pattern). - point.show = false; - - // Show orbital path - _showOrbitPath(noradId, Cesium.Color.YELLOW); - - // Tracked entity position propagates per evaluation through the per-frame - // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. - // Falls back to the point primitive's 1s-throttled position if SGP4 fails. - const positionProperty = new Cesium.CallbackProperty(() => { - const pos = _getTrackedFramePosition(); - return pos ? _trackedFrameCartesian : point.position; - }, false); - - const name = sat.name.trim(); - - // Comfortable tracking landing: ~726 km back for LEO (user-validated - // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled - // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = propagatePosition(sat.satrec, new Date()); - const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M - ? TRACK_VIEW_FROM_HIGH_SCALE - : 1; - const viewFrom = Cesium.Cartesian3.multiplyByScalar( - TRACK_VIEW_FROM_LEO, viewScale, new Cesium.Cartesian3() - ); - - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - viewFrom, - point: { - pixelSize: 14, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `satellites:${noradId}`; - _trackedEntity.gevDisplayPosition = _trackedDisplayCached; - _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); - - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'satellites', - id: noradId, - label: name, - position: Cesium.Cartesian3.clone(point.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); - _contextRefreshedAtMs = Date.now(); - - _viewer.trackedEntity = _trackedEntity; - console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); -} - -/** - * Propagate all CORE satellite positions and update point primitives. - * (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are - * excluded: they refresh on the round-robin budget in _propagateDenseChunk. - */ -function _propagateAll() { - const now = new Date(); - let updated = 0; - - for (const [noradId, sat] of _catalog) { - if (sat.group === 'dense') continue; - const pos = propagatePosition(sat.satrec, now); - if (!pos) continue; - - const cartesian = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - const point = _points.get(noradId); - if (point) { - point.position = cartesian; - updated++; - } - } - - return updated; -} - -/** - * Re-propagate a small per-frame slice of the dense extras (round-robin). - * Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES - * frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays - * ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the - * work per frame avoids the once-per-second spike a tick-sized chunk - * (~2K props ≈ 4ms) would cause. - */ -function _propagateDenseChunk() { - if (_denseIds.length === 0) return; - const perFrame = Math.max(1, Math.ceil(_denseIds.length / DENSE_REFRESH_FRAMES)); - const now = new Date(); - for (let i = 0; i < perFrame; i++) { - if (_denseCursor >= _denseIds.length) _denseCursor = 0; - const noradId = _denseIds[_denseCursor++]; - if (noradId === _trackedNorad) continue; // per-frame tracked path owns it - const sat = _catalog.get(noradId); - const point = _points.get(noradId); - if (!sat || !point) continue; - const pos = propagatePosition(sat.satrec, now); - if (pos) { - point.position = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - } - } -} - -/** - * Load the dense catalog extras (Starlink) as points-only satellites. - * Chunked so ~10K twoline2satrec builds + initial propagations never block a - * frame; a token guards against mode flips / catalog rebuilds mid-load. - */ -async function _loadDenseCatalog({ signal = null } = {}) { - if (!_viewer || !_pointCollection) return { status: 'source-unavailable', reason: 'layer-unavailable' }; - _denseLoadController?.abort(); - const resourceController = new AbortController(); - _denseLoadController = resourceController; - const loadSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - const token = ++_denseLoadToken; - _denseStatus = 'loading'; - _denseError = null; - _notifyRowControls(); - try { - loadSignal.throwIfAborted(); - const res = await fetch(`/api/celestrak/${DENSE_GROUP_PATH}`, { signal: loadSignal }); - if (!res.ok) { - console.warn(`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' fetch failed (${res.status})`); - _denseLoadFailed(token, `feed unavailable (${res.status})`); - return { status: 'source-unavailable', reason: `feed unavailable (${res.status})` }; - } - const text = await res.text(); - loadSignal.throwIfAborted(); - if (token !== _denseLoadToken || _params.catalog !== 'dense') { - return { status: 'superseded', reason: 'dense-load-superseded' }; - } - - const entries = parseTLE(text); - const style = POINT_STYLES.dense; - const now = new Date(); - let added = 0; - - for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { - loadSignal.throwIfAborted(); - if (token !== _denseLoadToken || _params.catalog !== 'dense' || !_pointCollection) { - return { status: 'superseded', reason: 'dense-load-superseded' }; - } - const end = Math.min(start + DENSE_CREATE_CHUNK, entries.length); - for (let i = start; i < end; i++) { - const entry = entries[i]; - const satrec = twoline2satrec(entry.line1, entry.line2); - if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); - if (_catalog.has(noradId)) continue; // core catalog keeps priority - const pos = propagatePosition(satrec, now); - if (!pos) continue; - - _catalog.set(noradId, { name: entry.name, satrec, group: 'dense' }); - const point = _pointCollection.add({ - position: Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude), - pixelSize: style.pixelSize, - color: style.color, - outlineColor: style.outlineColor, - outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), - id: noradId, - }); - _points.set(noradId, point); - _denseIds.push(noradId); - added++; - } - // Yield to the event loop between chunks. - await new Promise(resolve => setTimeout(resolve, 0)); - loadSignal.throwIfAborted(); - } - - // A 200 that yields nothing usable is still a failed load — an empty body, - // an HTML error page the proxy passed through, or a feed of TLEs the core - // catalog already owns. Treating it as success is the same lie as treating - // a 502 as success, just through a different door. - if (added === 0) { - console.warn(`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' returned no usable satellites`); - _denseLoadFailed(token, 'feed returned no satellites'); - return { status: 'source-unavailable', reason: 'feed returned no satellites' }; - } - - _count = _points.size; - _catalogRevision++; - _denseStatus = 'ready'; - console.log(`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`); - // The panel would otherwise keep the pre-load count and legend until the - // next natural refresh — up to the 5-minute catalog interval. - _notifyRowControls(); - _applyPendingTrackingRestore(); - return { status: 'ready', added }; - } catch (e) { - if (loadSignal.aborted || e?.name === 'AbortError') { - return { status: 'cancelled', reason: String(loadSignal.reason || 'aborted') }; - } - console.warn('[Data:Satellites] Dense catalog load failed:', e); - _denseLoadFailed(token, 'feed unreachable'); - return { status: 'source-unavailable', reason: 'feed unreachable' }; - } finally { - if (_denseLoadController === resourceController) _denseLoadController = null; - } -} - -/** - * Settle a failed dense load: drop any partial chunk, return the layer to the - * core catalog, and leave the reason on the chip. Reverting the param is the - * point — a chip that reads ACTIVE over an empty sky is a lie. - * @param {number} token The load token that failed. - * @param {string} reason Short operator-facing cause. - */ -function _denseLoadFailed(token, reason) { - // A newer load (or a mode flip) already owns the state — say nothing. - if (token !== _denseLoadToken) return; - _params.catalog = 'core'; - _removeDenseCatalog(); - _denseStatus = 'failed'; - _denseError = reason; - _notifyRowControls(); -} - -/** Remove all dense extras (catalog entries, points, tracking if needed). */ -function _removeDenseCatalog() { - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load - if (_trackedNorad !== null && _catalog.get(_trackedNorad)?.group === 'dense') { - _clearTracking(); - } - for (const noradId of _denseIds) { - const point = _points.get(noradId); - if (point && _pointCollection) _pointCollection.remove(point); - _points.delete(noradId); - _catalog.delete(noradId); - } - _denseIds = []; - _denseCursor = 0; - _count = _points.size; - _catalogRevision++; -} - -/** - * Shared scene.preRender tick (single definition for init + enable): - * - core fleet propagation at 200ms-tracked / 1s-idle cadence, - * - dense extras on a per-frame round-robin budget, - * - tracked satellite's point primitive per frame (WS-D2), - * - orbit ring GMST re-alignment every ~1s (WS-D1). - */ -function _preRenderTick() { - if (!_enabled) return; - const now = focusNowMs(Date.now()); - - const interval = _trackedNorad ? 200 : POSITION_UPDATE_MS; - // Space Missions keeps this layer enabled for TLE lookup while deliberately - // hiding its standalone fleet. Do not rebuild hidden point buffers on the - // one-second propagation cadence: that GPU upload presented as a periodic - // whole-globe pulse even though the camera remained stationary. - if (_params.showPoints && now - _lastPropagation >= interval) { - _propagateAll(); - _lastPropagation = now; - } - - if (_params.showPoints) _propagateDenseChunk(); - - // Keep the tracked dot on the per-frame epoch shared with label + camera — - // runs after _propagateAll so the per-frame sample wins over the 200ms one. - if (_trackedNorad !== null) { - const pos = _getTrackedFramePosition(); - const point = _points.get(_trackedNorad); - if (pos && point) { - point.position = _trackedFrameCartesian; // primitive setter clones - } - } - - _updatePointFocus(now); - - // Hidden standalone orbit primitives do not need GMST matrix writes while - // Space Missions draws the selected mission orbit itself. - if (_params.showOrbits && now - _lastRingRotation >= RING_ROTATION_MS) { - _updateOrbitPathRotations(new Date(now)); - _lastRingRotation = now; - } -} - -/** - * Seed a single tracked satellite so tests can invoke the exact production - * pre-render callback without constructing a WebGL viewer. - * @param {object} state - * @param {number} state.noradId - * @param {string} state.name - * @param {object} state.satrec - * @param {object} state.entity - * @param {object} state.point - * @param {object} state.viewer - * @param {() => (number|Date)} state.now - */ -export function _setTrackedSatelliteRefreshStateForTest({ - noradId, - name, - satrec, - entity, - point, - viewer, - now, - // Extra catalog/point rows so a test can exercise a docked cluster (and a - // control satellite that must NOT be treated as part of it). - neighbours = [], -}) { - _viewer = viewer; - _catalog = new Map([[noradId, { name, satrec, group: 'stations' }]]); - _points = new Map([[noradId, point]]); - for (const neighbour of neighbours) { - _catalog.set(neighbour.noradId, { - name: neighbour.name, - satrec: neighbour.satrec || satrec, - group: neighbour.group || 'stations', - }); - _points.set(neighbour.noradId, neighbour.point); - } - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - _trackedNorad = noradId; - _trackedEntity = entity; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = now; - _params = { catalog: 'core', showPoints: false, showOrbits: false }; - _enabled = true; -} - -/** Seed catalog authority and optional dense settlement for share-Follow tests. */ -export function _setSatelliteTrackingRefreshOutcomeForTest({ - status = 'accepted', - failedGroups = [], - catalog = 'core', - densePromise = null, -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { epoch, status, failedGroups: [...failedGroups] }; - _params.catalog = catalog; - _denseLoadPromise = densePromise || Promise.resolve({ status: 'not-requested' }); -} - -/** The tracked satellite's current ECEF sample — the value the docked-cluster - * scan measures against. Exposed so a test can place neighbours around it. */ -export function _trackedFrameCartesianForTest() { - return _trackedFrameCartesian; -} - -/** Invoke the same callback registered on `scene.preRender` in production. */ -export function _runSatellitePreRenderForTest() { - _preRenderTick(); -} - -/** - * Seed the minimum render state a dense-catalog load needs, so a test can - * exercise the real async load/settle/fail path (and the row-control states it - * drives) without constructing a WebGL viewer. - * @param {{ catalog?: 'core'|'dense', showPoints?: boolean }} [options] - */ -export function _setDenseCatalogStateForTest({ catalog = 'core', showPoints = true } = {}) { - _viewer = { scene: { primitives: { add: (p) => p, remove() {} } } }; - // Neutralize the shared world-overlay host: these tests exercise catalog and - // row-control logic, not the ISS callout. - _overlayHost = { setEntries() {}, setVisible() {}, clearSource() {} }; - _pointCollection = { - show: true, - add: (opts) => ({ ...opts }), - remove() {}, - removeAll() {}, - }; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _params = { catalog, showPoints, showOrbits: false }; - _enabled = true; -} - -/** Tear the dense seam back down so ordering cannot leak into other tests. */ -export function _clearDenseCatalogStateForTest() { - _rowControlsListener = null; - _overlayHost = DEFAULT_OVERLAY_HOST; - _orbitPaths = new Map(); - _viewer = null; - _pointCollection = null; - _catalog = new Map(); - _points = new Map(); - _denseIds = []; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _cancelPendingTrackingRestore(); - _enabled = false; -} - -/** Catalog group tag recorded for a satellite, for ingestion-path assertions. */ -export function _catalogGroupForTest(noradId) { - return _catalog.get(Number(noradId))?.group; -} - -/** Seed ISS/tracking state while retaining the production track and host paths. */ -export function _setSatelliteLabelLifecycleStateForTest({ - viewer, - satrec, - point, - overlayHost, - preservePending = false, -}) { - _viewer = viewer; - _catalog = new Map([[ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }]]); - _points = new Map([[ISS_NORAD, point]]); - _orbitPaths = new Map([[ - ISS_NORAD, - { primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, gmstAtBake: 0 }, - ]]); - _trackedNorad = null; - _trackedEntity = null; - if (!preservePending) _cancelPendingTrackingRestore(); - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _enabled = true; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _overlayHost = overlayHost || DEFAULT_OVERLAY_HOST; - _syncIssOverlay(); -} - -/** Exercise production ISS tracking from the cached catalog and point. */ -export function _trackIssForTest() { - _trackSatellite(ISS_NORAD); - return _trackedEntity; -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingSatelliteTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated catalog refresh. */ -export function _applyPendingSatelliteTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Remove a cached catalog row so tests can model a target arriving later. */ -export function _removeSatelliteTrackingCandidateForTest(noradId) { - const id = Number(noradId); - _catalog.delete(id); - _points.delete(id); -} - -/** Exercise production untrack and restore the default host seam. */ -export function _clearSatelliteLabelLifecycleForTest() { - _clearTracking(); - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - _overlayHost = DEFAULT_OVERLAY_HOST; -} - -/** Focus alpha for satellite points, inside the existing shared preRender tick. */ -function _updatePointFocus(nowMs) { - const target = getFocusTarget(); - if (!_params.showPoints || !focusPassIsNeeded(target, _activeFocusCount)) return; - if (nowMs - _lastFocusUpdate < 80) return; - _lastFocusUpdate = nowMs; - const scene = _viewer.scene; - const camera = _viewer.camera; - const result = applySatellitePointFocusDeemphasis({ - points: _points, - trackedId: _trackedNorad, - target, - previousActiveCount: _activeFocusCount, - nowMs, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchFocusScreen) - ), - cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - baseColorFor: (noradId) => _pointStyleFor(noradId, _catalog.get(noradId)?.group).color, - }); - _activeFocusCount = result.activeCount; -} - -/** - * Apply the gated satellite-point focus pass through the production color path. - * @param {object} input - * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} - */ -export function applySatellitePointFocusDeemphasis({ - points, - trackedId, - target, - previousActiveCount = 0, - nowMs, - screenPositionFor, - cameraDistanceFor, - baseColorFor, - 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 [noradId, point] of points || []) { - if (noradId === trackedId || !point?.position) continue; - const cameraDistance = cameraDistanceFor(point.position); - const distanceScale = nearFarScalarValueAtDistance(point.scaleByDistance, cameraDistance); - const halfExtentPx = (point.pixelSize || 5) * distanceScale * 0.5; - const focus = advanceSpriteFocus(point, { - // Hidden points still release toward identity, preventing stale dim - // alpha if a catalog/presentation toggle later makes them visible. - screenPosition: point.show === false ? null : screenPositionFor(point.position), - cameraDistance, - nowMs, - target, - params, - spriteHalfWidthPx: halfExtentPx, - spriteHalfHeightPx: halfExtentPx, - }); - transitioning ||= focus.transitioning; - if (focus.active) activeCount += 1; - const base = baseColorFor(noradId); - const alpha = base.alpha * focus.factor; - if (focusAlphaNeedsWrite(point.color?.alpha, alpha, params)) { - // Point stays continuously present at the non-zero emphasis floor; its - // own alpha yields around the tracked target, independent of draw order. - point.color = base.withAlpha(alpha); - writes += 1; - } - } - return { writes, transitioning, activeCount, ran: true }; -} - - -const satellitesLayer = { - id: 'satellites', - name: 'Satellites', - icon: '🛰️', - source: 'CelesTrak', - updateInterval: 0, // We use preRender for real-time updates, not interval polling - refreshInterval: 5 * 60 * 1000, // Catalog data refresh; propagation remains preRender-owned. - - async init(viewer) { - _abortActiveUpdates(); - clearFocusTarget('satellites'); - _viewer = viewer; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _count = 0; - _lastUpdate = null; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _trackedEntity = null; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - // Dense extras rebuild via update() when _params.catalog === 'dense' - // (the catalog-mode preference itself is sticky across init/destroy). - _denseIds = []; - _denseCursor = 0; - _denseLoadPromise = null; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - - // Point primitives for satellite dots - _pointCollection = new Cesium.PointPrimitiveCollection(); - viewer.scene.primitives.add(_pointCollection); - - _installClickHandler(viewer); - - // Pre-render listener for real-time position updates - // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - - console.log('[Data:Satellites] Initialized'); - }, - - enable(viewer) { - _enabled = true; - holdContinuousRender('satellites'); // per-frame animator (perf wave 2) - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - // Orbit ring primitives + persistent ISS host label — show them - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - // Re-attach input handlers and preRender propagation - _installClickHandler(viewer); - // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; - // the registry hands predicates String()-coerced ids, so match via Number(). - registerPickOwner('satellites', (pickedId) => { - const norad = Number(pickedId); - return Number.isFinite(norad) && _points.has(norad); - }); - if (!_preRenderListener && viewer) { - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - } - _applyPendingTrackingRestore(); - }, - - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - _enabled = false; - releaseContinuousRender('satellites'); - if (_pointCollection) _pointCollection.show = false; - for (const path of _orbitPaths.values()) path.primitive.show = false; - _clearTracking(); - _syncIssOverlay(); - // Remove click handler + keydown listener + preRender propagation while disabled - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - }, - - async update(viewer, { signal = null } = {}) { - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - failedGroups: [], - }; - const resourceController = new AbortController(); - _activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - // Load all core groups in parallel; a failed/empty group degrades - // gracefully (parseTLE of an upstream error body yields []). - const results = await Promise.all(CATALOG_GROUPS.map(async (groupDef) => { - try { - const res = await fetch(`/api/celestrak/${groupDef.path}`, { signal: updateSignal }); - if (!res.ok) return { ...groupDef, entries: [], ok: false }; - const entries = parseTLE(await res.text()); - updateSignal.throwIfAborted(); - return { ...groupDef, entries, ok: entries.length > 0 }; - } catch (error) { - if (updateSignal.aborted || error?.name === 'AbortError') throw error; - return { ...groupDef, entries: [], ok: false }; - } - })); - updateSignal.throwIfAborted(); - - const failed = results.filter(r => !r.ok).map(r => r.path); - if (failed.length > 0) { - console.warn(`[Data:Satellites] Groups failed or empty: ${failed.join(', ')}`); - } - console.log(`[Data:Satellites] Loaded ${results.map(r => `${r.tag}:${r.entries.length}`).join(' ')}`); - - // CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing. - // Wiping the collection + catalog here would blank all 838 satellites while - // the chip still read "just now". Keep the existing (stale) catalog on - // screen and surface the outage instead — do NOT stamp _lastUpdate. - if (results.every(r => !r.ok)) { - _lastError = 'CelesTrak unreachable'; - console.warn('[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage'); - // Re-apply dense mode is skipped (no fresh core catalog); tracking untouched. - return; - } - - // At least one group loaded — proceed with a fresh rebuild, but keep the - // partial outage visible at the layer control instead of presenting the - // reduced catalog as a fully healthy refresh. - _lastError = failed.length - ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` - : null; - - // Clear existing - _pointCollection.removeAll(); - _points.clear(); - for (const path of _orbitPaths.values()) viewer.scene.primitives.remove(path.primitive); - _orbitPaths.clear(); - _catalog.clear(); - // The detection overlay caches one record per satellite and stamps its - // id/class at creation only. A rebuild can re-tag a satellite (a failed - // group shifts which one wins dedupe), so the cache must go with the - // catalog or those labels stay stale for the life of the session. - _detectionObjects.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load against the old catalog - _syncIssOverlay(); - - const now = new Date(); - - // Process all TLE entries in CATALOG_GROUPS order - const allEntries = []; - for (const r of results) { - for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); - } - - // Deduplicate by NORAD ID — first (most specific) group tag wins - const seen = new Set(); - - for (const entry of allEntries) { - const satrec = twoline2satrec(entry.line1, entry.line2); - if (!satrec || satrec.error !== 0) continue; - - const noradId = Number(satrec.satnum); - if (seen.has(noradId)) continue; - seen.add(noradId); - - // Store in catalog - _catalog.set(noradId, { - name: entry.name, - satrec, - group: entry.group, - }); - - // Propagate initial position - const pos = propagatePosition(satrec, now); - if (!pos) continue; - - const cartesian = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - - // Add point primitive (styling from the shared table, WS-D3) - const style = _pointStyleFor(noradId, entry.group); - const point = _pointCollection.add({ - position: cartesian, - pixelSize: style.pixelSize, - color: style.color, - outlineColor: style.outlineColor, - outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), - id: noradId, - }); - - _points.set(noradId, point); - } - - // Show ISS orbital path by default - if (_catalog.has(ISS_NORAD)) { - _showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color); - const issPath = _orbitPaths.get(ISS_NORAD); - if (issPath) issPath.primitive.show = _params.showOrbits; - - _syncIssOverlay(); - } - - _count = _points.size; - _catalogRevision++; - _lastUpdate = Date.now(); - _lastPropagation = Date.now(); - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: failed.length ? 'partial' : 'accepted', - failedGroups: [...failed], - }; - console.log(`[Data:Satellites] ${_count} satellites active, ISS path shown`); - - // Re-apply dense mode after a full catalog rebuild (fire-and-forget — - // _loadDenseCatalog handles its own errors and token invalidation). - _denseLoadPromise = _params.catalog === 'dense' - ? _loadDenseCatalog({ signal: updateSignal }) - : Promise.resolve({ status: 'not-requested' }); - // The catalog the published voice subject was resolved against is gone. - // Re-resolve it, or release the slot if the subject provably did not - // survive. Deliberately not awaited: it waits on dense settlement, and - // the rebuild must not block on that. - void _reconcileTrackedSubjectContext(); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Satellite update aborted', 'AbortError'); - } - console.warn('[Data:Satellites] Fetch error:', e); - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _clearTracking(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - // Orbit ring primitives are removed (and destroyed) here - for (const path of _orbitPaths.values()) { - viewer.scene.primitives.remove(path.primitive); - } - _points.clear(); - _detectionObjects.clear(); - _orbitPaths.clear(); - _catalog.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _rowControlsListener = null; - _count = 0; - _lastUpdate = null; - _lastError = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - failedGroups: [], - }; - _viewer = null; - }, - - getDetectableObjects(options = {}) { - if (!_pointCollection || !_pointCollection.show) return []; - // Dense extras are points-only: excluded from the detection overlay. - const eligibleCount = Math.max(1, _points.size - _denseIds.length); - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : eligibleCount; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max(1, Math.ceil(eligibleCount / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [noradId, point] of _points) { - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (!point.position) continue; - const isTracked = noradId === _trackedNorad; - // A docked companion sits at the tracked subject's own position, so its - // mark and label would stack underneath the tracked card. It is listed on - // that card instead. Only members of the tracked cluster are affected — - // unrelated nearby satellites are never suppressed. - if (!isTracked && _dockedCompanions.has(noradId)) continue; - const cat = _catalog.get(noradId); - let object = _detectionObjects.get(noradId); - if (!object) { - object = { - sourceId: noradId, - id: cat?.name || `SAT-${noradId}`, - type: 'SAT', - // Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS"). - // The detection canvas composites ABOVE the post-FX chain, so this - // is how class survives NVG/FLIR once the dot colors are collapsed. - klass: satelliteClassLabel(cat?.group, { isIss: noradId === ISS_NORAD }), - }; - _detectionObjects.set(noradId, object); - } - object.position = point.position; - object.skipLabel = isTracked; - result.push(object); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * Find a satellite by exact NORAD id (numeric string) or case-insensitive - * name substring. Position is freshly propagated via SGP4. - * @param {string|number} query NORAD id or partial name. - * @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null} - */ - findByQuery(query) { - if (query === null || query === undefined || !_catalog || _catalog.size === 0) return null; - const q = String(query).trim(); - if (!q) return null; - - let noradId = null; - if (/^\d+$/.test(q) && _catalog.has(Number(q))) { - noradId = Number(q); - } else { - const lower = q.toLowerCase(); - for (const [id, sat] of _catalog) { - if (sat.name.toLowerCase().includes(lower)) { - noradId = id; - break; - } - } - } - if (noradId === null) return null; - - const sat = _catalog.get(noradId); - const pos = propagatePosition(sat.satrec, new Date()); - if (!pos) return null; - - return { - noradId, - name: sat.name.trim(), - position: Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude), - latitude: pos.latitude, - longitude: pos.longitude, - altitudeM: pos.altitude, - }; - }, - - /** - * Get positions of currently rendered satellites from per-point state - * (no SGP4 re-propagation). - * @param {number} [maxCount=300] Maximum entries to return. - * @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>} - */ - getAllPositions(maxCount = 300) { - const result = []; - if (!_points || _points.size === 0) return result; - const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300; - - for (const [noradId, point] of _points) { - if (result.length >= cap) break; - if (!point.position) continue; - // Dense extras are points-only — keep voice/framing lists to the core catalog. - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const carto = Cesium.Cartographic.fromCartesian(point.position); - if (!carto) continue; - const sat = _catalog.get(noradId); - result.push({ - id: noradId, - label: sat ? sat.name.trim() : String(noradId), - position: point.position, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - }); - } - return result; - }, - - /** - * Track a satellite by NORAD id (camera follow + orbit path + highlight), - * same path as clicking its point. - * @param {string|number} noradId NORAD catalog number. - * @returns {boolean} True if tracking started. - */ - trackById(noradId, { origin = 'programmatic' } = {}) { - const id = Number(noradId); - if (!Number.isFinite(id) || !_viewer || !_catalog.has(id) || !_points.has(id)) return false; - _cancelPendingTrackingRestore(); - _trackSatellite(id, { origin }); - return _trackedNorad === id; - }, - - /** - * Resolve a shared Follow target only after the applicable CelesTrak - * catalog has settled. A partial catalog can prove presence, never absence. - */ - async resolveTrackingRestoreTarget(noradId, { - signal = null, - origin = 'share-restore', - } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = _normalizeTrackedNorad(noradId); - if (id === null) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - const found = () => _catalog.has(id) && _points.has(id); - const follow = () => { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - return this.trackById(id, { origin }) - ? { status: 'found', refreshEpoch: outcome.epoch } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }; - - if (found()) return follow(); - if (outcome.status !== 'accepted' && outcome.status !== 'partial') { - return { - status: 'source-unavailable', - reason: 'CelesTrak catalog unavailable', - refreshEpoch: outcome.epoch, - }; - } - - if (_params.catalog === 'dense') { - const dense = await (_denseLoadPromise || Promise.resolve({ status: 'source-unavailable' })); - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - if (_lastTrackingRefreshOutcome.epoch !== outcome.epoch) { - return { status: 'superseded', reason: 'newer-catalog-refresh' }; - } - if (found()) return follow(); - if (dense?.status !== 'ready') { - return { - status: 'source-unavailable', - reason: dense?.reason || 'dense catalog unavailable', - refreshEpoch: outcome.epoch, - }; - } - } - - if (outcome.status === 'partial') { - return { - status: 'source-unavailable', - reason: 'partial CelesTrak catalog cannot prove absence', - refreshEpoch: outcome.epoch, - failedGroups: [...outcome.failedGroups], - }; - } - return { - status: 'missing', - reason: 'target-absent-from-catalog', - refreshEpoch: outcome.epoch, - }; - }, - - /** - * Stop tracking the currently tracked satellite (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Get info about the currently tracked satellite. - * @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null} - */ - getTrackedInfo() { - if (_trackedNorad === null || !_catalog.has(_trackedNorad)) return null; - const sat = _catalog.get(_trackedNorad); - // Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame. - const pos = _getTrackedFramePosition(); - if (!pos) return null; - return { - noradId: _trackedNorad, - name: sat.name.trim(), - latitude: pos.latitude, - longitude: pos.longitude, - altitudeM: pos.altitude, - }; - }, - - /** - * Runtime params (DataLayerManager.setLayerParams path). - * catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell - * as points-only extras on a relaxed propagation budget). - * @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params] - */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedSatTrackingId')) { - _cancelPendingTrackingRestore(); - } - const catalog = params.catalog; - if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense') return false; - const catalogChanged = satelliteCatalogModeChanged(_params.catalog, catalog); - if (catalogChanged) { - _params.catalog = catalog; - } - if (params.showPoints !== undefined) { - _params.showPoints = params.showPoints !== false; - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - } - if (params.showOrbits !== undefined) { - _params.showOrbits = params.showOrbits !== false; - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - } - if (catalogChanged && catalog === 'dense') { - _denseLoadPromise = _loadDenseCatalog(); - } else if (catalog === 'core') { - if (catalogChanged) _removeDenseCatalog(); - // Any explicit request for core clears the error, even when the mode did - // NOT change: a failed dense load already reverted the param to core, so - // a Space Missions restore of an already-core snapshot would otherwise - // leave the user staring at a DENSE ✕ they never caused. - _denseStatus = 'idle'; - _denseError = null; - } - if (catalogChanged) console.log(`[Data:Satellites] Catalog mode: ${catalog}`); - if (Object.hasOwn(params, 'selectedSatTrackingId')) { - const requested = _normalizeTrackedNorad(params.selectedSatTrackingId); - if (requested === _trackedNorad) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedNorad !== null) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedNorad !== null) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - - /** @returns {{ catalog: string }} Current runtime params. */ - getParams() { - return { - catalog: _params.catalog, - showPoints: _params.showPoints, - showOrbits: _params.showOrbits, - selectedSatTrackingId: _trackedNorad, - }; - }, - - /** - * Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE - * catalog chip plus a class legend so the point colors are learnable without - * a new panel. - * - * The chip is stateless — it declares the params to apply and the manager - * owns the write, so the Space Missions snapshot/restore path (which drives - * the same `catalog` param) stays the single source of truth and the chip - * always renders whatever the layer actually has. - * - * The chip reports the dense LOAD state, not the catalog param: the param - * flips synchronously while the Starlink shell takes seconds to arrive and - * frequently 502s, so ACTIVE means "dense points are on screen" and nothing - * less. The legend tally is cached against the catalog revision. - * @returns {{ chips: Array, legend: Array }} Row controls. - */ - getRowControls() { - // A dependency owner (Space Missions) borrows this layer for TLE lookup - // with showPoints:false. Nothing is rendered, so a legend would describe an - // empty sky and a chip write would be silently reverted by that owner's - // restore. Surrender the row rather than lie about it. - if (!_params.showPoints) return { chips: [], legend: [] }; - - const loading = _denseStatus === 'loading'; - const failed = _denseStatus === 'failed'; - const active = _params.catalog === 'dense' && _denseStatus === 'ready'; - let title = 'Add the full Starlink broadband shell (thousands of extra points)'; - if (loading) title = 'Loading the Starlink shell…'; - else if (failed) title = `Starlink ${_denseError || 'load failed'} — click to retry`; - else if (active) title = 'Showing the full Starlink shell — click for the core catalog only'; - return { - chips: [{ - id: 'catalog', - label: loading ? 'DENSE ···' : (failed ? 'DENSE ✕' : 'DENSE'), - active, - busy: loading, - disabled: loading, - state: loading ? 'loading' : (failed ? 'error' : (active ? 'active' : 'idle')), - title, - params: { catalog: active ? 'core' : 'dense' }, - }], - legend: satelliteClassLegend(_classTally()), - }; - }, - - /** - * Install the manager's "row controls changed" callback. The dense load is - * asynchronous, so completion and failure have to push a re-render — nothing - * else would repaint this row before the 5-minute catalog refresh. - * @param {(() => void)|null} listener Callback, or null to detach. - */ - setRowControlsListener(listener) { - _rowControlsListener = typeof listener === 'function' ? listener : null; - }, - - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - stale: false, - status: _lastError === 'CelesTrak unreachable' - ? 'unavailable' - : (_lastError ? 'degraded' : 'nominal'), - error: _lastError, - }; - }, -}; - -function _onKeyDown(e) { - if (_enabled && e.key === 'Escape' && _trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -function _installClickHandler(viewer) { - if (_clickHandler) return; // already installed - - // Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights, - // military, …) grabs the follow-camera, drop our tracking so the orbit ring / - // tracked entity don't orphan — without touching viewer.trackedEntity (the - // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity - // briefly undefined mid-_trackSatellite) doesn't self-clear. - if (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (!_enabled) return; - if (_trackedNorad && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking tracked entity itself — ignore - if (picked.id === _trackedEntity) return; - - // Check if it's a satellite point (id is NORAD catalog number) - const prim = picked.primitive; - if (prim && prim.id != null) { - const noradId = Number(prim.id); - if (!isNaN(noradId) && _catalog.has(noradId)) { - _cancelPendingTrackingRestore(); - _trackSatellite(noradId, { origin: 'user' }); - return; - } - } - } - - // A pick that belongs to a sibling layer (plane, vessel, station, CCTV - // camera…) is not "empty space" — leave OUR tracking (and crucially - // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; - } - - // Clicked empty space — deselect - if (_trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - document.addEventListener('keydown', _onKeyDown); -} - -/** - * Next ISS pass for an observer. Requires the catalog to have loaded (the - * satellites layer enabled at least once this session). - * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} - */ -export function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { - const sat = _catalog.get(ISS_NORAD); - if (!sat || !sat.satrec) return { status: 'no-tle' }; - const pass = findNextIssPass({ - satrec: sat.satrec, latDeg, lonDeg, fromMs: Date.now(), minElevDeg, - }); - return pass ? { status: 'ok', pass } : { status: 'none' }; -} - -/** - * Score a mission-to-catalog name match. Compact identifier containment handles - * names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as - * "Starlink Group 17-40" → an arbitrary "STARLINK-1008" remain below the - * acceptance threshold. - * @param {string} query Mission or payload name. - * @param {string} catalogName Satellite catalog name. - * @returns {number} Match score; 0 means no useful relationship. - */ -export function scoreSatelliteNameMatch(query, catalogName) { - const q = String(query || '').trim().toLowerCase(); - const name = String(catalogName || '').trim().toLowerCase(); - if (!q || !name) return 0; - const qCompact = q.replace(/[^a-z0-9]/g, ''); - const nameCompact = name.replace(/[^a-z0-9]/g, ''); - if (qCompact === nameCompact) return 1000; - let score = 0; - if ( - Math.min(qCompact.length, nameCompact.length) >= 5 - && (qCompact.includes(nameCompact) || nameCompact.includes(qCompact)) - ) { - score += 200 + Math.min(qCompact.length, nameCompact.length); - } - const ignored = new Set(['group', 'block', 'mission', 'launch', 'falcon', 'rocket']); - const tokens = q.split(/[^a-z0-9]+/).filter((token) => token.length >= 4 && !ignored.has(token)); - score += tokens.reduce((total, token) => total + (name.includes(token) ? token.length : 0), 0); - return score; -} - -function internationalDesignatorYear(satrec) { - const match = String(satrec?.intldesg || '').match(/^(\d{2})/); - if (!match) return null; - const shortYear = Number(match[1]); - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; -} - -let _lookupTleText = null; -let _lookupTleEntries = []; - -function lookupTleEntries(tleText) { - if (tleText !== _lookupTleText) { - _lookupTleText = tleText; - _lookupTleEntries = parseTLE(tleText); - } - return _lookupTleEntries; -} - -function tleLineLaunchYear(line1) { - const shortYear = Number(String(line1 || '').slice(9, 11)); - if (!Number.isFinite(shortYear)) return null; - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; -} - -function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); - const current = propagatePosition(satrec, referenceDate); - if (!current) return null; - return { - noradId: Number(satrec.satnum), - name: String(name || '').trim(), - current, - periodSec: orbitalPeriodSeconds(satrec), - orbitPath: computeOrbitPath(satrec, referenceDate), - gmstAtBake: gstime(referenceDate), - positionAt: (date) => propagatePosition(satrec, date), - }; -} - -/** - * Find and propagate a mission payload directly from a TLE catalog. - * This supports newly launched payloads that are present in CelesTrak's active - * feed but have not yet moved into a narrower operational group. - * @param {string} tleText Three-line-element catalog text. - * @param {string} query Mission or payload name. - * @param {{launchTime?: string|null}} [options] Optional launch epoch for namesake rejection. - * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} - */ -export function findSatelliteOrbitTrackInTle(tleText, query, options = {}) { - const launchYear = Number.isFinite(Date.parse(options.launchTime)) - ? new Date(options.launchTime).getUTCFullYear() - : null; - let bestEntry = null; - let bestScore = 0; - const catalogText = String(tleText || ''); - for (const entry of lookupTleEntries(catalogText)) { - const designatorYear = tleLineLaunchYear(entry.line1); - if (launchYear !== null && designatorYear !== null && designatorYear !== launchYear) continue; - const score = scoreSatelliteNameMatch(query, entry.name); - if (score > bestScore) { - bestEntry = entry; - bestScore = score; - } - } - if (!bestEntry || bestScore < 12) return null; - const satrec = twoline2satrec(bestEntry.line1, bestEntry.line2); - if (!satrec || satrec.error !== 0) return null; - return orbitTrackFromRecord(bestEntry.name, satrec); -} - -/** - * Return the current propagated position and one-orbit path for a catalog satellite. - * @param {string|number} query NORAD id or mission/payload name. - * @param {{launchTime?: string|null}} [options] Optional launch epoch used to reject namesakes from another launch year. - * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} - */ -export function getSatelliteOrbitTrack(query, options = {}) { - if (query === null || query === undefined || !_catalog?.size) return null; - const q = String(query).trim().toLowerCase(); - let noradId = /^\d+$/.test(q) ? Number(q) : null; - if (noradId === null || !_catalog.has(noradId)) { - noradId = null; - const launchYear = Number.isFinite(Date.parse(options.launchTime)) - ? new Date(options.launchTime).getUTCFullYear() - : null; - let bestScore = 0; - for (const [id, sat] of _catalog) { - const designatorYear = internationalDesignatorYear(sat.satrec); - if (launchYear !== null && designatorYear !== null && designatorYear !== launchYear) continue; - const score = scoreSatelliteNameMatch(q, sat.name); - if (score > bestScore) { bestScore = score; noradId = id; } - } - if (bestScore < 12) noradId = null; - } - if (noradId === null) return null; - const sat = _catalog.get(noradId); - return orbitTrackFromRecord(sat.name, sat.satrec); -} - -export default satellitesLayer; +export const satelliteVisualsVisible = layer.satelliteVisualsVisible; +export const satelliteCatalogModeChanged = layer.satelliteCatalogModeChanged; +export const createIssOverlayEntry = layer.createIssOverlayEntry; +export const orbitFrameModelMatrix = layer.orbitFrameModelMatrix; +export const _setTrackedSatelliteRefreshStateForTest = + layer._setTrackedSatelliteRefreshStateForTest; +export const _setSatelliteTrackingRefreshOutcomeForTest = + layer._setSatelliteTrackingRefreshOutcomeForTest; +export const _trackedFrameCartesianForTest = + layer._trackedFrameCartesianForTest; +export const _runSatellitePreRenderForTest = + layer._runSatellitePreRenderForTest; +export const _setDenseCatalogStateForTest = layer._setDenseCatalogStateForTest; +export const _clearDenseCatalogStateForTest = + layer._clearDenseCatalogStateForTest; +export const _catalogGroupForTest = layer._catalogGroupForTest; +export const _setSatelliteLabelLifecycleStateForTest = + layer._setSatelliteLabelLifecycleStateForTest; +export const _trackIssForTest = layer._trackIssForTest; +export const _pendingSatelliteTrackingRestoreForTest = + layer._pendingSatelliteTrackingRestoreForTest; +export const _applyPendingSatelliteTrackingRestoreForTest = + layer._applyPendingSatelliteTrackingRestoreForTest; +export const _removeSatelliteTrackingCandidateForTest = + layer._removeSatelliteTrackingCandidateForTest; +export const _clearSatelliteLabelLifecycleForTest = + layer._clearSatelliteLabelLifecycleForTest; +export const applySatellitePointFocusDeemphasis = + layer.applySatellitePointFocusDeemphasis; +export const getNextIssPass = layer.getNextIssPass; +export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch; +export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle; +export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack; +export { + ISS_OVERLAY_SOURCE_ID, + ISS_OVERLAY_SOURCE_OPTIONS, +} from '../layers/satellites/index.js'; +export default layer; diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 1225e462e..e5ea59d6c 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -1,3 +1,4 @@ +import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/); assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/); assert.match( - createFirmsHeatmapLayer.toString(), - /registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/, + createFirmsRendering.toString(), + /registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/, 'lazy FIRMS registration must restore order immediately', ); }); diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 53d1a664e..20d6e02ff 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -496,6 +496,14 @@ export function createTeleGeographySubmarineCableLayer({ let _cableDataSource = null; let _landingDataSource = null; let _referenceDataSource = null; + /** + * Fetched cable/landing JSON kept across disable/enable. A disable RELEASES + * the three data sources (see releaseDataSources) instead of hiding them, + * so a re-enable has to rebuild the entities; the network/parse half of + * that rebuild is the expensive part users would notice, so it is cached. + */ + let _cachedCableJson = null; + let _cachedLandingJson = null; let _referenceRecords = []; let _surfaceRecords = []; let _clickHandler = null; @@ -553,11 +561,17 @@ export function createTeleGeographySubmarineCableLayer({ const owns = () => generation === _loadGeneration && !abort.signal.aborted; try { - const [cableJson, landingJson] = await Promise.all([ - fetchJson(cableUrl, abort.signal), - fetchJson(landingPointUrl, abort.signal), - ]); - if (!owns()) return; + let cableJson = _cachedCableJson; + let landingJson = _cachedLandingJson; + if (!cableJson || !landingJson) { + [cableJson, landingJson] = await Promise.all([ + fetchJson(cableUrl, abort.signal), + fetchJson(landingPointUrl, abort.signal), + ]); + if (!owns()) return; + _cachedCableJson = cableJson; + _cachedLandingJson = landingJson; + } const cableFeatures = normalizeFeatures(cableJson, 'cable'); const landingFeatures = normalizeFeatures(landingJson, 'landing'); @@ -706,6 +720,37 @@ export function createTeleGeographySubmarineCableLayer({ } } + /** + * Remove the three data sources from the viewer and forget the built + * entities. Hiding them (`show = false`) is NOT enough: Cesium's + * DataSourceDisplay still walks every visualizer of a hidden source on + * every frame, and the thousands of hidden polylines/billboards keep + * costing CPU and GC pressure long after the user toggled the layer off — + * that is the "everything gets sluggish after I turn layers off" report. + * The fetched JSON stays cached, so the next enable rebuilds without a + * network round trip or a re-parse. + */ + function releaseDataSources(viewer) { + const sources = [_cableDataSource, _landingDataSource, _referenceDataSource]; + _cableDataSource = null; + _landingDataSource = null; + _referenceDataSource = null; + _referenceRecords = []; + _surfaceRecords = []; + _pickByEntity = new WeakMap(); + _referenceLabelCount = 0; + _publishScratch.length = 0; + // The blend pass targets the collections being dropped; the rebuilt + // sources need their own pass. + _markerBlendDone = false; + _loaded = false; + _referenceSweepGate.reset(); + for (const source of sources) { + if (!source) continue; + try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ } + } + } + function updateVisibility() { if (_cableDataSource) _cableDataSource.show = _enabled; if (_landingDataSource) _landingDataSource.show = _enabled; @@ -1093,7 +1138,6 @@ export function createTeleGeographySubmarineCableLayer({ disable() { _enabled = false; - updateVisibility(); _overlayPublisher.hide(); resetPublishSignature(); if (_loading && _abort) { @@ -1101,6 +1145,10 @@ export function createTeleGeographySubmarineCableLayer({ _loading = false; _loadingLabel = ''; } + // Free the entities rather than hide them; enable() rebuilds from the + // cached JSON. An in-flight load's own post-await ownership check + // removes whatever that stale generation adds later. + releaseDataSources(_viewer); }, update(viewer) { @@ -1109,9 +1157,9 @@ export function createTeleGeographySubmarineCableLayer({ destroy(viewer) { if (_abort) _abort.abort(); - if (_cableDataSource && viewer) viewer.dataSources.remove(_cableDataSource, true); - if (_landingDataSource && viewer) viewer.dataSources.remove(_landingDataSource, true); - if (_referenceDataSource && viewer) viewer.dataSources.remove(_referenceDataSource, true); + releaseDataSources(viewer || _viewer); + _cachedCableJson = null; + _cachedLandingJson = null; // hide() clears every published host entry and goes invisible while // keeping the publisher reusable — the legacy layer supported re-init // after destroy, and hidden publishers already drop late publishes. diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 11550561e..58bfaa87d 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -743,7 +743,15 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b env.layer.disable(); env.layer.enable(env.viewer); await env.settle(); - assert.equal(geojson.calls.length, 1, 'stale load A must not start the landing GeoJson build'); + // A already cached the fetched JSON, so load B skips the network and goes + // straight to its own CABLE build; the only new GeoJson call must be that + // one — a landing build here would mean stale A ran on past its check. + assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching'); + assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build'); + assert.ok( + Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke), + 'the second GeoJson call is load B\'s cable build, not A\'s landing build', + ); env.releaseAll(); await env.settle(); @@ -1254,3 +1262,36 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i env.layer.destroy(env.viewer); }); + +test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => { + const env = await createRealCableLayerHarness(); + assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on'); + const before = env.layer.getStats().count; + assert.equal(before, 2); + + // Any fetch after this point is a bug: the rebuild must come from the cache. + const originalFetch = globalThis.fetch; + let fetches = 0; + globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); }; + try { + env.layer.disable(); + assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources'); + + env.layer.enable(env.viewer); + const deadline = Date.now() + 2_000; + while (Date.now() < deadline && env.dataSources.length < 3) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(fetches, 0, 'the rebuild must not touch the network'); + assert.equal(env.layer.getStats().error, null); + assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio'); + assert.equal(env.layer.getStats().count, before); + const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); + assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt'); + + env.layer.destroy(env.viewer); + assert.equal(env.dataSources.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index f7f00b0bd..484768337 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/tr3bRegistry.test.mjs // TR-3B conversion Easter egg: registry state, sprite-variant selection, // class-label override, and the render-path invariants that keep a converted @@ -227,7 +228,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke test('both flight layers keep a converted contact 2D and visible (render invariants)', async () => { for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); // 1. The 3D model handoff is SUPPRESSED for a converted contact — there is // no TR-3B GLB, so the triangle billboard stays the visual. @@ -237,13 +238,13 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // (2026-08-19), so the suppression moved from a conjunct on // `_modelRegimeActive()` to an explicit early return. The invariant is // unchanged: a converted contact never reaches the model handoff. - assert.match(source, /if \(!_trackedIcao \|\| _cockpitContactMode \|\| isTr3b\(_trackedIcao\)\) \{/, + assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/, `${name}: the standalone tracked model is suppressed for a converted contact`); // 2. The billboard is never hidden by that suppression — it must keep // satisfying the getNearby/getDetectableObjects visibility guards, so a // converted contact still works in Contacts and Cockpit. - assert.match(source, /if \(bb && id !== _trackedIcao\) bb\.show = true;|if \(modelled && id !== _trackedIcao\) modelled\.show = true;/, + assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/, `${name}: converting restores the billboard the model handoff had hidden`); // 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no @@ -253,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || []; assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`); for (const call of callSites) { - assert.match(call, /aircraftIcon\(\s*_iconKind\(/, + assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/, `${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`); } @@ -270,8 +271,8 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( // personally clicked, and re-tracking the same aircraft after a layer restart // should still show the triangle. Only a page reload resets it — so no // production code may clear the registry. - for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) { + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); assert.doesNotMatch(source, /clearTr3bRegistry/, `${name}: teardown must not clear session conversions`); } @@ -406,10 +407,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => { // in the fleet tick (no seam to drive headlessly), so this asserts the guard's // POSITION rather than replaying the four-pass selection. for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); // Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated // ambient-enrichment candidate loop that also builds a `cand`. - const loop = /const cand = \[\];\s*\n\s*for \(const \[icao, bb\] of _billboards\)[\s\S]*?cand\.push\(/.exec(source)?.[0]; + const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0]; assert.ok(loop, `${name}: the model-eligibility candidate loop is present`); assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 4e453647a..1b02df1d5 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/trackedModelRegime.test.mjs // // Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19). @@ -931,15 +932,15 @@ for (const fixture of LAYERS) { test('both layers gate the tracked-model load and record its failures', async () => { const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); - assert.match(source, /!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed\(\)/, + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); + assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, `${name}: the driver asks permission before starting another tracked-model load`); - assert.match(source, /_noteTrackedModelLoadFailure\(trackedSpec\.url, err\)/, + assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/, `${name}: a rejected load is recorded against this selection, not silently retried`); // The reset must be reachable from the real teardown, not only a test seam. - assert.match(source, /_releaseTrackedModel\(\);\n _resetTrackedSelectionState\(\);/, + assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: deselect clears the per-selection latches in the production path`); - assert.match(source, /_trackedIcao = icao24;\n _resetTrackedSelectionState\(\);/, + assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: selecting a contact starts from a clean per-selection state`); } }); @@ -947,16 +948,16 @@ test('both layers gate the tracked-model load and record its failures', async () test('the tracked regime never consults the fleet models3d toggle', async () => { const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { - const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); - const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; + const source = readLayerSource(new URL(`./${name}`, import.meta.url)); + const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`); - assert.doesNotMatch(regime, /_models3dEnabled|_modelRegimeActive\(\)/, + assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/, `${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`); assert.match(regime, /trackedModelZoomActive\(/, `${name}: the tracked contact uses the shared hysteretic zoom policy`); // The FLEET must still obey the toggle — the budget decision stays the operator's. - const fleet = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0]; - assert.match(fleet, /if \(!_models3dEnabled\) return false;/, + const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; + assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, `${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`); } }); diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 1cf3873c6..48e484aca 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; @@ -244,14 +245,14 @@ test('tracking layers write gevLabelModel and expose only their cached display p 'militaryFlights.js', 'satellites.js', 'militaryInstallations.js', - ].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')])); + ].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))])); const sources = Object.fromEntries(files); for (const [name, source] of files) { assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`); assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`); } - assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached')); - assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached')); + assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); + assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached')); assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false); assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false); @@ -260,11 +261,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( - [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')] + [name, readLayerSource(new URL(`./${name}`, import.meta.url))] ))); for (const [name, source] of files) { assert.ok( - source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'), + (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), `${name} trail head uses the dedicated lower-centre model anchor`, ); assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'), diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index f19200b68..a9a918fef 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -110,16 +111,16 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno test('civilian and military click handlers apply duration only at the deselect branch', () => { const sources = [ - readFileSync(new URL('./flights.js', import.meta.url), 'utf8'), - readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'), + readLayerSource(new URL('./flights.js', import.meta.url)), + readLayerSource(new URL('./militaryFlights.js', import.meta.url)), ]; for (const source of sources) { assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/); - assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+_clearTracking\([^)]*\{ origin: 'user' \}\)/); + assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/); } assert.doesNotMatch( sources[0], - /_trackedEntity = _viewer\.entities\.add\(\{\s*id:/, + /(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/, 'civilian tracked entities must retain Cesium-generated GUIDs', ); }); diff --git a/src/data/traffic.js b/src/data/traffic.js index b5fe47cbe..05fed3bf1 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,2506 +1,13 @@ -import * as Cesium from 'cesium'; -import { deriveFetchCenter, clampBoundsAroundCenter } from './trafficBounds.js'; -import { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } from './flowTiles.js'; -import { matchFlowToRoads } from './flowMatch.js'; -import { flowBucket, flowSpeedScale, flowDensityMult } from './trafficFlowStyle.js'; -import { - trafficStyleProfile, - presetDotRgba, - presetSizeDelta, - presetDotOutline, - trafficBucketTier, -} from './trafficPresetStyle.js'; -import { queuePlatoons, locateAlongRoad } from './trafficQueue.js'; -import { registerDynamicCredit, TOMTOM_CREDIT } from './dataCredits.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -/** - * @file Street Traffic — animated dots along OSM road polylines, colored by - * live TomTom congestion when a key is configured. - * - * Road geometry: OSM Overpass API (free, no auth). Fetches road polylines for - * the camera viewport, spawns PointPrimitives that lerp along pre-computed - * Cartesian3 waypoints. Camera-gated: only active below ~8 km altitude. - * - * Two modes (decided once per session via `/api/tomtom/status`): - * - `sim` (keyless default): white dots at hardcoded per-road-class speeds — - * the original simulation, byte-identical behavior. - * - `live`: TomTom flow tiles (`flowTiles.js`) are matched onto the same - * Overpass roads (`flowMatch.js`); matched roads color/slow/densify their - * dots by real congestion (`trafficFlowStyle.js`), closed roads spawn no - * dots, and unmatched roads keep the simulated white. - * - * Architecture overview: - * - Camera-change listener triggers debounced road fetching per viewport tile. - * - Fetch bounds center on the camera's look-at point (`trafficBounds.js`, C4). - * - Roads are fetched in two passes: major-only (fast) then full graph (detailed). - * - Fetched tiles are cached by clamped bounding-box key to avoid re-fetching. - * - Dot budget allocation distributes a hard cap fairly across visible roads. - * - Each dot lerps along pre-computed Cartesian3 waypoints every preRender frame. - * - * @module data/traffic - */ - -/** @const {string} Proxy endpoint for Overpass API queries */ -const OVERPASS_URL = '/api/overpass'; -/** @const {number} Meters — hide all traffic dots above this camera altitude */ -const ACTIVATION_ALTITUDE = 8000; -/** @const {number} Meters — above this altitude, only major roads are fetched */ -const FAST_FETCH_ALTITUDE = 4500; -/** @const {number} Milliseconds — debounce delay before fetching after camera settles */ -const FETCH_DEBOUNCE = 320; -/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */ -const DOT_HEIGHT_OFFSET = 3.0; -/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */ -const OVERLAP_THRESHOLD = 0.6; -/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */ -const MAX_DOTS = 6000; -/** @const {number} Polylines longer than this are simplified by sub-sampling */ -const MAX_WAYPOINTS_PER_ROAD = 80; -/** @const {number} Km — minimum viewport center shift before allowing refresh */ -const MIN_CENTER_SHIFT_KM = 0.35; -/** - * @const {number} Km — max great-circle distance the fetch center may sit from - * the camera nadir. Traffic only activates below ACTIVATION_ALTITUDE (8 km), - * so a look-at ground point farther than this is horizon-gazing and gets - * pulled back toward nadir (C4 oblique-bounds fix). - */ -const MAX_LOOKAT_PULL_KM = 12; -/** - * Development-only causal timing. Vite folds `import.meta.env.DEV` to false - * in production, so the query-string read, nullable trace branches, and every - * debug helper are removed from production builds. The remaining load-path - * selectors point directly at the original functions: no marks, listeners, - * observers, timers, counters, or per-road timing checks are installed. - */ -const TRAFFIC_TIMING_ENABLED = import.meta.env?.DEV - && typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('trafficDebug') === '1'; - -/** @const {Object} Speed in meters per second by highway tag (approximate real-world values) */ -const SPEED_MPS = { - motorway: 25, // ~90 km/h - trunk: 20, // ~72 km/h - primary: 14, // ~50 km/h - secondary: 11, // ~40 km/h - tertiary: 8, // ~30 km/h - residential: 5, // ~18 km/h - unclassified: 5, -}; - -/** @const {Object} Density multiplier — higher values spawn more dots on important roads */ -const DENSITY_MULT = { - motorway: 3.0, trunk: 2.5, primary: 2.0, secondary: 1.5, - tertiary: 1.0, residential: 0.5, unclassified: 0.4, -}; - -/** @const {Object} Pixel size per road type (scaled up 25% for screen-recording visibility) */ -const SIZE_BY_TYPE = { - motorway: 6, trunk: 6, primary: 5, secondary: 5, - tertiary: 4, residential: 4, unclassified: 4, -}; - -/** - * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): - * green free flow / amber slow / red jam, all at 0.9 alpha. - * Roads without flow data (`road.flow == null`) keep the sim's white. - * @const {Object} - */ -const FLOW_BUCKET_COLORS = { - free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9), - slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9), - jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), -}; - -// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── -/** @const {number} Max congestion heat-line polylines per render (jam first). */ -const HEAT_LINE_CAP = 400; -/** @const {number} Px — glowing jam corridor line width. */ -const HEAT_LINE_JAM_WIDTH = 9; -/** @const {number} Px — flat slow corridor line width. */ -const HEAT_LINE_SLOW_WIDTH = 4; -/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */ -const HEAT_JAM_BASE_ALPHA = 0.55; -/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */ -const HEAT_JAM_PULSE_ALPHA = 0.2; -/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ -const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); -/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ -const HEAT_SLOW_COLOR = Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2); -/** - * @const {number} Meters — jam dots depth-test-punch through the 3D tiles out - * to this camera distance so queues stay visible at city scale. The single - * start-of-road terrain sample puts much of a road below the rendered mesh - * at oblique city views (first A/B capture: 396 jam dots, zero visible), so - * the shipped 2 km window hides exactly the congestion this prototype is - * meant to surface. Live jam dots only; sim dots keep the shipped 2 km. - */ -const JAM_DOT_DEPTH_PUNCH = 15000; -/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ -const JAM_DOT_FAR_SCALE = 0.55; -/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */ -const CREEP_BURST = 2.2; -/** @const {number[]} Ms range a jam dot creeps forward before stopping. */ -const CREEP_MOVE_MS = [1200, 3000]; -/** @const {number[]} Ms range a jam dot sits stopped between creeps. */ -const CREEP_STOP_MS = [1500, 5000]; - -// ─── Module State ────────────────────────────────────────── -/** @type {Cesium.Viewer|null} */ -let _viewer = null; -/** @type {Cesium.PointPrimitiveCollection|null} */ -let _pointCollection = null; -/** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ -let _dots = []; -/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ -let _roads = []; -/** @type {boolean} Whether the layer is currently enabled */ -let _enabled = false; -/** @type {Function|null} Disposer returned by preRender event subscription */ -let _preRenderRemover = null; -/** @type {Function|null} Disposer returned by camera.changed event subscription */ -let _cameraRemover = null; -/** @type {ReturnType|null} Debounce timer for camera-change fetch */ -let _fetchTimeout = null; -/** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */ -let _lastBounds = null; -/** @type {boolean} True while an Overpass fetch is in flight */ -let _fetching = false; -/** @type {number} Current count of rendered dots */ -let _count = 0; -/** @type {number|null} Timestamp of last successful render */ -let _lastUpdate = null; -/** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */ -let _loadGeneration = 0; -/** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */ -let _activeFetchAbort = null; -/** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */ -let _densityScale = 1.0; -/** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */ -let _speedScale = 1.0; -/** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */ -let _lastViewCenter = null; -/** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */ -let _prevPercentageChanged = null; -/** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */ -let _liveMode = false; -/** - * Short user-facing reason live flow is currently unavailable, or null while - * healthy. Only ever set in live mode: keyless simulation is a designed - * fallback, not a fault, and must never read as an error. - * @type {string|null} - */ -let _flowError = null; -/** - * True when `/api/tomtom/status` itself could not be reached, so the layer is - * simulating because it could not ask — not because the server said "no key". - * @type {boolean} - */ -let _flowStatusUnavailable = false; -/** - * Flow requests this layer still owns. The 250 ms paint race lets a flow - * fetch outlive the road load that started it (cached roads settle - * instantly), so `_fetching` alone under-reports the work in flight: the - * loading batch would close with LOAD COMPLETE and a failure landing after - * it could never be announced. Counted, not boolean — recolor-after-timeout - * means two loads can overlap. - * @type {number} - */ -let _flowPending = 0; -/** - * Rendered-dot counts per flow bucket (sim = white ambient, no flow data). - * Reset with the dots in clearDots; drives the data-panel diagnostics and - * the qa-traffic harness color assertions. - * @type {{free:number, slow:number, jam:number, sim:number}} - */ -let _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; -/** @type {number} Roads in the current render skipped entirely as closed. */ -let _closedRoads = 0; -/** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */ -let _uncoveredMode = 'sim'; -/** - * Jam-viz prototype mode: 'density' = deep-jam density boost + platoon queues - * + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both'; - * 'none' = shipped main behavior. Live mode only — the keyless simulation - * never has `road.flow`, so every jamViz path is unreachable there. - * Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available - * via setParams). - * @type {'none'|'density'|'heatline'|'both'} - */ -let _jamViz = 'density'; -/** - * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the - * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE) — polylines at the - * dots' single-sample heights depth-fail under the mesh across an oblique - * city view (first A/B capture: 198 lines rendered, zero visible). One - * primitive per bucket so the jam batch can pulse via a single shared - * material uniform. - * @type {Cesium.GroundPolylinePrimitive|null} - */ -let _heatJamPrim = null; -/** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ -let _heatSlowPrim = null; -/** @type {number} Heat-lines in the current render (stats). */ -let _heatLineCount = 0; -/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ -let _heatSupported = null; -/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ -let _lastRenderAltitude = 0; -/** - * Active post-FX style (StyleManager preset name), synced from - * `document.documentElement.dataset.gevStyle` at init and the - * `gev:style-change` window event thereafter. Drives the preset-aware dot - * styling (`trafficPresetStyle.js`): NVG/FLIR/noir re-encode congestion in - * luminance + size (their shaders discard hue), retro/CRT gets saturated - * hues + a size boost to survive pixelation. 'normal' → shipped palette. - * @type {string} - */ -let _stylePreset = 'normal'; -/** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */ -let _presetDots = 'on'; -/** @type {boolean} gev:style-change listener bound (bind once per page). */ -let _styleListenerBound = false; -/** - * Effective per-bucket dot colors: preset override when one applies, else - * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — - * spawn/recolor/restyle all read from here, no per-dot allocation. - * @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} - */ -let _activeBucketColors = { ...FLOW_BUCKET_COLORS }; -/** - * @const {number} Minimum base pixel size for COLORED dots while a styled - * preset is active — residential-road dots spawn at 4 px and vanish into - * post-FX pixelation; presence is the dots' whole job there (owner round - * 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched. - */ -const STYLED_MIN_BASE_PX = 5; - -/** @returns {boolean} A non-normal preset profile is active and enabled. */ -function presetProfileActive() { - return _presetDots === 'on' && trafficStyleProfile(_stylePreset) !== 'normal'; -} - -/** - * Pixel-size delta the active preset adds for a bucket (0 when the kill - * switch is off or the profile is normal). - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket. - * @returns {number} Pixels to add on top of the shipped sizing. - */ -function activeSizeDelta(bucket) { - return _presetDots === 'on' ? presetSizeDelta(_stylePreset, bucket) : 0; -} - -/** - * Base pixel size for a dot: shipped SIZE_BY_TYPE, floored at - * STYLED_MIN_BASE_PX for colored dots while a styled preset is active. - * @param {string} roadType - OSM highway class. - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). - * @returns {number} Base pixel size before jam/preset deltas. - */ -function baseDotSize(roadType, bucket) { - const base = SIZE_BY_TYPE[roadType] || 4; - return (bucket && presetProfileActive()) ? Math.max(base, STYLED_MIN_BASE_PX) : base; -} - -/** Recompute `_activeBucketColors` from the active style + kill switch. */ -function refreshBucketColors() { - for (const bucket of ['free', 'slow', 'jam']) { - const rgba = _presetDots === 'on' ? presetDotRgba(_stylePreset, bucket) : null; - _activeBucketColors[bucket] = rgba - ? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) - : FLOW_BUCKET_COLORS[bucket]; - } -} - -/** - * Apply the active preset's dark-halo outline to a colored dot (or clear - * it back to the shipped no-outline state). NVG's auto-gain saturates the - * scene, so brightness alone cannot separate a dot from a bright road — - * the dark ring restores local contrast through every luma-mapping shader. - * @param {Cesium.PointPrimitive} point - The dot primitive. - * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). - */ -function applyOutline(point, bucket) { - const spec = _presetDots === 'on' ? presetDotOutline(_stylePreset, bucket) : null; - if (spec) { - point.outlineColor = new Cesium.Color( - spec.rgba[0] / 255, spec.rgba[1] / 255, spec.rgba[2] / 255, spec.rgba[3], - ); - point.outlineWidth = spec.width; - } else { - point.outlineWidth = 0; - } -} - -/** - * Re-apply dot styling in place after a style/param change: colored dots - * get the (new) effective bucket color and size; sim (white) dots are - * never touched. No refetch, no respawn — heat-lines rebuild for their - * per-preset colors. - */ -function restyleDotsInPlace() { - refreshBucketColors(); - if (!_dots.length && !_heatLineCount) return; - for (const dot of _dots) { - const bucket = dot.bucket; - if (!bucket) continue; // sim/uncovered dots stay byte-identical - dot.point.color = _activeBucketColors[bucket]; - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - applyOutline(dot.point, bucket); - } - rebuildHeatLines(visibleRoadsForAltitude(_roads, _lastRenderAltitude)); -} - -/** - * Adopt a new active style preset (from the gev:style-change event or the - * dataset read at init) and restyle live dots immediately. - * @param {string|null|undefined} name - StyleManager preset name. - */ -function setStylePreset(name) { - const next = (typeof name === 'string' && name) ? name : 'normal'; - if (next === _stylePreset) return; - _stylePreset = next; - restyleDotsInPlace(); -} - -/** @returns {boolean} Density/queue/creep prototype active. */ -const jamDensityOn = () => _jamViz === 'density' || _jamViz === 'both'; -/** @returns {boolean} Heat-line prototype active. */ -const heatlineOn = () => _jamViz === 'heatline' || _jamViz === 'both'; -/** - * Dot fade-out distances, recomputed per render from the camera-to-area - * distance. The original constants (8 km scale / 10 km translucency) - * assumed a nadir view; at oblique pitch the loaded area legitimately sits - * 7–12+ km from the CAMERA and every dot faded to invisible (field-test - * round 1: "the screen itself was empty"). - */ -let _fadeScaleFar = 8000; -let _fadeTransFar = 10000; -/** @type {Promise|null} Session-cached status check (one fetch per session) */ -let _flowStatusPromise = null; -/** @type {ReturnType|null} Enable-time retry until the first load commits. */ -let _enableKickTimer = null; -/** @type {number} 0–100 int — matched roads / roads with any flow candidates */ -let _flowCoveragePct = 0; -/** @type {Function|null} Development-only camera moveEnd timing disposer. */ -let _trafficTimingMoveEndRemover = null; -/** @type {Set|null} Development-only one-shot postRender disposers. */ -let _trafficTimingPostRenderRemovers = null; -/** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */ -let _trafficTimingCurrentAnchor = null; -/** @type {number} Development-only unique mark/trace sequence. */ -let _trafficTimingSequence = 0; -/** @type {number} Development-only count of correlated trace objects created. */ -let _trafficTimingTracesCreated = 0; -/** @type {number} Development-only count of loads dropped for missing/stale anchors. */ -let _trafficTimingDroppedTraces = 0; - -/** - * Return development timing counters for the capture harness and inertness test. - * This named export is unused by the application and removed from production. - * @returns {{enabled:boolean, marksInstalled:number, traceObjectsCreated:number, - * uncorrelatedTracesDropped:number}} - */ -export function getTrafficTimingDiagnostics() { - return { - enabled: Boolean(TRAFFIC_TIMING_ENABLED), - marksInstalled: _trafficTimingMoveEndRemover ? 1 : 0, - traceObjectsCreated: _trafficTimingTracesCreated, - uncorrelatedTracesDropped: _trafficTimingDroppedTraces, - }; -} - -/** - * Tile cache keyed by "s,w,n,e" string. - * Each entry stores separately fetched major-only and full road sets - * so the major pass can be served from cache while a full fetch continues. - * @type {Map} - */ -const _tileCache = new Map(); -/** @const {number} Maximum tile cache entries before LRU eviction */ -const TILE_CACHE_MAX_ENTRIES = 64; - -/** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ -const _scratchLerp = new Cesium.Cartesian3(); - -// ─── Overpass API ────────────────────────────────────────── - -/** - * Build an Overpass QL query string to fetch road ways within a bounding box. - * - * The query uses a highway tag regex filter. When `majorOnly` is true, only - * motorway/trunk/primary/secondary are included; otherwise all drivable road - * classes are fetched. The `out geom qt;` suffix returns inline geometry - * (lat/lon per node) sorted by quadtile for faster server response. - * - * @param {number} south - Southern latitude bound (degrees). - * @param {number} west - Western longitude bound (degrees). - * @param {number} north - Northern latitude bound (degrees). - * @param {number} east - Eastern longitude bound (degrees). - * @param {Object} [opts] - * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only. - * @param {number} [opts.timeoutSec=25] - Overpass server-side timeout. - * @returns {string} Overpass QL query body. - */ -function buildOverpassQuery(south, west, north, east, { majorOnly = false, timeoutSec = 25 } = {}) { - // Regex matches the OSM `highway` tag value against allowed road types - const regex = majorOnly - ? '^(motorway|trunk|primary|secondary)$' - : '^(motorway|trunk|primary|secondary|tertiary|residential|unclassified)$'; - return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`; -} - -/** - * Fetch road geometries from the Overpass API via the local proxy. - * - * Sends a POST with the query as form-encoded `data`. Supports - * AbortController signals so in-flight requests can be cancelled - * when the camera moves before the response arrives. - * - * @param {number} south - Southern latitude bound (degrees). - * @param {number} west - Western longitude bound (degrees). - * @param {number} north - Northern latitude bound (degrees). - * @param {number} east - Eastern longitude bound (degrees). - * @param {Object} [opts] - * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes. - * @param {number} [opts.timeoutSec=25] - Server-side Overpass timeout. - * @param {AbortSignal} [opts.signal] - Abort signal for cancellation. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} Parsed JSON response from Overpass. - * @throws {Error} If the HTTP response status is not OK. - */ -async function fetchRoads( - south, - west, - north, - east, - { majorOnly = false, timeoutSec = 25, signal } = {}, - trace = null, -) { - const query = buildOverpassQuery(south, west, north, east, { majorOnly, timeoutSec }); - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingPass(trace, majorOnly ? 'major' : 'full', 'proxy') - : null; - if (state) trace.currentPass = state.pass; - const fetchStart = state ? trafficTimingMark(state, 'fetch-start') : null; - if (state) { - trafficTimingMeasure( - 'last-camera-change-to-fetch-start', state, trace.cameraChangeMark, fetchStart, - ); - } - const response = await fetch(OVERPASS_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - signal, - }); - - if (!response.ok) { - throw new Error(`Overpass API returned ${response.status}`); - } - - if (!state) return response.json(); - - if (state) { - state.proxyCache = response.headers.get('x-overpass-cache'); - state.proxyUpstream = response.headers.get('x-overpass-upstream'); - } - const responseStart = state ? trafficTimingMark(state, 'response-json-start', { - responseStatus: response.status, - }) : null; - if (state) { - trafficTimingMeasure('fetch-to-response', state, fetchStart, responseStart, { - responseStatus: response.status, - }); - } - const data = await response.json(); - if (state) { - const responseEnd = trafficTimingMark(state, 'response-json-end', { - responseStatus: response.status, - }); - trafficTimingMeasure('response-json', state, responseStart, responseEnd, { - responseStatus: response.status, - }); - } - return data; -} - -/** - * Parse an Overpass `out geom;` JSON response into internal road objects. - * - * Each OSM `way` element carries an inline `geometry` array of `{lat, lon}` - * objects, so no separate node look-up or osmtogeojson conversion is needed. - * - * Processing per way: - * 1. Extract [lon, lat] coordinate pairs. - * 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices. - * 3. Sample terrain height once at the first vertex (avoids per-vertex cost). - * 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances. - * - * @param {Object} overpassData - Raw JSON response from the Overpass API. - * @param {Array} overpassData.elements - Array of OSM elements. - * @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} - * Parsed road objects ready for dot spawning. - */ -function parseRoads(overpassData) { - if (!overpassData || !overpassData.elements) return []; - - const roads = []; - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; - - const rawCoords = el.geometry.map(g => [g.lon, g.lat]); - - // Sub-sample long polylines: keep every Nth vertex to stay within budget - const simplifyStep = rawCoords.length > MAX_WAYPOINTS_PER_ROAD - ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) - : 1; - const coords = []; - for (let i = 0; i < rawCoords.length; i += simplifyStep) { - coords.push(rawCoords[i]); - } - - // Ensure the original endpoint is always preserved - const last = rawCoords[rawCoords.length - 1]; - const tail = coords[coords.length - 1]; - if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { - coords.push(last); - } - - if (coords.length < 2) continue; - - const type = el.tags?.highway || 'unclassified'; - // One-way capture (field-test round 1: "a car would never go in - // reverse"): dots on one-way roads all travel the legal direction. - // OSM: oneway=yes/1/true → digitization order; '-1' → reversed; - // roundabouts are one-way by definition. 0 = two-way (alternate). - const onewayTag = el.tags?.oneway; - const oneway = (onewayTag === 'yes' || onewayTag === '1' || onewayTag === 'true' || el.tags?.junction === 'roundabout') - ? 1 - : (onewayTag === '-1' ? -1 : 0); - - // Sample terrain height once at the road start to avoid per-vertex cost - let baseHeight = 0; - const firstCoord = coords[0]; - if (_viewer?.scene?.sampleHeightSupported && firstCoord) { - const carto = Cesium.Cartographic.fromDegrees(firstCoord[0], firstCoord[1]); - const sampled = _viewer.scene.sampleHeight(carto); - if (Number.isFinite(sampled)) baseHeight = sampled; - } - - // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation - const waypoints = coords.map(([lng, lat]) => { - const h = baseHeight + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); - }); - - // Pre-compute segment distances in meters for speed-to-t conversion - const segmentDist = []; - for (let i = 0; i < waypoints.length - 1; i++) { - segmentDist.push(Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1])); - } - - roads.push({ coords, type, oneway, waypoints, segmentDist }); - } - - return roads; -} - -// ─── Road Length Estimation ──────────────────────────────── - -/** - * Estimate the total length of a road in meters from its degree-based coordinates. - * - * Uses Euclidean distance in degree-space then multiplies by the equatorial - * approximation of 111 km per degree. Accurate enough for dot density spacing - * but not for navigation. - * - * @param {number[][]} coords - Array of [lon, lat] pairs. - * @returns {number} Approximate road length in meters. - */ -function estimateRoadLengthDeg(coords) { - let len = 0; - for (let i = 0; i < coords.length - 1; i++) { - const dx = coords[i + 1][0] - coords[i][0]; - const dy = coords[i + 1][1] - coords[i][1]; - len += Math.sqrt(dx * dx + dy * dy); - } - // Rough conversion: 1 degree ~ 111,000 meters at equator - return len * 111000; -} - -// ─── Dot Spawning ────────────────────────────────────────── - -/** - * Compute the ideal number of dots for a single road at a given camera altitude. - * - * Spacing increases with altitude so fewer dots are rendered when zoomed out. - * The result is further scaled by the road-type density multiplier and the - * user-adjustable `_densityScale`. - * - * @param {{coords:number[][], type:string}} road - Parsed road object. - * @param {number} altitude - Current camera altitude in meters. - * @returns {number} Ideal dot count (minimum 1). - */ -function computeDotCount(road, altitude) { - // Live flow: closed roads carry zero traffic; congestion packs more dots. - // `road.flow` is only ever set in live mode, so the keyless path is - // untouched (flow stays undefined → multiplier 1, identical output). - const flow = _liveMode ? road.flow : null; - if (flow?.closure) return 0; - // Strict data-integrity view: uncovered roads spawn nothing when hidden. - if (_liveMode && !flow && _uncoveredMode === 'hide') return 0; - - const lengthM = estimateRoadLengthDeg(road.coords); - - // Altitude-adaptive spacing: closer camera = denser dots - let spacing; - if (altitude < 1000) spacing = 30; - else if (altitude < 3000) spacing = 80; - else if (altitude < 5000) spacing = 150; - else spacing = 250; - - const mult = (DENSITY_MULT[road.type] || 1) - * _densityScale - * (flow ? flowDensityMult(flow.level, { jamBoost: jamDensityOn() }) : 1); - return Math.max(1, Math.floor((lengthM / spacing) * mult)); -} - -/** - * Distribute a fixed dot budget fairly across all visible roads. - * - * Algorithm: - * 1. Compute ideal dot count per road via `computeDotCount`. - * 2. Seed one dot to every road that wants at least one (fairness pass). - * 3. Distribute remaining budget proportionally to each road's ideal count. - * 4. Assign leftover dots (from floor rounding) to roads with the highest - * fractional residuals (largest-remainder method). - * - * This prevents high-density motorways from starving smaller residential roads - * when the global MAX_DOTS cap is reached. - * - * @param {Array} roads - Parsed road objects. - * @param {number} altitude - Camera altitude in meters (affects spacing). - * @param {number} dotCap - Maximum total dots to allocate. - * @returns {number[]} Per-road dot budgets, same length as `roads`. - */ -function allocateRoadDotBudgets(roads, altitude, dotCap) { - const planned = roads.map((road) => computeDotCount(road, altitude)); - const budgets = new Array(roads.length).fill(0); - let remaining = Math.max(0, dotCap); - - // Pass 1 — fairness seed: give one dot to every road (highest-demand first) - const firstPassOrder = planned - .map((count, index) => ({ count, index })) - .sort((a, b) => b.count - a.count); - - for (const entry of firstPassOrder) { - if (remaining <= 0) break; - if (entry.count <= 0) continue; - budgets[entry.index] = 1; - remaining -= 1; - } - - if (remaining <= 0) return budgets; - - // Pass 2 — proportional distribution of the remaining budget - let totalRemainder = 0; - for (let i = 0; i < planned.length; i++) { - totalRemainder += Math.max(0, planned[i] - budgets[i]); - } - if (totalRemainder <= 0) return budgets; - - const residuals = []; - let assigned = 0; - for (let i = 0; i < planned.length; i++) { - const cap = Math.max(0, planned[i] - budgets[i]); - if (cap <= 0) continue; - const ideal = (cap / totalRemainder) * remaining; - const add = Math.min(cap, Math.floor(ideal)); - budgets[i] += add; - assigned += add; - residuals.push({ index: i, residual: ideal - add }); - } - - // Pass 3 — largest-remainder: hand out leftover dots from floor rounding - let leftover = remaining - assigned; - if (leftover > 0 && residuals.length > 0) { - residuals.sort((a, b) => b.residual - a.residual); - let cursor = 0; - while (leftover > 0 && residuals.length > 0) { - const idx = residuals[cursor % residuals.length].index; - if (budgets[idx] < planned[idx]) { - budgets[idx] += 1; - leftover -= 1; - } - cursor += 1; - // Safety valve: avoid infinite loop if all roads are already at their ideal - if (cursor > residuals.length * 3 && leftover > 0) break; - } - } - - return budgets; -} - -/** - * Spawn animated dot primitives along a single road. - * - * Each dot is placed at a random position along the road, assigned a - * randomized speed (base +/-30%), and given a direction (alternating - * forward/backward to simulate two-way traffic). - * - * @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road - * Parsed road object with pre-computed waypoints. - * @param {number} altitude - Camera altitude (used if budgetCount is null). - * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back - * to `computeDotCount` when null. - */ -function spawnDotsForRoad(road, altitude, budgetCount = null) { - // Live flow styling (`road.flow` only exists in live mode; keyless path is - // byte-identical): closures spawn nothing, congestion colors/slows dots. - const flow = _liveMode ? road.flow : null; - if (flow?.closure) return; - if (_liveMode && !flow && _uncoveredMode === 'hide') return; - - const count = Number.isFinite(budgetCount) - ? Math.max(0, Math.floor(budgetCount)) - : computeDotCount(road, altitude); - const numSegments = road.waypoints.length - 1; - if (numSegments < 1 || count <= 0) return; - - const baseMps = SPEED_MPS[road.type] || 5; - const bucket = flow ? flowBucket(flow.level) : null; - // Jam dots get +1px: a red queue should read as a queue at a glance. - // Preset-aware styling adds its own size delta and floors the base (0 / - // no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT. - const pixelSize = baseDotSize(road.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - const flowColor = bucket ? _activeBucketColors[bucket] : null; - // Dark-halo outline under styled presets (null = shipped no-outline). - const outlineSpec = (bucket && _presetDots === 'on') ? presetDotOutline(_stylePreset, bucket) : null; - const outlineColor = outlineSpec - ? new Cesium.Color(outlineSpec.rgba[0] / 255, outlineSpec.rgba[1] / 255, outlineSpec.rgba[2] / 255, outlineSpec.rgba[3]) - : null; - const flowSpeed = flow ? flowSpeedScale(flow.level) : 1; - const now = Date.now(); - - // Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper - // platoons (one shared direction per queue) instead of uniform scatter. - // Unreachable in sim mode — `bucket` requires flow. - let placements = null; - if (bucket === 'jam' && jamDensityOn()) { - let totalLen = 0; - for (const d of road.segmentDist) totalLen += d; - const platoons = queuePlatoons(totalLen, count); - if (platoons.length) { - placements = []; - for (let p = 0; p < platoons.length; p++) { - const dir = road.oneway ? road.oneway : ((p % 2 === 0) ? 1 : -1); - for (const s of platoons[p]) { - const { segIdx, t } = locateAlongRoad(road.segmentDist, s); - placements.push({ segIdx, t, direction: dir }); - } - } - } - } - - for (let i = 0; i < count; i++) { - if (_dots.length >= MAX_DOTS) return; - - // Random start position: pick a random segment and offset within it — - // unless this is a queued jam dot with a platoon placement. - const segIdx = placements ? placements[i].segIdx : Math.floor(Math.random() * numSegments); - const t = placements ? placements[i].t : Math.random(); - - // Speed noise: base speed +/-30% for organic variation. baseMps (noise - // included, flow excluded) is kept on the dot so a late-arriving flow - // match can rescale speed in place (recolorDotsInPlace). - const noisedMps = baseMps * _speedScale * (0.7 + Math.random() * 0.6); - const mps = noisedMps * flowSpeed; - - // One-way roads flow only their legal direction; two-way alternates - // (per platoon in queue mode — a queue moves as one). - const direction = placements - ? placements[i].direction - : (road.oneway ? road.oneway : ((i % 2 === 0) ? 1 : -1)); - - // Compute initial Cartesian3 position via linear interpolation - Cesium.Cartesian3.lerp(road.waypoints[segIdx], road.waypoints[segIdx + 1], t, _scratchLerp); - - // Jam-viz density prototype: jam dots stay visible at city scale — a - // longer depth-test punch-through (single-sample road heights sit under - // the mesh at oblique views) and a higher far-scale floor. Sim dots and - // other buckets keep the shipped values. - const jamProminent = bucket === 'jam' && jamDensityOn(); - const point = _pointCollection.add({ - position: Cesium.Cartesian3.clone(_scratchLerp), - pixelSize, - // No flow data → today's exact simulated white. - color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), - scaleByDistance: new Cesium.NearFarScalar(100, 1.5, _fadeScaleFar, jamProminent ? JAM_DOT_FAR_SCALE : 0.3), - translucencyByDistance: new Cesium.NearFarScalar(100, 1.0, _fadeTransFar, 0.0), - // visible through tiles only when very close (jam: city-scale punch) - disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000, - // Preset dark halo (spread only when present — the keyless/normal - // path passes the exact shipped option set). - ...(outlineSpec ? { outlineColor, outlineWidth: outlineSpec.width } : {}), - }); - _bucketCounts[bucket || 'sim'] += 1; - - _dots.push({ - point, - road, - bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse - waypoints: road.waypoints, - segmentDist: road.segmentDist, - numSegments, - segIdx, - t, - mps, // meters per second (flow-scaled) - baseMps: noisedMps, // pre-flow speed, for in-place flow rescale - direction, - stoppedUntil: 0, - // Stop-and-go creep state (jam-viz density prototype): jam dots - // alternate move-bursts and stops. Null in sim mode and for non-jam. - creep: (bucket === 'jam' && jamDensityOn()) - ? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 } - : null, - }); - } -} - -// ─── Animation ───────────────────────────────────────────── - -/** @type {number} Timestamp of the last animation tick (ms) */ -let _lastAnimTime = 0; -/** @type {number} Running frame counter (for diagnostics) */ -let _animFrame = 0; - -/** - * Per-frame animation callback registered on `scene.preRender`. - * - * For every active dot: - * 1. Skip if currently paused by a simulated stop-light. - * 2. Convert speed (m/s) to a parametric t-delta relative to the current - * segment's Cartesian distance. - * 3. Advance t in the dot's travel direction, handling segment boundary - * crossings and end-of-road reversals. - * 4. Linearly interpolate between the two bounding waypoints and update the - * point primitive's position. - * - * Delta time is capped at 100 ms to prevent large jumps after background tabs. - */ -function animate() { - const now = Date.now(); - // Delta time in seconds, capped to avoid jumps when returning from background tab - const dt = _lastAnimTime ? Math.min((now - _lastAnimTime) / 1000, 0.1) : 0.016; - _lastAnimTime = now; - - for (let i = 0; i < _dots.length; i++) { - const dot = _dots[i]; - - // Simulated stop-light pause — skip movement while timer is active - if (now < dot.stoppedUntil) continue; - - // Stop-and-go creep (jam-viz density prototype, live jam dots only): - // alternate short forward bursts with stops. The burst multiplier keeps - // the long-run average near the honest TomTom crawl speed. - let burst = 1; - if (dot.creep) { - if (now >= dot.creep.until) { - dot.creep.moving = !dot.creep.moving; - const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS; - dot.creep.until = now + lo + Math.random() * (hi - lo); - } - if (!dot.creep.moving) continue; - burst = CREEP_BURST; - } - - // Convert m/s speed to parametric t-delta for the current segment length - const segLen = dot.segmentDist[dot.segIdx] || 1; - const tDelta = (dot.mps * burst * dt) / segLen; - - // Advance parametric position along the road in the current direction - dot.t += tDelta * dot.direction; - - // Handle forward segment boundary crossing (t >= 1.0) - if (dot.t >= 1.0) { - dot.t -= 1.0; - dot.segIdx++; - if (dot.segIdx >= dot.numSegments) { - // End of road: recycle to the road's entry with a small stagger — - // cars don't reverse at the end of a street (field-test round 1). - // Direction is preserved, so one-way flow stays legal. - dot.segIdx = 0; - dot.t = Math.random() * 0.3; - } - maybeStopLight(dot, now); - } else if (dot.t <= 0.0) { - // Handle backward segment boundary crossing (t <= 0.0) - dot.t += 1.0; - dot.segIdx--; - if (dot.segIdx < 0) { - // Start of road (traveling backward): recycle to the far end. - dot.segIdx = dot.numSegments - 1; - dot.t = 1.0 - Math.random() * 0.3; - } - maybeStopLight(dot, now); - } - - // Lerp between pre-computed Cartesian3 waypoints (no trig needed). - // Pass the scratch directly: PointPrimitive's position setter clones the - // value into its own storage (and skips the VBO dirty flag when equal), so - // the extra defensive clone here allocated 360–720k Cartesian3/s of pure - // garbage across 6000 dots. (perf item 5) - const a = dot.waypoints[dot.segIdx]; - const b = dot.waypoints[dot.segIdx + 1]; - Cesium.Cartesian3.lerp(a, b, dot.t, _scratchLerp); - dot.point.position = _scratchLerp; - } - - // Jam heat-lines throb (~1.6 s period) — one shared material uniform for - // the whole jam batch, no geometry rebuild. Null outside live heatline mode. - if (_heatJamPrim?.appearance) { - _heatJamPrim.appearance.material.uniforms.color.alpha = - HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); - } - - _animFrame++; -} - -/** - * Randomly pause a dot near road endpoints to simulate stop-light behaviour. - * - * Only triggers within the first 2 or last 2 segments of the road, and only - * with a very low per-frame probability (0.8%) to keep traffic flowing. - * - * @param {Object} dot - The dot state object. - * @param {number} now - Current timestamp in milliseconds. - */ -function maybeStopLight(dot, now) { - const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2; - if (nearEnd && Math.random() < 0.008) { - // Pause for 2–6 seconds - dot.stoppedUntil = now + 2000 + Math.random() * 4000; - } -} - -// ─── Camera Monitoring ───────────────────────────────────── - -/** - * Get the current camera altitude in meters above the ellipsoid. - * @returns {number} Camera height in meters, or Infinity if unavailable. - */ -function getCameraAltitude() { - const carto = _viewer.camera.positionCartographic; - return carto ? carto.height : Infinity; -} - -/** - * Compute the current camera view rectangle in degrees. - * @returns {{south:number, west:number, north:number, east:number}|null} - * Bounding box in degrees, or null if the rectangle cannot be computed. - */ -function getViewBounds() { - const rect = _viewer.camera.computeViewRectangle(); - if (!rect) return null; - return { - south: Cesium.Math.toDegrees(rect.south), - west: Cesium.Math.toDegrees(rect.west), - north: Cesium.Math.toDegrees(rect.north), - east: Cesium.Math.toDegrees(rect.east), - }; -} - -/** - * Derive the road-fetch center from the camera's look-at ground point. - * - * C4 fix: at oblique pitch `computeViewRectangle()` spans toward the horizon, - * so its midpoint can sit tens of km from what the user is looking at. Instead - * we pick the ellipsoid under the canvas center (`camera.pickEllipsoid` — this - * works with the globe hidden under Google 3D tiles, where `scene.globe.pick` - * is NOT reliable), fall back to the camera nadir on a sky/horizon look, and - * pull horizon-gaze hits back to MAX_LOOKAT_PULL_KM from nadir. - * - * @returns {{lat:number, lon:number, source:string}|null} Fetch center in - * degrees, or null when the camera position is unavailable. - */ -function getFetchCenter() { - const carto = _viewer.camera.positionCartographic; - if (!carto) return null; - const nadirLat = Cesium.Math.toDegrees(carto.latitude); - const nadirLon = Cesium.Math.toDegrees(carto.longitude); - - let hitLat; - let hitLon; - const canvas = _viewer.scene.canvas; - const width = canvas.clientWidth || canvas.width; - const height = canvas.clientHeight || canvas.height; - if (width > 0 && height > 0) { - const hit = _viewer.camera.pickEllipsoid( - new Cesium.Cartesian2(width / 2, height / 2), - Cesium.Ellipsoid.WGS84 - ); - if (hit) { - const hitCarto = Cesium.Cartographic.fromCartesian(hit); - hitLat = Cesium.Math.toDegrees(hitCarto.latitude); - hitLon = Cesium.Math.toDegrees(hitCarto.longitude); - } - } - - return deriveFetchCenter({ - nadirLat, nadirLon, hitLat, hitLon, maxPullKm: MAX_LOOKAT_PULL_KM, - }); -} - -/** - * Compute the geographic center of a bounding box. - * @param {{south:number, west:number, north:number, east:number}} bounds - * @returns {{lat:number, lon:number}} Center point in degrees. - */ -function getBoundsCenter(bounds) { - return { - lat: (bounds.south + bounds.north) / 2, - lon: (bounds.west + bounds.east) / 2, - }; -} - -/** - * Approximate great-circle distance between two points in kilometres. - * - * Uses an equirectangular projection (cosine correction on longitude) - * with the 111 km/degree approximation. Sufficient for the small - * viewport-center shifts being compared. - * - * @param {{lat:number, lon:number}} a - First point. - * @param {{lat:number, lon:number}} b - Second point. - * @returns {number} Distance in kilometres. - */ -function distanceKm(a, b) { - const dLat = (a.lat - b.lat) * 111; - const avgLat = ((a.lat + b.lat) / 2) * (Math.PI / 180); - const dLon = (a.lon - b.lon) * 111 * Math.cos(avgLat); - return Math.sqrt((dLat * dLat) + (dLon * dLon)); -} - -/** - * Check whether two bounding boxes overlap by at least a given fraction of - * the first box's area. Used to decide if a camera move is large enough to - * warrant a new road fetch. - * - * @param {{south:number, west:number, north:number, east:number}} a - Reference bounds. - * @param {{south:number, west:number, north:number, east:number}} b - Bounds to compare. - * @param {number} threshold - Minimum overlap fraction (0-1) relative to `a`. - * @returns {boolean} True if overlap area / a area >= threshold. - */ -function boundsOverlap(a, b, threshold) { - // Compute the intersection rectangle - const overlapS = Math.max(a.south, b.south); - const overlapN = Math.min(a.north, b.north); - const overlapW = Math.max(a.west, b.west); - const overlapE = Math.min(a.east, b.east); - - // No intersection if the rectangle is degenerate - if (overlapN <= overlapS || overlapE <= overlapW) return false; - - const overlapArea = (overlapN - overlapS) * (overlapE - overlapW); - const aArea = (a.north - a.south) * (a.east - a.west); - - return aArea > 0 && (overlapArea / aArea) >= threshold; -} - -/** - * Clamp a bounding box to a maximum span to avoid overloading the Overpass API. - * - * The box is centered on the input's midpoint with each axis capped at 0.05 - * degrees (~5.5 km at the equator). This keeps query area and response size - * manageable while still covering the visible neighbourhood. - * - * NOTE (C4): the camera-driven path in `onCameraChanged` centers on the - * derived look-at point via `clampBoundsAroundCenter` instead; this - * midpoint-centered variant remains as the internal re-clamp guard in - * `loadRoadsForBounds` (idempotent on already-clamped bounds). - * - * @param {{south:number, west:number, north:number, east:number}} bounds - * @returns {{south:number, west:number, north:number, east:number}} Clamped bounds. - */ -function clampBounds(bounds) { - return clampBoundsAroundCenter(bounds, getBoundsCenter(bounds)); -} - -/** - * Camera-change handler — the main entry point for viewport-driven road loading. - * - * Gating logic: - * 1. If the camera is above ACTIVATION_ALTITUDE, clear all dots and bail. - * 2. Clamp the view bounds and compute the viewport center. - * 3. Skip the fetch if the new viewport significantly overlaps the last-fetched - * bounds AND the center has shifted less than MIN_CENTER_SHIFT_KM. This - * prevents redundant fetches during small pans. - * 4. Otherwise, debounce and schedule `loadRoadsForBounds`. - */ -function onCameraChanged() { - if (!_enabled) return; - - const alt = getCameraAltitude(); - - // Above activation altitude — remove all traffic and stop. - // Also null the last-fetch gate: otherwise zooming back down to the SAME - // viewport hits the overlap/center-shift skip in step 3 and the dots - // (cleared here) never reload (H5). Clearing the gate forces a fresh fetch. - if (alt > ACTIVATION_ALTITUDE) { - clearDots(); - _lastBounds = null; - _lastViewCenter = null; - return; - } - - const bounds = getViewBounds(); - if (!bounds) return; - // C4 fix: center the fetch box on the camera's look-at ground point (with - // nadir fallback + 12 km horizon-gaze pull-back), NOT the view rectangle's - // midpoint — at oblique pitch that midpoint drifts toward the horizon. - const fetchCenter = getFetchCenter(); - const clamped = fetchCenter - ? clampBoundsAroundCenter(bounds, fetchCenter) - : clampBounds(bounds); - const center = getBoundsCenter(clamped); - - // Skip re-fetch when viewport overlap is high and center shift is negligible - if ( - _lastBounds - && _lastViewCenter - && boundsOverlap(clamped, _lastBounds, OVERLAP_THRESHOLD) - && distanceKm(center, _lastViewCenter) < MIN_CENTER_SHIFT_KM - ) { - return; - } - - // Debounce: wait for camera to settle before triggering a fetch. In debug - // captures the final changed event that arms this exact timeout is its - // causal anchor; Cesium's later moveEnd notification is diagnostic only. - const interactionAnchor = TRAFFIC_TIMING_ENABLED ? markTrafficTimingCameraChange() : null; - clearTimeout(_fetchTimeout); - _fetchTimeout = setTimeout( - () => _loadRoadsForBounds(clamped, alt, interactionAnchor), - FETCH_DEBOUNCE, - ); -} - -/** Abort any in-flight Overpass fetch and clear the controller reference. */ -function cancelActiveFetch() { - if (_activeFetchAbort) { - _activeFetchAbort.abort(); - _activeFetchAbort = null; - } -} - -// ─── Live Flow (TomTom) ──────────────────────────────────── - -/** - * Map a failed flow fetch onto one short, honest user-facing reason. - * - * `fetchFlowForBounds` only rejects when EVERY covering tile failed, so a - * non-null result here always means "there is no live flow to show right - * now" — the dots fall back to simulated white. Mirrors the - * `deriveAisFeedError` honesty helper. - * - * @param {Error|{name?:string, message?:string}|null|undefined} error - Rejection from the flow fetch. - * @returns {string|null} Short reason, or null for an aborted (superseded) fetch. - */ -export function deriveTrafficFlowError(error) { - if (!error || error.name === 'AbortError') return null; - const message = String(error.message || error); - const status = Number(message.match(/HTTP (\d{3})/)?.[1]); - if (status === 503) return 'TomTom key unavailable'; - if (status === 429) return 'TomTom daily budget reached'; - if (status === 502 || status === 504) return 'TomTom upstream unreachable'; - if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`; - return 'TomTom flow unavailable'; -} - -/** - * Derive the layer's honest feed presentation from its live-flow state. - * - * The three states a user can be in, and what each must read as: - * - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a - * label that never claims live data; - * - live and healthy → LIVE with real coverage; - * - live but flow-down → an `error` string, so the chip degrades and says - * the colors on screen are simulated. Never a stale "LIVE · N% cov". - * - * @param {Object} [input] - * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. - * @param {boolean} [input.fetching] - A viewport load is in flight. - * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. - * @param {number} [input.coveragePct] - Matched-road coverage, 0–100. - * @param {boolean} [input.statusUnavailable] - The status probe itself failed. - * @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}} - */ -export function trafficFeedPresentation({ - liveMode = false, - fetching = false, - flowError = null, - coveragePct = 0, - statusUnavailable = false, -} = {}) { - // `mode` is the CONFIGURED source (live key present vs keyless), not this - // instant's health — health rides on `error`. The qa-traffic harness pins - // that meaning. - const mode = liveMode ? 'live' : 'sim'; - if (liveMode && flowError) { - // One string for both fields. The manager's meta line renders `error` and - // drops `loadingLabel` in its error branch, so the owner's SIMULATED copy - // has to BE the error text or the steady state reverts to a bare - // "TomTom daily budget reached" that never says what is on screen. - const degraded = `SIMULATED — ${flowError}`; - return { mode, error: degraded, loadingLabel: degraded }; - } - if (liveMode) { - return { - mode, - error: null, - loadingLabel: fetching - ? 'syncing LIVE traffic flow' - : `LIVE · TomTom flow · ${coveragePct}% cov`, - }; - } - // Keyless simulation — one terse line that names the mode and the remedy - // (owner's copy shape). The chip's own progress text carries "working"; - // this line must never imply a live feed. - return { - mode, - error: null, - loadingLabel: statusUnavailable - ? 'SIMULATED — traffic service unreachable' - : 'SIMULATED — add TomTom key for live', - }; -} - -/** - * Check `/api/tomtom/status` once per session and cache the result. - * Live mode iff the server holds a TomTom key; the TomTom attribution credit - * registers the first time live mode activates. Keyless or unreachable → - * simulation mode, exactly today's behavior. - * - * @returns {Promise} Resolves when `_liveMode` is settled. - */ -function ensureFlowStatus() { - if (!_flowStatusPromise) { - _flowStatusPromise = fetch('/api/tomtom/status') - .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))) - .then((status) => { - _liveMode = Boolean(status?.hasKey); - _flowStatusUnavailable = false; - if (_liveMode) { - console.log('[Data:Traffic] TomTom key present — live flow mode'); - registerDynamicCredit(_viewer, TOMTOM_CREDIT); - } - }) - .catch((e) => { - // Simulating because we could not ask, which is NOT the same as - // "server says no key" — getStats() distinguishes the two. - _liveMode = false; - _flowStatusUnavailable = true; - console.warn('[Data:Traffic] TomTom status unreachable — simulated traffic:', e?.message || e); - }); - } - return _flowStatusPromise; -} - -/** - * Live mode only: fetch TomTom flow for the clamped bounds, match it onto the - * parsed roads, and attach `road.flow` (`{level, closure}` or null). - * - * Reuses the load-generation guard: stale flow responses are discarded, and - * the shared AbortController lets `cancelActiveFetch()` (next load / disable) - * cancel an in-flight flow fetch. Any failure leaves roads unmatched — the - * dots then render in today's simulated white, never a phantom color — and is - * recorded in `_flowError` so `getStats()` degrades honestly instead of - * reporting a stale "LIVE · N% cov" over simulated dots. - * - * @param {Array} roads - Parsed road objects (mutated: `road.flow`). - * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. - * @param {number} generation - `_loadGeneration` at call time. - * @returns {Promise} - */ -async function applyFlowToRoads(roads, clamped, generation) { - // Claim the work synchronously, before the first await, so `stats.loading` - // covers this request from the same tick the caller started it — the - // loading batch must not be able to close underneath an in-flight fetch. - _flowPending += 1; - try { - if (!_flowStatusPromise) return; // status check not started — sim mode - await _flowStatusPromise; - if (!_liveMode || !_enabled) return; - if (generation !== _loadGeneration) return; - if (!Array.isArray(roads) || roads.length === 0) return; - try { - // Cached paths reach here without a live controller; the fetch paths - // reuse theirs so one cancel covers both roads and flow. - if (!_activeFetchAbort) _activeFetchAbort = new AbortController(); - const segments = await fetchFlowForBounds(clamped, { signal: _activeFetchAbort.signal }); - if (generation !== _loadGeneration) return; - const { matches, matchedCount, candidateCount } = matchFlowToRoads(roads, segments); - for (let i = 0; i < roads.length; i++) { - roads[i].flow = matches[i]; - } - _flowCoveragePct = candidateCount > 0 - ? Math.round((matchedCount / candidateCount) * 100) - : 0; - _flowError = null; - } catch (e) { - if (e?.name === 'AbortError') return; - // Same guard the success path gets: a superseded request rejecting late - // (or after disable() cleared the state) must not restore a stale - // outage over newer good data. - if (generation !== _loadGeneration || !_enabled) return; - // Every covering tile failed: there is no live flow on screen. Drop the - // now-false coverage number and surface the reason through getStats(). - _flowError = deriveTrafficFlowError(e); - _flowCoveragePct = 0; - console.warn('[Data:Traffic] Flow fetch failed (sim colors remain):', e?.message || e); - } - } finally { - _flowPending -= 1; - } -} - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ -const FLOW_RENDER_RACE_MS = 250; - -/** - * Race flow application against the paint deadline, render, and schedule an - * in-place recolor if flow lost the race. - * @param {Array} roads - Parsed road objects. - * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. - * @param {number} generation - `_loadGeneration` at call time. - * @param {number} altitude - Camera altitude in meters. - * @param {string} label - Render log label. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} True if this generation rendered. - */ -async function applyFlowThenRender(roads, clamped, generation, altitude, label, trace = null) { - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingRenderState(trace, label) - : null; - const flowRaceStart = state ? trafficTimingMark(state, 'flow-render-race-start', { - deadlineMs: FLOW_RENDER_RACE_MS, - }) : null; - const flowJob = applyFlowToRoads(roads, clamped, generation); - const outcome = await Promise.race([ - flowJob.then(() => 'flow'), - new Promise((resolve) => setTimeout(() => resolve('timeout'), FLOW_RENDER_RACE_MS)), - ]); - if (state) { - const flowRaceEnd = trafficTimingMark(state, 'flow-render-race-end', { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - trafficTimingMeasure('flow-render-race', state, flowRaceStart, flowRaceEnd, { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - } - if (generation !== _loadGeneration) return false; - renderRoadsForAltitude(roads, altitude, label, trace); - if (outcome === 'timeout') { - flowJob.then(() => { - if (generation !== _loadGeneration) return; - recolorDotsInPlace(label); - }).catch(() => { /* applyFlowToRoads settles its own failures */ }); - } - return true; -} - -/** - * Apply late-arriving flow data to already-rendered dots without a respawn: - * color, jam size, and speed update in place; closed roads' dots hide. - * Density bunching intentionally waits for the next natural re-render — - * color and speed are the live signal, dot count is a refinement. - * @param {string} label - Render log label (for the console trace). - */ -function recolorDotsInPlace(label) { - if (!_liveMode || !_dots.length) return; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - let closedDots = 0; - const now = Date.now(); - for (const dot of _dots) { - const flow = dot.road ? dot.road.flow : null; - if (flow?.closure) { - dot.point.show = false; - closedDots += 1; - continue; - } - const bucket = flow ? flowBucket(flow.level) : null; - dot.bucket = bucket; - dot.point.color = bucket ? _activeBucketColors[bucket] : Cesium.Color.WHITE.withAlpha(0.85); - if (bucket === 'jam') { - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + 1 + activeSizeDelta('jam'); - } else if (bucket && presetProfileActive()) { - // Preset profiles size-floor every bucket; the shipped normal path - // keeps its jam-only size touch (byte-identical behavior). - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + activeSizeDelta(bucket); - } - // Late flow can move a dot between buckets — keep the preset halo in - // step (no-op writes under the normal profile, whose dots have none). - if (presetProfileActive()) applyOutline(dot.point, bucket); - dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1); - // Late flow tags/untags stop-and-go creep + city-scale prominence the - // same way it rescales speed. Queue *positions* wait for the next - // natural re-render, like density bunching. - if (bucket === 'jam' && jamDensityOn()) { - if (!dot.creep) dot.creep = { moving: Math.random() < 0.4, until: now + Math.random() * 2000 }; - dot.point.scaleByDistance = new Cesium.NearFarScalar(100, 1.5, _fadeScaleFar, JAM_DOT_FAR_SCALE); - dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH; - } else { - dot.creep = null; - } - _bucketCounts[bucket || 'sim'] += 1; - } - _closedRoads = _roads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0); - rebuildHeatLines(visibleRoadsForAltitude(_roads, _lastRenderAltitude)); - console.log(`[Data:Traffic] Flow recolor (${label}): ${_dots.length} dots, closedDots=${closedDots}`); -} - -/** - * At high altitude only major roads render — shared by the render pass and - * the late-flow heat-line rebuild so lines never mark roads without dots. - * @param {Array} roads - Parsed road objects. - * @param {number} altitude - Camera altitude in meters. - * @returns {Array} The roads visible at this altitude. - */ -function visibleRoadsForAltitude(roads, altitude) { - return altitude > 5000 - ? roads.filter(r => r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary') - : roads; -} - -/** Remove both heat-line ground primitives from the scene. */ -function removeHeatLines() { - if (_heatJamPrim) { - _viewer?.scene.groundPrimitives.remove(_heatJamPrim); - _heatJamPrim = null; - } - if (_heatSlowPrim) { - _viewer?.scene.groundPrimitives.remove(_heatSlowPrim); - _heatSlowPrim = null; - } - _heatLineCount = 0; -} - -/** - * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): - * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing - * pulsing red for jam, faint flat amber for slow — with the dots animating - * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam - * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam - * first, longest first); overflow is logged. No-op in sim mode, when the - * heatline mode is off, or without ground-primitive support. - * - * @param {Array} roads - Road objects visible in the current render. - */ -function rebuildHeatLines(roads) { - removeHeatLines(); - if (!_viewer || !_liveMode || !heatlineOn()) return; - if (_heatSupported === null) { - _heatSupported = Cesium.GroundPolylinePrimitive.isSupported(_viewer.scene); - if (!_heatSupported) console.warn('[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled'); - } - if (!_heatSupported) return; - - const candidates = []; - for (const road of roads) { - const flow = road.flow; - if (!flow || flow.closure) continue; - const bucket = flowBucket(flow.level); - if (bucket === 'free') continue; - let len = 0; - for (const d of road.segmentDist) len += d; - candidates.push({ road, bucket, len }); - } - candidates.sort((a, b) => (a.bucket === b.bucket - ? b.len - a.len - : (a.bucket === 'jam' ? -1 : 1))); - const kept = candidates.slice(0, HEAT_LINE_CAP); - - const instancesFor = (bucket, width) => kept - .filter((c) => c.bucket === bucket) - .map((c) => new Cesium.GeometryInstance({ - geometry: new Cesium.GroundPolylineGeometry({ positions: c.road.waypoints, width }), - })); - - // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in - // luminance like the dots: jam = white glow, slow = faint gray. - const monoHeat = _presetDots === 'on' && trafficStyleProfile(_stylePreset) === 'mono'; - const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; - const slowLineColor = monoHeat - ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) - : HEAT_SLOW_COLOR; - - const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); - if (jamInstances.length) { - _heatJamPrim = _viewer.scene.groundPrimitives.add(new Cesium.GroundPolylinePrimitive({ - geometryInstances: jamInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), - glowPower: 0.25, - }), - }), - })); - } - const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); - if (slowInstances.length) { - _heatSlowPrim = _viewer.scene.groundPrimitives.add(new Cesium.GroundPolylinePrimitive({ - geometryInstances: slowInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('Color', { color: slowLineColor }), - }), - })); - } - - _heatLineCount = kept.length; - if (candidates.length > kept.length) { - console.log(`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`); - } -} - -/** - * Clear existing dots and re-spawn them for the given road set and altitude. - * - * When zoomed out (>5 km), only major road types are rendered to reduce clutter. - * Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`. - * - * @param {Array} roads - Parsed road objects to render. - * @param {number} altitude - Camera altitude in meters. - * @param {string} label - Logging label (e.g. "Cache full", "Loaded major"). - * @param {Object|null} [trace=null] - Development-only correlated load trace. - */ -function renderRoadsForAltitude(roads, altitude, label, trace = null) { - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingRenderState(trace, label) - : null; - const renderId = state ? ++trace.renderSequence : null; - clearDots(); - _roads = roads; - _lastRenderAltitude = altitude; - - // At high altitude, drop minor roads to reduce visual noise - const filteredRoads = visibleRoadsForAltitude(roads, altitude); - - // Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count - // them here so the closure signal is visible in stats even at zero dots. - _closedRoads = _liveMode - ? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0) - : 0; - - // Fade distances must track the camera-to-AREA distance, not assume a - // nadir view: oblique pitches put the loaded roads many km away even at - // low altitude. Probe three roads and stretch the curves accordingly. - let areaDist = altitude; - if (_viewer && filteredRoads.length) { - const probes = [ - filteredRoads[0], - filteredRoads[Math.floor(filteredRoads.length / 2)], - filteredRoads[filteredRoads.length - 1], - ]; - for (const probe of probes) { - const wp = probe?.waypoints?.[0]; - if (wp) areaDist = Math.max(areaDist, Cesium.Cartesian3.distance(_viewer.camera.positionWC, wp)); - } - } - _fadeScaleFar = Math.max(8000, areaDist * 1.5); - _fadeTransFar = Math.max(10000, areaDist * 1.8); - - const dotStart = state ? trafficTimingMark(state, 'dot-construction-start', { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - }) : null; - const roadBudgets = allocateRoadDotBudgets(filteredRoads, altitude, MAX_DOTS); - for (let i = 0; i < filteredRoads.length; i++) { - const road = filteredRoads[i]; - const budget = roadBudgets[i] || 0; - if (budget <= 0) continue; - spawnDotsForRoad(road, altitude, budget); - if (_dots.length >= MAX_DOTS) break; - } - - const renderMetrics = state ? { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - dotCount: _dots.length, - } : null; - if (state) { - const dotEnd = trafficTimingMark(state, 'dot-construction-end', renderMetrics); - trafficTimingMeasure('dot-construction', state, dotStart, dotEnd, renderMetrics); - } - - const heatStart = state ? trafficTimingMark(state, 'rebuild-heat-lines-start', renderMetrics) : null; - rebuildHeatLines(filteredRoads); - if (state) { - const heatEnd = trafficTimingMark(state, 'rebuild-heat-lines-end', { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - trafficTimingMeasure('rebuild-heat-lines', state, heatStart, heatEnd, { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - } - - _count = _dots.length; - _lastUpdate = Date.now(); - console.log(`[Data:Traffic] ${label}: ${_count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`); - if (state) { - const renderEnd = trafficTimingMark(state, 'render-return', renderMetrics); - scheduleTrafficTimingPostRender(state, renderEnd, renderId, renderMetrics); - } -} - -// ─── Development-only causal timing ─────────────────────── - -/** - * Return (and optionally update) the current trace's state for a render pass. - * This function is only reachable when `TRAFFIC_TIMING_ENABLED` is true. - * - * @param {Object|null} trace - Correlated load trace. - * @param {'major'|'full'} pass - Road-fetch/render pass. - * @param {string} [source] - Client cache or proxy/network source. - * @returns {Object|null} Mutable pass timing state. - */ -function trafficTimingPass(trace, pass, source) { - if (!trace) return null; - let state = trace.passes.get(pass); - if (!state) { - state = { - trace, - pass, - source: source || 'unknown', - proxyCache: null, - proxyUpstream: null, - }; - trace.passes.set(pass, state); - } else if (source) { - state.source = source; - } - return state; -} - -/** Build a structured-clone-safe detail object for User Timing entries. */ -function trafficTimingDetail(state, segment, extra = {}) { - return { - trafficTiming: true, - segment, - traceId: state?.trace?.id ?? null, - interactionId: state?.trace?.interactionId ?? null, - cameraChangeTimestamp: state?.trace?.cameraChangeTimestamp ?? null, - generation: state?.trace?.generation ?? null, - cacheKey: state?.trace?.cacheKey ?? null, - pass: state?.pass || 'load', - source: state?.source || 'unknown', - proxyCache: state?.proxyCache || null, - proxyUpstream: state?.proxyUpstream || null, - ...extra, - }; -} - -/** Add a uniquely named User Timing mark and return its name. */ -function trafficTimingMark(state, phase, extra = {}, startTime) { - const traceId = state?.trace?.id ?? 'interaction'; - const pass = state?.pass || 'load'; - const name = `traffic:${traceId}:${pass}:${phase}:${++_trafficTimingSequence}`; - const options = { detail: trafficTimingDetail(state, phase, extra) }; - if (Number.isFinite(startTime)) options.startTime = startTime; - performance.mark(name, options); - return name; -} - -/** Emit a named User Timing measure between two marks. */ -function trafficTimingMeasure(segment, state, start, end, extra = {}) { - performance.measure(`traffic:${segment}:${state?.pass || 'load'}`, { - start, - end, - detail: trafficTimingDetail(state, segment, extra), - }); -} - -/** Emit an aggregate-duration measure without pretending its work was contiguous. */ -function trafficTimingAggregate(segment, state, anchorTime, duration, extra = {}) { - const start = trafficTimingMark(state, `${segment}-aggregate-start`, extra, anchorTime); - const end = trafficTimingMark(state, `${segment}-aggregate-end`, extra, anchorTime + duration); - trafficTimingMeasure(segment, state, start, end, { aggregate: true, ...extra }); -} - -/** Clear only this module's stale User Timing entries before a new debug run. */ -function clearTrafficTimingEntries() { - const markNames = new Set( - performance.getEntriesByType('mark') - .filter((entry) => entry.name.startsWith('traffic:')) - .map((entry) => entry.name), - ); - const measureNames = new Set( - performance.getEntriesByType('measure') - .filter((entry) => entry.name.startsWith('traffic:')) - .map((entry) => entry.name), - ); - for (const name of markNames) performance.clearMarks(name); - for (const name of measureNames) performance.clearMeasures(name); -} - -/** Return the stable User Timing mark name for a scheduling interaction. */ -function trafficTimingCameraChangeMarkName(interactionId) { - return `traffic:interaction:${interactionId}:last-camera-change`; -} - -/** Mint and mark the exact camera-change interaction that armed a debounced load. */ -function markTrafficTimingCameraChange() { - const interactionId = ++_trafficTimingSequence; - const timestamp = performance.now(); - const anchor = { interactionId, timestamp }; - _trafficTimingCurrentAnchor = anchor; - performance.mark(trafficTimingCameraChangeMarkName(interactionId), { - startTime: timestamp, - detail: { - trafficTiming: true, - segment: 'last-camera-change', - interactionId, - timestamp, - }, - }); - return anchor; -} - -/** - * Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this - * about 500 ms after stillness, so fetch has typically already started and - * never waits for this boundary. - */ -function markTrafficTimingMoveEnd() { - const diagnosticId = ++_trafficTimingSequence; - const timestamp = performance.now(); - const name = `traffic:diagnostic:${diagnosticId}:camera-move-end`; - performance.mark(name, { - startTime: timestamp, - detail: { - trafficTiming: true, - segment: 'camera-move-end', - diagnosticOnly: true, - cameraEventWaitTimeMs: 500, - fetchWaitsForMoveEnd: false, - timestamp, - }, - }); -} - -/** - * Instrumented twin of `parseRoads`. Operation ordering and road output match - * the normal function; debug-only clocks accumulate synchronous height and - * waypoint-materialization time independently. - */ -function parseRoadsTimed(overpassData, trace) { - /* TRACE_ONLY_BEGIN */ - const _trafficTimingState = trafficTimingPass(trace, trace?.currentPass || 'full'); - const _trafficTimingParseStartTime = performance.now(); - const _trafficTimingParseStart = trafficTimingMark( - _trafficTimingState, 'road-parse-start', {}, _trafficTimingParseStartTime - ); - /* TRACE_ONLY_END */ - if (!overpassData || !overpassData.elements) { - /* TRACE_ONLY_BEGIN */ - const _trafficTimingParseEnd = trafficTimingMark( - _trafficTimingState, 'road-parse-end', { roadCount: 0 } - ); - trafficTimingMeasure( - 'road-parse-total', _trafficTimingState, - _trafficTimingParseStart, _trafficTimingParseEnd, { roadCount: 0 } - ); - trafficTimingAggregate('sample-height-total', _trafficTimingState, _trafficTimingParseStartTime, 0, { - sampleHeightCalls: 0, - sampleHeightMeanMs: 0, - distinctCells: 0, - roadCount: 0, - }); - trafficTimingAggregate( - 'waypoint-materialization', _trafficTimingState, _trafficTimingParseStartTime, 0, - { roadCount: 0 } - ); - /* TRACE_ONLY_END */ - return []; - } - - const roads = []; - /* TRACE_ONLY_BEGIN */ - const _trafficTimingSampledCells = new Set(); - let _trafficTimingSampleHeightCalls = 0; - let _trafficTimingSampleHeightMs = 0; - let _trafficTimingWaypointMaterializationMs = 0; - /* TRACE_ONLY_END */ - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; - - const rawCoords = el.geometry.map(g => [g.lon, g.lat]); - const simplifyStep = rawCoords.length > MAX_WAYPOINTS_PER_ROAD - ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) - : 1; - const coords = []; - for (let i = 0; i < rawCoords.length; i += simplifyStep) { - coords.push(rawCoords[i]); - } - - const last = rawCoords[rawCoords.length - 1]; - const tail = coords[coords.length - 1]; - if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { - coords.push(last); - } - if (coords.length < 2) continue; - - const type = el.tags?.highway || 'unclassified'; - const onewayTag = el.tags?.oneway; - const oneway = (onewayTag === 'yes' || onewayTag === '1' || onewayTag === 'true' || el.tags?.junction === 'roundabout') - ? 1 - : (onewayTag === '-1' ? -1 : 0); - - let baseHeight = 0; - const firstCoord = coords[0]; - if (_viewer?.scene?.sampleHeightSupported && firstCoord) { - /* TRACE_ONLY_BEGIN */ - _trafficTimingSampleHeightCalls += 1; - _trafficTimingSampledCells.add(`${firstCoord[1].toFixed(3)},${firstCoord[0].toFixed(3)}`); - /* TRACE_ONLY_END */ - const carto = Cesium.Cartographic.fromDegrees(firstCoord[0], firstCoord[1]); - /* TRACE_ONLY_BEGIN */ - const _trafficTimingSampleStart = performance.now(); - /* TRACE_ONLY_END */ - const sampled = _viewer.scene.sampleHeight(carto); - /* TRACE_ONLY_BEGIN */ - _trafficTimingSampleHeightMs += performance.now() - _trafficTimingSampleStart; - /* TRACE_ONLY_END */ - if (Number.isFinite(sampled)) baseHeight = sampled; - } - - /* TRACE_ONLY_BEGIN */ - const _trafficTimingMaterializeStart = performance.now(); - /* TRACE_ONLY_END */ - const waypoints = coords.map(([lng, lat]) => { - const h = baseHeight + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); - }); - const segmentDist = []; - for (let i = 0; i < waypoints.length - 1; i++) { - segmentDist.push(Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1])); - } - /* TRACE_ONLY_BEGIN */ - _trafficTimingWaypointMaterializationMs += performance.now() - _trafficTimingMaterializeStart; - /* TRACE_ONLY_END */ - - roads.push({ coords, type, oneway, waypoints, segmentDist }); - } - - /* TRACE_ONLY_BEGIN */ - const _trafficTimingMetrics = { - roadCount: roads.length, - sampleHeightCalls: _trafficTimingSampleHeightCalls, - sampleHeightMeanMs: _trafficTimingSampleHeightCalls - ? _trafficTimingSampleHeightMs / _trafficTimingSampleHeightCalls - : 0, - distinctCells: _trafficTimingSampledCells.size, - }; - trafficTimingAggregate( - 'sample-height-total', _trafficTimingState, _trafficTimingParseStartTime, - _trafficTimingSampleHeightMs, _trafficTimingMetrics - ); - trafficTimingAggregate( - 'waypoint-materialization', _trafficTimingState, _trafficTimingParseStartTime, - _trafficTimingWaypointMaterializationMs, _trafficTimingMetrics - ); - const _trafficTimingParseEnd = trafficTimingMark( - _trafficTimingState, 'road-parse-end', _trafficTimingMetrics - ); - trafficTimingMeasure( - 'road-parse-total', _trafficTimingState, - _trafficTimingParseStart, _trafficTimingParseEnd, _trafficTimingMetrics - ); - /* TRACE_ONLY_END */ - return roads; -} - -/** Resolve a render label into its correlated pass and data source. */ -function trafficTimingRenderState(trace, label) { - const pass = label.toLowerCase().includes('major') ? 'major' : 'full'; - const source = label.startsWith('Cache') ? 'client-cache' : 'proxy'; - return trafficTimingPass(trace, pass, source); -} - -/** Record the first Cesium postRender following a completed dot render. */ -function scheduleTrafficTimingPostRender(state, renderEnd, renderId, renderMetrics) { - if (!_viewer?.scene) return; - let remove = null; - remove = _viewer.scene.postRender.addEventListener(() => { - remove?.(); - _trafficTimingPostRenderRemovers?.delete(remove); - const visibleTime = performance.now(); - const postRender = trafficTimingMark(state, 'next-post-render', { - renderId, - ...renderMetrics, - }, visibleTime); - const firstVisible = trafficTimingMark(state, 'first-visible-pixel', { - renderId, - visibleBoundary: 'next-postRender', - ...renderMetrics, - }, visibleTime); - trafficTimingMeasure('render-to-post-render', state, renderEnd, postRender, { - renderId, - visibleBoundary: 'next-postRender', - ...renderMetrics, - }); - trafficTimingMeasure( - 'last-camera-change-to-first-visible', state, state.trace.cameraChangeMark, firstVisible, - { renderId, visibleBoundary: 'next-postRender', ...renderMetrics } - ); - }); - _trafficTimingPostRenderRemovers?.add(remove); -} - -/** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */ -async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) { - const generation = _loadGeneration + 1; - if (!expectedAnchor || expectedAnchor !== _trafficTimingCurrentAnchor) { - _trafficTimingDroppedTraces += 1; - await loadRoadsForBounds(bounds, altitude); - return; - } - _trafficTimingCurrentAnchor = null; - const clamped = clampBounds(bounds); - const trace = { - id: ++_trafficTimingSequence, - interactionId: expectedAnchor.interactionId, - cameraChangeTimestamp: expectedAnchor.timestamp, - cameraChangeMark: trafficTimingCameraChangeMarkName(expectedAnchor.interactionId), - generation, - cacheKey: `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`, - currentPass: null, - renderSequence: 0, - passes: new Map(), - }; - _trafficTimingTracesCreated += 1; - await loadRoadsForBounds(bounds, altitude, trace); -} - -// Disabled-path contract: these references resolve directly to the original -// functions. Instrumentation adds no load-path callbacks or per-item checks. -const _parseRoads = TRAFFIC_TIMING_ENABLED - ? (data, trace) => (trace ? parseRoadsTimed(data, trace) : parseRoads(data)) - : parseRoads; -const _loadRoadsForBounds = TRAFFIC_TIMING_ENABLED - ? loadRoadsForBoundsTimed - : loadRoadsForBounds; - -/** - * Load road data for the given viewport bounds and render traffic dots. - * - * Implements a two-pass fetch strategy with tile caching: - * - * 1. Check the tile cache (keyed by clamped bounding-box coordinates). - * - If a full road set is cached, render immediately and return. - * - If only major roads are cached, render those first. - * 2. Fetch major roads from Overpass (fast, small payload). Render. - * 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full - * road graph (includes tertiary/residential). Render again to upgrade. - * - * Each fetch is guarded by a monotonic `_loadGeneration` counter so that - * stale responses from superseded requests are silently discarded. - * - * @param {{south:number, west:number, north:number, east:number}} bounds - * Viewport bounds (will be clamped internally). - * @param {number} altitude - Camera altitude in meters. - * @param {Object|null} [trace=null] - Development-only correlated load trace. - * @returns {Promise} - */ -async function loadRoadsForBounds(bounds, altitude, trace = null) { - // Increment generation to invalidate any in-flight responses from prior calls - const generation = ++_loadGeneration; - cancelActiveFetch(); - const clamped = clampBounds(bounds); - - // Cache key: fixed-precision bounding-box string for deterministic lookups - const cacheKey = `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`; - - // Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road - // fetch — sequential fetches doubled first-paint latency (field-test - // round 1). Failures are irrelevant; applyFlowToRoads settles the truth. - ensureFlowStatus().then(() => { - if (_liveMode && _enabled && generation === _loadGeneration) { - fetchFlowForBounds(clamped, {}).catch(() => { /* warm-up only */ }); - } - }); - - _fetching = true; - // Only COMMIT these on success. Committing up-front means a failed Overpass - // fetch (rate-limited / feed down) still trips the overlap gate in - // onCameraChanged, so a stationary user never retries (H3/H5). Stage the - // prospective values and roll back if nothing rendered. - const prevBounds = _lastBounds; - const prevViewCenter = _lastViewCenter; - _lastBounds = clamped; - _lastViewCenter = getBoundsCenter(clamped); - let renderedSomething = false; - - try { - let cache = _tileCache.get(cacheKey); - if (!cache) { - // LRU eviction: drop the oldest entry when cache exceeds the cap - if (_tileCache.size >= TILE_CACHE_MAX_ENTRIES) { - const oldest = _tileCache.keys().next().value; - _tileCache.delete(oldest); - } - cache = { major: null, full: null }; - _tileCache.set(cacheKey, cache); - } - - // Fast path: full road set already cached — render and return. - // Flow is (re)applied even on cache hits: roads cache for the session, - // but congestion data has a 120s shelf life. The race renders within - // FLOW_RENDER_RACE_MS either way; late flow recolors in place. - if (cache.full) { - renderedSomething = await applyFlowThenRender( - cache.full, clamped, generation, altitude, 'Cache full', trace - ); - return; - } - - // Intermediate path: render cached major roads while fetching the rest - if (cache.major) { - if (!await applyFlowThenRender( - cache.major, clamped, generation, altitude, 'Cache major', trace - )) return; - renderedSomething = true; - } else { - // Fetch major roads first (smaller payload, faster response) - _activeFetchAbort = new AbortController(); - console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`); - const majorData = await fetchRoads( - clamped.south, clamped.west, clamped.north, clamped.east, - { majorOnly: true, timeoutSec: 12, signal: _activeFetchAbort.signal }, - trace, - ); - // Discard stale response if a newer load was triggered while waiting - if (generation !== _loadGeneration) return; - cache.major = _parseRoads(majorData, trace); - if (!await applyFlowThenRender( - cache.major, clamped, generation, altitude, 'Loaded major', trace - )) return; - renderedSomething = true; - } - - // At higher altitude, major roads provide sufficient motion density - if (altitude > FAST_FETCH_ALTITUDE) return; - - // Detailed pass: fetch the full road graph (tertiary, residential, etc.) - _activeFetchAbort = new AbortController(); - console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`); - const fullData = await fetchRoads( - clamped.south, clamped.west, clamped.north, clamped.east, - { majorOnly: false, timeoutSec: 20, signal: _activeFetchAbort.signal }, - trace, - ); - if (generation !== _loadGeneration) return; - - cache.full = _parseRoads(fullData, trace); - if (!await applyFlowThenRender( - cache.full, clamped, generation, altitude, 'Loaded full', trace - )) return; - renderedSomething = true; - - } catch (e) { - if (e?.name === 'AbortError') return; - console.warn('[Data:Traffic] Fetch error:', e); - } finally { - if (generation === _loadGeneration) { - _fetching = false; - // Roll back the bounds commit if this load rendered nothing (e.g. the - // Overpass fetch failed). Leaving them committed would make the overlap - // gate skip the retry while the user sits still. Guarded on generation so - // a superseding load's commit is not clobbered. - if (!renderedSomething) { - _lastBounds = prevBounds; - _lastViewCenter = prevViewCenter; - } - } - _activeFetchAbort = null; - } -} - -// ─── Cleanup ─────────────────────────────────────────────── - -/** Remove all point primitives and reset dot/road arrays and counters. */ -function clearDots() { - if (_pointCollection) _pointCollection.removeAll(); - removeHeatLines(); - _dots = []; - _roads = []; - _count = 0; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - _closedRoads = 0; -} - -// ─── Data Layer Interface ────────────────────────────────── - -/** - * Traffic data layer — conforms to the God's Eye View data-layer interface. - * - * Lifecycle: init -> enable -> (animate loop + camera-driven loads) -> disable -> destroy. - * The layer is self-updating: no external tick is needed (`updateInterval: 0`). - * - * @type {Object} - */ -const trafficLayer = { - id: 'traffic', - name: 'Street Traffic', - icon: '🚗', - source: 'OpenStreetMap', - /** @type {number} Zero — layer is self-managed via camera listener + preRender */ - updateInterval: 0, - - /** - * One-time initialisation. Creates the PointPrimitiveCollection and adds it - * to the scene (hidden). The collection is never removed and re-added — only - * toggled via `.show` to avoid destroy-on-remove errors. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - init(viewer) { - _viewer = viewer; - _pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - // Add permanently — toggle with .show to avoid destroy-on-remove errors - viewer.scene.primitives.add(_pointCollection); - _pointCollection.show = false; - _dots = []; - _roads = []; - _count = 0; - _lastUpdate = null; - _lastBounds = null; - _fetching = false; - _loadGeneration = 0; - _densityScale = 1.0; - _speedScale = 1.0; - _lastViewCenter = null; - _flowCoveragePct = 0; - _flowError = null; - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingCurrentAnchor = null; - _trafficTimingSequence = 0; - _trafficTimingTracesCreated = 0; - _trafficTimingDroppedTraces = 0; - } - - // Preset-aware dot styling: adopt the active post-FX style (persisted - // style restore may run before layer registration, so read the dataset) - // and follow StyleManager's gev:style-change event thereafter. Guarded - // for non-browser contexts; bound once per page (init survives layer - // destroy/re-register). - if (typeof window !== 'undefined') { - _stylePreset = document?.documentElement?.dataset?.gevStyle || 'normal'; - if (!_styleListenerBound) { - window.addEventListener('gev:style-change', (e) => setStylePreset(e?.detail?.style)); - _styleListenerBound = true; - } - } - refreshBucketColors(); - console.log('[Data:Traffic] Initialized'); - }, - - /** - * Enable the traffic layer. Shows the point collection, subscribes to the - * preRender animation loop and camera-change events, and kicks off an - * initial viewport check. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - enable(viewer) { - _enabled = true; - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) - _lastAnimTime = 0; - _pointCollection.show = true; - - // One status check per session decides sim vs live-TomTom mode. - ensureFlowStatus(); - - _preRenderRemover = viewer.scene.preRender.addEventListener(animate); - if (TRAFFIC_TIMING_ENABLED) { - clearTrafficTimingEntries(); - _trafficTimingCurrentAnchor = null; - _trafficTimingPostRenderRemovers = new Set(); - _trafficTimingMoveEndRemover = viewer.camera.moveEnd.addEventListener(markTrafficTimingMoveEnd); - } - - // Subscribe to camera changes with a 5% movement threshold. Save the prior - // value so disable() can restore it — percentageChanged is a shared global - // on the camera and leaving it mutated affects every other camera.changed - // listener in the app. - viewer.camera.changed.addEventListener(onCameraChanged); - _prevPercentageChanged = viewer.camera.percentageChanged; - viewer.camera.percentageChanged = 0.05; - - // Kick off initial viewport check - onCameraChanged(); - - // Boot-order guard (field-test round 1: layer sat empty until the user - // moved): when the persisted layer state re-enables traffic during the - // intro flyTo, the initial check bails at high altitude — and a camera - // that then parks never re-fires camera.changed. Retry cheaply until the - // first load commits, then self-clear. Also acts as a safety kick if a - // failed first fetch left the viewport unloaded while parked. - clearInterval(_enableKickTimer); - _enableKickTimer = setInterval(() => { - if (!_enabled || _lastUpdate) { - clearInterval(_enableKickTimer); - _enableKickTimer = null; - return; - } - if (!_fetching) onCameraChanged(); - }, 1500); - }, - - /** - * Disable the traffic layer. Cancels pending fetches, clears all dots, - * unsubscribes from events, and hides the point collection. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - disable(viewer) { - _enabled = false; - releaseContinuousRender('traffic'); - clearTimeout(_fetchTimeout); - clearInterval(_enableKickTimer); - _enableKickTimer = null; - cancelActiveFetch(); - _loadGeneration++; - clearDots(); - _lastViewCenter = null; - // A stale outage from the last session would misreport a fresh enable — - // the next load re-derives feed health from real evidence. - _flowError = null; - - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingMoveEndRemover?.(); - _trafficTimingMoveEndRemover = null; - for (const remove of _trafficTimingPostRenderRemovers || []) remove(); - _trafficTimingPostRenderRemovers = null; - _trafficTimingCurrentAnchor = null; - } - - viewer.camera.changed.removeEventListener(onCameraChanged); - // Restore the camera's global percentageChanged so other layers/listeners - // keep their expected sensitivity. - if (_prevPercentageChanged != null) { - viewer.camera.percentageChanged = _prevPercentageChanged; - _prevPercentageChanged = null; - } - if (_pointCollection) _pointCollection.show = false; - }, - - /** - * No-op — traffic updates are entirely camera-driven, not timer-driven. - * @returns {Promise} - */ - async update() { - // No-op — updates are camera-driven - }, - - /** - * Update user-adjustable parameters (density and speed scaling). - * - * @param {Object} [params] - * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). - * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). - */ - setParams(params = {}) { - if (typeof params.densityScale === 'number') { - _densityScale = Math.max(0.2, Math.min(2.5, params.densityScale)); - } - if (typeof params.speedScale === 'number') { - _speedScale = Math.max(0.3, Math.min(3.0, params.speedScale)); - } - // Live-mode treatment of roads TomTom has no flow data for: - // 'sim' (default) keeps them as today's white ambient dots — colored = - // real data, white = simulation; 'hide' spawns nothing on them (strict - // data-integrity view). Owner-explorable; re-render applies on the next - // camera-driven load. - if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') { - _uncoveredMode = params.uncoveredRoads; - } - // Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior; - // live mode only, applies on the next camera-driven load like the - // uncoveredRoads param above. - if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) { - _jamViz = params.jamViz; - } - // Preset-aware dot styling kill switch (owner A/B): 'off' forces the - // shipped palette under every post-FX preset. Applies immediately via - // in-place restyle — no refetch — so A/B legs share identical dots. - if (params.presetDots === 'on' || params.presetDots === 'off') { - if (params.presetDots !== _presetDots) { - _presetDots = params.presetDots; - restyleDotsInPlace(); - } - } - }, - - /** - * Return the current user-adjustable parameters. - * @returns {{densityScale:number, speedScale:number}} - */ - getParams() { - return { - densityScale: _densityScale, - speedScale: _speedScale, - uncoveredRoads: _uncoveredMode, - jamViz: _jamViz, - presetDots: _presetDots, - }; - }, - - /** - * Return a sub-sampled list of active dot positions for detection overlays - * (e.g. CCTV bounding-box rendering). - * - * Uses a deterministic stride-based sampling so different seeds yield - * non-overlapping subsets without sorting or shuffling. - * - * @param {Object} [options] - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). - * @param {number} [options.seed] - Integer seed to offset the sampling start. - * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} - */ - getDetectableObjects(options = {}) { - if (!_enabled || _dots.length === 0) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _dots.length; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Stride-based sampling: step through dots evenly to get ~maxCount samples - const stride = Math.max(1, Math.ceil(_dots.length / maxCount)); - const start = seed % stride; - - const result = []; - for (let i = start; i < _dots.length; i += stride) { - const pos = _dots[i].point.position; - if (!pos) continue; - const entry = { - position: pos, - id: `VEH-${String(i).padStart(4, '0')}`, - type: 'VEH', - }; - // Live mode: the detection bracket carries the congestion signal — - // its canvas sits ABOVE the post-FX chain, so tier colors survive - // every preset (owner round 2: "bounding boxes do the heavy - // lifting"). Keyless mode sets no tier: contacts keep the stock - // 'vehicle' bracket and the keyless experience stays untouched. - if (_liveMode) { - const tier = trafficBucketTier(_dots[i].bucket || 'sim'); - if (tier) entry.tier = tier; - } - result.push(entry); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * Permanently tear down the layer. Disables it, removes the point collection - * from the scene, and clears the tile cache. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - */ - destroy(viewer) { - this.disable(viewer); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - removeHeatLines(); - _tileCache.clear(); - resetFlowTileCache(); - _count = 0; - _lastUpdate = null; - }, - - /** - * Return current layer statistics for UI status chips. - * `mode` is the CONFIGURED source — 'live' (a TomTom key is present) or - * 'sim' (keyless simulation, which the manager renders as a FALLBACK chip); - * `error` carries this instant's health, so a live-configured layer whose - * flow feed went down reads DEGRADED with the reason instead of a stale - * LIVE coverage number. `flowCoveragePct` is matched roads / roads with any - * flow candidates (0–100 int); `tilesFetched` counts flow-tile requests - * issued to the proxy this session (decode-cache hits excluded). - * @returns {{count:number, lastUpdate:number|null, loading:boolean, - * mode:'live'|'sim', error:string|null, flowCoveragePct:number, - * tilesFetched:number}} - */ - getStats() { - // Outstanding flow work counts as loading: the paint race can leave a - // TomTom request in flight after the roads have settled, and the shared - // loading batch has to stay open long enough to announce its failure. - const loading = _fetching || _flowPending > 0; - const feed = trafficFeedPresentation({ - liveMode: _liveMode, - fetching: loading, - flowError: _flowError, - coveragePct: _flowCoveragePct, - statusUnavailable: _flowStatusUnavailable, - }); - return { - count: _count, - lastUpdate: _lastUpdate, - loading, - mode: feed.mode, - error: feed.error, - flowCoveragePct: _flowCoveragePct, - tilesFetched: getFlowSessionStats().tilesFetched, - ...(TRAFFIC_TIMING_ENABLED ? { trafficTiming: getTrafficTimingDiagnostics() } : {}), - // Per-bucket rendered-dot counts (sim = white ambient). Drives the - // qa-traffic color assertions and the sync-chip mode label below. - flowBuckets: { ..._bucketCounts }, - closedRoads: _closedRoads, - // Jam-viz prototype diagnostics (additive — harness contract untouched). - heatLines: _heatLineCount, - jamViz: _jamViz, - // Preset-styling diagnostics (additive): active style + profile. - stylePreset: _stylePreset, - styleProfile: _presetDots === 'on' ? trafficStyleProfile(_stylePreset) : 'normal', - // Sync-chip text: shown while busy, and flashed on its own for 1.5 s - // after each completed load (ui.js _updateTrafficSyncChip semantics). - // The settled flash carries NO progress number beside it — this label's - // coverage figure is the chip's only percentage — so a label that ends - // in one had better be the honest one. This is also where LIVE vs - // SIMULATED mode is surfaced, and it must never imply a live feed the - // layer does not have. - loadingLabel: feed.loadingLabel, - }; - }, -}; - -export default trafficLayer; +import { createTrafficSource } from '../layers/traffic/source.js'; +import { createTrafficLayer } from '../layers/traffic/index.js'; +import * as credits from './dataCredits.js'; +import * as render from '../renderGovernor.js'; + +const layer = createTrafficLayer({ + source: createTrafficSource(), + services: { credits, render }, +}); +export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; +export const deriveTrafficFlowError = layer.deriveTrafficFlowError; +export const trafficFeedPresentation = layer.trafficFeedPresentation; +export default layer; diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index eb354cda4..1b87deb5b 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { getTrafficTimingDiagnostics } from './traffic.js'; -const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8'); +const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, ''); function functionBody(name) { const declaration = `function ${name}(`; @@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- plugins: [{ name: 'traffic-timing-test-hooks', transform(code, id) { - if (!id.endsWith('/src/data/traffic.js')) return null; - return `${code}\nexport const __trafficTimingTestHooks = {\n` + - ` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`; + if (!id.endsWith('/src/layers/traffic/index.js')) return null; + return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },'); }, }], }); @@ -240,7 +239,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- }); trafficLayer.enable(viewer); assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1); - assert.equal(moveEnd.listenerCount(), 1, 'marksInstalled must represent a live listener'); + assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check'); assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0); assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0); @@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- // and moveEnd arrives only after cameraEventWaitTime (~500 ms). setLongitude(-97.72); changed.raise(); - const anchorA = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor(); const anchorMarkA = performance.getEntriesByType('mark').find((entry) => ( entry.detail?.segment === 'last-camera-change' && entry.detail?.interactionId === anchorA.interactionId @@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- traceObjectsCreated: 1, uncorrelatedTracesDropped: 0, }); - assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null); + assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null); const fetchFromA = performance.getEntriesByType('measure').find((entry) => ( entry.detail?.segment === 'last-camera-change-to-fetch-start' && entry.detail?.interactionId === anchorA.interactionId @@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- // and must count one drop without consuming C's current anchor. setLongitude(-97.70); changed.raise(); - const anchorB = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor(); const [timerBId, timerB] = pendingDebounce(); setLongitude(-97.68); changed.raise(); - const anchorC = traffic.__trafficTimingTestHooks.currentAnchor(); + const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor(); assert.notEqual(anchorC.interactionId, anchorB.interactionId); assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B'); await timerB.callback(); assert.equal( - traffic.__trafficTimingTestHooks.currentAnchor().interactionId, + traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId, anchorC.interactionId, 'a stale callback must not consume the newer C anchor', ); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index c45446183..002959ba9 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.deepEqual( [...EXCLUSIVE_SURFACE_CLASSES].sort(), @@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', ( test('an overlay with NO class to watch still disarms the launcher', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // The repro, pinned as the stacking it actually is: the attribution lightbox // is full-screen ABOVE the card and announces itself with nothing. The card @@ -263,14 +264,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => { }); test('one ESC does one thing — the radio disclosure stops the launcher outright', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher // dismissed itself off the same key. The earlier listener is the only one that // can stop the later one — and only the immediate form does it. - const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock")); + const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||")); const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); @@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', () test('the scroll fade only appears when the list really overflows', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // A fade on a card where all five tiles fit promises a sixth mission that does // not exist, which is worse than no affordance at all. assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/); @@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', () test('markup, startup ordering and accessibility remain pinned', () => { const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4); @@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt }); test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8'); // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON // now, so it was opening to offer controls for things already happening — // while competing with the mission card for the one first impression there is. diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index 3e6cfa541..5244cffdc 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,8 +1,9 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); function ruleBody(selector) { const start = css.indexOf(selector); diff --git a/src/layers/aircraft/classification.js b/src/layers/aircraft/classification.js new file mode 100644 index 000000000..09f136912 --- /dev/null +++ b/src/layers/aircraft/classification.js @@ -0,0 +1,167 @@ +/** Instance-owned military identity and active-layer classification. + * Both aircraft layers share the same application-supplied registry. */ +export function createMilitaryRegistry({ source, now = Date.now } = {}) { + const MIL_POLL_INTERVAL_MS = 60000; + + /** @type {Set} Lowercase ICAO24 hexes known to be military. */ + const _milIcaos = new Set(); + /** @type {boolean} True while the dedicated military layer is enabled. */ + let _militaryLayerActive = false; + /** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ + const _activeChangeListeners = new Set(); + /** @type {number} Epoch ms of the last registry refresh (any source). */ + let _lastRefreshMs = 0; + /** @type {boolean} A self-poll fetch is in flight. */ + let _polling = false; + let currentSource = null; + let lifetime = null; + let releaseSource = null; + + /** Replace the classification source and cancel its outstanding work. */ + function configureSource(nextSource, { signal } = {}) { + if (typeof nextSource?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + releaseSource?.(); + const controller = new AbortController(); + lifetime = controller; + currentSource = nextSource; + _milIcaos.clear(); + _lastRefreshMs = 0; + _polling = false; + const release = () => { + controller.abort(); + signal?.removeEventListener('abort', release); + if (lifetime !== controller) return; + currentSource = null; + lifetime = null; + releaseSource = null; + _milIcaos.clear(); + _lastRefreshMs = 0; + _polling = false; + }; + releaseSource = release; + if (signal?.aborted) release(); + else signal?.addEventListener('abort', release, { once: true }); + return release; + } + + /** + * True when the dedicated military layer currently renders these aircraft + * (the flights layer should suppress duplicates rather than restyle them). + * @returns {boolean} + */ + function isMilitaryLayerActive() { + return _militaryLayerActive; + } + + /** + * Marks the dedicated military layer enabled/disabled. On a TRANSITION + * (value actually changed) the registered change listeners fire so the + * flights layer can reconcile its duplicates immediately instead of waiting + * out its 30 s poll (pre-ship audit M2). + * @param {boolean} active - Whether the military layer renders. + * @returns {void} + */ + function setMilitaryLayerActive(active) { + const next = !!active; + if (next === _militaryLayerActive) return; + _militaryLayerActive = next; + for (const listener of _activeChangeListeners) { + try { + listener(next); + } catch { + // a broken listener must never break the layer toggle + } + } + } + + /** + * Subscribes to military-layer active-state transitions (fired only when the + * value changes, AFTER the new state is committed). + * @param {(active: boolean) => void} listener - Change callback. + * @returns {() => void} Unsubscribe function. + */ + function onMilitaryLayerActiveChange(listener) { + if (typeof listener !== 'function') return () => {}; + _activeChangeListeners.add(listener); + return () => _activeChangeListeners.delete(listener); + } + + /** + * Replaces/extends the known-military set from a fresh poll. + * Adds only — transient dropouts from one poll must not declassify an + * aircraft mid-session (the set stays small: a few hundred hexes). + * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. + * @returns {void} + */ + function registerMilitaryIcaos(icaos) { + for (const icao of icaos || []) { + const hex = String(icao || '') + .trim() + .toLowerCase(); + if (hex) _milIcaos.add(hex); + } + _lastRefreshMs = now(); + } + + /** + * Whether an aircraft is known military. + * @param {string} icao24 - ICAO24 hex (any case). + * @returns {boolean} + */ + function isMilitaryIcao(icao24) { + return _milIcaos.has(String(icao24 || '').toLowerCase()); + } + + /** Refresh known identities while the dedicated layer is disabled. + * A failed or cancelled request retains the current set and never swaps sources. */ + async function refreshMilitaryRegistryIfStale() { + if ( + !currentSource || + !lifetime || + lifetime.signal.aborted || + _militaryLayerActive + ) + return; + if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return; + const owner = lifetime; + const requestSource = currentSource; + const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]); + _polling = true; + try { + const ids = + typeof requestSource.getIdentities === 'function' + ? await requestSource.getIdentities({}, { signal }) + : (await requestSource.getSnapshot({}, { signal })).records.map( + (record) => record.id, + ); + signal.throwIfAborted(); + if (lifetime !== owner) return; + if (!Array.isArray(ids)) + throw new TypeError('Expected aircraft identities'); + registerMilitaryIcaos(ids); + } catch { + // Keep the existing classification through source outages. + } finally { + if (lifetime === owner) _polling = false; + } + } + function dispose() { + releaseSource?.(); + _milIcaos.clear(); + _lastRefreshMs = 0; + _activeChangeListeners.clear(); + _militaryLayerActive = false; + } + if (source) configureSource(source); + return { + configureSource, + dispose, + isMilitaryLayerActive, + setMilitaryLayerActive, + onMilitaryLayerActiveChange, + registerMilitaryIcaos, + isMilitaryIcao, + refreshMilitaryRegistryIfStale, + }; +} diff --git a/src/layers/aircraft/classification.test.mjs b/src/layers/aircraft/classification.test.mjs new file mode 100644 index 000000000..c0416b466 --- /dev/null +++ b/src/layers/aircraft/classification.test.mjs @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMilitaryRegistry } from './classification.js'; + +test('classification instances have independent identities, listeners and active states', () => { + let requests = 0; + const source = { + getSnapshot() { + requests++; + }, + }; + const first = createMilitaryRegistry({ source }); + const second = createMilitaryRegistry({ source }); + let firstEvents = 0; + let secondEvents = 0; + first.onMilitaryLayerActiveChange(() => firstEvents++); + second.onMilitaryLayerActiveChange(() => secondEvents++); + first.registerMilitaryIcaos([' ABC123 ']); + first.setMilitaryLayerActive(true); + assert.equal(first.isMilitaryIcao('abc123'), true); + assert.equal(second.isMilitaryIcao('abc123'), false); + assert.equal(firstEvents, 1); + assert.equal(secondEvents, 0); + assert.equal(second.isMilitaryLayerActive(), false); + assert.equal(requests, 0); + first.dispose(); + second.dispose(); +}); + +test('classification uses normalized identities and respects its polling interval and active layer', async () => { + let time = 120000; + let calls = 0; + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + return { records: [{ id: 'abc123' }] }; + }, + }, + }); + await registry.refreshMilitaryRegistryIfStale(); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 1); + assert.equal(registry.isMilitaryIcao('abc123'), true); + time += 60000; + registry.setMilitaryLayerActive(true); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 1); + registry.setMilitaryLayerActive(false); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(calls, 2); + registry.dispose(); +}); + +test('source replacement rejects an old response and disposal cancels the current request', async () => { + let resolveOld; + let resolveNew; + let oldSignal; + let newSignal; + const registry = createMilitaryRegistry({ + source: { + getSnapshot(query, { signal }) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + }, + }, + }); + const old = registry.refreshMilitaryRegistryIfStale(); + const application = new AbortController(); + registry.configureSource( + { + getSnapshot(query, { signal }) { + newSignal = signal; + return new Promise((resolve) => { + resolveNew = resolve; + }); + }, + }, + { signal: application.signal }, + ); + const current = registry.refreshMilitaryRegistryIfStale(); + resolveOld({ records: [{ id: 'abc123' }] }); + await old; + application.abort(); + resolveNew({ records: [{ id: 'def456' }] }); + await current; + assert.equal(oldSignal.aborted, true); + assert.equal(newSignal.aborted, true); + assert.equal(registry.isMilitaryIcao('abc123'), false); + assert.equal(registry.isMilitaryIcao('def456'), false); + registry.dispose(); +}); + +test('a failed classification source preserves known identities and makes no fallback request', async () => { + let time = 120000; + let calls = 0; + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + throw new Error('fixture source unavailable'); + }, + }, + }); + registry.registerMilitaryIcaos(['abc123']); + time += 60000; + await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale()); + assert.equal(calls, 1); + assert.equal(registry.isMilitaryIcao('abc123'), true); + registry.dispose(); + assert.equal(registry.isMilitaryIcao('abc123'), false); +}); + +test('disposing a registry fed directly by its layer also clears classification', () => { + const registry = createMilitaryRegistry(); + registry.registerMilitaryIcaos(['abc123']); + registry.dispose(); + assert.equal(registry.isMilitaryIcao('abc123'), false); +}); + +test('classification prefers an explicit identity capability over positioned observations', async () => { + const registry = createMilitaryRegistry({ + source: { + getSnapshot() { + assert.fail( + 'position snapshots must not replace an available identity capability', + ); + }, + async getIdentities() { + return ['abc123']; + }, + }, + }); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + registry.dispose(); +}); diff --git a/src/layers/aircraft/index.js b/src/layers/aircraft/index.js new file mode 100644 index 000000000..b9b7315f7 --- /dev/null +++ b/src/layers/aircraft/index.js @@ -0,0 +1,17 @@ +/** Common aircraft calculations and explicitly constructed classification. */ +export { createMilitaryRegistry } from './classification.js'; +export * from '../../data/aircraftClass.js'; +export * from '../../data/aircraftIcons.js'; +export * from '../../data/aircraftMeta.js'; +export * from '../../data/aircraftNearbyPolicy.js'; +export * from '../../data/cockpitAirLod.js'; +export * from '../../data/cockpitContactDot.js'; +export * from '../../data/contactMatch.js'; +export * from '../../data/iconOrientation.js'; +export * from '../../data/layerState.js'; +export * from '../../data/modelVisualAnchor.js'; +export * from '../../data/motionModel.js'; +export * from '../../data/renderAltitude.js'; +export * from '../../data/routePlausible.js'; +export * from '../../data/trackedModelRegime.js'; +export * from '../../data/trackingClickGesture.js'; diff --git a/src/layers/awareness/controls.js b/src/layers/awareness/controls.js new file mode 100644 index 000000000..ca8fd7781 --- /dev/null +++ b/src/layers/awareness/controls.js @@ -0,0 +1,126 @@ +import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + const aisLiveVesselsLayer = services.vessels; + + const methods = { + id: 'military-awareness', + + name: 'Global Context', + + icon: '◎', + + source: 'Open-source proximity context', + + // Context is entered from its dedicated right rail, not as a raw layer. + showInTogglePanel: false, + + updateInterval: 0, + + statsRefreshInterval: 1000, + + attachDataManager(dataManager) { + layerState.dataManager = dataManager; + }, + + setParams(params = {}) { + if (typeof params.passive !== 'boolean') return; + const wasPassive = layerState.passive; + layerState.passive = params.passive; + if (layerState.enabled && wasPassive && !layerState.passive) + parts.dependencies.activateOperationalContext(); + }, + + /** @returns {{ passive: boolean }} Current runtime parameters. */ + getParams() { + return { passive: layerState.passive }; + }, + + getStats() { + return { + count: layerState.results ? 1 : 0, + lastUpdate: layerState.results?.evaluatedAt || null, + stale: false, + error: null, + status: layerState.enabled ? 'ready' : 'idle', + }; + }, + + /** Return the latest read-only context result for compact HUD consumers. */ + getContextSnapshot() { + if (!layerState.enabled || !layerState.subject) return null; + if (!layerState.results) { + return parts.model.buildAwarenessContextSnapshot( + { + subject: { ...layerState.subject }, + evaluatedAt: null, + radiusM: AWARENESS_RADIUS_M, + cohorts: [], + }, + parts.model.navigationState(), + { + subjectPresent: !layerState.subjectMissing, + }, + ); + } + return parts.model.buildAwarenessContextSnapshot( + layerState.results, + parts.model.navigationState(), + { + subjectPresent: !layerState.subjectMissing, + }, + ); + }, + + /** + * Release Contact-owned camera tracking without discarding the selected + * subject. Reset-to-globe uses this route so the normal Context FOCUS action + * can explicitly return to the same contact, while delayed activation work + * cannot silently reclaim the camera after the reset. + * @returns {boolean} Whether a Contact subject remains selected. + */ + releaseCameraOwnership({ + preserveVesselSelection = false, + origin = 'programmatic', + } = {}) { + ++layerState.activationId; + layerState.autoFocusAttempted = true; + layerState.autoFocusRetryPending = false; + + const preservedSelectionKey = + parts.subject.subjectKey(layerState.subject) || 'camera-release'; + layerState.pendingSelectionKey = preservedSelectionKey; + try { + flightsLayer.stopTracking?.({ origin }); + militaryFlightsLayer.stopTracking?.({ origin }); + if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.(); + } finally { + if (layerState.pendingSelectionKey === preservedSelectionKey) { + layerState.pendingSelectionKey = null; + } + } + return Boolean(layerState.subject); + }, + + navigatePrevious(options = {}) { + return parts.history.navigateHistory(-1, options); + }, + + focusCurrent(options = {}) { + return parts.focus.focusCurrentSubject(options); + }, + + navigateNext(options = {}) { + return parts.history.navigateHistory(1, options); + }, + + /** Select a context target through its owning layer's established tracker. */ + focusTarget(layerId, id, options = {}) { + return parts.focus.requestFocus(layerId, id, false, options); + }, + }; + + return { methods }; +} diff --git a/src/layers/awareness/dependencies.js b/src/layers/awareness/dependencies.js new file mode 100644 index 000000000..15a23e455 --- /dev/null +++ b/src/layers/awareness/dependencies.js @@ -0,0 +1,118 @@ +import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js'; + +export function createDependencies({ + state: layerState, + services, + parts, + source, +}) { + function refreshAfterDeferredDependency(activationId) { + if ( + !layerState.enabled || + layerState.passive || + activationId !== layerState.activationId + ) + return; + if (layerState.subject) { + parts.subject.refreshSelectedSubject(true); + return; + } + if (layerState.autoFocusRetryPending) { + layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget(); + } + } + + async function enableDependencies(activationId = layerState.activationId) { + const aircraftPending = []; + for (const layerId of DEPENDENCIES) { + if (!layerState.enabled) return; + if (!layerState.dataManager) continue; + const alreadyEnabled = + layerState.dataManager.isEffectivelyEnabled?.(layerId) ?? + layerState.dataManager.isEnabled(layerId); + if (alreadyEnabled) continue; + layerState.ownedDependencies.add(layerId); + // Each layer serializes its own lifecycle internally. Start them together + // so a slow or unavailable live feed cannot prevent the remaining context + // sources from turning on. + let pending; + try { + // Register the ON intent synchronously. If Contacts is turned OFF in the + // same turn, DataLayerManager then sees OFF as the newest intent and a + // stale deferred ON cannot resurrect a released dependency. + pending = Promise.resolve( + layerState.dataManager.setEnabled(layerId, true), + ); + } catch (error) { + pending = Promise.reject(error); + } + if (AIRCRAFT_DEPENDENCIES.includes(layerId)) { + aircraftPending.push(pending); + } else { + // Vessels and mapped installations enrich an already usable aircraft + // context. Their settlement must never hold the Contacts transaction or + // Cockpit gate open, and the activation token prevents a late result + // from repainting or refocusing a newer/closed session. + pending.then( + () => refreshAfterDeferredDependency(activationId), + () => refreshAfterDeferredDependency(activationId), + ); + } + } + await Promise.allSettled(aircraftPending); + } + + function releaseOwnedDependencies(releaseActivationId) { + const owned = [...layerState.ownedDependencies]; + layerState.ownedDependencies.clear(); + if (!layerState.dataManager || !owned.length) return Promise.resolve(); + return Promise.allSettled( + owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)), + ).then(() => { + if ( + layerState.enabled && + layerState.activationId !== releaseActivationId + ) { + return enableDependencies(layerState.activationId); + } + return null; + }); + } + + function activateOperationalContext() { + const activationId = ++layerState.activationId; + layerState.autoFocusRetryPending = false; + const initialTrackedSubject = parts.subject.currentTrackedFlightSubject(); + if (initialTrackedSubject) + parts.subject.selectSubject(initialTrackedSubject); + return enableDependencies(activationId) + .then(() => { + if ( + !layerState.enabled || + layerState.passive || + activationId !== layerState.activationId + ) + return; + const trackedSubject = parts.subject.currentTrackedFlightSubject(); + if (trackedSubject) { + parts.subject.selectSubject(trackedSubject); + return; + } + if (layerState.subject) { + parts.subject.selectSubject(layerState.subject); + return; + } + if (layerState.autoFocusAttempted) return; + layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget(); + }) + .catch((error) => + console.warn('[Global Context] dependency enable failed', error), + ); + } + return { + refreshAfterDeferredDependency, + enableDependencies, + releaseOwnedDependencies, + activateOperationalContext, + }; +} diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js new file mode 100644 index 000000000..e7681b14b --- /dev/null +++ b/src/layers/awareness/focus.js @@ -0,0 +1,198 @@ +import * as Cesium from 'cesium'; +import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; + +export function createFocus({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + const { announceNavigationAuthority } = services.navigation; + const militaryInstallationsLayer = services.installations; + const aisLiveVesselsLayer = services.vessels; + + /** Focus a nearby example through the source layer that owns its selection. */ + + function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) { + if (!layerId || !id) return false; + if (layerId === 'flights') { + return ( + flightsLayer.refocusTrackedById?.(id, { origin }) || + flightsLayer.trackById(id, { origin }) + ); + } + if (layerId === 'military') { + return ( + militaryFlightsLayer.refocusTrackedById?.(id, { origin }) || + militaryFlightsLayer.trackById(id, { origin }) + ); + } + if (layerId === 'military-installations') { + if (!parts.model.contextTargetFlyToAllowed(layerId)) + return selectKnownContextTarget(layerId, id); + // focusById flies the camera; it never assigns a tracked entity, so the + // stamp has to be announced here. + announceNavigationAuthority('context-installation-focus'); + return militaryInstallationsLayer.focusById(id); + } + if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id)) + return false; + + const vessel = aisLiveVesselsLayer + .getAllPositions(12000) + .find((item) => String(item.id) === String(id)); + if ( + !parts.model.contextTargetFlyToAllowed(layerId) || + !vessel?.position || + !layerState.viewer + ) + return true; + announceNavigationAuthority('context-vessel-focus'); + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), + { duration: 1.4 }, + ); + return true; + } + + function selectKnownContextTarget(layerId, id) { + const key = `${layerId}:${id}`; + const known = [ + layerState.subject, + ...layerState.navigationHistory, + ...(layerState.results?.cohorts || []).flatMap((cohort) => + cohort.id === layerId + ? cohort.summary.navigationNearest || cohort.summary.nearest || [] + : [], + ), + ].find( + (item) => + item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key, + ); + if (!known?.position) return false; + if (parts.model.contextTargetFlyToAllowed(layerId)) + parts.model.releaseAircraftTracking(); + parts.subject.selectSubject({ + ...known, + layerId, + id: String(known.id || known.mmsi), + label: known.label || known.name || known.callsign || String(id), + position: Cesium.Cartesian3.clone(known.position), + }); + return true; + } + + function requestFocus( + layerId, + id, + preserveHistory = false, + { origin = 'programmatic' } = {}, + ) { + const key = `${layerId}:${id}`; + layerState.pendingSelectionKey = key; + if (preserveHistory) layerState.suppressedHistoryKey = key; + const focused = focusNearbyTarget(layerId, id, { origin }); + layerState.pendingSelectionKey = null; + layerState.suppressedHistoryKey = null; + return focused; + } + + function focusSubject(subject, preserveHistory = false, options = {}) { + if (!subject) return false; + return requestFocus(subject.layerId, subject.id, preserveHistory, options); + } + + function focusCurrentSubject(options = {}) { + return focusSubject(layerState.subject, true, options); + } + + /** + * Pick the observed candidate closest to the current view. This is intentionally + * a navigation preference, not a risk, capability, or affiliation calculation. + * @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates. + * @returns {Object|null} The best currently observable candidate. + */ + + function closestToCurrentView(candidates) { + if (!Array.isArray(candidates) || !candidates.length) return null; + const cameraPosition = layerState.viewer?.camera?.positionWC; + if (!cameraPosition) + return candidates.find((candidate) => candidate?.position) || null; + + let closest = null; + let closestDistance = Infinity; + for (const candidate of candidates) { + if (!candidate?.position) continue; + const distance = Cesium.Cartesian3.distance( + cameraPosition, + candidate.position, + ); + if (Number.isFinite(distance) && distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + return closest; + } + + /** + * Focus a single observed, context-priority target after activation. Civilian + * and military aircraft compete in one nearest-to-view pool; military is + * concatenated first so exact distance ties resolve to military under the + * strict comparison. An AIS vessel is only a fallback and is never inferred + * to be military. There is deliberately no distance cap. + * @returns {boolean} Whether a target was selected and framed. + */ + + function focusAttentionTarget() { + if ( + !layerState.enabled || + layerState.subject || + !layerState.viewer || + layerState.autoFocusAttempted + ) + return false; + + const nearestFlight = closestToCurrentView([ + ...militaryFlightsLayer + .getAllPositions(800) + .map((item) => ({ ...item, layerId: 'military' })), + ...flightsLayer + .getAllPositions(1000) + .map((item) => ({ ...item, layerId: 'flights' })), + ]); + if (nearestFlight) { + const layer = + nearestFlight.layerId === 'military' + ? militaryFlightsLayer + : flightsLayer; + if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) { + layerState.autoFocusAttempted = true; + return true; + } + } + + const vessel = closestToCurrentView( + aisLiveVesselsLayer.getAllPositions(12000), + ); + if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false; + + layerState.autoFocusAttempted = true; + if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true; + + announceNavigationAuthority('context-vessel-autofocus', { + cancelPendingSelection: false, + }); + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), + { duration: 1.6 }, + ); + return true; + } + return { + focusNearbyTarget, + selectKnownContextTarget, + requestFocus, + focusSubject, + focusCurrentSubject, + closestToCurrentView, + focusAttentionTarget, + }; +} diff --git a/src/layers/awareness/history.js b/src/layers/awareness/history.js new file mode 100644 index 000000000..e8e05505a --- /dev/null +++ b/src/layers/awareness/history.js @@ -0,0 +1,239 @@ +export function createHistory({ state: layerState, services, parts, source }) { + const militaryFlightsLayer = services.military; + const flightsLayer = services.flights; + + /** + * Walk navigation history for the next entry that can still be focused. + * + * A history entry only holds a SNAPSHOT of a contact; the live contact behind + * it can be evicted from its layer at any time (the cull that drives + * CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the + * only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead + * entry strands the operator there for the rest of the session, because the + * index never advances past it. + * + * `state.navigationIndex` moves only on a successful focus: a caller that + * falls through to the live cohort must still splice forward history from the + * position the operator actually occupies. + * @param {number} direction Walk direction (negative = previous). + * @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters. + * @returns {boolean} Whether a history entry was focused. + */ + + function focusCompatibleHistory( + direction, + { targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' }, + ) { + let cursor = layerState.navigationIndex; + for (;;) { + const historyIndex = findCompatibleHistoryIndex( + layerState.navigationHistory, + cursor, + direction, + { + targetLayer, + aircraftClass, + aircraftOnly, + resolveItem: historySubjectItem, + }, + ); + if (historyIndex === -1) return false; + if ( + parts.focus.focusSubject( + layerState.navigationHistory[historyIndex], + true, + { origin }, + ) + ) { + layerState.navigationIndex = historyIndex; + return true; + } + // Contact evicted since it entered history — step over it. The scan is + // strictly monotonic, so this terminates at the end of history. + cursor = historyIndex; + } + } + + function navigateHistory( + direction, + { + targetLayer = null, + aircraftClass = null, + aircraftOnly = false, + origin = 'programmatic', + } = {}, + ) { + if ( + focusCompatibleHistory(direction, { + targetLayer, + aircraftClass, + aircraftOnly, + origin, + }) + ) + return true; + + if (direction < 0) return false; + + let targets = parts.navigation.selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass, aircraftOnly }, + ); + const targetHistoryFullyVisited = + targets.length > 0 && targets.every((target) => target.visited); + const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown(); + if ( + targetHistoryFullyVisited && + parts.navigation.isFlightLayer(layerState.subject?.layerId) && + !subjectCohortUnknown + ) { + const expanded = parts.navigation.findExpandedFlightTarget({ + targetLayer, + aircraftClass, + }); + if (expanded) { + return parts.focus.requestFocus( + expanded.candidate.layerId, + expanded.candidate.id, + false, + { origin }, + ); + } + } + if (targetHistoryFullyVisited) { + parts.navigation.resetNavigationVisitedCycle(); + targets = parts.navigation.selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass, aircraftOnly }, + ); + } + const hasNearbyFlight = targets.some((target) => + parts.navigation.isFlightLayer(target.layerId), + ); + if ( + parts.navigation.isFlightLayer(layerState.subject?.layerId) && + !hasNearbyFlight && + !subjectCohortUnknown + ) { + const expanded = parts.navigation.findExpandedFlightTarget({ + targetLayer, + aircraftClass, + }); + if (expanded) { + return parts.focus.requestFocus( + expanded.candidate.layerId, + expanded.candidate.id, + false, + { origin }, + ); + } + } + if (parts.subject.subjectCohortFeedUnknown()) return false; + const candidate = targets[0]; + if (!candidate) return false; + return parts.focus.requestFocus(candidate.layerId, candidate.id, false, { + origin, + }); + } + + function historySubjectItem(subject) { + if (!subject) return null; + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === subject.layerId, + ); + const items = + cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; + return ( + items.find( + (item) => + String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id), + ) || subject + ); + } + + /** Retain filter metadata when a production subject enters navigation history. */ + + function historySubjectSnapshot(subject, sourceItem = null) { + const aircraftClass = + sourceItem?.aircraftClass || + sourceItem?.klass || + sourceItem?.type || + subject?.aircraftClass || + null; + return aircraftClass ? { ...subject, aircraftClass } : { ...subject }; + } + + function historySourceItem(subject) { + if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position) + return null; + // The current sweep already holds this contact's record. Reusing it keeps + // selection O(1) against the cohort instead of paying a fresh full-layer + // proximity scan per selection — the burst cost when NEXT walks a cohort. + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === subject.layerId, + ); + const items = + cohort?.summary?.navigationNearest || cohort?.summary?.nearest || []; + const fromSweep = items.find( + (item) => String(item?.icao24 || item?.id) === String(subject.id), + ); + if (fromSweep) return fromSweep; + const layer = + subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer; + return ( + layer + .getNearby(subject.position, 1000, 25, { includeHidden: true }) + .find( + (item) => String(item?.icao24 || item?.id) === String(subject.id), + ) || null + ); + } + + /** Find the next history entry compatible with requested navigation filters. */ + + function findCompatibleHistoryIndex( + history, + startIndex, + direction, + { + targetLayer = null, + aircraftClass = null, + aircraftOnly = false, + resolveItem = (subject) => subject, + } = {}, + ) { + const step = direction < 0 ? -1 : 1; + for ( + let index = startIndex + step; + index >= 0 && index < history.length; + index += step + ) { + const subject = history[index]; + if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId)) + continue; + if (targetLayer && subject?.layerId !== targetLayer) continue; + if ( + aircraftClass && + !parts.navigation.aircraftClassMatchesFilter( + resolveItem(subject), + aircraftClass, + ) + ) + continue; + return index; + } + return -1; + } + return { + focusCompatibleHistory, + navigateHistory, + historySubjectItem, + historySubjectSnapshot, + historySourceItem, + findCompatibleHistoryIndex, + }; +} diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js new file mode 100644 index 000000000..cd01e0cfc --- /dev/null +++ b/src/layers/awareness/index.js @@ -0,0 +1,66 @@ +import { createModel } from './model.js'; +import { createSubject } from './subject.js'; +import { createPanel } from './panel.js'; +import { createQueries } from './queries.js'; +import { createNavigation } from './navigation.js'; +import { createFocus } from './focus.js'; +import { createTesting } from './testing.js'; +import { createHistory } from './history.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; +import { createDependencies } from './dependencies.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createAwarenessLayer({ services, source }) { + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.subject = createSubject(context); + parts.panel = createPanel(context); + parts.queries = createQueries(context); + parts.navigation = createNavigation(context); + parts.focus = createFocus(context); + parts.testing = createTesting(context); + parts.history = createHistory(context); + parts.rendering = createRendering(context); + parts.lifecycle = createLifecycle(context); + parts.dependencies = createDependencies(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed, + awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject, + awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs, + awarenessRefreshDecision: parts.model.awarenessRefreshDecision, + awarenessClearIsEviction: parts.model.awarenessClearIsEviction, + awarenessRefreshRequired: parts.model.awarenessRefreshRequired, + summarizeInstallationViewport: + parts.queries.summarizeInstallationViewport, + contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot, + buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot, + collectAircraftProximityWindow: + parts.queries.collectAircraftProximityWindow, + _getAwarenessNavigationStateForTest: + parts.testing._getAwarenessNavigationStateForTest, + canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext, + historySubjectSnapshot: parts.history.historySubjectSnapshot, + findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex, + awarenessPanelControlKey: parts.panel.awarenessPanelControlKey, + captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus, + restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus, + awarenessResultsAreLive: parts.panel.awarenessResultsAreLive, + awarenessNeedsContinuousRender: + parts.model.awarenessNeedsContinuousRender, + }, + ); +} +export { AWARENESS_QUERY_LIMIT } from './policy.js'; diff --git a/src/layers/awareness/ingestion.js b/src/layers/awareness/ingestion.js new file mode 100644 index 000000000..68d0db43f --- /dev/null +++ b/src/layers/awareness/ingestion.js @@ -0,0 +1,15 @@ +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + update() { + parts.subject.refreshSelectedSubject(); + return Promise.resolve(); + }, + }; + + return { methods }; +} diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js new file mode 100644 index 000000000..ff8e30ec0 --- /dev/null +++ b/src/layers/awareness/lifecycle.js @@ -0,0 +1,263 @@ +import { cameraPoseSignature } from '../../data/iconOrientation.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + /** + * Quantized camera-pose signature, or '' when the camera cannot report a full + * pose yet. An unknown pose reads as PARKED, so a camera that is still coming + * up can never be mistaken for continuous movement. + * @param {Cesium.Camera|null|undefined} camera + * @returns {string} + */ + + function cameraMotionSignature(camera) { + if (!camera?.positionWC || !Number.isFinite(camera.heading)) return ''; + return cameraPoseSignature(camera); + } + + /** + * Take or drop the continuous-render hold to match the current need. Idempotent + * (the governor is identity-keyed), and self-healing: the release decision is + * made on a frame the hold itself guaranteed, so a hold can never strand the + * scene in continuous mode. + * @returns {void} + */ + + function syncAwarenessRenderHold() { + if (layerState.enabled && parts.model.awarenessNeedsContinuousRender()) + holdContinuousRender('military-awareness'); + else releaseContinuousRender('military-awareness'); + } + + function attachRuntimeListeners() { + if (layerState.runtimeListenersAttached || !layerState.viewer) return; + window.addEventListener( + 'gev:awareness-subject-selected', + layerState.subjectListener, + ); + window.addEventListener('gev:entity-selected', layerState.contextListener); + window.addEventListener( + 'gev:entity-selection-cleared', + layerState.clearListener, + ); + window.addEventListener( + 'gev:awareness-subject-cleared', + layerState.subjectClearListener, + ); + layerState.preRenderRemover = + layerState.viewer.scene.preRender.addEventListener(() => { + if (!layerState.enabled) return; + const now = Date.now(); + // "Did the camera move" uses the SAME quantized pose signature the fleet + // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than + // camera.changed, whose granularity is globally degraded by other layers + // mutating camera.percentageChanged. + const poseSig = cameraMotionSignature(layerState.viewer.camera); + if (poseSig !== layerState.lastCameraPoseSig) { + layerState.lastCameraPoseSig = poseSig; + layerState.lastCameraPoseChangeMs = now; + } + const decision = parts.model.awarenessRefreshDecision({ + nowMs: now, + lastRefreshMs: layerState.lastSubjectRefreshMs, + lastPoseChangeMs: layerState.lastCameraPoseChangeMs, + wasMoving: layerState.cameraMoving, + }); + layerState.cameraMoving = decision.moving; + syncAwarenessRenderHold(); + if (!decision.refresh) return; + layerState.lastSubjectRefreshMs = now; + if ( + layerState.subject && + ['flights', 'military', 'ais-live-vessels'].includes( + layerState.subject.layerId, + ) + ) { + parts.subject.refreshSelectedSubject(); + } else if (!layerState.passive && layerState.autoFocusRetryPending) { + layerState.autoFocusRetryPending = false; + parts.focus.focusAttentionTarget(); + } else { + parts.rendering.scheduleDirectionOverlayUpdate(); + } + }); + layerState.runtimeListenersAttached = true; + } + + function detachRuntimeListeners() { + if (layerState.runtimeListenersAttached) { + window.removeEventListener( + 'gev:awareness-subject-selected', + layerState.subjectListener, + ); + window.removeEventListener( + 'gev:entity-selected', + layerState.contextListener, + ); + window.removeEventListener( + 'gev:entity-selection-cleared', + layerState.clearListener, + ); + window.removeEventListener( + 'gev:awareness-subject-cleared', + layerState.subjectClearListener, + ); + } + layerState.preRenderRemover?.(); + layerState.preRenderRemover = null; + layerState.lastCameraPoseSig = ''; + layerState.lastCameraPoseChangeMs = 0; + layerState.cameraMoving = false; + // No frames will arrive to run the per-frame release once the listener is + // gone, so drop the hold here. + releaseContinuousRender('military-awareness'); + parts.rendering.cancelDirectionOverlayUpdate(); + layerState.runtimeListenersAttached = false; + } + const methods = { + init(viewer) { + detachRuntimeListeners(); + layerState.viewer = viewer; + layerState.subjectListener = (event) => { + if (!layerState.enabled) return; + parts.subject.selectSubject(event.detail); + }; + layerState.contextListener = (event) => { + if (!layerState.enabled) return; + const subject = parts.subject.subjectFromContext(event.detail); + if (!subject) return; + if (parts.model.contextTargetFlyToAllowed(subject.layerId)) + parts.model.releaseAircraftTracking(); + parts.subject.selectSubject(subject); + }; + layerState.clearListener = (event) => { + if (!layerState.enabled || layerState.pendingSelectionKey) return; + if ( + !parts.subject.awarenessClearMatchesSubject( + layerState.subject, + event.detail, + ) + ) + return; + // An eviction keeps the subject so the readout can hold last-known + // values; only a deliberate clear tears the selection down. + if (parts.model.awarenessClearIsEviction(event.detail)) { + parts.subject.markSubjectEvicted(); + return; + } + parts.subject.clearAwarenessSubject(); + }; + layerState.subjectClearListener = (event) => { + if (!layerState.enabled) return; + const cleared = event.detail; + if (layerState.pendingSelectionKey) return; + if ( + parts.subject.awarenessClearMatchesSubject( + layerState.subject, + cleared, + ) && + String(layerState.subject?.id) === String(cleared?.id) + ) { + if (parts.model.awarenessClearIsEviction(cleared)) { + // Deliberately does NOT set autoFocusAttempted: the subject survives, + // so the entry fallback has nothing to replace and the settlement + // guard below stays scoped to real deselects. + parts.subject.markSubjectEvicted(); + return; + } + // A deliberate clear during dependency settlement must win over the + // entry fallback; otherwise Contacts can silently select a replacement. + layerState.autoFocusAttempted = true; + parts.subject.clearAwarenessSubject(); + } + }; + }, + + enable() { + layerState.enabled = true; + // NO unconditional continuous-render hold here. Contacts animates per frame + // only while the VIEW is moving (the direction arrows are screen-projected); + // parked, it is a throttled readout with nothing to animate. The hold is + // taken and released per frame by syncAwarenessRenderHold(). + attachRuntimeListeners(); + parts.panel.startAwarenessPageRotation(); + layerState.autoFocusAttempted = false; + layerState.autoFocusRetryPending = false; + parts.panel.ensurePanel(); + parts.panel.hidePanel(); + if (!layerState.passive) + return parts.dependencies.activateOperationalContext(); + return true; + }, + + disable() { + layerState.enabled = false; + releaseContinuousRender('military-awareness'); + detachRuntimeListeners(); + parts.panel.stopAwarenessPageRotation(); + layerState.cohortPages.clear(); + const releaseActivationId = ++layerState.activationId; + layerState.autoFocusAttempted = false; + layerState.autoFocusRetryPending = false; + layerState.subject = null; + layerState.results = null; + layerState.lastSubjectRefreshMs = 0; + layerState.lastEvaluatedPosition = null; + layerState.sourceRevision = ''; + layerState.navigationHistory = []; + layerState.navigationVisited.clear(); + layerState.navigationIndex = -1; + layerState.suppressedHistoryKey = null; + layerState.pendingSelectionKey = null; + parts.rendering.clearVisual(); + parts.panel.hidePanel(); + // The manager must not publish this coordinator as settled OFF while its + // owned dependency releases are still issuing newer absolute intents. + // Context mode handoffs restore the pre-entry snapshot only after this + // promise resolves, preventing those releases from superseding restore. + layerState.passive = true; + return parts.dependencies.releaseOwnedDependencies(releaseActivationId); + }, + + destroy() { + this.disable(); + detachRuntimeListeners(); + layerState.panel?.removeEventListener( + 'click', + layerState.panelClickListener, + ); + layerState.panelClickListener = null; + if (layerState.panelOwned) layerState.panel?.remove(); + else if (layerState.panel) layerState.panel.replaceChildren(); + layerState.panel = null; + layerState.panelOwned = false; + layerState.directionRoot?.remove(); + layerState.directionRoot = null; + layerState.compassRing = null; + layerState.compassLabels = []; + layerState.compassHeading = null; + layerState.directionMarkers = []; + layerState.panelMarkup = ''; + layerState.subjectListener = null; + layerState.contextListener = null; + layerState.clearListener = null; + layerState.subjectClearListener = null; + layerState.viewer = null; + layerState.dataManager = null; + }, + }; + + return { + cameraMotionSignature, + syncAwarenessRenderHold, + attachRuntimeListeners, + detachRuntimeListeners, + methods, + }; +} diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js new file mode 100644 index 000000000..c99f35fee --- /dev/null +++ b/src/layers/awareness/model.js @@ -0,0 +1,251 @@ +import { + AWARENESS_MOTION_REFRESH_MS, + AWARENESS_REFRESH_MS, + AWARENESS_MOTION_SETTLE_MS, + AWARENESS_REEVALUATE_DISTANCE_M, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + /** + * Whether focusing a Context target may take ownership of the camera. + * Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable + * there but must not start a competing flyTo animation. + * + * @param {string} layerId Context target layer id. + * @param {HTMLElement|null} [body=document.body] Document body to inspect. + * @returns {boolean} Whether the target may start a camera flight. + */ + + function contextTargetFlyToAllowed( + layerId, + body = globalThis.document?.body, + ) { + const nonAircraft = + layerId === 'ais-live-vessels' || layerId === 'military-installations'; + return !nonAircraft || !body?.classList?.contains('cockpit-mode'); + } + + /** + * Refresh cadence for the Contacts direction arrows and card readouts. + * + * A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms + * one. This is safe for requestRenderMode because the cadence is applied inside + * `scene.preRender`: a faster interval can only ever consume frames the camera + * movement is ALREADY forcing. A parked scene renders no frames at all, so no + * extra refresh happens and no timer is introduced — the render governor and + * the idle-leak fix both depend on parked staying quiet. + * @param {boolean} cameraMoving Whether the camera pose changed since the last frame. + * @returns {number} Minimum ms between refreshes. + */ + + function awarenessRefreshIntervalMs(cameraMoving) { + return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS; + } + + /** + * Decide, for one rendered frame, whether the view counts as MOVING and whether + * the Contacts readout may refresh. + * + * Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a + * slow drag crosses a bin only every few frames: treating a single unchanged + * frame as "parked" made every crossing look like a motion-end and refreshed at + * nearly display rate. Motion therefore ends only after the pose has been + * unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD + * floor in every state — no bin-crossing pattern, and no motion-end settle, can + * refresh faster than AWARENESS_MOTION_REFRESH_MS. + * + * @param {object} input + * @param {number} input.nowMs This frame's clock. + * @param {number} input.lastRefreshMs When the readout last refreshed. + * @param {number} input.lastPoseChangeMs When the camera pose last changed bins. + * @param {boolean} input.wasMoving Whether the previous frame counted as moving. + * @returns {{moving: boolean, refresh: boolean}} + */ + + function awarenessRefreshDecision({ + nowMs, + lastRefreshMs, + lastPoseChangeMs, + wasMoving, + }) { + const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS; + // The single frame where hysteresis expires: settle the readout at rest + // instead of leaving it up to a parked interval stale. + const settling = Boolean(wasMoving) && !moving; + const sinceRefresh = nowMs - lastRefreshMs; + const refresh = + sinceRefresh >= awarenessRefreshIntervalMs(true) && + (moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false)); + return { moving, refresh }; + } + + /** + * Decide whether a source-scoped clear was an eviction rather than a deselect. + * + * Both arrive on the same events: the owning layer calls the same teardown for + * "the user clicked away" and "this contact aged out of the feed". Only the + * origin separates them, and it matters — a deliberate clear takes the Contact + * panel down, while an eviction must keep it up in its CONTACT LOST state, or + * the panel disappears out from under its own PREVIOUS/NEXT controls. + * @param {{reason?: string}|null} cleared Cleared source detail. + * @returns {boolean} Whether the subject left the feed on its own. + */ + + function awarenessClearIsEviction(cleared) { + return cleared?.reason === 'evicted'; + } + + /** + * Decide whether proximity cohorts need an expensive rescan. + * @param {object} input Refresh evidence. + * @param {boolean} input.force Explicit invalidation. + * @param {boolean} input.hasResults Whether a prior evaluation exists. + * @param {number} input.movementM Subject displacement since evaluation. + * @param {boolean} input.sourceRevisionChanged Whether any source state changed. + * @returns {boolean} Whether cohorts should be evaluated again. + */ + + function awarenessRefreshRequired({ + force, + hasResults, + movementM, + sourceRevisionChanged, + }) { + return Boolean( + force || + !hasResults || + sourceRevisionChanged || + !Number.isFinite(movementM) || + movementM >= AWARENESS_REEVALUATE_DISTANCE_M, + ); + } + + /** + * The per-layer counts the Contacts panel is showing, as one flat block. + * + * Three honest numbers were reaching the operator at once: this cohort count + * (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the + * layer-wide loaded total in the coverage note. After the camera dives to a + * tracked contact the flights layer reloads by viewport, so the loaded set can + * hold a fraction of the cohort — 8 against the panel's 42 in the field. The + * numbers are all correct and the disagreement still reads as chaos. + * + * Derived from the same snapshot the panel renders (`cohort.summary.count` via + * `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort + * whose feed cannot answer reports 'unknown' rather than a misleading zero. + * @param {object|null} snapshot `getContextSnapshot()` result. + * @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string, + * flights: number|string, military: number|string, vessels: number|string}|null} + * Panel-equivalent counts. + */ + + function contactsWindowFromSnapshot(snapshot) { + if (!snapshot?.subject) return null; + const countFor = (cohortId) => { + const cohort = Array.isArray(snapshot.cohorts) + ? snapshot.cohorts.find((item) => item?.id === cohortId) + : null; + return Number.isFinite(cohort?.count) ? cohort.count : 'unknown'; + }; + const flights = countFor('flights'); + const military = countFor('military'); + return { + centeredOn: snapshot.subject.label || snapshot.subject.id || null, + radiusKm: Number.isFinite(snapshot.radiusM) + ? Math.round(snapshot.radiusM / 1000) + : null, + aircraft: + Number.isFinite(flights) && Number.isFinite(military) + ? flights + military + : 'unknown', + flights, + military, + vessels: countFor('ais-live-vessels'), + }; + } + + /** Build the read-only Awareness snapshot shared with compact HUD consumers. */ + + function buildAwarenessContextSnapshot( + results, + navigation = {}, + { subjectPresent = true } = {}, + ) { + if (!results) return null; + return { + subject: { ...results.subject }, + // Whether the subject is still reported by its source. The cockpit Contact + // readout holds its last-known values behind a CONTACT LOST cue when this + // is false, rather than presenting frozen geometry as a live reading. + subjectPresent: subjectPresent !== false, + evaluatedAt: results.evaluatedAt, + radiusM: results.radiusM, + cohorts: results.cohorts.map((cohort) => ({ + id: cohort.id, + label: cohort.label, + source: cohort.source, + coverage: cohort.coverage || null, + relationship: cohort.summary.relationship, + count: cohort.summary.count, + reason: cohort.summary.reason, + nearest: cohort.summary.nearest.slice(), + })), + navigation: { ...navigation }, + }; + } + + function navigationState() { + return { + canPrevious: layerState.navigationIndex > 0, + canFocus: Boolean(layerState.subject), + canNext: parts.navigation.canNavigateNext(), + }; + } + + /** + * Whether Contacts genuinely has per-frame work right now. + * + * The direction arrows are screen-projected against the camera basis, so they + * must be redrawn every frame while the view MOVES. Parked, or with no live + * cohort to point at — nothing selected, every feed empty, every feed failed — + * there is nothing to animate and Contacts must not be the reason the whole + * scene keeps repainting. + * @param {object} [snapshot] Explicit state, for tests. + * @returns {boolean} + */ + + function awarenessNeedsContinuousRender({ + cameraMoving = layerState.cameraMoving, + hasSubject = Boolean(layerState.subject), + hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results), + } = {}) { + return Boolean(cameraMoving && hasSubject && hasLiveResults); + } + + /** + * Release any aircraft-owned follow camera before a non-aircraft context + * selection takes ownership. Both layers are safe no-ops when idle and release + * Cesium tracking in place, so the subsequent vessel/site framing starts from + * the current view without the previous aircraft continuing to drag it. + */ + + function releaseAircraftTracking() { + flightsLayer.stopTracking?.(); + militaryFlightsLayer.stopTracking?.(); + } + return { + contextTargetFlyToAllowed, + awarenessRefreshIntervalMs, + awarenessRefreshDecision, + awarenessClearIsEviction, + awarenessRefreshRequired, + contactsWindowFromSnapshot, + buildAwarenessContextSnapshot, + navigationState, + awarenessNeedsContinuousRender, + releaseAircraftTracking, + }; +} diff --git a/src/layers/awareness/navigation.js b/src/layers/awareness/navigation.js new file mode 100644 index 000000000..23505e944 --- /dev/null +++ b/src/layers/awareness/navigation.js @@ -0,0 +1,274 @@ +import { + summarizeAwarenessCohort, + getAwarenessNavigationTargets, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + findByDoublingRadius, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { + AWARENESS_MAX_EXAMPLES, + AWARENESS_MAX_NAVIGATION_EXAMPLES, +} from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + function summarizeAwarenessCohortForNavigation( + items, + source, + { + displayLimit = AWARENESS_MAX_EXAMPLES, + navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES, + } = {}, + ) { + const summary = summarizeAwarenessCohort(items, { + ...source, + limit: navigationLimit, + }); + if (summary.count === null) return summary; + return { + ...summary, + nearest: summary.nearest.slice(0, displayLimit), + navigationNearest: summary.nearest, + }; + } + + function resetNavigationVisitedCycle() { + layerState.navigationVisited.clear(); + const currentKey = parts.subject.subjectKey(layerState.subject); + if (currentKey) layerState.navigationVisited.add(currentKey); + } + + function isFlightLayer(layerId) { + return layerId === 'flights' || layerId === 'military'; + } + + function normalizeAircraftClass(value) { + return String(value || '') + .trim() + .toLowerCase(); + } + + function aircraftClassMatchesFilter(item, aircraftClass) { + const requested = normalizeAircraftClass(aircraftClass); + if (!requested) return true; + const candidates = [ + item?.aircraftClass, + item?.klass, + item?.type, + item?.typeCode, + item?.typeName, + ] + .map((value) => normalizeAircraftClass(value)) + .filter(Boolean); + if (!candidates.length) return false; + return candidates.some( + (candidate) => candidate === requested || candidate.includes(requested), + ); + } + + function selectNavigationTargets( + sourceCohorts, + subject, + visitedKeys, + { targetLayer = null, aircraftClass = null, aircraftOnly = false } = {}, + ) { + let targets = getAwarenessNavigationTargets( + sourceCohorts, + subject, + visitedKeys, + ); + if (aircraftOnly) { + targets = targets.filter((target) => isFlightLayer(target.layerId)); + } + if (targetLayer) { + targets = targets.filter((target) => target.layerId === targetLayer); + } + if (aircraftClass) { + targets = targets.filter((target) => + aircraftClassMatchesFilter(target.item, aircraftClass), + ); + } + return targets; + } + + function alternativeFlightAvailable({ + targetLayer = null, + aircraftClass = null, + } = {}) { + if ( + !isFlightLayer(layerState.subject?.layerId) || + !layerState.subject?.position + ) + return false; + if (targetLayer && !isFlightLayer(targetLayer)) return false; + + const flights = + targetLayer === 'military' + ? [] + : flightsLayer + .getNearby( + layerState.subject.position, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + 2, + { includeHidden: true }, + ) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); + + const military = + targetLayer === 'flights' + ? [] + : militaryFlightsLayer + .getNearby( + layerState.subject.position, + AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + 2, + { includeHidden: true }, + ) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)); + + return [ + ['flights', flights], + ['military', military], + ].some(([layerId, items]) => + items.some( + (item) => + `${layerId}:${item.icao24 || item.id}` !== + parts.subject.subjectKey(layerState.subject), + ), + ); + } + + function closestFlightWithinRadius( + radiusM, + visitedKeys, + excludeVisited, + { targetLayer = null, aircraftClass = null } = {}, + ) { + if (!layerState.subject?.position) return null; + const visited = new Set(visitedKeys); + if (targetLayer && !isFlightLayer(targetLayer)) return null; + const candidates = [ + ...(targetLayer === 'military' + ? [] + : flightsLayer + .getNearby(layerState.subject.position, radiusM, 25000, { + includeHidden: true, + }) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) + .map((item) => ({ + layerId: 'flights', + id: String(item.icao24), + item, + }))), + ...(targetLayer === 'flights' + ? [] + : militaryFlightsLayer + .getNearby(layerState.subject.position, radiusM, 5000, { + includeHidden: true, + }) + .filter((item) => aircraftClassMatchesFilter(item, aircraftClass)) + .map((item) => ({ + layerId: 'military', + id: String(item.icao24), + item, + }))), + ].filter((target) => { + const key = `${target.layerId}:${target.id}`; + if (key === parts.subject.subjectKey(layerState.subject)) return false; + return !excludeVisited || !visited.has(key); + }); + candidates.sort( + (a, b) => + (a.item.distanceM ?? a.item.distance ?? Infinity) - + (b.item.distanceM ?? b.item.distance ?? Infinity), + ); + return candidates[0] || null; + } + + function findExpandedFlightTarget(options = {}) { + if (!isFlightLayer(layerState.subject?.layerId)) return null; + let visitedKeys = [...layerState.navigationVisited]; + const search = () => + findByDoublingRadius( + (radiusM) => + closestFlightWithinRadius(radiusM, visitedKeys, true, options), + { + initialRadiusM: AWARENESS_RADIUS_M, + maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, + }, + ); + const target = search(); + if (target) return target; + resetNavigationVisitedCycle(); + visitedKeys = [...layerState.navigationVisited]; + return search(); + } + + /** + * Resolves NEXT availability from the same three branches used by navigation. + * Forward history remains usable while a feed is unavailable; discovering a + * new nearby or expanded target requires a known subject cohort. + * @param {Object} availability Navigation branch availability. + * @returns {boolean} Whether NEXT has a path that can run. + */ + + function canNavigateAwarenessNext({ + hasForwardHistory = false, + hasExpandedFlightTarget = false, + hasNearbyTarget = false, + subjectCohortUnknown = false, + } = {}) { + return Boolean( + hasForwardHistory || + (!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)), + ); + } + + function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) { + return canNavigateAwarenessNext({ + hasForwardHistory: + parts.history.findCompatibleHistoryIndex( + layerState.navigationHistory, + layerState.navigationIndex, + 1, + { + targetLayer, + aircraftClass, + resolveItem: parts.history.historySubjectItem, + }, + ) !== -1, + hasNearbyTarget: + selectNavigationTargets( + layerState.results?.cohorts, + layerState.subject, + [...layerState.navigationVisited], + { targetLayer, aircraftClass }, + ).length > 0, + hasExpandedFlightTarget: alternativeFlightAvailable({ + targetLayer, + aircraftClass, + }), + subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(), + }); + } + return { + summarizeAwarenessCohortForNavigation, + resetNavigationVisitedCycle, + isFlightLayer, + normalizeAircraftClass, + aircraftClassMatchesFilter, + selectNavigationTargets, + alternativeFlightAvailable, + closestFlightWithinRadius, + findExpandedFlightTarget, + canNavigateAwarenessNext, + canNavigateNext, + }; +} diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js new file mode 100644 index 000000000..8a831907a --- /dev/null +++ b/src/layers/awareness/panel.js @@ -0,0 +1,249 @@ +import { + formatAwarenessLabel, + formatAwarenessDistance, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; + +export function createPanel({ state: layerState, services, parts, source }) { + function ensurePanel() { + if (layerState.panel) return layerState.panel; + const existing = document.getElementById('military-awareness-panel'); + const panel = existing || document.createElement('aside'); + layerState.panelOwned = !existing; + if (!existing) { + panel.id = 'military-awareness-panel'; + } + layerState.panelClickListener = (event) => { + const action = event.target.closest('button[data-awareness-action]'); + if (action) { + event.preventDefault(); + if (action.dataset.awarenessAction === 'previous') + parts.history.navigateHistory(-1, { origin: 'user' }); + if (action.dataset.awarenessAction === 'next') + parts.history.navigateHistory(1, { origin: 'user' }); + if (action.dataset.awarenessAction === 'focus') + parts.focus.focusCurrentSubject({ origin: 'user' }); + return; + } + const target = event.target.closest( + 'button[data-awareness-layer][data-awareness-id]', + ); + if (!target) return; + event.preventDefault(); + parts.focus.requestFocus( + target.dataset.awarenessLayer, + target.dataset.awarenessId, + false, + { origin: 'user' }, + ); + }; + panel.addEventListener('click', layerState.panelClickListener); + if (!existing) document.body.appendChild(panel); + layerState.panel = panel; + return panel; + } + + function hidePanel() { + if (layerState.panel) { + const markup = ` + ${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'} + ${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'} + `; + layerState.panel.hidden = false; + if (layerState.panelMarkup !== markup) { + layerState.panel.innerHTML = markup; + layerState.panelMarkup = markup; + } + } + if (layerState.directionRoot) layerState.directionRoot.hidden = true; + } + + function rowHtml(cohort) { + const summary = cohort.summary; + const count = summary.count === null ? '?' : String(summary.count); + const page = layerState.cohortPages.get(cohort.id) || 0; + const nearest = summary.nearest + .slice(page, page + AWARENESS_PAGE_SIZE) + .map((item) => { + const label = formatAwarenessLabel(item); + const targetId = item.icao24 || item.mmsi || item.id; + if (!targetId) { + return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; + } + const accessibleLabel = label === '—' ? 'Unavailable' : label; + return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}`; + }) + .join(''); + const pageCount = Math.max( + 1, + Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE), + ); + const pageLabel = + pageCount > 1 + ? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}` + : ''; + const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : ''; + return ` + ${escapeHtml(cohort.label)}${count}${pageLabel} + ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)} + ${nearest ? `${nearest}` : ''} + `; + } + + function navigationControlsHtml() { + const canPrevious = layerState.navigationIndex > 0; + return ` + PREVIOUS + FOCUS + NEXT + `; + } + + /** Stable identity for a delegated Contacts-panel button across live repaints. */ + + function awarenessPanelControlKey(element) { + const control = element?.closest?.( + 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', + ); + if (!control) return null; + if (control.dataset.awarenessAction) + return `action:${control.dataset.awarenessAction}`; + return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`; + } + + /** Capture stable identity so a live repaint can restore only the same control. */ + + function captureAwarenessPanelFocus( + panel, + activeElement = document.activeElement, + ) { + if (!panel?.contains?.(activeElement)) return null; + if (activeElement?.matches?.('[data-awareness-focus-continuation]')) { + return { key: 'continuation' }; + } + const key = awarenessPanelControlKey(activeElement); + if (!key) return null; + return { key }; + } + + /** Restore the same control, or continue beyond the list when it is no longer rendered. */ + + function restoreAwarenessPanelFocus(panel, snapshot) { + if (!panel || !snapshot) return null; + const continuation = panel.querySelector( + '[data-awareness-focus-continuation]', + ); + const controls = [ + ...panel.querySelectorAll( + 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]', + ), + ].filter((control) => !control.disabled); + const retained = + snapshot.key === 'continuation' + ? continuation + : controls.find( + (control) => awarenessPanelControlKey(control) === snapshot.key, + ); + const target = retained || continuation; + target?.focus?.({ preventScroll: true }); + return target || null; + } + + function escapeHtml(value) { + return String(value ?? '').replace( + /[&<>'"]/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"', + })[char], + ); + } + + function renderResults() { + if (!layerState.enabled || !layerState.results) return hidePanel(); + const panel = ensurePanel(); + const { subject, cohorts } = layerState.results; + const markup = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW + ${navigationControlsHtml()} + ${cohorts.map(rowHtml).join('')} + Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.`; + panel.hidden = false; + if (layerState.panelMarkup !== markup) { + const focusSnapshot = captureAwarenessPanelFocus(panel); + panel.innerHTML = markup; + layerState.panelMarkup = markup; + restoreAwarenessPanelFocus(panel, focusSnapshot); + } + } + + function rotateAwarenessPages() { + if (!layerState.enabled || !layerState.results) return; + let changed = false; + for (const cohort of layerState.results.cohorts) { + const pageCount = Math.max( + 1, + Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE), + ); + if (pageCount <= 1) continue; + const current = layerState.cohortPages.get(cohort.id) || 0; + layerState.cohortPages.set( + cohort.id, + ((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) * + AWARENESS_PAGE_SIZE, + ); + changed = true; + } + if (changed) { + renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(true); + } + } + + function startAwarenessPageRotation() { + if (!layerState.pageTimer) + layerState.pageTimer = window.setInterval( + rotateAwarenessPages, + AWARENESS_PAGE_ROTATE_MS, + ); + } + + function stopAwarenessPageRotation() { + if (layerState.pageTimer) window.clearInterval(layerState.pageTimer); + layerState.pageTimer = null; + } + + /** + * Whether an evaluation actually has a contact to point an arrow at. + * + * A cohort reports `count: null` when its feed is unavailable or stale, and `0` + * when the feed is healthy but empty. Only a positive count puts a marker on + * the compass rim, so anything else means there is nothing to animate. + * @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results + * @returns {boolean} + */ + + function awarenessResultsAreLive(results) { + const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : []; + return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0); + } + return { + ensurePanel, + hidePanel, + rowHtml, + navigationControlsHtml, + awarenessPanelControlKey, + captureAwarenessPanelFocus, + restoreAwarenessPanelFocus, + escapeHtml, + renderResults, + rotateAwarenessPages, + startAwarenessPageRotation, + stopAwarenessPageRotation, + awarenessResultsAreLive, + }; +} diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js new file mode 100644 index 000000000..621c3983a --- /dev/null +++ b/src/layers/awareness/policy.js @@ -0,0 +1,68 @@ +export const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; + +export const DEFERRED_DEPENDENCIES = [ + 'ais-live-vessels', + 'military-installations', +]; + +export const DEPENDENCIES = [ + ...AIRCRAFT_DEPENDENCIES, + ...DEFERRED_DEPENDENCIES, +]; + +export const AWARENESS_REFRESH_MS = 750; + +/** @constant {number} Refresh cadence while the camera pose is CHANGING. + * Owner playtest 2026-08-18: the Contacts direction arrows and card readouts + * "feel sluggish when you look around" — at the parked 750 ms cadence the + * arrows lag the view by up to three quarters of a second. */ + +export const AWARENESS_MOTION_REFRESH_MS = 175; + +/** @constant {number} How long the pose signature must stay UNCHANGED before + * the view counts as parked again. The signature is quantized, so a slow drag + * crosses a bin only every few frames; without this hysteresis every crossing + * read as a motion-end and refreshed at nearly display rate. */ + +export const AWARENESS_MOTION_SETTLE_MS = 250; + +export const AWARENESS_REEVALUATE_DISTANCE_M = 250; + +export const AWARENESS_PAGE_ROTATE_MS = 10000; + +export const AWARENESS_PAGE_SIZE = 3; + +export const AWARENESS_MAX_EXAMPLES = 10; + +// Navigation walks the whole 250 km cohort, not the ten rows the panel shows, +// so these caps sit far above any realistic in-range population rather than at +// the display limit. They are still FINITE: an unbounded materialization would +// let a pathological feed sort an eleven-thousand-contact array on every +// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort` +// derives `count` from the full in-range set before any slice. + +export const AWARENESS_QUERY_LIMIT = 20000; + +export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000; + +export const CONTEXT_RIM_HEIGHT_M = 2500; + +export const VESSEL_FOCUS_RADIUS_M = 3000; + +export const SOURCE_LABEL = { + flights: 'OpenSky', + military: 'adsb.lol', + 'ais-live-vessels': 'AISStream', + 'military-installations': 'OpenStreetMap', +}; + +/** + * Whether a refresh tick could confirm the subject in its source collection. + * UNCHECKED means the tick did not look — it must never change the verdict. + */ + +export const SUBJECT_PRESENCE = Object.freeze({ + LIVE: 'live', + MISSING: 'missing', + UNCHECKED: 'unchecked', +}); diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js new file mode 100644 index 000000000..d5a2c9e5c --- /dev/null +++ b/src/layers/awareness/queries.js @@ -0,0 +1,164 @@ +import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; +import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js'; + +export function createQueries({ state: layerState, services, parts, source }) { + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + function sourceState(layerId) { + const lifecycle = + layerState.dataManager?.getLayerLifecycleState?.(layerId) || null; + const enabled = + lifecycle?.enabled === true || + layerState.dataManager?.isEnabled(layerId) === true; + const enabling = lifecycle?.lifecycleState === 'enabling'; + const moduleStats = + layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {}; + const stats = enabling + ? { ...moduleStats, loading: true, status: 'loading' } + : moduleStats; + // A deferred source that has started but not settled is not evidence of an + // empty cohort. Keep it explicitly unavailable so the panel cannot flash a + // false all-clear, even if a lifecycle adapter briefly reports the requested + // visibility intent as enabled. + // + // `enabling` and the never-answered predicate cover two DIFFERENT windows, and + // both are needed because these dependencies do not settle the same way. + // + // - military-installations is covered by `enabling` alone. Its enable() is + // SYNCHRONOUS and the manager awaits update(), which owns the first + // Overpass fetch, so the lifecycle stays `enabling` for that whole window + // however long the fetch takes. Confirmed live: a held 17 s first fetch + // read `enabling` across 34 samples with the panel non-numeric throughout, + // and a failing one settled to `enabled` with status 'unavailable'. Its + // getStats() also reports loading while a request is in progress — + // setInstallationStatus is only ever called with + // loading/zoom-in/ready/stale/empty/unavailable. + // - ais-live-vessels is what the predicate below is FOR. Its enable() and + // update() both resolve as soon as the first /api/ais-live poll answers, + // so the lifecycle settles to `enabled` — but until the server-side socket + // delivers a position, firstConnectPhase is 'loading' and getStats() + // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED + // status. That zero is an absence of evidence, not evidence of absence, + // and neither the lifecycle nor the status list can tell. + // + // Deliberately `loading === true` and not truthiness: layers that never report + // a busy flag (flights, military) must keep answering for themselves, and a + // source that HAS answered once (lastUpdate set) keeps its last real count + // through every later poll rather than blanking to `?` on each refresh. + const neverAnswered = stats.loading === true && !stats.lastUpdate; + const unavailable = + enabling || + !enabled || + neverAnswered || + ['unavailable', 'zoom-in'].includes(stats.status) || + Boolean(stats.error && stats.count === 0); + return { available: !unavailable, stale: Boolean(stats.stale), stats }; + } + + function collectSourceStates() { + return Object.fromEntries( + DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]), + ); + } + + function sourceRevision(sourceStates) { + return DEPENDENCIES.map((layerId) => { + const source = sourceStates[layerId]; + const stats = source?.stats || {}; + return [ + layerId, + source?.available, + source?.stale, + stats.lastUpdate || null, + stats.count ?? null, + stats.status || null, + stats.error || null, + ]; + }) + .map((parts) => parts.join(':')) + .join('|'); + } + + function isSame(subject, item, prefix, key) { + return ( + subject?.layerId === prefix && String(subject.id) === String(item?.[key]) + ); + } + + /** + * Summarize viewport-backed installations without implying radius-complete coverage. + * @param {Array} items Loaded installation matches. + * @param {object} source Current source availability and staleness. + * @returns {object} Awareness summary with viewport-honest reasoning. + */ + + function summarizeInstallationViewport(items, source) { + const summary = parts.navigation.summarizeAwarenessCohortForNavigation( + items, + source, + ); + if (summary.count === null) + return source.stats?.statusMessage + ? { ...summary, reason: source.stats.statusMessage } + : summary; + return { + ...summary, + reason: summary.count + ? 'mapped matches from the loaded viewport' + : 'viewport feed is not a complete 250 km survey', + }; + } + + /** + * THE aircraft-proximity engine. One computation, two consumers: the Contacts + * panel window and the voice analyst's entity-centred "how many nearby". + * + * They used to be separate. The panel read live billboard positions through + * `getNearby` with a 20 000 cap; the analyst re-derived its own answer from + * last-fix coordinates over a 2 000-record slice. Same question, same centre, + * two numbers — and in the owner's trial the spoken answer (15) and the panel + * (111) disagreed badly enough that the model narrated the difference away. + * Routing both through here makes them the same number BY CONSTRUCTION, so + * they cannot drift again. + * + * The subject is excluded from its own window, which is why the panel reads + * "contacts around X" rather than "including X". + * @param {Cesium.Cartesian3} position Window centre. + * @param {object} [options] + * @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius. + * @param {object|null} [options.subject=null] Contact at the centre, excluded. + * @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts + * plus the combined aircraft count, or null without a position. + */ + + function collectAircraftProximityWindow( + position, + { radiusM = AWARENESS_RADIUS_M, subject = null } = {}, + ) { + if (!position) return null; + const flights = flightsLayer + .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { + includeHidden: true, + }) + .filter( + (item) => !subject || !isSame(subject, item, 'flights', 'icao24'), + ); + const military = militaryFlightsLayer + .getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, { + includeHidden: true, + }) + .filter( + (item) => !subject || !isSame(subject, item, 'military', 'icao24'), + ); + return { flights, military, aircraft: flights.length + military.length }; + } + return { + sourceState, + collectSourceStates, + sourceRevision, + isSame, + summarizeInstallationViewport, + collectAircraftProximityWindow, + }; +} diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js new file mode 100644 index 000000000..8ef9b2afe --- /dev/null +++ b/src/layers/awareness/rendering.js @@ -0,0 +1,288 @@ +import * as Cesium from 'cesium'; +import { + formatAwarenessLabel, + formatAwarenessDistance, + AWARENESS_RADIUS_M, +} from '../../data/militaryAwarenessEngine.js'; +import { bearingBetweenCoordinates } from '../../cockpitMath.js'; +import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { getKeyholeGeometry, celestialScreenAngle } = services.geometry; + const { governorRequestRender } = services.render; + + function ensureDirectionOverlay() { + if (layerState.directionRoot || !layerState.viewer) + return layerState.directionRoot; + const root = document.createElement('div'); + root.id = 'military-awareness-direction-overlay'; + root.hidden = true; + root.setAttribute('aria-hidden', 'true'); + + const compass = document.createElement('div'); + compass.className = 'military-awareness-compass-ring'; + const cardinals = [ + ['N', 0], + ['E', 90], + ['S', 180], + ['W', 270], + ]; + layerState.compassLabels = cardinals.map(([label, bearing]) => { + const element = document.createElement('span'); + element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`; + element.textContent = label; + element.dataset.bearing = String(bearing); + root.appendChild(element); + return element; + }); + const heading = document.createElement('span'); + heading.className = 'military-awareness-compass-heading'; + root.append(compass, heading); + + layerState.directionMarkers = Array.from({ length: 3 }, () => { + const marker = document.createElement('div'); + marker.className = 'military-awareness-direction-marker'; + const arrow = document.createElement('span'); + arrow.className = 'military-awareness-direction-arrow'; + arrow.textContent = '➜'; + const label = document.createElement('span'); + label.className = 'military-awareness-direction-label'; + marker.append(arrow, label); + marker._arrow = arrow; + marker._label = label; + marker._lastAngle = 0; + root.appendChild(marker); + return marker; + }); + + layerState.compassRing = compass; + layerState.compassHeading = heading; + layerState.viewer.container.appendChild(root); + layerState.directionRoot = root; + return root; + } + + function scheduleDirectionOverlayUpdate(force = false) { + if (layerState.directionFrame !== null || !layerState.enabled) return; + const now = Date.now(); + if ( + !force && + now - layerState.lastDirectionUpdateMs < + parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving) + ) + return; + layerState.directionFrame = window.requestAnimationFrame(() => { + layerState.directionFrame = null; + layerState.lastDirectionUpdateMs = Date.now(); + updateDirectionOverlay(); + }); + } + + function cancelDirectionOverlayUpdate() { + if (layerState.directionFrame !== null) + window.cancelAnimationFrame(layerState.directionFrame); + layerState.directionFrame = null; + layerState.lastDirectionUpdateMs = 0; + } + + function updateDirectionOverlay() { + const root = ensureDirectionOverlay(); + if ( + !root || + !layerState.enabled || + !layerState.subject?.position || + !layerState.results + ) { + if (root) root.hidden = true; + return; + } + + const canvas = layerState.viewer.scene.canvas; + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + const geometry = getKeyholeGeometry(width, height); + if (!(geometry.radius > 0)) { + root.hidden = true; + return; + } + + root.hidden = false; + // Keep the compass comfortably inside the shader keyhole so its cardinals + // remain visible around the subject instead of hiding under edge panels. + const compassRadius = Math.max( + 120, + Math.min(geometry.radius - 48, geometry.radius * 0.66), + ); + layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`; + layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`; + layerState.compassRing.style.width = `${compassRadius * 2}px`; + layerState.compassRing.style.height = `${compassRadius * 2}px`; + + const cameraHeading = layerState.viewer.camera.heading || 0; + const headingDeg = + (Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360; + layerState.compassRing.style.setProperty( + '--compass-rotation', + `${-headingDeg}deg`, + ); + layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`; + layerState.compassHeading.style.left = `${geometry.centerX}px`; + layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`; + for (const label of layerState.compassLabels) { + const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing)); + const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO; + const labelRadius = compassRadius - 17; + label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`; + label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`; + } + + const directionalCohort = + layerState.results.cohorts.find( + (cohort) => cohort.id === layerState.subject.layerId, + ) || + layerState.results.cohorts.find((cohort) => cohort.id === 'military'); + const page = layerState.cohortPages.get(directionalCohort?.id) || 0; + const military = + directionalCohort?.summary.nearest.slice( + page, + page + AWARENESS_PAGE_SIZE, + ) || []; + for (let index = 0; index < layerState.directionMarkers.length; index++) { + const marker = layerState.directionMarkers[index]; + const item = military[index]; + if (!item?.position) { + marker.hidden = true; + continue; + } + const direction = Cesium.Cartesian3.subtract( + item.position, + layerState.subject.position, + layerState.DIRECTION_SCRATCH[index], + ); + if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) { + marker.hidden = true; + continue; + } + Cesium.Cartesian3.normalize(direction, direction); + const projection = celestialScreenAngle( + Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC), + Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC), + marker._lastAngle, + ); + if (projection.stable) marker._lastAngle = projection.angle; + // Every detected contact shares the compass rim. Keeping the bearing + // markers on one radius makes them read as compass contacts rather than + // unconstrained labels floating over the map. + const markerRadius = compassRadius; + marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`; + marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`; + marker.style.opacity = String(projection.opacity); + marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`; + marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`; + const label = formatAwarenessLabel(item); + const subjectCartographic = Cesium.Cartographic.fromCartesian( + layerState.subject.position, + undefined, + layerState.SUBJECT_CARTOGRAPHIC_SCRATCH, + ); + const targetCartographic = Cesium.Cartographic.fromCartesian( + item.position, + undefined, + layerState.TARGET_CARTOGRAPHIC_SCRATCH[index], + ); + const bearing = + subjectCartographic && targetCartographic + ? bearingBetweenCoordinates( + Cesium.Math.toDegrees(subjectCartographic.latitude), + Cesium.Math.toDegrees(subjectCartographic.longitude), + Cesium.Math.toDegrees(targetCartographic.latitude), + Cesium.Math.toDegrees(targetCartographic.longitude), + ) + : null; + const bearingText = Number.isFinite(bearing) + ? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°` + : 'BRG —'; + const courseText = Number.isFinite(item.track) + ? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°` + : ''; + marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; + marker.hidden = false; + } + } + + function clearVisual() { + if (layerState.visual?.entities && layerState.viewer) { + for (const entity of layerState.visual.entities) + layerState.viewer.entities.remove(entity); + // Idle mode renders only on request; a removed ring must not linger. + governorRequestRender('awareness-visual'); + } + layerState.visual = null; + } + + function renderVisual(subject) { + if (!layerState.viewer || !subject?.position) return; + const cartographic = Cesium.Cartographic.fromCartesian(subject.position); + if (!cartographic) return; + const groundCenter = Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + 0, + ); + const key = `${subject.layerId}:${subject.id}`; + if (layerState.visual?.key === key) { + // Large ellipse geometry is more reliable as a ConstantPositionProperty. + // Move it only after a meaningful displacement so live tracking does not + // churn ten large geometries for sub-pixel motion every 750 ms. + if ( + Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >= + 250 + ) { + for (const entity of layerState.visual.entities) + entity.position.setValue(groundCenter); + Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); + // Content actually changed — buy exactly one frame instead of holding. + governorRequestRender('awareness-visual'); + } + return; + } + clearVisual(); + + const entities = []; + + entities.push( + layerState.viewer.entities.add({ + position: groundCenter, + ellipse: { + semiMajorAxis: AWARENESS_RADIUS_M, + semiMinorAxis: AWARENESS_RADIUS_M, + fill: false, + outline: true, + outlineColor: + Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72), + height: CONTEXT_RIM_HEIGHT_M, + }, + }), + ); + + layerState.visual = { + key, + entities, + center: Cesium.Cartesian3.clone(groundCenter), + }; + governorRequestRender('awareness-visual'); + } + return { + ensureDirectionOverlay, + scheduleDirectionOverlayUpdate, + cancelDirectionOverlayUpdate, + updateDirectionOverlay, + clearVisual, + renderVisual, + }; +} diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js new file mode 100644 index 000000000..17ffd0e28 --- /dev/null +++ b/src/layers/awareness/state.js @@ -0,0 +1,70 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const state = {}; + + state.DIRECTION_SCRATCH = Array.from( + { length: 3 }, + () => new Cesium.Cartesian3(), + ); + + state.SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic(); + + state.TARGET_CARTOGRAPHIC_SCRATCH = Array.from( + { length: 3 }, + () => new Cesium.Cartographic(), + ); + + Object.assign(state, { + viewer: null, + dataManager: null, + enabled: false, + // Set once a refresh observes the subject gone from a still-reporting source. + // The Contact readout turns this into its CONTACT LOST hold state. + subjectMissing: false, + // The dedicated Context chooser exposes a passive shell. Operational source + // layers activate only after Contacts is explicitly selected. + passive: true, + ownedDependencies: new Set(), + subject: null, + results: null, + visual: null, + panel: null, + panelOwned: false, + subjectListener: null, + contextListener: null, + clearListener: null, + subjectClearListener: null, + runtimeListenersAttached: false, + preRenderRemover: null, + panelClickListener: null, + directionRoot: null, + compassRing: null, + compassLabels: [], + compassHeading: null, + directionMarkers: [], + navigationHistory: [], + navigationVisited: new Set(), + navigationIndex: -1, + suppressedHistoryKey: null, + pendingSelectionKey: null, + lastSubjectRefreshMs: 0, + /** Camera-pose signature observed on the previous rendered frame. */ + lastCameraPoseSig: '', + /** When the pose signature last changed bins (hysteresis anchor). */ + lastCameraPoseChangeMs: 0, + /** Whether the view currently counts as moving (hysteretic, not per-frame). */ + cameraMoving: false, + lastEvaluatedPosition: null, + sourceRevision: '', + panelMarkup: '', + directionFrame: null, + lastDirectionUpdateMs: 0, + activationId: 0, + autoFocusAttempted: false, + autoFocusRetryPending: false, + pageTimer: null, + cohortPages: new Map(), + }); + return state; +} diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js new file mode 100644 index 000000000..742d96f20 --- /dev/null +++ b/src/layers/awareness/subject.js @@ -0,0 +1,447 @@ +import { + AWARENESS_RADIUS_M, + AWARENESS_RELATIONSHIP, +} from '../../data/militaryAwarenessEngine.js'; +import * as Cesium from 'cesium'; +import { + AWARENESS_QUERY_LIMIT, + SOURCE_LABEL, + SUBJECT_PRESENCE, + AWARENESS_PAGE_SIZE, +} from './policy.js'; + +export function createSubject({ state: layerState, services, parts, source }) { + const aisLiveVesselsLayer = services.vessels; + const militaryInstallationsLayer = services.installations; + const flightsLayer = services.flights; + const militaryFlightsLayer = services.military; + + /** + * Decide whether a source-scoped context clear belongs to the current subject. + * @param {{layerId?: string}|null} subject Current awareness subject. + * @param {{layerId?: string}|null} cleared Cleared source detail. + * @returns {boolean} Whether the current subject should be cleared. + */ + + function awarenessClearMatchesSubject(subject, cleared) { + return Boolean(subject?.layerId && cleared?.layerId === subject.layerId); + } + + function evaluateSubject( + subject, + sourceStates = parts.queries.collectSourceStates(), + ) { + const position = subject?.position; + if (!position) return null; + const flightsState = sourceStates.flights; + const militaryState = sourceStates.military; + const vesselsState = sourceStates['ais-live-vessels']; + const installationsState = sourceStates['military-installations']; + // Same engine the voice analyst calls — see collectAircraftProximityWindow. + const { flights, military } = parts.queries.collectAircraftProximityWindow( + position, + { subject }, + ); + const vessels = aisLiveVesselsLayer + .getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) + .filter( + (item) => + !parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'), + ); + const installations = militaryInstallationsLayer + .getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT) + .filter( + (item) => + !parts.queries.isSame(subject, item, 'military-installations', 'id'), + ); + return { + subject, + evaluatedAt: Date.now(), + radiusM: AWARENESS_RADIUS_M, + cohorts: [ + { + id: 'flights', + label: 'Flights', + source: flightsState.stats.source || SOURCE_LABEL.flights, + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + flights, + flightsState, + ), + }, + { + id: 'military', + label: 'Military flights', + source: militaryState.stats.source || SOURCE_LABEL.military, + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + military, + militaryState, + ), + }, + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + summary: parts.navigation.summarizeAwarenessCohortForNavigation( + vessels, + vesselsState, + ), + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: + installationsState.stats.source || + SOURCE_LABEL['military-installations'], + coverage: 'CURRENT VIEWPORT ONLY', + summary: parts.queries.summarizeInstallationViewport( + installations, + installationsState, + ), + }, + ], + }; + } + + function subjectKey(subject) { + return subject ? `${subject.layerId}:${subject.id}` : ''; + } + + function normalizeContextId(value) { + return String(value || '') + .trim() + .toLowerCase(); + } + + function normalizedSubjectKey(subject) { + if (!subject) return ''; + return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`; + } + + /** + * Resolve the source-owned aircraft that currently owns Cesium tracking. + * Both flight layers may briefly retain local state during a cross-layer + * handoff, so the viewer's normalized tracked identity is the tie-breaker. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached awareness subject, or null when no flight owns the follow camera. + */ + + function currentTrackedFlightSubject() { + const trackedKey = normalizeContextId( + layerState.viewer?.trackedEntity?.gevTrackedId, + ); + if (!trackedKey) return null; + const subjects = [ + flightsLayer.getTrackedSubject?.(), + militaryFlightsLayer.getTrackedSubject?.(), + ]; + return ( + subjects.find( + (subject) => normalizedSubjectKey(subject) === trackedKey, + ) || null + ); + } + + function subjectCohortFeedUnknown() { + const cohort = layerState.results?.cohorts?.find( + (item) => item.id === layerState.subject?.layerId, + ); + return ( + cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN && + cohort.summary.count === null + ); + } + + function selectSubject(subject) { + if (!layerState.enabled || !subject?.position) return; + parts.panel.startAwarenessPageRotation(); + const key = subjectKey(subject); + layerState.navigationVisited.add(key); + const suppressHistory = layerState.suppressedHistoryKey === key; + layerState.pendingSelectionKey = null; + layerState.suppressedHistoryKey = null; + if (!suppressHistory) { + const current = layerState.navigationHistory[layerState.navigationIndex]; + if (subjectKey(current) !== key) { + layerState.navigationHistory.splice(layerState.navigationIndex + 1); + layerState.navigationHistory.push({ + ...parts.history.historySubjectSnapshot( + subject, + parts.history.historySourceItem(subject), + ), + position: Cesium.Cartesian3.clone(subject.position), + }); + layerState.navigationIndex = layerState.navigationHistory.length - 1; + } + } + layerState.subject = subject; + // A newly chosen contact starts present; the next refresh re-decides. + layerState.subjectMissing = false; + refreshSelectedSubject(true); + } + + /** + * Resolve the currently displayed position for a selected live subject. Flight + * tracking owns the motion callback, so awareness consumes its cached display + * position instead of creating a second tracker or extrapolation path. + * @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject. + * @param {object} [options] Position materialization controls. + * @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run. + * @returns {Cesium.Cartesian3|null} A cloned live position when one is available. + */ + + function resolveSubjectPosition( + subject, + { allowCollectionMaterialization = true } = {}, + ) { + if (!subject?.position) return null; + if (subject.layerId === 'flights' || subject.layerId === 'military') { + const trackedPosition = + layerState.viewer?.trackedEntity?.gevDisplayPosition?.(); + if (trackedPosition) { + return { + position: Cesium.Cartesian3.clone(trackedPosition), + // Only the follow camera's own contact proves presence this way; a + // different tracked entity says nothing about this subject. + presence: + String(layerState.viewer?.trackedEntity?.gevTrackedId || '') === + subjectKey(subject) + ? SUBJECT_PRESENCE.LIVE + : SUBJECT_PRESENCE.UNCHECKED, + }; + } + // Cockpit already materializes its tracked position on a fixed 20 Hz loop. + // If that frame-owned cache is temporarily unavailable, keep the last + // awareness position instead of allocating/scanning up to 1,000 contacts. + if (!allowCollectionMaterialization) { + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.UNCHECKED, + }; + } + const layer = + subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; + return collectionSubjectPosition( + subject, + layer.getAllPositions(1000), + layer, + ); + } + if (subject.layerId === 'ais-live-vessels') { + if (!allowCollectionMaterialization) { + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.UNCHECKED, + }; + } + return collectionSubjectPosition( + subject, + aisLiveVesselsLayer.getAllPositions(12000), + aisLiveVesselsLayer, + ); + } + // Mapped installations come from static geometry, not a live feed: there is + // nothing to be culled from. + return { + position: Cesium.Cartesian3.clone(subject.position), + presence: SUBJECT_PRESENCE.LIVE, + }; + } + + /** + * Locate a subject inside its layer's live collection, keeping the last known + * position when it is gone. + * + * Presence comes from the layer's O(1) `hasContact`, never from the rows: + * `getAllPositions` stops at its cap and the flights layer routinely carries + * ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone". + * The rows are only consulted for a fresher position — a contact past the cap + * keeps its last known position while still reading as present. + * @param {{id: string, position: Cesium.Cartesian3}} subject Current subject. + * @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows. + * @param {{hasContact?: function}} layer The owning source layer. + * @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict. + */ + + function collectionSubjectPosition(subject, rows, layer) { + const collection = Array.isArray(rows) ? rows : []; + const current = collection.find( + (item) => String(item.id) === String(subject.id), + ); + // null/undefined means the layer cannot answer (disabled or not yet loaded). + const known = layer?.hasContact?.(subject.id); + const presence = + known === true + ? SUBJECT_PRESENCE.LIVE + : known === false + ? SUBJECT_PRESENCE.MISSING + : SUBJECT_PRESENCE.UNCHECKED; + return { + position: current?.position + ? Cesium.Cartesian3.clone(current.position) + : Cesium.Cartesian3.clone(subject.position), + presence, + }; + } + + /** + * Re-resolve a live subject's DISPLAY label from the layer that owns it. + * + * The subject snapshot is captured once at selection time, but a contact's + * label INPUTS can arrive later: adsbdb enrichment supplies a registration + * seconds after a callsign-less aircraft is selected, so every other surface + * swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex. + * The owning layer's tracked-subject accessor already applies the layer's + * label convention (callsign → registration → icao24), so ask it rather than + * re-deriving the chain here. + * + * Identity (`subject.id`) is NEVER re-derived — only the rendered string. + * @param {{layerId: string, id: string, label: string}} subject Current subject. + * @returns {string} The current label, or the cached one when the owning layer + * has no tracked subject (e.g. a cross-layer handoff is in flight). + */ + + function resolveSubjectLabel(subject) { + if (!parts.navigation.isFlightLayer(subject?.layerId)) + return subject?.label; + const layer = + subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; + const tracked = layer.getTrackedSubject?.(); + if (tracked?.label && String(tracked.id) === String(subject.id)) + return tracked.label; + return subject.label; + } + + /** Refresh proximity counts/distances against the subject's current live position. */ + + function refreshSelectedSubject(force = false) { + if (!layerState.enabled || !layerState.subject) return; + const sources = parts.queries.collectSourceStates(); + const nextSourceRevision = parts.queries.sourceRevision(sources); + const sourceRevisionChanged = + nextSourceRevision !== layerState.sourceRevision; + const resolved = resolveSubjectPosition(layerState.subject, { + allowCollectionMaterialization: + !document.body?.classList?.contains('cockpit-mode') || + sourceRevisionChanged, + }); + if (!resolved) return; + const { position, presence } = resolved; + // UNCHECKED leaves the verdict alone: this tick simply did not look. + if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false; + else if (presence === SUBJECT_PRESENCE.MISSING) + layerState.subjectMissing = true; + const nextLabel = resolveSubjectLabel(layerState.subject); + const labelChanged = nextLabel !== layerState.subject.label; + layerState.subject = { ...layerState.subject, position, label: nextLabel }; + const movementM = layerState.lastEvaluatedPosition + ? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position) + : Infinity; + if ( + !parts.model.awarenessRefreshRequired({ + force, + hasResults: Boolean(layerState.results), + movementM, + sourceRevisionChanged, + }) + ) { + layerState.results.subject = layerState.subject; + parts.rendering.renderVisual(layerState.subject); + // The panel markup embeds `subject.label`, so a label that changed while + // the contact was stationary (async enrichment answering after selection) + // is render-worthy ON ITS OWN. Without this the standalone Context panel + // kept the ICAO hex until unrelated movement, a source-revision bump, or + // page rotation happened to repaint it. Gated on an ACTUAL change so the + // common no-op refresh (every 750 ms) still costs no repaint. + if (labelChanged) parts.panel.renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(force); + return; + } + layerState.results = evaluateSubject(layerState.subject, sources); + layerState.sourceRevision = nextSourceRevision; + layerState.lastEvaluatedPosition = Cesium.Cartesian3.clone( + position, + layerState.lastEvaluatedPosition, + ); + for (const cohort of layerState.results.cohorts) { + const maxPage = Math.max( + 0, + Math.floor( + Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE, + ) * AWARENESS_PAGE_SIZE, + ); + layerState.cohortPages.set( + cohort.id, + Math.min(layerState.cohortPages.get(cohort.id) || 0, maxPage), + ); + } + parts.rendering.renderVisual(layerState.subject); + parts.panel.renderResults(); + parts.rendering.scheduleDirectionOverlayUpdate(force); + } + + function subjectFromContext(record) { + if ( + !record || + !['ais-live-vessels', 'military-installations'].includes(record.layerId) + ) + return null; + const latitude = Number(record.latitude); + const longitude = Number(record.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { + layerId: record.layerId, + id: record.properties?.mmsi || record.id, + label: record.label || record.id, + position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), + }; + } + + /** + * Record that the current subject left its feed on its own. + * + * The subject, its cohort results and its navigation history all survive: the + * Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS / + * NEXT stay operable so the operator can step off. Nulling the subject here + * instead would take the panel — and those controls — off screen. + */ + + function markSubjectEvicted() { + if (!layerState.subject) return; + layerState.subjectMissing = true; + } + + function clearAwarenessSubject() { + layerState.autoFocusRetryPending = false; + layerState.subject = null; + layerState.subjectMissing = false; + layerState.results = null; + layerState.navigationHistory = []; + layerState.navigationVisited.clear(); + layerState.navigationIndex = -1; + layerState.suppressedHistoryKey = null; + layerState.pendingSelectionKey = null; + layerState.lastEvaluatedPosition = null; + layerState.sourceRevision = ''; + layerState.cohortPages.clear(); + parts.panel.stopAwarenessPageRotation(); + parts.rendering.clearVisual(); + parts.panel.hidePanel(); + } + return { + awarenessClearMatchesSubject, + evaluateSubject, + subjectKey, + normalizeContextId, + normalizedSubjectKey, + currentTrackedFlightSubject, + subjectCohortFeedUnknown, + selectSubject, + resolveSubjectPosition, + collectionSubjectPosition, + resolveSubjectLabel, + refreshSelectedSubject, + subjectFromContext, + markSubjectEvicted, + clearAwarenessSubject, + }; +} diff --git a/src/layers/awareness/testing.js b/src/layers/awareness/testing.js new file mode 100644 index 000000000..d7bee51cd --- /dev/null +++ b/src/layers/awareness/testing.js @@ -0,0 +1,15 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */ + + function _getAwarenessNavigationStateForTest() { + return { + historyKeys: layerState.navigationHistory.map(parts.subject.subjectKey), + navigationVisitedKeys: [...layerState.navigationVisited], + historyLength: layerState.navigationHistory.length, + navigationIndex: layerState.navigationIndex, + suppressedHistoryKey: layerState.suppressedHistoryKey, + pendingSelectionKey: layerState.pendingSelectionKey, + }; + } + return { _getAwarenessNavigationStateForTest }; +} diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js new file mode 100644 index 000000000..bfeccf924 --- /dev/null +++ b/src/layers/bikeshare/controls.js @@ -0,0 +1,46 @@ +import { STATUS_POLL_MS } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'bikeshare', + + name: 'Bikeshare', + + icon: '🚲', + + source: 'GBFS', + + updateInterval: STATUS_POLL_MS, + + /** + * 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 parts.queries.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: layerState._count, + lastUpdate: layerState._lastUpdate, + loading: layerState._loading, + }; + if (layerState._loading) { + stats.loadingLabel = + layerState._activeCityIds.size > 0 + ? `syncing ${layerState._activeCityIds.size} city feeds...` + : 'scanning nearby systems...'; + } + if (layerState._error) stats.error = layerState._error; + return stats; + }, + }; + + return { methods }; +} diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js new file mode 100644 index 000000000..b66bbae35 --- /dev/null +++ b/src/layers/bikeshare/index.js @@ -0,0 +1,50 @@ +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createQueries } from './queries.js'; +import { createTesting } from './testing.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createBikeshareLayer({ services, source }) { + if (typeof source?.getStations !== 'function') + throw new TypeError('A bikeshare source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.ingestion = createIngestion(context); + parts.model = createModel(context); + parts.viewport = createViewport(context); + parts.rendering = createRendering(context); + parts.selection = createSelection(context); + parts.queries = createQueries(context); + parts.testing = createTesting(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + createBikeshareSelectedOverlayEntry: + parts.selection.createBikeshareSelectedOverlayEntry, + _setBikeshareSelectionStateForTest: + parts.testing._setBikeshareSelectionStateForTest, + _selectBikeshareStationForTest: + parts.testing._selectBikeshareStationForTest, + _clearBikeshareSelectionForTest: + parts.testing._clearBikeshareSelectionForTest, + }, + ); +} +export { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export { createBikeshareSource } from './source.js'; diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js new file mode 100644 index 000000000..732ec9c90 --- /dev/null +++ b/src/layers/bikeshare/ingestion.js @@ -0,0 +1,212 @@ +import { CITY_BY_ID } from './registry.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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() { + layerState._loadingOps++; + layerState._loading = true; + } + + /** Decrement the loading reference count; clear loading flag when zero. */ + + function endLoading() { + layerState._loadingOps = Math.max(0, layerState._loadingOps - 1); + layerState._loading = layerState._loadingOps > 0; + } + + /** + * 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, options) { + return source.getStations(upstreamUrl, options); + } + + /** + * 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 layerState._inFlightInfo.keys()) + abortInFlight(layerState._inFlightInfo, cityId); + for (const cityId of layerState._inFlightStatus.keys()) + abortInFlight(layerState._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 (layerState._stationInfoCache.has(cityId)) + return layerState._stationInfoCache.get(cityId); + const inFlight = layerState._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 = parts.model.parseStationInformation(payload); + if (stationMap.size === 0) { + throw new Error(`No station information for ${city.city}`); + } + layerState._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 = layerState._inFlightInfo.get(cityId); + if (current && current.controller === controller) + layerState._inFlightInfo.delete(cityId); + }); + + layerState._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 = layerState._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 = parts.model.parseStationStatus(payload); + layerState._statusCache.set(cityId, { + statusMap, + timestamp: Date.now(), + }); + return statusMap; + } finally { + endLoading(); + } + })().finally(() => { + const current = layerState._inFlightStatus.get(cityId); + if (current && current.controller === controller) + layerState._inFlightStatus.delete(cityId); + }); + + layerState._inFlightStatus.set(cityId, { promise, controller, generation }); + return promise; + } + const methods = { + /** + * 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 (!layerState._enabled || layerState._activeCityIds.size === 0) return; + + const generation = layerState._proximityGeneration; + const cityIds = Array.from(layerState._activeCityIds); + await Promise.all( + cityIds.map(async (cityId) => { + try { + const statusMap = await loadCityStationStatus(cityId, generation); + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + parts.rendering.applyStatusToPoints(cityId, statusMap); + } catch (error) { + if (error?.name === 'AbortError') return; + console.warn( + `[Data:Bikeshare] ${cityId} status update error:`, + error, + ); + layerState._error = 'GBFS status update failed'; + } + }), + ); + + layerState._count = layerState._stationRenderMap.size; + if (layerState._count > 0) layerState._lastUpdate = Date.now(); + }, + }; + + return { + toProxyUrl, + beginLoading, + endLoading, + fetchGbfsJson, + abortInFlight, + abortAllInFlight, + loadCityStationInfo, + loadCityStationStatus, + methods, + }; +} diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js new file mode 100644 index 000000000..aeabac64b --- /dev/null +++ b/src/layers/bikeshare/lifecycle.js @@ -0,0 +1,171 @@ +import * as Cesium from 'cesium'; +import { GBFS_CITY_REGISTRY } from './registry.js'; +import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + /** + * 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) { + layerState._viewer = viewer; + layerState._pointCollection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + viewer.scene.primitives.add(layerState._pointCollection); + registerSpriteCollection('bikeshare', layerState._pointCollection); + layerState._pointCollection.show = false; + + layerState._enabled = false; + layerState._cameraDebounceTimer = null; + layerState._cameraChangedAttached = false; + layerState._altitudeGateEnabled = false; + layerState._proximityGeneration = 0; + + layerState._activeCityIds = new Set(); + layerState._cityRuntime = new Map(); + layerState._stationInfoCache = new Map(); + layerState._statusCache = new Map(); + layerState._inFlightInfo = new Map(); + layerState._inFlightStatus = new Map(); + layerState._stationRenderMap = new Map(); + layerState._clickHandler = null; + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._loading = false; + layerState._loadingOps = 0; + layerState._error = null; + layerState._limitWarned = false; + + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + + parts.selection._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) { + layerState._enabled = true; + layerState._error = null; + layerState._pointCollection.show = true; + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + true, + ); + parts.selection._installClickHandler(viewer); + // Pick-ownership (H2): station point ids are string render-map keys. + registerPickOwner('bikeshare', (pickedId) => + layerState._stationRenderMap.has(pickedId), + ); + + if (!layerState._cameraChangedAttached) { + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + viewer.camera.percentageChanged = Math.min( + viewer.camera.percentageChanged || 1, + 0.05, + ); + layerState._cameraChangedAttached = true; + } + + void parts.viewport.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) { + layerState._enabled = false; + layerState._proximityGeneration++; + layerState._altitudeGateEnabled = false; + clearTimeout(layerState._cameraDebounceTimer); + layerState._cameraDebounceTimer = null; + parts.selection._clearSelection(); + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + document.removeEventListener('keydown', parts.selection._onKeyDown); + unregisterPickOwner('bikeshare'); + + if (layerState._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + layerState._cameraChangedAttached = false; + } + + parts.ingestion.abortAllInFlight(); + parts.viewport.deactivateAllCities(); + layerState._cityRuntime.clear(); + layerState._pointCollection.show = false; + layerState._count = 0; + layerState._loading = false; + layerState._loadingOps = 0; + }, + + /** Permanently release primitives, handlers, and the selected host source. */ + destroy(viewer) { + if (layerState._enabled) this.disable(viewer); + else { + parts.selection._clearSelection(); + layerState._overlayHost.setVisible( + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + document.removeEventListener('keydown', parts.selection._onKeyDown); + unregisterPickOwner('bikeshare'); + } + if (layerState._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + layerState._cameraChangedAttached = false; + } + parts.ingestion.abortAllInFlight(); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + layerState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js new file mode 100644 index 000000000..89628f613 --- /dev/null +++ b/src/layers/bikeshare/model.js @@ -0,0 +1,162 @@ +export function createModel({ state: layerState, services, parts, source }) { + /** + * 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}`; + } + + /** + * 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)); + } + + /** + * 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; + } + return { + normalizeGbfsBool, + toNonNegativeInteger, + stationKey, + haversineKm, + extractStationsArray, + parseStationInformation, + parseStationStatus, + }; +} diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js new file mode 100644 index 000000000..14f1edf6e --- /dev/null +++ b/src/layers/bikeshare/policy.js @@ -0,0 +1,85 @@ +import * as Cesium from 'cesium'; + +export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected'; + +export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 0, + moving: false, +}); + +// --- Activation / display thresholds --- +/** Altitude (m) at which bikeshare layer becomes eligible for display. */ + +export const ACTIVATION_ALTITUDE_M = 50000; + +/** Hysteresis enter threshold — layer activates when camera drops below this. */ + +export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000; + +/** Hysteresis exit threshold — layer deactivates when camera rises above this. */ + +export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000; + +/** Debounce interval (ms) for camera-change proximity checks. */ + +export const CAMERA_DEBOUNCE_MS = 340; + +/** Default radius (km) for determining whether a city is in camera range. */ + +export const CITY_RANGE_BASE_KM = 100; + +/** Polling interval (ms) for refreshing station status data. */ + +export const STATUS_POLL_MS = 60000; + +// --- Point rendering constants --- +/** Minimum rendered point size in pixels. */ + +export const POINT_SIZE_MIN = 4; + +/** Maximum rendered point size in pixels. */ + +export const POINT_SIZE_MAX = 12; + +/** Fallback station capacity when real data is unavailable. */ + +export const DEFAULT_CAPACITY = 15; + +/** Vertical offset (m) above terrain for station points. */ + +export const POINT_HEIGHT_OFFSET_M = 2.0; + +/** Hard cap on total rendered station points across all cities. */ + +export const MAX_TOTAL_POINTS = 8000; + +// --- Availability color palette --- +/** Station has >60% bikes available. */ + +export const COLOR_GREEN = + Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95); + +/** Station has 30-60% bikes available. */ + +export const COLOR_YELLOW = + Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94); + +/** Station has <30% bikes available. */ + +export const COLOR_RED = + Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94); + +/** No status data available for station. */ + +export const COLOR_NEUTRAL = + Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62); + +/** Station is offline (not installed, not renting, or not returning). */ + +export const COLOR_MUTED = + Cesium.Color.fromCssColorString('#687581').withAlpha(0.48); + +/** Outline color for all station points. */ + +export const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25); diff --git a/src/layers/bikeshare/queries.js b/src/layers/bikeshare/queries.js new file mode 100644 index 000000000..550dbf030 --- /dev/null +++ b/src/layers/bikeshare/queries.js @@ -0,0 +1,75 @@ +export function createQueries({ state: layerState, services, parts, source }) { + /** + * 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 ( + !layerState._enabled || + !layerState._pointCollection || + !layerState._pointCollection.show || + layerState._stationRenderMap.size === 0 + ) + return []; + + // Gather all visible station records (include selected even though its point is hidden) + const records = []; + for (const record of layerState._stationRenderMap.values()) { + const isSelected = record.key === layerState._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 === layerState._selectedKey, + }); + if (result.length >= maxCount) break; + } + + return result; + } + return { buildDetectionId, collectDetectableStations }; +} diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js new file mode 100644 index 000000000..ff752f664 --- /dev/null +++ b/src/layers/bikeshare/registry.js @@ -0,0 +1,394 @@ +function buildBcycleUrls(systemId) { + return { + stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`, + stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`, + }; +} +function bcycleEntry({ + id, + city, + centerLat, + centerLon, + systemId, + loadRadiusKm = 100, + provider = 'BCycle', +}) { + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + ...buildBcycleUrls(systemId), + }; +} +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.015, + 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.891, + 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.512, + systemId: 'bcycle_cincyredbike', + provider: 'Red Bike', + }), + bcycleEntry({ + id: 'el-paso-bcycle', + city: 'El Paso, TX', + centerLat: 31.7619, + centerLon: -106.485, + 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.049, + systemId: 'bcycle_memphis', + }), + bcycleEntry({ + id: 'des-moines-bcycle', + city: 'Des Moines, IA', + centerLat: 41.5868, + centerLon: -93.625, + 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.394, + 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', + }), +]; +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, + }; +} +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; + }); +})(); +const CITY_BY_ID = new Map( + GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry]), +); +export { GBFS_CITY_REGISTRY, CITY_BY_ID }; diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js new file mode 100644 index 000000000..f0c737d1d --- /dev/null +++ b/src/layers/bikeshare/rendering.js @@ -0,0 +1,282 @@ +import * as Cesium from 'cesium'; +import { + DEFAULT_CAPACITY, + POINT_SIZE_MIN, + POINT_SIZE_MAX, + COLOR_NEUTRAL, + COLOR_MUTED, + COLOR_GREEN, + COLOR_YELLOW, + COLOR_RED, + POINT_HEIGHT_OFFSET_M, + MAX_TOTAL_POINTS, + COLOR_OUTLINE, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { governorRequestRender } = services.render; + + /** + * 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 = parts.model.toNonNegativeInteger(capacityFromInfo); + if (fromInfo && fromInfo > 0) return fromInfo; + + // Derive capacity from current bikes + available docks + const bikes = parts.model.toNonNegativeInteger(status?.bikesAvailable, 0); + const docks = parts.model.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 = parts.model.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; + } + + /** + * 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 (layerState._viewer?.scene?.sampleHeightSupported) { + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + const sampled = layerState._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 (layerState._cityRuntime.has(cityId)) + return layerState._cityRuntime.get(cityId); + const runtime = { stationKeys: new Set() }; + layerState._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 = parts.model.stationKey(cityId, station.stationId); + if (layerState._stationRenderMap.has(key)) { + runtime.stationKeys.add(key); + continue; + } + + // Enforce global point cap + if (layerState._stationRenderMap.size >= MAX_TOTAL_POINTS) { + if (!layerState._limitWarned) { + layerState._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 = layerState._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, + }); + + layerState._stationRenderMap.set(key, { + key, + cityId, + stationId: station.stationId, + stationName: station.name, + point, + capacity: parts.model.toNonNegativeInteger(station.capacity), + bikesAvailable: null, + docksAvailable: null, + isInstalled: station.isInstalled, + isRenting: station.isRenting, + isReturning: station.isReturning, + }); + + runtime.stationKeys.add(key); + } + + layerState._count = layerState._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 = layerState._cityRuntime.get(cityId); + if (!runtime) return; + + // Clear selection if it belongs to the city being removed + if ( + layerState._selectedKey && + runtime.stationKeys.has(layerState._selectedKey) + ) { + parts.selection._clearSelection(); + } + + for (const key of runtime.stationKeys) { + const record = layerState._stationRenderMap.get(key); + if (!record) continue; + layerState._pointCollection.remove(record.point); + layerState._stationRenderMap.delete(key); + } + + layerState._cityRuntime.delete(cityId); + layerState._count = layerState._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 = layerState._cityRuntime.get(cityId); + if (!runtime) return; + + for (const key of runtime.stationKeys) { + const record = layerState._stationRenderMap.get(key); + if (!record) continue; + + const status = statusMap.get(record.stationId) || null; + const capacity = resolveCapacity(record.capacity, status); + record.capacity = capacity; + + record.bikesAvailable = parts.model.toNonNegativeInteger( + status?.bikesAvailable, + ); + record.docksAvailable = parts.model.toNonNegativeInteger( + status?.docksAvailable, + ); + record.isInstalled = parts.model.normalizeGbfsBool( + status?.isInstalled, + true, + ); + record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true); + record.isReturning = parts.model.normalizeGbfsBool( + status?.isReturning, + true, + ); + + // Update visual properties based on current status + record.point.pixelSize = capacityToPixelSize(capacity); + record.point.color = statusToColor(status, capacity); + } + } + return { + resolveCapacity, + capacityToPixelSize, + statusToColor, + createStationPosition, + ensureCityRuntime, + ensureCityPoints, + removeCityPoints, + applyStatusToPoints, + }; +} diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/selection.js new file mode 100644 index 000000000..74e540230 --- /dev/null +++ b/src/layers/bikeshare/selection.js @@ -0,0 +1,206 @@ +import * as Cesium from 'cesium'; +import { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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} + */ + + 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 (layerState._selectedKey) { + const record = layerState._stationRenderMap.get(layerState._selectedKey); + if (record?.point) { + record.point.show = true; + } + } + + if (layerState._selectedEntity && layerState._viewer) { + layerState._viewer.entities.remove(layerState._selectedEntity); + } + + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._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 = layerState._stationRenderMap.get(key); + if (!record || !record.point?.position || !layerState._viewer) return; + + layerState._selectedKey = key; + // Hide the base point so the highlight entity replaces it visually + record.point.show = false; + + layerState._selectedEntity = layerState._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) { + layerState._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 (layerState._clickHandler) return; + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking selected entity itself — ignore (don't deselect). + if (picked.id === layerState._selectedEntity) return; + + // Check if the picked primitive or entity id matches a station key + const primitive = picked.primitive; + if ( + primitive && + typeof primitive.id === 'string' && + layerState._stationRenderMap.has(primitive.id) + ) { + _selectStation(primitive.id); + return; + } + if ( + typeof picked.id === 'string' && + layerState._stationRenderMap.has(picked.id) + ) { + _selectStation(picked.id); + return; + } + } + + // Clicked empty space — deselect. + if (layerState._selectedKey) _clearSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + document.addEventListener('keydown', _onKeyDown); + } + + /** + * Global keydown handler — deselects the current station on Escape. + * @param {KeyboardEvent} e - Keyboard event. + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && layerState._selectedKey) { + _clearSelection(); + } + } + return { + buildSelectionLabel, + createBikeshareSelectedOverlayEntry, + _clearSelection, + _selectStation, + _installClickHandler, + _onKeyDown, + }; +} diff --git a/src/layers/bikeshare/source.js b/src/layers/bikeshare/source.js new file mode 100644 index 000000000..effcb9a57 --- /dev/null +++ b/src/layers/bikeshare/source.js @@ -0,0 +1,23 @@ +/** Read a GBFS station document through the existing bounded server proxy. */ +export function createBikeshareSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getStations(upstreamUrl, { signal } = {}) { + const url = new URL(upstreamUrl); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) + throw new TypeError('A public HTTPS GBFS URL is required'); + signal?.throwIfAborted(); + const response = await fetchImpl( + '/api/gbfs?url=' + encodeURIComponent(url.href), + { method: 'GET', headers: { Accept: 'application/json' }, signal }, + ); + if (!response.ok) throw new Error('GBFS HTTP ' + response.status); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!payload || typeof payload !== 'object') + throw new Error('Malformed GBFS payload'); + return payload; + }, + }; +} diff --git a/src/layers/bikeshare/source.test.mjs b/src/layers/bikeshare/source.test.mjs new file mode 100644 index 000000000..cee7e2ab1 --- /dev/null +++ b/src/layers/bikeshare/source.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBikeshareSource } from './source.js'; +test('station source keeps upstream URLs behind the fixed GBFS endpoint', async () => { + const calls = []; + const source = createBikeshareSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"data":{"stations":[]}}'); + }, + }); + for (const url of [ + 'file:///etc/passwd', + 'http://example.test/stations', + 'https://user:pass@example.test/stations', + ]) + await assert.rejects(source.getStations(url), /HTTPS GBFS/); + assert.equal(calls.length, 0); + await source.getStations('https://example.test/stations.json'); + const url = new URL(calls[0][0], 'https://app.example'); + assert.equal(url.pathname, '/api/gbfs'); + assert.equal( + url.searchParams.get('url'), + 'https://example.test/stations.json', + ); +}); +test('cancelled station parsing never publishes the response', async () => { + const controller = new AbortController(); + const source = createBikeshareSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { data: { stations: [] } }; + }, + }), + }); + await assert.rejects( + source.getStations('https://example.test/stations', { + signal: controller.signal, + }), + { name: 'AbortError' }, + ); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js new file mode 100644 index 000000000..381384147 --- /dev/null +++ b/src/layers/bikeshare/state.js @@ -0,0 +1,110 @@ +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const layerState = {}; + + layerState.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + + // --------------------------------------------------------------------------- + // Module-level mutable state + // --------------------------------------------------------------------------- + + /** @type {Cesium.Viewer|null} Active Cesium viewer instance. */ + + layerState._viewer = null; + + /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ + + layerState._pointCollection = null; + + /** Whether the bikeshare layer is currently enabled. */ + + layerState._enabled = false; + + /** Timer handle for camera-move debounce. */ + + layerState._cameraDebounceTimer = null; + + /** Whether the camera.changed listener is currently attached. */ + + layerState._cameraChangedAttached = false; + + /** Hysteresis flag for altitude-based activation. */ + + layerState._altitudeGateEnabled = false; + + /** Monotonic generation counter; incremented on each proximity check to cancel stale work. */ + + layerState._proximityGeneration = 0; + + /** @type {Set} City ids currently considered in-range. */ + + layerState._activeCityIds = new Set(); + + /** @type {Map }>} Per-city runtime tracking of rendered station keys. */ + + layerState._cityRuntime = new Map(); + + /** @type {Map>} Cached station information per city (cityId -> stationId -> StationInfo). */ + + layerState._stationInfoCache = new Map(); + + /** @type {Map }>} Cached station status per city. */ + + layerState._statusCache = new Map(); + + /** @type {Map} In-flight station info requests. */ + + layerState._inFlightInfo = new Map(); + + /** @type {Map} In-flight station status requests. */ + + layerState._inFlightStatus = new Map(); + + /** @type {Map} Render records keyed by "cityId:stationId". */ + + layerState._stationRenderMap = new Map(); + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */ + + layerState._clickHandler = null; + + /** @type {string|null} Key of the currently selected station, or null. */ + + layerState._selectedKey = null; + + /** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */ + + layerState._selectedEntity = null; + + /** Total number of currently rendered station points. */ + + layerState._count = 0; + + /** Timestamp (ms) of the last successful status update. */ + + layerState._lastUpdate = null; + + /** Whether any GBFS fetch is currently in progress. */ + + layerState._loading = false; + + /** Reference count of concurrent loading operations. */ + + layerState._loadingOps = 0; + + /** Most recent error message string, or null. */ + + layerState._error = null; + + /** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */ + + layerState._limitWarned = false; + return layerState; +} diff --git a/src/layers/bikeshare/testing.js b/src/layers/bikeshare/testing.js new file mode 100644 index 000000000..6dca0e658 --- /dev/null +++ b/src/layers/bikeshare/testing.js @@ -0,0 +1,34 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** Seed a selected-station runtime record while still exercising real select/clear paths. */ + + function _setBikeshareSelectionStateForTest({ + viewer, + key, + record, + overlayHost, + }) { + layerState._viewer = viewer; + layerState._stationRenderMap = new Map([[key, record]]); + layerState._selectedKey = null; + layerState._selectedEntity = null; + layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST; + } + + /** Exercise the production selection path in focused runtime tests. */ + + function _selectBikeshareStationForTest(key) { + parts.selection._selectStation(key); + } + + /** Exercise the production clear path and restore the production host seam. */ + + function _clearBikeshareSelectionForTest() { + parts.selection._clearSelection(); + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + } + return { + _setBikeshareSelectionStateForTest, + _selectBikeshareStationForTest, + _clearBikeshareSelectionForTest, + }; +} diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js new file mode 100644 index 000000000..e4c03a690 --- /dev/null +++ b/src/layers/bikeshare/viewport.js @@ -0,0 +1,257 @@ +import * as Cesium from 'cesium'; +import { GBFS_CITY_REGISTRY } from './registry.js'; +import { + ACTIVATION_EXIT_ALTITUDE_M, + ACTIVATION_ENTER_ALTITUDE_M, + CITY_RANGE_BASE_KM, + CAMERA_DEBOUNCE_MS, +} from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + const { governorRequestRender } = services.render; + + /** + * 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; + } + + /** + * 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)) { + layerState._altitudeGateEnabled = false; + return false; + } + if (layerState._altitudeGateEnabled) { + // Deactivate only after crossing the higher exit threshold + if (altitude >= ACTIVATION_EXIT_ALTITUDE_M) + layerState._altitudeGateEnabled = false; + } else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) { + // Activate when dropping below the lower enter threshold + layerState._altitudeGateEnabled = true; + } + return layerState._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 = parts.model.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; + } + + /** + * 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'); + parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId); + parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId); + parts.rendering.removeCityPoints(cityId); + } + + /** Deactivate all currently active cities and clear the active set. */ + + function deactivateAllCities() { + const cityIds = Array.from(layerState._activeCityIds); + for (const cityId of cityIds) deactivateCity(cityId); + layerState._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 (!layerState._enabled || !layerState._activeCityIds.has(cityId)) return; + + try { + const stationMap = await parts.ingestion.loadCityStationInfo( + cityId, + generation, + ); + // Bail if context changed during fetch + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + + parts.rendering.ensureCityPoints(cityId, stationMap); + + // Apply any cached status immediately for snappier initial rendering + const cachedStatus = layerState._statusCache.get(cityId)?.statusMap; + if (cachedStatus) { + parts.rendering.applyStatusToPoints(cityId, cachedStatus); + } + + const statusMap = await parts.ingestion.loadCityStationStatus( + cityId, + generation, + ); + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; + parts.rendering.applyStatusToPoints(cityId, statusMap); + layerState._lastUpdate = Date.now(); + layerState._error = null; + } catch (error) { + if (error?.name === 'AbortError') return; + console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); + layerState._error = 'GBFS fetch error'; + deactivateCity(cityId); + layerState._activeCityIds.delete(cityId); + } finally { + layerState._count = layerState._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 (!layerState._enabled || !layerState._viewer) return; + + const generation = ++layerState._proximityGeneration; + const altitude = getCameraAltitude(layerState._viewer); + // Altitude gate: disable all cities when camera is too high + if (!shouldActivateAtAltitude(altitude)) { + deactivateAllCities(); + return; + } + + const center = getCameraCenterLatLon(layerState._viewer); + if (!center) return; + + // Diff active set against newly computed in-range set + const nextActive = computeInRangeCities(center); + for (const cityId of layerState._activeCityIds) { + if (!nextActive.has(cityId)) { + deactivateCity(cityId); + } + } + + layerState._activeCityIds = nextActive; + if (layerState._activeCityIds.size === 0) { + layerState._count = 0; + return; + } + + // Only activate cities that don't already have rendered points + const toActivate = []; + for (const cityId of layerState._activeCityIds) { + if (!layerState._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(layerState._cameraDebounceTimer); + layerState._cameraDebounceTimer = setTimeout(() => { + void runProximityCheck(); + }, CAMERA_DEBOUNCE_MS); + } + + /** Camera change event handler — triggers a debounced proximity check. */ + + function onCameraChanged() { + if (!layerState._enabled) return; + scheduleProximityCheck(); + } + return { + getCameraAltitude, + getCameraCenterLatLon, + shouldActivateAtAltitude, + computeInRangeCities, + deactivateCity, + deactivateAllCities, + activateCity, + runProximityCheck, + scheduleProximityCheck, + onCameraChanged, + }; +} diff --git a/src/layers/cctv/calibration.js b/src/layers/cctv/calibration.js new file mode 100644 index 000000000..35402d530 --- /dev/null +++ b/src/layers/cctv/calibration.js @@ -0,0 +1,309 @@ +import { createCalibrationGizmo } from '../../data/cctvGizmo.js'; +import { + DEFAULT_CAMERA_CALIBRATION, + CCTV_CALIBRATION_STORAGE_KEY_V2, +} from './policy.js'; + +export function createCalibration({ + state: layerState, + services, + parts, + source, +}) { + /** + * 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} + */ + + function calibrationPatchMovesAnchor(patch) { + if (!patch || typeof patch !== 'object') return false; + return ( + Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') || + Object.prototype.hasOwnProperty.call(patch, 'offsetEastM') + ); + } + + /** + * Sanitizes and clamps a calibration object to valid ranges. + * Missing or non-finite fields fall back to defaults. + * @param {Object} [value={}] - Raw calibration values. + * @returns {{ offsetNorthM: number, offsetEastM: number, headingDeg: number, pitchDeg: number, fovDeg: number, rangeScale: number, heightM: number }} + */ + + function normalizeCalibration(value = {}) { + const raw = value && typeof value === 'object' ? value : {}; + return { + offsetNorthM: parts.model.quantize( + parts.model.clamp( + parts.model.safeNumber(raw.offsetNorthM, 0), + -900, + 900, + ), + 0.1, + ), + offsetEastM: parts.model.quantize( + parts.model.clamp( + parts.model.safeNumber(raw.offsetEastM, 0), + -900, + 900, + ), + 0.1, + ), + headingDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.headingDeg, 0), -180, 180), + 0.1, + ), + pitchDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.pitchDeg, 0), -45, 45), + 0.1, + ), + fovDeg: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.fovDeg, 0), -50, 50), + 0.1, + ), + rangeScale: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.rangeScale, 1), 0.35, 3.0), + 0.01, + ), + heightM: parts.model.quantize( + parts.model.clamp(parts.model.safeNumber(raw.heightM, 0), -120, 240), + 0.1, + ), + }; + } + + /** + * Returns true if the given calibration is effectively the default (all offsets near zero). + * @param {Object} calibration + * @returns {boolean} + */ + + function isDefaultCalibration(calibration) { + const probe = normalizeCalibration(calibration); + return Object.keys(DEFAULT_CAMERA_CALIBRATION).every( + (key) => Math.abs(probe[key] - DEFAULT_CAMERA_CALIBRATION[key]) < 0.0001, + ); + } + + /** + * Loads all persisted per-camera calibration overrides from the v2 store. + * + * v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual', + * savedAt: }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is + * NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import. + * + * @param {{getItem:function}|null} [storage] - Injectable storage (defaults + * to `window.localStorage`); lets the unit suite test this pure of a DOM. + * @returns {Map} + */ + + function readCalibrationStoreV2( + storage = parts.model.safeWindowLocalStorage(), + ) { + const map = new Map(); + if (!storage) return map; + try { + const raw = storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2); + if (!raw) return map; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return map; + for (const [cameraId, entry] of Object.entries(parsed)) { + if (!cameraId || !entry || typeof entry !== 'object') continue; + if (!entry.values || typeof entry.values !== 'object') continue; + // 'manual' is the only provenance v2 knows (§9.3 killed 'legacy'); a + // malformed/foreign source string still normalizes to 'manual' rather + // than surfacing an unrecognized value into the badge logic. + map.set(cameraId, { + values: normalizeCalibration(entry.values), + source: 'manual', + savedAt: parts.model.safeNumber(entry.savedAt, 0), + }); + } + return map; + } catch { + return map; + } + } + + /** + * Persists a calibration map to the v2 store. + * @param {Map} map + * @param {{setItem:function}|null} [storage] - Injectable storage (defaults + * to `window.localStorage`). + */ + + function writeCalibrationStoreV2( + map, + storage = parts.model.safeWindowLocalStorage(), + ) { + if (!storage) return; + try { + const payload = {}; + for (const [cameraId, entry] of map.entries()) { + payload[cameraId] = { + values: normalizeCalibration(entry.values), + source: 'manual', + savedAt: parts.model.safeNumber(entry.savedAt, Date.now()), + }; + } + storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload)); + } catch { + // storage unavailable + } + } + + /** + * Loads the v2 calibration store. `_calibrationById` holds these entries + * directly (`{values, source:'manual', savedAt}`) — never bare offset values + * — so it round-trips straight back through `writeCalibrationStoreV2`. + * @returns {Map} + */ + + function loadCalibrationStore() { + return readCalibrationStoreV2(); + } + + /** Persists the in-memory calibration entries (values + provenance) to the v2 store. */ + + function saveCalibrationStore() { + writeCalibrationStoreV2(layerState._calibrationById); + } + + /** + * Derives the panel CAL badge state for a camera (design §3b, as amended by + * the LOCKED §9.2 — panel-only, no in-world tint). + * + * Three states: + * - 'calibrated' — a human explicitly saved a v2 calibration (`source:'manual'`). + * - 'curated' — no manual save, but the catalog entry was hand-authored + * (`poseSource:'curated'`, file/env sources only). + * - 'raw-prior' — everything else (all Austin Open Data today). + * + * Pure — no scoring math, no raycasts. `confidenceFromScore` and score-based + * quality seeding are retired; this replaces them. + * @param {{calSource?: string|null, poseSource?: string|null}} camera + * @returns {'calibrated'|'curated'|'raw-prior'} + */ + + function deriveCalBadge(camera) { + if (camera?.calSource === 'manual') return 'calibrated'; + if (camera?.poseSource === 'curated') return 'curated'; + return 'raw-prior'; + } + + /** + * Applies a calibration patch to a record's IN-MEMORY pose (save-gated + * persistence, design §3e: no localStorage write here — only the explicit + * `calibration.save` action persists). + * + * Transient grade (gizmo mid-drag): recompute pose + frustum geometry only + * (the cheap v2 path) with throttled notify — no ground re-arm, no frame + * re-fetch, no store touch. + * Commit grade (drag end, numeric entry, voice): only an E/N anchor move + * resolves a new shared floor; all other edits keep the frozen reference. + * + * @param {Object} record - Camera record. + * @param {Object} patch - Partial 7-field calibration (absolute offset values). + * @param {{transient?: boolean}} [options] + * @returns {boolean} True when the patch applied. + */ + + function applyCalibrationPatch(record, patch, options = {}) { + if (!record || !patch || typeof patch !== 'object') return false; + record.camera.calibration = normalizeCalibration({ + ...record.camera.calibration, + ...patch, + }); + parts.model.ensureCameraPose(record.camera); + // §9.1: touching range takes manual control — clear the activation clamp. + if ('rangeScale' in patch) { + record.probeClampRangeM = null; + } + const anchorMoved = calibrationPatchMovesAnchor(patch); + if (options.transient === true && anchorMoved) { + record.calibrationAnchorDirty = true; + } + record.calDirty = true; + if (options.transient === true) { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + parts.presentation.notifyListenersThrottled(); + return true; + } + if (anchorMoved) { + parts.ground.resolveCommittedGroundAnchor(record); + } else { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + } + parts.frames.refreshProjectionImage(record, true); + return true; + } + + /** + * Lazily creates the calibration gizmo controller. The gizmo sees the layer + * only through these callbacks: it attaches to the active record while the + * layer is enabled AND ADJUST mode is on, funnels drags through + * applyCalibrationPatch (transient), and runs the commit tail on release. + */ + + function ensureGizmo() { + if (layerState._gizmo || !layerState._viewer) return; + // Both patch callbacks receive the gizmo's PINNED drag record — never + // re-resolve the active camera here: a mid-drag voice select or auto-hop + // would route the captured offsets onto a camera with a different basePose. + const liveRecord = (record) => + record && layerState._recordById.get(record.camera?.id) === record + ? record + : null; + layerState._gizmo = createCalibrationGizmo({ + viewer: layerState._viewer, + getActiveRecord: () => + layerState._enabled && layerState._calibrationMode + ? parts.selection.getActiveRecord() + : null, + applyPatch: (patch, draggedRecord) => { + const record = + layerState._enabled && layerState._calibrationMode + ? liveRecord(draggedRecord) + : null; + if (record) applyCalibrationPatch(record, patch, { transient: true }); + }, + endPatch: (draggedRecord) => { + const record = liveRecord(draggedRecord); + if (!record) return; + if (record.calibrationAnchorDirty) { + record.calibrationAnchorDirty = false; + parts.ground.resolveCommittedGroundAnchor(record); + } else { + parts.geometry.applyFrustumGeometry( + record, + parts.ground.groundAltFor(record), + ); + } + parts.frames.refreshProjectionImage(record, true); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }, + }); + } + return { + calibrationPatchMovesAnchor, + normalizeCalibration, + isDefaultCalibration, + readCalibrationStoreV2, + writeCalibrationStoreV2, + loadCalibrationStore, + saveCalibrationStore, + deriveCalBadge, + applyCalibrationPatch, + ensureGizmo, + }; +} diff --git a/src/layers/cctv/cards.js b/src/layers/cctv/cards.js new file mode 100644 index 000000000..72f0b88a4 --- /dev/null +++ b/src/layers/cctv/cards.js @@ -0,0 +1,404 @@ +import { + createFrameSlot, + CCTV_OVERLAY_SOURCE_ID, + declutterCctvCards, + planFrameCachePrune, + createCctvThumbnailOverlayEntry, + CCTV_FRAME_CANVAS_W, + CCTV_FRAME_CANVAS_H, + applyFrameResult, +} from '../../data/cctvCards.js'; +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js'; +import { + CCTV_OVERLAY_SOURCE_OPTIONS, + CARD_VIEW_MARGIN, + CCTV_AMBIENT_CARD_DRAIN_CAP, + CARD_GAP_PX, + CARD_FETCH_TICK_MS, +} from './policy.js'; + +export function createCards({ state: layerState, services, parts, source }) { + /** + * 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}} + */ + + function setCctvCardPresentationOptions({ + activeCameraCardEnabled = false, + } = {}) { + layerState._activeCameraCardEnabled = activeCameraCardEnabled === true; + if (layerState._enabled) pushAmbientCardEntries(); + layerState._viewer?.scene?.requestRender?.(); + + return { activeCameraCardEnabled: layerState._activeCameraCardEnabled }; + } + + // --------------------------------------------------------------------------- + // Ambient card tier (2026-07-29 design — spec: + // docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md) + // --------------------------------------------------------------------------- + + /** Returns (creating on demand) the stable frame slot for a camera id. */ + + function ensureCardFrameSlot(cameraId) { + let slot = layerState._cardFrameSlots.get(cameraId); + if (!slot) { + slot = createFrameSlot(); + layerState._cardFrameSlots.set(cameraId, slot); + } + return slot; + } + + /** + * Rebuilds the ambient card selection: horizon + in-view projection of the + * catalog (pure math — no scene queries), the zoom-budgeted nearest-first + * LOD pick, greedy screen-space declutter, and the eviction-grace pass that + * keeps budget-edge cards alive across small camera moves (zero-flicker). + * Runs on camera.moveEnd, enable, activation change, and the one-shot + * geometry-drain completion — NEVER per frame. The active camera is excluded + * from the ambient selection/quota. By default its monitor plane is the sole + * active representation; the optional protected-card path is applied only by + * `pushAmbientCardEntries`. Camera icons are never touched here (cards + * annotate markers, they don't replace them). + */ + + function refreshAmbientCards() { + if ( + !layerState._enabled || + !layerState._viewer || + layerState._viewer.isDestroyed() || + !layerState._records.length + ) { + layerState._cctvOverlayHost.setEntries( + CCTV_OVERLAY_SOURCE_ID, + [], + CCTV_OVERLAY_SOURCE_OPTIONS, + ); + return; + } + const scene = layerState._viewer.scene; + const carto = layerState._viewer.camera.positionCartographic; + const viewerLat = carto ? Cesium.Math.toDegrees(carto.latitude) : 0; + const viewerLon = carto ? Cesium.Math.toDegrees(carto.longitude) : 0; + // The active camera is excluded from ambient selection ALWAYS (not just + // after its async activation settles). It is published separately in the + // protected lane below, and grace never applies to it. + const activeId = layerState._activeCameraId; + const occluder = horizonOccluder(layerState._viewer.camera); + const width = scene.canvas.clientWidth || scene.canvas.width || 0; + const height = scene.canvas.clientHeight || scene.canvas.height || 0; + const marginX = width * CARD_VIEW_MARGIN; + const marginY = height * CARD_VIEW_MARGIN; + + const candidates = []; + const screenById = new Map(); + for (const record of layerState._records) { + const id = record.camera.id; + if (id === activeId || !record.position) continue; + let inView = false; + let sx = NaN; + let sy = NaN; + if (occluder.isPointVisible(record.position)) { + const screen = scene.cartesianToCanvasCoordinates(record.position); + if ( + screen && + Number.isFinite(screen.x) && + Number.isFinite(screen.y) && + screen.x >= -marginX && + screen.x <= width + marginX && + screen.y >= -marginY && + screen.y <= height + marginY + ) { + inView = true; + sx = screen.x; + sy = screen.y; + screenById.set(id, { sx, sy }); + } + } + candidates.push({ + id, + distanceKm: parts.model.haversineKm( + viewerLat, + viewerLon, + record.camera.lat, + record.camera.lon, + ), + inView, + isVideo: parts.model.isVideoFeedType( + parts.model.normalizeFeedType(record.camera.feedType), + ), + sx, + sy, + }); + } + + // Owner finding 4: current card holders rank with the 20% incumbency + // distance discount, so a small camera move never batch-swaps the ring. + // Item C: passing the viewport dims + per-candidate screen anchors routes + // the budget fill through the screen-distribution grid, so periphery + // cells hold cards instead of everything clustering at screen center. + const { cardIds, budgets } = selectCctvLod(candidates, { + cameraHeightM: carto?.height, + incumbentIds: layerState._cardIds, + viewW: width, + viewH: height, + }); + // Like the cold-fill burst, card density yields to the staggered geometry + // drain: painting the raised 20/28/40 budget per frame starves the + // frame-paced mesh-floor queue on weak GPUs (qa-cctv-v2 N=800 drain-budget + // regression). During the initial load the budget holds at the low tier; + // full density arrives the moment the drain completes (which triggers its + // own refreshAmbientCards pass). + const cardLimit = layerState._geoLoading + ? Math.min(budgets.cardLimit, CCTV_AMBIENT_CARD_DRAIN_CAP) + : budgets.cardLimit; + const decluttered = declutterCctvCards( + cardIds + .filter((id) => screenById.has(id)) + .slice(0, cardLimit) + .map((id, index) => ({ + id, + ...screenById.get(id), + // Priority carrier, not kilometers: declutter sorts ascending on + // this field, and the selection's order (distribution + incumbency) + // must survive — a periphery cell-winner must not be re-outranked + // by central proximity when two anchors contest the min separation. + distanceKm: index, + })), + { limit: cardLimit }, + ); + // Owner finding 2: grace must never apply to the active camera — drop any + // lingering grace entry and keep it out of the retained-card baseline. + if (activeId) { + layerState._cardIds.delete(activeId); + layerState._cardGraceState.delete(activeId); + } + const retention = applyEvictionGrace({ + selectedIds: decluttered, + builtIds: [...layerState._cardIds], + graceState: layerState._cardGraceState, + nowMs: Date.now(), + cardLimit: budgets.cardLimit, + }); + layerState._cardIds = new Set(retention.keepIds); + layerState._cardGraceState = retention.graceState; + + // Bounded thumbnail LRU: live cards (grace included) never lose their + // persisted frame — that persistence IS the no-flicker guarantee. The + // hover card's slot is protected too while the gesture lasts. + const keepFrames = new Set(layerState._cardIds); + if (layerState._hoverCardId) keepFrames.add(layerState._hoverCardId); + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) + keepFrames.add(layerState._activeCameraId); + const drops = planFrameCachePrune( + [...layerState._cardFrameSlots].map(([id, slot]) => ({ + id, + stamp: slot.stamp, + })), + keepFrames, + ); + for (const id of drops) layerState._cardFrameSlots.delete(id); + + pushAmbientCardEntries(); + } + + /** + * Builds and publishes the card entry list from the current kept set and the + * hover-summoned pin. The hover entry is budget-exempt and high-priority. The + * active camera is absent by default; `activeCameraCardEnabled` retains the + * migrated protected-publication path as an explicit product option. If the + * LOD selection adopts the hovered camera it remains a normal budgeted entry + * that keeps the pin while the gesture lasts. + */ + + function pushAmbientCardEntries() { + const entries = []; + let rank = 0; + const push = (id, { pinned = false, active = false } = {}) => { + const record = layerState._recordById.get(id); + if (!record?.position) return; + entries.push( + createCctvThumbnailOverlayEntry({ + id, + position: record.position, + gapPx: CARD_GAP_PX, + title: record.camera.name, + frameSlot: ensureCardFrameSlot(id), + rank: rank++, + pinned, + active, + }), + ); + }; + for (const id of layerState._cardIds) + push(id, { pinned: id === layerState._hoverCardId }); + if ( + layerState._hoverCardId && + !layerState._cardIds.has(layerState._hoverCardId) && + layerState._hoverCardId !== layerState._activeCameraId + ) { + push(layerState._hoverCardId, { pinned: true }); + } + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) { + push(layerState._activeCameraId, { active: true }); + } + layerState._cctvOverlayHost.setEntries( + CCTV_OVERLAY_SOURCE_ID, + entries, + CCTV_OVERLAY_SOURCE_OPTIONS, + ); + } + + /** + * Fetches one paced static frame and settles it into the stable slot via the + * pure persistence rule (applyFrameResult): success replaces the thumbnail, + * failure leaves the drawn frame untouched. The frame is downscaled once + * into a 2x-thumb offscreen canvas; the renderer reads the slot live. + * @param {Object} record - Camera record. + * @param {Object} slot - The camera's stable frame slot. + * @param {number} refreshMs - Source cadence (also keys the frame-URL tick). + * @param {Object} [options] + * @param {boolean} [options.userGesture] - Hover fast-track (item B): the + * launch bypasses the pacer gate, so its spacing sample would pollute the + * pacing telemetry — skip the min-spacing sample only. The launch still + * stamps `_cardLastFetchAt`, so the pacer waits a full interval after it. + */ + + function fetchCardFrame( + record, + slot, + refreshMs, + { userGesture = false } = {}, + ) { + if (typeof document !== 'undefined' && document.hidden && !userGesture) + return; + const now = Date.now(); + const cameraId = record.camera.id; + layerState._cardFetchInFlightCount += 1; + layerState._cardFetchPendingIds.add(cameraId); + if (layerState._cardLastFetchAt > 0 && !userGesture) { + const spacing = now - layerState._cardLastFetchAt; + // NOTE: cold-fill bursts legitimately push this to ~250 ms — read it + // together with the ambientCards.fetchMode telemetry. + layerState._cardMinFetchSpacingMs = + layerState._cardMinFetchSpacingMs == null + ? spacing + : Math.min(layerState._cardMinFetchSpacingMs, spacing); + } + layerState._cardLastFetchAt = now; + layerState._cardFetchCount += 1; + + const image = new Image(); + layerState._cardFetchImages.add(image); + const settle = (ok) => { + image.onload = null; + image.onerror = null; + if (layerState._cardFetchImages.delete(image)) { + layerState._cardFetchInFlightCount = Math.max( + 0, + layerState._cardFetchInFlightCount - 1, + ); + layerState._cardFetchPendingIds.delete(cameraId); + } + let frame = null; + if (ok) { + try { + const canvas = document.createElement('canvas'); + canvas.width = CCTV_FRAME_CANVAS_W; + canvas.height = CCTV_FRAME_CANVAS_H; + canvas + .getContext('2d') + .drawImage(image, 0, 0, canvas.width, canvas.height); + frame = canvas; + } catch { + frame = null; + } + } + Object.assign( + slot, + applyFrameResult(slot, { ok: !!frame, frame }, Date.now()), + ); + layerState._viewer?.scene?.requestRender?.(); + }; + image.onload = () => settle(true); + image.onerror = () => settle(false); + image.src = parts.frames.frameUrlFor(record.camera, refreshMs); + } + + /** Starts the card-frame pacer (idempotent; policy-gated per tick). */ + + function startCardFrameLoop() { + if (layerState._cardFetchTimer) return; + layerState._cardFetchTimer = setInterval( + parts.model.cardFrameTick, + CARD_FETCH_TICK_MS, + ); + } + + /** Stops the pacer and detaches all in-flight fetch handlers. */ + + function stopCardFrameLoop() { + if (layerState._cardFetchTimer) { + clearInterval(layerState._cardFetchTimer); + layerState._cardFetchTimer = 0; + } + for (const image of layerState._cardFetchImages) { + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + layerState._cardFetchImages.clear(); + layerState._cardFetchPendingIds.clear(); + layerState._cardFetchInFlightCount = 0; + layerState._cardFetchMode = 'steady'; + } + + /** + * Complete ambient-tier teardown (layer disable/destroy): pacer + in-flight + * handlers, shared-host source entries, card set, grace state, thumbnail + * cache, and fetch telemetry. Nothing leaks across a toggle. + */ + + function teardownAmbientCards() { + stopCardFrameLoop(); + layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID); + layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false); + parts.hover.clearHoverCard(); + layerState._cameraMoving = false; + layerState._cardIds = new Set(); + layerState._cardGraceState = new Map(); + layerState._cardFrameSlots = new Map(); + layerState._cardFetchCount = 0; + layerState._cardLastFetchAt = 0; + layerState._cardMinFetchSpacingMs = null; + } + function handleVisibilityChange() { + if (!document.hidden) return; + for (const image of layerState._cardFetchImages) { + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + layerState._cardFetchImages.clear(); + layerState._cardFetchPendingIds.clear(); + layerState._cardFetchInFlightCount = 0; + } + return { + handleVisibilityChange, + setCctvCardPresentationOptions, + ensureCardFrameSlot, + refreshAmbientCards, + pushAmbientCardEntries, + fetchCardFrame, + startCardFrameLoop, + stopCardFrameLoop, + teardownAmbientCards, + }; +} diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js new file mode 100644 index 000000000..4cc66727c --- /dev/null +++ b/src/layers/cctv/catalog.js @@ -0,0 +1,210 @@ +import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; + +export function createCatalog({ state: layerState, services, parts, source }) { + const { CITY_POIS } = services.locations; + + /** + * Builds the initial camera catalog from CAMERA_SEEDS definitions. + * Each seed is resolved against its city's POI coordinates, offset, and + * passed through ensureCameraPose to populate derived fields. + * @returns {Object[]} Array of fully-initialized camera objects. + */ + + function seedCatalog() { + const catalog = []; + for (const seed of CAMERA_SEEDS) { + const city = CITY_POIS[seed.cityId]; + const poi = city?.pois?.[seed.poiIndex]; + if (!city || !poi) continue; + const { latOffset, lonOffset } = parts.model.offsetDegrees( + poi.lat, + seed.offsetNorthM || 0, + seed.offsetEastM || 0, + ); + const camera = { + id: seed.id, + name: seed.label, + cityId: seed.cityId, + city: city.name, + provider: 'OSM Camera Grid', + sourceKind: 'seed', + feedType: 'image', + feedConfigured: false, + headingConfidence: 'medium', + lat: poi.lat + latOffset, + lon: poi.lon + lonOffset, + headingDeg: parts.model.normalizeHeading( + seed.headingDeg ?? poi.heading ?? 0, + ), + fovDeg: parts.model.clamp(seed.fovDeg ?? 70, 20, 120), + rangeM: parts.model.clamp(seed.rangeM ?? 700, 260, 1800), + mountHeightM: parts.model.clamp(seed.elevationM ?? 22, 8, 80), + groundElevationM: Number(city.groundElevation) || 0, + absoluteHeightM: + (Number(city.groundElevation) || 0) + + parts.model.clamp(seed.elevationM ?? 22, 8, 80), + pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), + }; + parts.model.ensureCameraPose(camera); + catalog.push(camera); + } + return catalog; + } + + /** + * Looks up a city ID from CITY_POIS by exact or partial name match. + * @param {string} cityName + * @returns {string|null} Matching city ID or null. + */ + + function cityIdByName(cityName) { + const probe = String(cityName || '') + .trim() + .toLowerCase(); + if (!probe) return null; + for (const [cityId, city] of Object.entries(CITY_POIS)) { + if (city.name.toLowerCase() === probe) return cityId; + } + for (const [cityId, city] of Object.entries(CITY_POIS)) { + if ( + city.name.toLowerCase().includes(probe) || + probe.includes(city.name.toLowerCase()) + ) + return cityId; + } + return null; + } + + /** + * Fetches configured camera sources from the backend. + * @returns {Promise} Array of raw source objects, or empty on failure. + */ + + async function loadCameraSources() { + try { + const signal = layerState._sourceAbort?.signal; + const data = await source.getCatalog({ signal }); + signal?.throwIfAborted(); + if (!Array.isArray(data?.sources)) return []; + return data.sources; + } catch (error) { + if (error?.name === 'AbortError') throw error; + return []; + } + } + + /** + * Merges raw backend sources with seed data to produce the final camera catalog. + * Seeds provide fallback values for heading, FOV, range, etc. when not specified + * by the source. Each camera is passed through ensureCameraPose. + * @param {Object[]} rawSources - Raw source objects from the backend. + * @returns {Object[]} Array of fully-initialized camera objects. + */ + + function buildCatalogFromSources(rawSources) { + const sources = Array.isArray(rawSources) ? rawSources : []; + if (!sources.length) return []; + + const seeded = seedCatalog(); + const seedById = new Map(seeded.map((camera) => [camera.id, camera])); + + const catalog = []; + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + const id = String(source.id || '').trim(); + if (!id) continue; + const seed = seedById.get(id); + const cityId = + String(source.cityId || '').trim() || + cityIdByName(source.city) || + seed?.cityId || + ''; + const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null; + + const lat = parts.model.safeNumber(source.lat, seed?.lat ?? NaN); + const lon = parts.model.safeNumber(source.lon, seed?.lon ?? NaN); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + const sourceHeading = parts.model.safeNumber(source.headingDeg, NaN); + const headingDeg = parts.model.normalizeHeading( + Number.isFinite(sourceHeading) + ? sourceHeading + : (seed?.headingDeg ?? parts.model.headingFromId(id)), + ); + const fovDeg = parts.model.clamp( + parts.model.safeNumber(source.fovDeg, seed?.fovDeg ?? 74), + 20, + 125, + ); + const rangeM = parts.model.clamp( + parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700), + 220, + 2200, + ); + const mountHeightM = parts.model.clamp( + parts.model.safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24), + 6, + 120, + ); + const pitchDeg = parts.model.clamp( + parts.model.safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17), + -55, + -2, + ); + const groundElevationM = parts.model.safeNumber( + source.groundElevationM, + city?.groundElevation ?? seed?.groundElevationM ?? 0, + ); + const feedType = parts.model.normalizeFeedType( + source.feedType || source.type || 'image', + ); + const headingConfidence = String( + source.headingConfidence || (seed ? 'high' : 'low'), + ).toLowerCase(); + // CAL badge input (design §3b passthrough): hand-authored file/env source + // entries may carry poseSource:'curated'. Austin Open Data rows never set + // this — they stay RAW PRIOR until a human manually calibrates them. + const poseSource = + source.poseSource === 'curated' ? 'curated' : seed?.poseSource || null; + + const camera = { + id, + name: String(source.name || seed?.name || id), + cityId, + city: String(source.city || city?.name || seed?.city || 'Global'), + provider: String( + source.provider || seed?.provider || 'Configured CCTV Source', + ), + sourceKind: String( + source.sourceKind || + source.kind || + (source.url ? 'configured' : 'seed'), + ).toLowerCase(), + feedType, + feedConfigured: typeof source.url === 'string' && !!source.url.trim(), + lat, + lon, + headingDeg, + headingConfidence, + fovDeg, + rangeM, + mountHeightM, + groundElevationM, + absoluteHeightM: groundElevationM + mountHeightM, + pitchDeg, + license: String(source.license || source.licenseNote || ''), + poseSource, + }; + parts.model.ensureCameraPose(camera); + catalog.push(camera); + } + + return catalog; + } + return { + seedCatalog, + cityIdByName, + loadCameraSources, + buildCatalogFromSources, + }; +} diff --git a/src/layers/cctv/controls.js b/src/layers/cctv/controls.js new file mode 100644 index 000000000..5db8f04fe --- /dev/null +++ b/src/layers/cctv/controls.js @@ -0,0 +1,352 @@ +import { + DEFAULT_UPDATE_INTERVAL_MS, + MIN_AUTO_HOP_SEC, + MAX_AUTO_HOP_SEC, + DEFAULT_CAMERA_CALIBRATION, +} from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { CCTV_ACTIVATION_RESULT } = services.activation; + + const methods = { + id: 'cctv', + + name: 'CCTV', + + icon: '📹', + + source: 'CCTV + Street View fallback', + + updateInterval: DEFAULT_UPDATE_INTERVAL_MS, + + /** + * Applies runtime parameter changes: coverage/projection toggles, auto-hop + * settings, camera selection, and calibration patches/resets. + * @param {Object} [params={}] - Parameter object. + * @param {boolean} [params.showCoverage] - Back-compat coverage toggle (true→'on', false→'off'). + * @param {'off'|'on'|'viewshed'} [params.coverageMode] - Full coverage-mode API. + * @param {boolean} [params.showProjection] - Toggle projection overlay visibility. + * @param {boolean} [params.autoHop] - Enable/disable auto-hop. + * @param {number} [params.autoHopSec] - Auto-hop interval in seconds. + * @param {string} [params.selectedCameraId] - Camera ID to activate. + * @param {Object} [params.calibration] - Calibration config: `patch` edits + * the live pose (save-gated — no persistence), `save` persists the current + * calibration as manual, `reset` restores the base prior. + * @param {boolean} [params.calibrationMode] - Toggle the ADJUST gizmo. + * @param {boolean} [params.focusSelected] - Fly to the active camera. + * @param {number} [params.focusDurationSec] - Fly-to duration. + */ + setParams(params = {}) { + if (typeof params.showCoverage === 'boolean') { + layerState._coverageMode = parts.model.normalizeCoverageMode( + params.showCoverage, + layerState._coverageMode, + ); + } + if (typeof params.coverageMode === 'string') { + layerState._coverageMode = parts.model.normalizeCoverageMode( + params.coverageMode, + layerState._coverageMode, + ); + } + if (typeof params.showProjection === 'boolean') { + layerState._showProjection = params.showProjection; + if (layerState._showProjection) { + const active = parts.selection.getActiveRecord(); + if (active) parts.projection.ensureProjectionRuntime(active); + parts.projection.startProjectionLoop(); + } + } + if (typeof params.autoHop === 'boolean') { + layerState._autoHop = params.autoHop; + if (params.autoHop) layerState._autoHopSuspended = false; + } + if ( + typeof params.autoHopSec === 'number' && + Number.isFinite(params.autoHopSec) + ) { + layerState._autoHopSec = parts.model.clamp( + Math.round(params.autoHopSec), + MIN_AUTO_HOP_SEC, + MAX_AUTO_HOP_SEC, + ); + } + if ( + typeof params.selectedCameraId === 'string' && + layerState._recordById.has(params.selectedCameraId) + ) { + parts.selection.setActiveCamera(params.selectedCameraId); + } + if (params.calibration && typeof params.calibration === 'object') { + const calibrationCfg = params.calibration; + const targetCameraId = + typeof calibrationCfg.cameraId === 'string' && calibrationCfg.cameraId + ? calibrationCfg.cameraId + : layerState._activeCameraId; + const targetRecord = targetCameraId + ? layerState._recordById.get(targetCameraId) + : null; + if (targetRecord) { + if (calibrationCfg.reset) { + // RESET: back to the base prior, delete the persisted entry, clear + // the dirty flag (semantics unchanged from v2). + targetRecord.camera.calibration = + parts.calibration.normalizeCalibration( + DEFAULT_CAMERA_CALIBRATION, + ); + targetRecord.camera.calSource = null; + targetRecord.calDirty = false; + parts.model.ensureCameraPose(targetRecord.camera); + layerState._calibrationById.delete(targetCameraId); + parts.calibration.saveCalibrationStore(); + // Reset returns to the base lat/lon, so resolve that anchor once. + parts.ground.resolveCommittedGroundAnchor(targetRecord); + parts.frames.refreshProjectionImage(targetRecord, true); + } + if ( + calibrationCfg.patch && + typeof calibrationCfg.patch === 'object' + ) { + // Save-gated persistence (design §3e): a patch edits the LIVE pose + // only. The store — and the CALIBRATED badge's `calSource` — move + // exclusively on the explicit `save` action below. (§9.1 range-slider + // clamp override + B9b re-ground live inside applyCalibrationPatch.) + parts.calibration.applyCalibrationPatch( + targetRecord, + calibrationCfg.patch, + ); + } + if (calibrationCfg.save) { + // SAVE CAL: persist the current in-memory calibration with manual + // provenance. Saving an all-default calibration clears the entry + // (a no-op calibration is not a calibration). + if ( + parts.calibration.isDefaultCalibration( + targetRecord.camera.calibration, + ) + ) { + targetRecord.camera.calSource = null; + layerState._calibrationById.delete(targetCameraId); + } else { + targetRecord.camera.calSource = 'manual'; + layerState._calibrationById.set(targetCameraId, { + values: { ...targetRecord.camera.calibration }, + source: 'manual', + savedAt: Date.now(), + }); + } + targetRecord.calDirty = false; + parts.calibration.saveCalibrationStore(); + } + } + } + if (typeof params.calibrationMode === 'boolean') { + layerState._calibrationMode = params.calibrationMode; + if (layerState._calibrationMode) { + parts.calibration.ensureGizmo(); + layerState._gizmo?.setEnabled(true); + // ADJUST mode: gizmo drags mutate entity geometry from pointer events, + // which don't trigger renders in requestRenderMode. (perf wave 2) + holdContinuousRender('cctv-adjust'); + } else { + releaseContinuousRender('cctv-adjust'); + layerState._gizmo?.setEnabled(false); + } + } + if (params.focusSelected && layerState._activeCameraId) { + parts.navigation.focusCamera( + layerState._activeCameraId, + Number(params.focusDurationSec) || 1.8, + ); + } + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }, + + /** + * Returns the current runtime parameters including toggle states, + * active camera, and calibration values. + * @returns {Object} + */ + getParams() { + const active = parts.selection.getActiveRecord(); + return { + showCoverage: layerState._coverageMode !== 'off', + coverageMode: layerState._coverageMode, + showProjection: layerState._showProjection, + calibrationMode: layerState._calibrationMode, + autoHop: layerState._autoHop, + autoHopSec: layerState._autoHopSec, + selectedCameraId: active?.camera.id || null, + calibration: active?.camera + ? { + cameraId: active.camera.id, + values: { + ...parts.calibration.normalizeCalibration( + active.camera.calibration, + ), + }, + } + : null, + }; + }, + + /** + * Returns a sampled list of camera positions for the detection overlay system. + * @param {Object} [options={}] + * @param {number} [options.maxCount] - Maximum number of objects to return. + * @param {number} [options.seed] - Offset seed for deterministic stride sampling. + * @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || layerState._records.length === 0) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : layerState._records.length; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max( + 1, + Math.ceil(layerState._records.length / maxCount), + ); + const start = seed % stride; + + const objects = []; + for (let i = start; i < layerState._records.length; i += stride) { + const camera = layerState._records[i].camera; + objects.push({ + position: layerState._records[i].position, + sourceId: camera.id, + id: `CAM-${camera.id}`, + type: 'CAM', + }); + if (objects.length >= maxCount) break; + } + return objects; + }, + + /** + * Returns basic layer statistics, including initial-load progress while + * the staggered geometry queue is draining. + * @returns {{ count: number, lastUpdate: number|null, error: string|null, loading: boolean, loadingLoaded: number, loadingTotal: number }} + */ + getStats() { + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + loading: layerState._geoLoading, + loadingLoaded: Math.min( + layerState._geoLoadDone, + layerState._geoLoadTotal, + ), + loadingTotal: layerState._geoLoadTotal, + }; + }, + + /** + * Registers a callback that receives the full UI state on every change. + * The callback is invoked immediately with the current state. + * @param {Function} callback - Listener function receiving the UI state object. + * @returns {Function} Unsubscribe function. + */ + subscribe(callback) { + if (typeof callback !== 'function') return () => {}; + layerState._listeners.add(callback); + callback(parts.presentation.uiState()); + return () => { + layerState._listeners.delete(callback); + }; + }, + + /** + * Returns the current UI state snapshot without subscribing. + * @returns {Object} + */ + getUIState() { + return parts.presentation.uiState(); + }, + + /** + * Opts the active camera into or out of protected thumbnail publication. + * The default is false: the monitor plane remains the sole active-camera + * representation while ambient and hover-pinned cards continue unchanged. + * @param {Object} [options] + * @param {boolean} [options.activeCameraCardEnabled=false] + * @returns {{activeCameraCardEnabled:boolean}} + */ + setCardPresentationOptions(options = {}) { + return parts.cards.setCctvCardPresentationOptions(options); + }, + + /** + * Selects a camera by ID and optionally flies to it. + * @param {string} cameraId - Camera ID to select. + * @param {Object} [options={}] + * @param {boolean} [options.focus] - If true, fly the viewer to the camera. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {boolean} True if the camera was found and selected. + */ + selectCamera(cameraId, options = {}) { + const result = parts.selection.setActiveCamera(cameraId); + if (result === CCTV_ACTIVATION_RESULT.NOT_FOUND) return false; + if (options.focus) { + parts.navigation.focusCamera(cameraId, options.durationSec || 1.8); + } + return true; + }, + + /** + * Flies the viewer to a specific camera. + * @param {string} cameraId - Camera ID to focus on. + * @param {number} [durationSec=2.2] - Flight duration in seconds. + * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. + */ + focusCamera(cameraId, durationSec = 2.2) { + return parts.navigation.focusCamera(cameraId, durationSec); + }, + + /** + * Cycles the active camera forward or backward by `step` positions in the catalog. + * @param {number} [step=1] - Number of positions to advance (negative to go back). + * @param {Object} [options={}] + * @param {boolean} [options.focus] - If true, fly to the new camera. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {string|null} The newly active camera ID, or null if catalog is empty. + */ + cycleCamera(step = 1, options = {}) { + if (!layerState._records.length) return null; + const current = parts.selection.getActiveRecord(); + const nextIdx = parts.navigation.cctvCycleIndex( + layerState._records.findIndex((record) => record === current), + step, + layerState._records.length, + ); + const nextId = layerState._records[nextIdx].camera.id; + parts.selection.setActiveCamera(nextId); + if (options.focus) { + parts.navigation.focusCamera(nextId, options.durationSec || 1.8); + } + return nextId; + }, + + /** + * Selects and flies to the camera nearest the current viewer position. + * @param {Object} [options={}] + * @param {boolean} [options.focus=true] Whether to fly after selection. + * @param {number} [options.durationSec] - Fly-to duration in seconds. + * @returns {string|null} The nearest camera ID, or null if none found. + */ + focusNearest(options = {}) { + const nearest = parts.navigation.nearestCameraIdToViewer(); + if (!nearest) return null; + parts.selection.setActiveCamera(nearest); + if (options.focus !== false) { + parts.navigation.focusCamera(nearest, options.durationSec || 1.8); + } + return nearest; + }, + }; + + return { methods }; +} diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js new file mode 100644 index 000000000..d137b74b2 --- /dev/null +++ b/src/layers/cctv/frames.js @@ -0,0 +1,368 @@ +import { + FRAME_SIGNATURE_W, + FRAME_SIGNATURE_H, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + PROJECTION_TEXTURE_SWAP_MS, + ACTIVE_FRAME_REFRESH_MS, + FRAME_ENDPOINT, + MEDIA_ENDPOINT, + PROJECTION_ACTIVE_REFRESH_MS, + PROJECTION_IDLE_REFRESH_MS, + PLACEHOLDER_REPAINT_MS, +} from './policy.js'; + +export function createFrames({ state: layerState, services, parts, source }) { + /** + * FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw + * pixel buffer, no DOM) so it is unit-testable. + * + * Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would + * cost a third more work to mix in a constant. + * + * @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel. + * @returns {number|null} Unsigned 32-bit signature, or null for empty input. + */ + + function frameSignatureFromPixels(data) { + if (!data || typeof data.length !== 'number' || data.length < 4) + return null; + let hash = 0x811c9dc5; + for (let i = 0; i < data.length; i += 4) { + hash = Math.imul(hash ^ data[i], 0x01000193); + hash = Math.imul(hash ^ data[i + 1], 0x01000193); + hash = Math.imul(hash ^ data[i + 2], 0x01000193); + } + return hash >>> 0; + } + + /** + * Signature of the runtime's freshly decoded frame, via a reused 64x36 + * scratch canvas. + * + * @param {Object} runtime - Projection runtime holding the decoded `.image`. + * @returns {number|null} Signature, or null when it cannot be computed (the + * caller then treats the frame as changed — the pre-2026-07-30 behavior). + */ + + function projectionFrameSignature(runtime) { + const image = runtime?.image; + if (!image) return null; + if (!runtime.signatureCtx) { + const canvas = document.createElement('canvas'); + canvas.width = FRAME_SIGNATURE_W; + canvas.height = FRAME_SIGNATURE_H; + runtime.signatureCanvas = canvas; + runtime.signatureCtx = canvas.getContext('2d', { + willReadFrequently: true, + }); + } + const ctx = runtime.signatureCtx; + if (!ctx) return null; + try { + ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); + ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H); + return frameSignatureFromPixels( + ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data, + ); + } catch { + // Tainted canvas (a cross-origin source served without CORS) or a decode + // race. Returning null means "assume changed", so behavior degrades to + // the unconditional redraw this optimization replaced. + return null; + } + } + + /** + * Blits the latest projection canvas into the next of two alternating + * offscreen buffer canvases and returns it (H5). + * + * Two buffers are required because Cesium's Material image path re-uploads a + * canvas texture only when the uniform receives a NEW object reference — + * redrawing the same canvas in place is invisible to the GPU. Alternating + * references forces a texture recreate, which at <=1Hz and 1080p is cheap. + * + * @param {Object} runtime - Projection runtime with `.canvas`. + * @returns {HTMLCanvasElement|null} The freshly painted buffer, or null. + */ + + function paintNextProjectionBuffer(runtime) { + if (!runtime?.canvas) return null; + if (!runtime.buffers) { + runtime.buffers = [0, 1].map(() => { + const buffer = document.createElement('canvas'); + buffer.width = PROJECTION_CANVAS_WIDTH; + buffer.height = PROJECTION_CANVAS_HEIGHT; + return buffer; + }); + runtime.bufferIndex = 0; + } + runtime.bufferIndex = (runtime.bufferIndex + 1) % 2; + const buffer = runtime.buffers[runtime.bufferIndex]; + const ctx = buffer.getContext('2d'); + if (!ctx) return null; + ctx.clearRect(0, 0, buffer.width, buffer.height); + ctx.drawImage(runtime.canvas, 0, 0); + return buffer; + } + + /** + * Pushes fresh pixels into the monitor plane material. Called every + * projection tick. + * + * Video feeds are skipped entirely — their HTMLVideoElement uniform is + * updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap + * the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS. + * + * @param {Object} record - Camera record with an initialized projection runtime. + */ + + function refreshProjectionTextures(record) { + const runtime = record?.projection; + if (!runtime || runtime.mode === 'video') return; + const now = Date.now(); + if ( + now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) < + PROJECTION_TEXTURE_SWAP_MS + ) + return; + + const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial); + if (!planeShowing) return; + + // Only swap when the canvas content actually changed since the last swap. + // Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED + // canvas re-uploads the texture for nothing, and each material image + // reassignment is a flash opportunity on the live plane (owner field test + // 2026-07-04: intermittent white flashes on the monitor plane). + if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return; + + const buffer = paintNextProjectionBuffer(runtime); + if (!buffer) return; + runtime.lastTextureSwapAt = now; + runtime.lastSwappedCanvasStamp = runtime.canvasStamp; + runtime.planeMaterial.image = buffer; + } + + /** + * Builds the URL for fetching a camera frame image from the backend. + * Includes a tick parameter to control cache invalidation cadence. + * @param {Object} camera - Camera object. + * @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing. + * @returns {string} Frame URL. + */ + + function frameUrlFor(...args) { + return source.getFrameUrl(...args); + } + + /** + * Builds the URL for fetching a camera's video/media stream. + * @param {Object} camera - Camera object. + * @returns {string} Media URL. + */ + + function mediaUrlFor(...args) { + return source.getMediaUrl(...args); + } + + /** + * Paints a placeholder frame onto the projection canvas when no live feed + * image or video is available. Shows camera name, city, and status text + * over a dark gradient with tactical border lines. + * @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas. + * @param {Object} camera - Camera object for label info. + * @param {Object|null} [health=null] - Health state for status message. + */ + + function paintProjectionPlaceholder(ctx, camera, health = null) { + if (!ctx) return; + const w = PROJECTION_CANVAS_WIDTH; + const h = PROJECTION_CANVAS_HEIGHT; + ctx.clearRect(0, 0, w, h); + const g = ctx.createLinearGradient(0, 0, w, h); + g.addColorStop(0, '#05111a'); + g.addColorStop(1, '#01070c'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + + const label = String(camera?.name || 'CCTV'); + const city = String(camera?.city || 'GLOBAL'); + const status = String( + health?.message || health?.status || camera?.feedType || 'NO FEED', + ).toUpperCase(); + + ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)'; + ctx.lineWidth = 2; + ctx.strokeRect(18, 18, w - 36, h - 36); + ctx.strokeRect(36, 36, w - 72, h - 72); + + ctx.fillStyle = 'rgba(170, 242, 255, 0.95)'; + ctx.font = '600 32px "JetBrains Mono", monospace'; + ctx.fillText(label.slice(0, 42), 46, 74); + ctx.fillStyle = 'rgba(127, 216, 231, 0.8)'; + ctx.font = '500 24px "JetBrains Mono", monospace'; + ctx.fillText(city.toUpperCase(), 46, 112); + ctx.font = '500 21px "JetBrains Mono", monospace'; + ctx.fillText(status.slice(0, 58), 46, h - 42); + } + + /** + * Triggers a new frame fetch for an image-mode projection if the refresh + * interval has elapsed. Active cameras refresh more frequently than idle ones. + * @param {Object} record - Camera record. + * @param {boolean} [force=false] - Bypass the interval check. + */ + + function refreshProjectionImage(record, force = false) { + const runtime = record?.projection; + if (!runtime || runtime.mode !== 'image' || !runtime.image) return; + // Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas + // nobody can see. The refresh interval re-fills naturally on return. + if (typeof document !== 'undefined' && document.hidden && !force) return; + // Do not replace an in-flight URL on the 10-second refresh boundary. Slow + // providers otherwise leave cancelled server requests behind and the plane + // can remain permanently pending. The proxy bounds each attempt; load/error + // clears this latch so the next normal tick can refresh. + if (runtime.imageLoading) return; + const now = Date.now(); + const refreshMs = + record.camera.id === layerState._activeCameraId + ? PROJECTION_ACTIVE_REFRESH_MS + : PROJECTION_IDLE_REFRESH_MS; + if (!force && now - runtime.lastImageRefreshAt < refreshMs) return; + runtime.lastImageRefreshAt = now; + + const frameUrl = frameUrlFor(record.camera, refreshMs); + const sep = frameUrl.includes('?') ? '&' : '?'; + runtime.imageLoading = true; + runtime.imageReady = false; + runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`; + } + + /** + * Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS. + * The projection loop runs at RAF cadence — unthrottled, a pending feed would + * re-fill the 1080p canvas (gradient + text) on every single frame. + * @param {Object} record - Camera record. + * @param {Object} runtime - Projection runtime. + * @param {Object|null} health - Health state for status text. + */ + + function paintPlaceholderThrottled(record, runtime, health) { + const now = Date.now(); + if ( + now - parts.model.safeNumber(runtime.lastPlaceholderPaintAt, 0) < + PLACEHOLDER_REPAINT_MS + ) + return; + runtime.lastPlaceholderPaintAt = now; + runtime.drawnImageStamp = -1; + // The placeholder overwrites the canvas, so the last real frame is no longer + // on it. Drop the signature or an identical frame returning after an outage + // would be skipped as "unchanged" and leave the placeholder on the plane. + runtime.lastFrameSignature = null; + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + paintProjectionPlaceholder(runtime.ctx, record.camera, health); + } + + /** + * Draws the current frame (video or image) onto the projection canvas. + * Falls back to the placeholder if the media source is not yet ready. + * Image feeds only repaint when a NEW image finished loading (stamp check): + * re-blitting an unchanged 1080p image every RAF tick is pure waste since + * texture uploads are already throttled to 1Hz buffer swaps. + * @param {Object} record - Camera record with an initialized projection runtime. + */ + + function drawProjectionFrame(record) { + const runtime = record?.projection; + if (!runtime || !runtime.ctx) return; + + const health = layerState._healthById.get(record.camera.id) || null; + + if (runtime.mode === 'video' && runtime.video) { + const video = runtime.video; + if ( + video.readyState >= 2 && + video.videoWidth > 0 && + video.videoHeight > 0 + ) { + runtime.ctx.clearRect( + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.ctx.drawImage( + video, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + return; + } + paintPlaceholderThrottled(record, runtime, health); + return; + } + + refreshProjectionImage(record); + if (runtime.image && runtime.imageReady) { + if (runtime.drawnImageStamp !== runtime.imageStamp) { + // The frame URL carries a 10s cache-buster tick, so a fresh Image + // DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider + // actually published a new picture — measured 2026-07-30: a London + // camera republished once in 5 minutes, an Austin one not at all. + // Redrawing regardless bumped canvasStamp, which forced a buffer swap + // and a fresh 1920x1080 texture upload; the plane renders its white + // base color (planeMaterial color = WHITE, alpha .95) for the frame or + // two Cesium needs to rebind, which IS the periodic white flash from the + // owner field tests (2026-07-04 and 2026-07-30). + const signature = projectionFrameSignature(runtime); + runtime.drawnImageStamp = runtime.imageStamp; + if (signature !== null && signature === runtime.lastFrameSignature) { + // Identical pixels — leave the canvas, and therefore the bound + // texture, completely alone. No canvasStamp bump, no swap, no flash. + return; + } + runtime.lastFrameSignature = signature; + runtime.ctx.clearRect( + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.ctx.drawImage( + runtime.image, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ); + runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; + runtime.lastPlaceholderPaintAt = 0; + } + return; + } + // A refresh is in flight (imageReady=false): keep the last good frame on + // the canvas instead of flashing the placeholder. Placeholder only paints + // when nothing has ever been drawn for this camera. + if (runtime.drawnImageStamp === -1) { + paintPlaceholderThrottled(record, runtime, health); + } + } + return { + frameSignatureFromPixels, + projectionFrameSignature, + paintNextProjectionBuffer, + refreshProjectionTextures, + frameUrlFor, + mediaUrlFor, + paintProjectionPlaceholder, + refreshProjectionImage, + paintPlaceholderThrottled, + drawProjectionFrame, + }; +} diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js new file mode 100644 index 000000000..221648a3a --- /dev/null +++ b/src/layers/cctv/geometry.js @@ -0,0 +1,719 @@ +import * as Cesium from 'cesium'; +import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js'; +import { + PROJECTION_VERT_ASPECT, + FRUSTUM_GROUND_CLEARANCE_M, + PROBE_MIN_RANGE_M, + PROBE_CLEARANCE_M, + COVERAGE_NEIGHBOR_RADIUS_KM, + COVERAGE_NEIGHBOR_LIMIT, + IDLE_COVERAGE_COLOR, +} from './policy.js'; + +export function createGeometry({ state: layerState, services, parts, source }) { + const { warmGroundFloor, cachedGroundFloor } = services.ground; + const { sampleMeshFloorCells } = services.mesh; + + /** + * V2 core geometry (design §2a): computes the pitched frustum pyramid — mount + * point, far-cap (monitor plane) center, and the 4 far-plane corners — purely + * from the calibrated pose + a caller-supplied ground altitude. ZERO scene + * queries: ground sampling happens in the caller (one-shot snap), and the + * obstruction probe passes its clamp in as `rangeOverrideM`. + * + * Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges): + * mountAlt = groundAltM + mountHeightM + * capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch) + * halfW = R·tan(hFov/2) + * vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2) + * upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading + * corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset + * The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated + * pitch must never bury the plane's anchor); corners derive rigidly from the + * clamped center so the wireframe rays always terminate on the plane's + * corners — the bottom pair may dip below ground (tiles occlude it). + * + * @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg, + * rangeM, mountHeightM. + * @param {number} groundAltM - Ground altitude at the mount (metres). + * @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the + * effective range (never lengthens it). + * @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number, + * mount: {lat:number,lon:number,alt:number}, + * capCenter: {lat:number,lon:number,alt:number}, + * corners: { tl: Object, tr: Object, br: Object, bl: Object }, + * topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }} + */ + + function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) { + const ground = parts.model.safeNumber(groundAltM, 0); + const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700)); + const override = parts.model.safeNumber(rangeOverrideM, NaN); + const R = + Number.isFinite(override) && override > 0 + ? Math.min(poseRange, override) + : poseRange; + const pitch = parts.model.toRad( + parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89), + ); + const hFov = parts.model.toRad( + parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160), + ); + const heading = parts.model.safeNumber(camera.headingDeg, 0); + const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24); + + const horiz = R * Math.cos(pitch); + const vert = R * Math.sin(pitch); + const capLL = parts.model.projectPoint( + camera.lat, + camera.lon, + heading, + horiz, + ); + const capAlt = mountAlt + vert; + + const halfW = R * Math.tan(hFov / 2); + const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT); + const halfH = R * Math.tan(vFovRad / 2); + + // In-plane "up" of the pitched cap, decomposed into a vertical part and a + // horizontal part along the heading (pitch < 0 tilts the cap's top forward). + const upVert = Math.cos(pitch) * halfH; + const upHoriz = -Math.sin(pitch) * halfH; + + const capL = parts.model.projectPoint( + capLL.lat, + capLL.lon, + heading - 90, + halfW, + ); + const capR = parts.model.projectPoint( + capLL.lat, + capLL.lon, + heading + 90, + halfW, + ); + // Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch + // never buries the plane's anchor — then derive the corners RIGIDLY from the + // lifted center. Clamping each corner independently flattened the wireframe + // into a ground-hugging fan while the rigid plane kept its height (owner + // field test 2026-07-04): the corner rays must always terminate exactly on + // the monitor plane's corners. The bottom pair may dip below ground; the 3D + // tiles occlude that portion, exactly as they do for the plane itself. + const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M; + const capAltClamped = Math.max(minAlt, capAlt); + const corner = (base, sign) => { + const ll = parts.model.projectPoint( + base.lat, + base.lon, + heading, + sign * upHoriz, + ); + return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert }; + }; + + const topCenter = corner(capLL, 1); + return { + rangeM: R, + vFovDeg: Cesium.Math.toDegrees(vFovRad), + halfW, + halfH, + mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt }, + capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped }, + corners: { + tl: corner(capL, 1), + tr: corner(capR, 1), + br: corner(capR, -1), + bl: corner(capL, -1), + }, + topCenter, + groundAltM: ground, + }; + } + + /** + * Resolves the obstruction probe's effective-range clamp just short of a hit, + * with the field-derived 12 m floor used by the original H6 monitor. + * @param {number} rangeM Nominal camera range. + * @param {number} hitDistanceM Distance to the first obstruction. + * @returns {number|null} Clamp range, or null when the hit does not shorten it. + */ + + function activationProbeClampRange(rangeM, hitDistanceM) { + const nominalRange = Number(rangeM); + const hitDistance = Number(hitDistanceM); + if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null; + if ( + !Number.isFinite(hitDistance) || + hitDistance <= 0 || + hitDistance >= nominalRange + ) + return null; + return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M); + } + + /** + * Converts a computeFrustumGeometry result into the Cartesian3 positions the + * entities consume. Fresh objects per call (geometry updates are rare — + * slider/save/activation — and entities must never share scratch objects). + * @param {Object} geometry - Result of computeFrustumGeometry. + * @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3, + * tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3, + * bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }} + */ + + function frustumCartesians(geometry) { + const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt); + return { + mount: at(geometry.mount), + capCenter: at(geometry.capCenter), + tl: at(geometry.corners.tl), + tr: at(geometry.corners.tr), + br: at(geometry.corners.br), + bl: at(geometry.corners.bl), + label: Cesium.Cartesian3.fromDegrees( + geometry.topCenter.lon, + geometry.topCenter.lat, + geometry.topCenter.alt + 1.2, + ), + }; + } + + /** + * Unit ECEF direction of the frustum view axis (heading/pitch) at a position. + * Used by the plane orientation and the activation obstruction probe — both + * need the UNCLAMPED axis, so it comes from the pose, not from clamped points. + * @param {Object} camera - Camera pose (headingDeg, pitchDeg). + * @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame. + * @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the + * frame's in-plane "up" (both unit, mutually perpendicular). + */ + + function frustumFrameEcef(camera, atPos) { + const h = parts.model.toRad(camera.headingDeg); + const p = parts.model.toRad(camera.pitchDeg); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos); + const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3()); + // ENU components: view axis + the perpendicular "up" of the pitched cap. + const dirEnu = new Cesium.Cartesian3( + Math.sin(h) * Math.cos(p), + Math.cos(h) * Math.cos(p), + Math.sin(p), + ); + const upEnu = new Cesium.Cartesian3( + -Math.sin(p) * Math.sin(h), + -Math.sin(p) * Math.cos(h), + Math.cos(p), + ); + return { + dir: Cesium.Matrix3.multiplyByVector( + rot, + dirEnu, + new Cesium.Cartesian3(), + ), + up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()), + }; + } + + /** + * Recomputes the pure frustum geometry from the camera pose + the given ground + * altitude and writes it into the scene: billboard position, the 5 wireframe + * polylines (4 corner rays + closed far-plane rectangle), and the monitor + * plane placement. This is the ONLY place v2 geometry is written — called on + * slider input / save / activation / the one-shot ground snap, never per frame. + * @param {Object} record - Camera record. + * @param {number} groundAltM - Ground altitude at the mount (metres). + */ + + function applyFrustumGeometry(record, groundAltM) { + const geometry = computeFrustumGeometry( + record.camera, + groundAltM, + record.probeClampRangeM, + ); + const positions = frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + record.position = positions.mount; + record.camera.absoluteHeightM = geometry.mount.alt; + if (record.billboard) { + record.billboard.position = positions.mount; + } + if (record.coverageEntities?.length >= 5) { + record.coverageEntities[0].polyline.positions = [ + positions.mount, + positions.tl, + ]; + record.coverageEntities[1].polyline.positions = [ + positions.mount, + positions.tr, + ]; + record.coverageEntities[2].polyline.positions = [ + positions.mount, + positions.br, + ]; + record.coverageEntities[3].polyline.positions = [ + positions.mount, + positions.bl, + ]; + record.coverageEntities[4].polyline.positions = [ + positions.tl, + positions.tr, + positions.br, + positions.bl, + positions.tl, + ]; + } + // A live viewshed volume tracks its wireframe: rebuild from the SAME fresh + // positions (weld invariant). Only records currently showing a volume pay + // this (6 triangles, synchronous — trivial even during slider/gizmo drags). + // Tint derives from the live active id, not the cached viewshedActiveTint — + // during an activation switch this runs BEFORE refreshCoverageStyles, and + // the cache is stale for exactly that window. + if (record.viewshedPrimitive) { + rebuildViewshedVolume( + record, + record.camera.id === layerState._activeCameraId, + ); + } + parts.projection.updatePlanePlacement(record); + // Gizmo handles track the pose they manipulate: refresh when the ACTIVE + // camera's geometry rewrites (incl. during its own drag). + if ( + layerState._gizmo?.isEnabled() && + record.camera.id === layerState._activeCameraId + ) { + layerState._gizmo.refresh(); + } + } + + /** + * Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground + * resolution (Task 5, spec §2). Per regime: + * + * - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM + * floor because mesh floors are regime-disabled. The exact Re:Earth prior + * remains the immediate fallback while that coarse cell warms. + * - `google-3d` (photoreal): the shared mesh-floor sampler may refine the + * DEM cell once, subject to its existing tiles-ready, distance, + * camera-height, and acceptance gates. Geometry reads only + * `cachedGroundFloor`, never a CCTV-owned point sample. + * + * v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No + * timer, no deadband: this function is called only from the staggered + * geometry queue (the enable-time drain + update()'s one-shot tiles-ready + * completion re-enqueue), from explicit pose-edit call sites, and from the + * map-stack regime-change handler. + * @param {Object} record - Camera record. + * @param {Object} [options={}] + * @param {boolean} [options.sampleGround=true] - When false, skip shared + * mesh-floor refinement and use the cached/prior ground instead. + */ + + function updateRecordGeometry(record, options = {}) { + const sampleGround = options.sampleGround !== false; + const regime = parts.ground.currentSurfaceRegime(); + const point = { lat: record.camera.lat, lon: record.camera.lon }; + warmGroundFloor([point]); + + if (regime === 'terrain-globe') { + const cachedFloor = cachedGroundFloor(point.lat, point.lon); + const ground = Number.isFinite(cachedFloor) + ? cachedFloor + : parts.ground.groundPriorAltFor(record); + record.groundSamples['terrain-globe'] = ground; + record.groundResolved['terrain-globe'] = true; + applyFrustumGeometry(record, ground); + return; + } + + // Photoreal regime. Sampling is delegated to the shared coarse-cell + // sampler. It remains event-driven, one-shot per cell, and keeps its + // existing acceptance window; CCTV adds no rooftop rejection policy. + if (sampleGround && parts.model.projectionTilesReady()) { + record.groundMeshSampleRequestCount = + (record.groundMeshSampleRequestCount || 0) + 1; + const viewerCarto = layerState._viewer?.camera?.positionCartographic; + const excludeObjects = [...(record.coverageEntities || [])]; + if (record.billboard) excludeObjects.push(record.billboard); + if (record.projection?.planeEntity) + excludeObjects.push(record.projection.planeEntity); + sampleMeshFloorCells(layerState._viewer?.scene, [point], { + excludeObjects: excludeObjects.filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + const cachedFloor = cachedGroundFloor(point.lat, point.lon); + const ground = Number.isFinite(cachedFloor) + ? cachedFloor + : parts.ground.groundAltFor(record, 'google-3d'); + applyFrustumGeometry(record, ground); + + record.groundResolved['google-3d'] = Number.isFinite(cachedFloor); + if (Number.isFinite(cachedFloor)) { + record.groundSamples['google-3d'] = ground; + } + } + + /** + * Determines which camera coverage overlays should be visible based on + * proximity to the active camera. Limits visibility to + * COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM. + * @param {Object|null} activeRecord - The active camera record. + * @returns {Set} Set of visible camera IDs. + */ + + function buildCoverageVisibleSet(activeRecord) { + if (!layerState._records.length) return new Set(); + // Coverage emphasis is relative to a selected camera. Without one, do not + // invent an arbitrary catalog-order cohort. + if (!activeRecord) return new Set(); + + const ranked = layerState._records.map((record) => { + if (record === activeRecord) { + return { record, distKm: -1 }; + } + return { + record, + distKm: parts.model.haversineKm( + activeRecord.camera.lat, + activeRecord.camera.lon, + record.camera.lat, + record.camera.lon, + ), + }; + }); + + ranked.sort((a, b) => a.distKm - b.distKm); + + const primary = ranked.filter( + (entry) => + entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1, + ); + const fallback = ranked; + const chosen = ( + primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback + ) + .slice(0, COVERAGE_NEIGHBOR_LIMIT) + .map((entry) => entry.record.camera.id); + return new Set(chosen); + } + + /** + * Removes (and destroys) a record's viewshed volume primitive, if any. + * @param {Object} record - Camera record. + */ + + function destroyViewshedVolume(record) { + if (!record) return; + if (record.viewshedPrimitive && layerState._viewer) { + layerState._viewer.scene.primitives.remove(record.viewshedPrimitive); + } + record.viewshedPrimitive = null; + } + + /** + * (Re)builds a record's translucent viewshed volume from its CURRENT + * frustumPositions — the same 5 Cartesians the wireframe draws, so the volume + * is welded to the cone by construction. Called only where the wireframe + * already rewrites (style refresh on mode/visible-set/active changes, + * applyFrustumGeometry on pose edits) — no new update cadence, zero scene + * queries (viewshed design §3b). + * @param {Object} record - Camera record. + * @param {boolean} isActive - Active camera gets the brighter fill. + */ + + function rebuildViewshedVolume(record, isActive) { + destroyViewshedVolume(record); + if ( + !layerState._viewer || + !record?.frustumPositions || + !record.viewshedColors + ) + return; + const color = isActive + ? record.viewshedColors.fillActive + : record.viewshedColors.fill; + const primitive = createFrustumVolumePrimitive( + record.frustumPositions, + color, + ); + // QA tag: the harness counts viewshed volumes by this marker. + primitive._gevViewshed = record.camera.id; + record.viewshedPrimitive = + layerState._viewer.scene.primitives.add(primitive); + record.viewshedActiveTint = !!isActive; + } + + /** + * Counts how many other cameras have overlapping coverage with the target. + * Overlap is approximated by comparing inter-camera distance against the + * combined range of both cameras (scaled by 0.92). + * @param {Object} targetRecord - Camera record to check. + * @returns {number} Number of overlapping neighbors. + */ + + function coverageNeighborCount(targetRecord) { + if (!targetRecord) return 0; + let count = 0; + for (const record of layerState._records) { + if (record === targetRecord) continue; + const dKm = parts.model.haversineKm( + targetRecord.camera.lat, + targetRecord.camera.lon, + record.camera.lat, + record.camera.lon, + ); + const overlapKm = + ((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92; + if (dKm <= overlapKm) count++; + } + return count; + } + + /** + * §9.1 activation obstruction probe (LOCKED owner decision): on camera + * ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis + * (mount → cap-center direction). If it hits the tiles closer than the pose + * range, clamp the plane's effective range just short of the first hit so the + * "big and dramatic" true end cap never clips into downtown buildings. + * + * This is the ONLY raycast in the whole CCTV subsystem — once per activation, + * never per-frame (the zero-raycast invariant applies to steady state). The + * per-camera range slider overrides the clamp: a user-set rangeScale skips the + * probe entirely. Probe failure/miss keeps the unclamped range. + * @param {Object} record - Camera record being activated. + */ + + function runActivationObstructionProbe(record) { + record.probeClampRangeM = null; + const scene = layerState._viewer?.scene; + if (!scene || typeof scene.pickFromRay !== 'function') return; + const camera = record.camera; + const rangeScale = parts.calibration.normalizeCalibration( + camera.calibration, + ).rangeScale; + if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp + try { + const mountAlt = parts.ground.groundAltFor(record) + camera.mountHeightM; + const mountPos = Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + mountAlt, + ); + const { dir } = frustumFrameEcef(camera, mountPos); + // Exclude everything the layer itself draws so the probe can only hit the + // world (3D tiles), not our own billboards/polylines/planes. + const exclude = [layerState._billboards, ...layerState._coverageEntities]; + for (const runtime of layerState._projectionEntities) { + if (runtime?.planeEntity) exclude.push(runtime.planeEntity); + } + const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); + if (!hit?.position) return; + const dist = Cesium.Cartesian3.distance(mountPos, hit.position); + record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); + } catch { + // probe failure → keep the unclamped range + } + } + + /** + * Clears a deactivated camera's temporary obstruction clamp and rewrites its + * geometry through the normal single-range path. + * @param {Object|null} record Camera runtime record being deactivated. + * @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite. + * @returns {boolean} Whether a clamp was cleared. + */ + + function clearProbeClampOnDeactivation(record, rewriteGeometry) { + if (!record || !Number.isFinite(record.probeClampRangeM)) return false; + record.probeClampRangeM = null; + rewriteGeometry?.(record); + return true; + } + + /** + * Creates the five Cesium polyline entities that visualize a camera's pitched + * frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane + * rectangle. Entity ids stay in the `cctv--` scheme (pick-owner + * regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap. + * @param {Object} record - Camera record. + * @returns {Cesium.Entity[]} Array of five coverage entities. + */ + + function buildCoverageEntities(record) { + const { camera } = record; + // Prefer the record's already-refined geometry. Lazy creation commonly + // happens after the staggered ground pass; recomputing from the catalog + // prior here would regress the camera to its pre-sampled datum. + let geometry = record.frustumGeometry; + let positions = record.frustumPositions; + if (!geometry || !positions) { + geometry = computeFrustumGeometry( + camera, + parts.ground.groundPriorAltFor(record), + record.probeClampRangeM, + ); + positions = frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + record.position = positions.mount; + } + + const addPolyline = (role, linePositions) => + layerState._viewer.entities.add({ + id: `cctv-${camera.id}-${role}`, + properties: { cctvCameraId: camera.id }, + polyline: { + positions: linePositions, + width: 1.2, + material: IDLE_COVERAGE_COLOR, + }, + }); + + const entities = [ + addPolyline('ray-tl', [positions.mount, positions.tl]), + addPolyline('ray-tr', [positions.mount, positions.tr]), + addPolyline('ray-br', [positions.mount, positions.br]), + addPolyline('ray-bl', [positions.mount, positions.bl]), + addPolyline('cap', [ + positions.tl, + positions.tr, + positions.br, + positions.bl, + positions.tl, + ]), + ]; + + entities[0]._coverageRole = 'edge'; + entities[1]._coverageRole = 'edge'; + entities[2]._coverageRole = 'edge'; + entities[3]._coverageRole = 'edge'; + entities[4]._coverageRole = 'cap'; + return entities; + } + + /** + * Materializes coverage entities for eligible records exactly once. + * The helper is dependency-injected so unit tests can prove the enable policy + * without constructing Cesium entities. + * + * @param {Object[]} records CCTV runtime records. + * @param {(record: Object) => boolean} [isEligible] Eligibility predicate. + * @param {(record: Object) => Object[]} buildEntities Coverage builder. + * @returns {Object[]} Newly created entities across all eligible records. + */ + + function materializeCctvCoverageEntities( + records, + isEligible = () => true, + buildEntities, + ) { + const created = []; + if (typeof buildEntities !== 'function') return created; + for (const record of Array.isArray(records) ? records : []) { + if (!record || record.coverageEntities?.length || !isEligible(record)) + continue; + const entities = buildEntities(record); + record.coverageEntities = Array.isArray(entities) + ? entities.filter(Boolean) + : []; + created.push(...record.coverageEntities); + } + return created; + } + + /** Materializes one active camera's coverage set. */ + + function materializeCctvActiveCoverageEntities(record, buildEntities) { + return materializeCctvCoverageEntities([record], () => true, buildEntities); + } + + /** Materializes only records in the current coverage-visible ID set. */ + + function materializeCctvVisibleCoverageEntities( + records, + visibleIds, + buildEntities, + ) { + const eligibleIds = + visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []); + return materializeCctvCoverageEntities( + records, + (record) => eligibleIds.has(record.camera?.id), + buildEntities, + ); + } + + /** Registers newly built coverage entities with the layer-global collection. */ + + function registerCoverageEntities(created) { + layerState._coverageEntities.push(...created); + return created; + } + + function ensureActiveCoverageEntities(record) { + return registerCoverageEntities( + materializeCctvActiveCoverageEntities(record, buildCoverageEntities), + ); + } + + function ensureVisibleCoverageEntities(records, visibleIds) { + return registerCoverageEntities( + materializeCctvVisibleCoverageEntities( + records, + visibleIds, + buildCoverageEntities, + ), + ); + } + + /** + * Removes all coverage entities and projection runtimes from the scene. + */ + + function destroyCoverageEntities() { + if (!layerState._viewer) return; + for (const entity of layerState._coverageEntities) { + layerState._viewer.entities.remove(entity); + } + layerState._coverageEntities = []; + + for (const record of layerState._records) { + destroyViewshedVolume(record); + } + + for (const runtime of layerState._projectionEntities) { + parts.projection.destroyProjectionRuntime(runtime); + } + layerState._projectionEntities = []; + } + return { + computeFrustumGeometry, + activationProbeClampRange, + frustumCartesians, + frustumFrameEcef, + applyFrustumGeometry, + updateRecordGeometry, + buildCoverageVisibleSet, + destroyViewshedVolume, + rebuildViewshedVolume, + coverageNeighborCount, + runActivationObstructionProbe, + clearProbeClampOnDeactivation, + buildCoverageEntities, + materializeCctvCoverageEntities, + materializeCctvActiveCoverageEntities, + materializeCctvVisibleCoverageEntities, + registerCoverageEntities, + ensureActiveCoverageEntities, + ensureVisibleCoverageEntities, + destroyCoverageEntities, + }; +} diff --git a/src/layers/cctv/geometryQueue.js b/src/layers/cctv/geometryQueue.js new file mode 100644 index 000000000..5583e36af --- /dev/null +++ b/src/layers/cctv/geometryQueue.js @@ -0,0 +1,339 @@ +import * as Cesium from 'cesium'; +import { + GEO_PROGRESS_NOTIFY_INTERVAL_MS, + GEO_PROGRESS_NOTIFY_BATCH_LIMIT, + GEO_TRACKING_BATCH_SIZE, + GEO_TRACKING_BATCH_DELAY_MS, + GEO_LOAD_BATCH_SIZE, + GEO_LOAD_BATCH_DELAY_MS, +} from './policy.js'; + +export function createGeometryQueue({ + state: layerState, + services, + parts, + source, +}) { + /** + * Stops the staggered geometry-load queue and optionally clears progress + * counters (kept when pausing mid-flight is not needed — we always clear). + * @param {boolean} [clearProgress=true] + */ + + function stopGeometryLoadQueue(clearProgress = true) { + if (layerState._geoQueueTimer) { + clearTimeout(layerState._geoQueueTimer); + layerState._geoQueueTimer = 0; + } + layerState._geoQueue = []; + layerState._geoProgressNotifier = null; + if (clearProgress) { + layerState._geoLoading = false; + layerState._geoLoadTotal = 0; + layerState._geoLoadDone = 0; + } + } + + /** + * Creates the notification coalescer used by a staggered geometry drain. + * Progress emits after roughly 300 ms or ten batches, whichever comes first; + * finish always emits once even when the last progress tick just fired. + * + * @param {Function} notify Notification callback. + * @param {Object} [options={}] Testable timing options. + * @param {() => number} [options.now] Monotonic clock returning milliseconds. + * @param {number} [options.intervalMs] Maximum progress-notification cadence. + * @param {number} [options.batchLimit] Maximum batches between progress ticks. + * @returns {{ progress: () => boolean, finish: () => void }} Drain notifier. + */ + + function createGeometryProgressNotifier(notify, options = {}) { + const now = typeof options.now === 'function' ? options.now : Date.now; + const intervalMs = Number.isFinite(options.intervalMs) + ? Math.max(0, options.intervalMs) + : GEO_PROGRESS_NOTIFY_INTERVAL_MS; + const batchLimit = Number.isFinite(options.batchLimit) + ? Math.max(1, Math.floor(options.batchLimit)) + : GEO_PROGRESS_NOTIFY_BATCH_LIMIT; + let lastNotifyAt = now(); + let batchesSinceNotify = 0; + + return { + progress() { + batchesSinceNotify += 1; + const current = now(); + if ( + current - lastNotifyAt < intervalMs && + batchesSinceNotify < batchLimit + ) { + return false; + } + batchesSinceNotify = 0; + lastNotifyAt = current; + notify?.(); + return true; + }, + finish() { + batchesSinceNotify = 0; + lastNotifyAt = now(); + notify?.(); + }, + }; + } + + /** + * Processes one geometry-queue batch and routes progress/completion through + * the callbacks shared by production and the unit drain harness. + * + * @param {Object} options Batch inputs. + * @param {Object[]} options.queue Mutable record queue. + * @param {number} options.batchSize Maximum records to visit. + * @param {(record: Object) => void} options.visit Per-record geometry work. + * @param {() => void} options.progress Coalesced progress publication. + * @param {() => void} options.complete Unconditional completion publication. + * @returns {boolean} True when more records remain. + */ + + function processCctvGeometryQueueBatch({ + queue, + batchSize, + visit, + progress, + complete, + }) { + const safeQueue = Array.isArray(queue) ? queue : []; + const take = Number.isFinite(batchSize) + ? Math.max(1, Math.floor(batchSize)) + : 1; + const batch = safeQueue.splice(0, take); + for (const record of batch) visit?.(record); + if (safeQueue.length) { + progress?.(); + return true; + } + complete?.(); + return false; + } + + /** + * Selects per-batch geometry-drain pacing from current camera ownership. + * Called for every batch so releasing tracking immediately restores normal + * throughput without restarting the queue. + * + * @param {Object} [ownership={}] Current camera-ownership state. + * @param {*} [ownership.trackedEntity] Cesium tracked entity, if any. + * @param {boolean} [ownership.cockpitActive] Whether cockpit owns the camera. + * @returns {{ batchSize: number, delayMs: number }} Drain pacing. + */ + + function cctvGeometryDrainPacing({ + trackedEntity = null, + cockpitActive = false, + } = {}) { + if (trackedEntity || cockpitActive) { + return { + batchSize: GEO_TRACKING_BATCH_SIZE, + delayMs: GEO_TRACKING_BATCH_DELAY_MS, + }; + } + return { batchSize: GEO_LOAD_BATCH_SIZE, delayMs: GEO_LOAD_BATCH_DELAY_MS }; + } + + /** + * Processes one tracking-aware geometry-drain batch. Ownership is read inside + * every call so a mid-drain tracking/cockpit transition changes the very next + * batch's size and delay. + * + * @param {Object} options Batch inputs. + * @param {Object[]} options.queue Mutable record queue. + * @param {() => Object} [options.readOwnership] Current camera ownership. + * @param {(record: Object) => void} options.visit Per-record geometry work. + * @param {() => void} options.progress Coalesced progress publication. + * @param {() => void} options.complete Unconditional completion publication. + * @returns {{ hasMore: boolean, batchSize: number, delayMs: number }} Batch result and pacing. + */ + + function processCctvGeometryDrainBatch({ + queue, + readOwnership, + visit, + progress, + complete, + }) { + const pacing = cctvGeometryDrainPacing(readOwnership?.() || {}); + const hasMore = processCctvGeometryQueueBatch({ + queue, + batchSize: pacing.batchSize, + visit, + progress, + complete, + }); + return { hasMore, ...pacing }; + } + + /** + * Moves the current active record to the front of a live drain queue. + * @param {Object[]} queue Mutable geometry queue. + * @param {Object|null} activeRecord Current active CCTV record. + * @returns {boolean} Whether the queue order changed. + */ + + function prioritizeActiveCctvGeometryRecord(queue, activeRecord) { + if (!Array.isArray(queue) || !activeRecord) return false; + const index = queue.indexOf(activeRecord); + if (index <= 0) return false; + queue.splice(index, 1); + queue.unshift(activeRecord); + return true; + } + + /** + * Processes one batch (GEO_LOAD_BATCH_SIZE records) of the geometry queue: + * full ground-sampled coverage geometry per record, then yields back to the + * event loop before the next batch so tile rendering never stalls. When the + * initial-load pass completes it clears the loading flag and refreshes styles. + */ + + function processGeometryBatch() { + layerState._geoQueueTimer = 0; + if (!layerState._viewer) { + stopGeometryLoadQueue(); + return; + } + // Active-camera-first is re-established every batch because the operator + // can select a new camera while a long catalog drain is in flight. + prioritizeActiveCctvGeometryRecord( + layerState._geoQueue, + parts.selection.getActiveRecord(), + ); + const batchResult = processCctvGeometryDrainBatch({ + queue: layerState._geoQueue, + readOwnership: () => ({ + trackedEntity: layerState._viewer.trackedEntity, + cockpitActive: + typeof document !== 'undefined' && + document.body?.classList.contains('cockpit-mode'), + }), + visit: (record) => { + try { + parts.geometry.updateRecordGeometry(record); + } catch (err) { + console.warn( + '[Data:CCTV] geometry refresh error:', + err?.message || err, + ); + } + if ( + layerState._geoLoading && + layerState._geoLoadDone < layerState._geoLoadTotal + ) { + layerState._geoLoadDone += 1; + } + }, + progress: () => layerState._geoProgressNotifier?.progress(), + complete: () => { + const wasInitialLoad = layerState._geoLoading; + layerState._geoLoading = false; + if (wasInitialLoad) { + layerState._geoLoadDone = layerState._geoLoadTotal; + if (layerState._enabled) { + parts.rendering.refreshCoverageStyles(); + // Geometry refinement may have replaced record.position objects — the + // one-shot drain completion re-anchors the card entries (event-driven, + // not a per-frame or timer pass). + parts.cards.refreshAmbientCards(); + } + } + // Completion is never coalesced: subscribers must observe the final + // loading state even if the last progress tick just happened. + layerState._geoProgressNotifier?.finish(); + layerState._geoProgressNotifier = null; + }, + }); + if (batchResult.hasMore) { + layerState._geoQueueTimer = setTimeout( + processGeometryBatch, + batchResult.delayMs, + ); + return; + } + } + + /** + * Appends records to the geometry queue (no progress tracking) and starts + * the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion + * pass (records left `!groundResolved` by an enable-time drain that ran while + * tiles were still streaming) so it shares the same stagger machinery as the + * initial load. Fires at most once per enable — never on a recurring timer. + * @param {Object[]} records - Camera records needing geometry refresh. + */ + + function enqueueGeometryRefresh(records) { + for (const record of records) { + if (!layerState._geoQueue.includes(record)) { + layerState._geoQueue.push(record); + } + } + if (!layerState._geoQueueTimer && layerState._geoQueue.length) { + layerState._geoProgressNotifier = createGeometryProgressNotifier( + parts.presentation.notifyListeners, + ); + layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0); + } + } + + /** + * Starts the initial staggered load: orders all records active-camera-first, + * then by distance from the current viewer position (nearest first, so + * cameras likely in view refine before off-screen ones), and exposes + * loaded/total progress through uiState()/getStats() while running. + */ + + function startGeometryLoadQueue() { + stopGeometryLoadQueue(); + // Fresh drain → fresh one-shot completion pass: re-arm the tiles-ready + // latch so update() can complete any records this drain leaves unresolved. + layerState._tilesReadyReenqueued = false; + if (!layerState._records.length) return; + const active = parts.selection.getActiveRecord(); + const carto = layerState._viewer?.camera?.positionCartographic; + const refLat = carto + ? Cesium.Math.toDegrees(carto.latitude) + : (active?.camera.lat ?? 0); + const refLon = carto + ? Cesium.Math.toDegrees(carto.longitude) + : (active?.camera.lon ?? 0); + const pending = layerState._records + .filter((record) => record !== active) + .map((record) => ({ + record, + distKm: parts.model.haversineKm( + refLat, + refLon, + record.camera.lat, + record.camera.lon, + ), + })) + .sort((a, b) => a.distKm - b.distKm) + .map((entry) => entry.record); + layerState._geoQueue = active ? [active, ...pending] : pending; + layerState._geoLoadTotal = layerState._geoQueue.length; + layerState._geoLoadDone = 0; + layerState._geoLoading = true; + layerState._geoProgressNotifier = createGeometryProgressNotifier( + parts.presentation.notifyListeners, + ); + layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0); + } + return { + stopGeometryLoadQueue, + createGeometryProgressNotifier, + processCctvGeometryQueueBatch, + cctvGeometryDrainPacing, + processCctvGeometryDrainBatch, + prioritizeActiveCctvGeometryRecord, + processGeometryBatch, + enqueueGeometryRefresh, + startGeometryLoadQueue, + }; +} diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js new file mode 100644 index 000000000..4e6439306 --- /dev/null +++ b/src/layers/cctv/ground.js @@ -0,0 +1,283 @@ +export function createGround({ state: layerState, services, parts, source }) { + const { resolveGroundFloorCells } = services.ground; + const { resolveEllipsoidalGround } = services.terrain; + + /** + * 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'} + */ + + function surfaceRegimeKey(globeShow) { + return globeShow === false ? 'google-3d' : 'terrain-globe'; + } + + /** + * Task 5: the surface regime the scene is CURRENTLY rendering, derived live + * from `globe.show` (mapStackController's `_activatePhotoreal` / + * `_activateGlobeStack` flip exactly this flag). Reading scene state directly + * — rather than caching the map-stack id — means the regime is correct even + * for stack changes this module never got an event for. + * @returns {'google-3d'|'terrain-globe'} + */ + + function currentSurfaceRegime() { + return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show); + } + + /** + * Task 5: the record's ellipsoidal ground PRIOR (Re:Earth point-height batch, + * `record.groundPrior.ellipsoid`), falling back to the catalog's fabricated + * orthometric `groundElevationM` only when the prior batch hasn't landed yet. + * This is the value that replaces every previous + * `Number(camera.groundElevationM) || 0` ground fallback — it alone lifts + * London's cameras from the fabricated 15 m to ~52.7 m ellipsoidal in every + * regime, on first paint. + * @param {Object} record - Camera record. + * @returns {number} Ellipsoidal ground altitude in metres. + */ + + function groundPriorAltFor(record) { + const prior = record?.groundPrior?.ellipsoid; + return Number.isFinite(prior) + ? prior + : Number(record?.camera?.groundElevationM) || 0; + } + + /** + * Task 5: whether the record's one-shot ground resolution has completed for + * the given regime (per-regime latch — replaces the old boolean + * `groundResolved`). + * @param {Object} record - Camera record. + * @param {string} [regime] - Defaults to the current surface regime. + * @returns {boolean} + */ + + function isGroundResolved(record, regime = currentSurfaceRegime()) { + return record?.groundResolved?.[regime] === true; + } + + /** + * Ground altitude used for pure geometry recomputes: the given regime's + * cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor + * in google-3d, the DEM/prior in terrain-globe) when it exists, else the prior + * itself. Never queries the scene. + * @param {Object} record - Camera record. + * @param {string} [regime] - Defaults to the current surface regime. + * @returns {number} Ground altitude in metres. + */ + + function groundAltFor(record, regime = currentSurfaceRegime()) { + const cached = record?.groundSamples?.[regime]; + return Number.isFinite(cached) ? cached : groundPriorAltFor(record); + } + + /** + * Re-arms a record for a fresh one-shot ground resolution after a GENUINE + * pose change (an explicit user select/move or manual calibration edit). + * Clears the CURRENT regime's resolved latch so the next real pass in + * updateRecordGeometry always applies, then the record re-freezes. Never + * called on a timer. + * + * The cached `groundSamples` entries are deliberately KEPT (only the latch is + * cleared). They are the record's "has ever resolved" memory: the B9c + * fallback guard in updateRecordGeometry reads the google-3d entry so a + * rearmed camera whose tiles are mid-stream (e.g. select → flyTo → + * tilesLoaded false) is not yanked back to prior/catalog heights before its + * fresh shared floor lands. In the terrain-globe regime the re-arm is + * effectively free: the next pass re-latches from the DEM/prior with zero + * scene queries. + * @param {Object} record - Camera record. + */ + + function rearmGroundResolution(record) { + if (!record) return; + record.groundResolved[currentSurfaceRegime()] = false; + } + + /** + * Resolves a user-moved ground anchor exactly once at commit. The synchronous + * pass uses a warm shared floor immediately, or preserves the pre-drag floor + * while the new cell is cold. A revision and coordinate check prevent an + * older asynchronous release from rewriting a newer edit. + * @param {Object} record - Camera record whose lat/lon just committed. + */ + + function resolveCommittedGroundAnchor(record) { + if (!record?.camera) return; + record.calibrationGroundResolveCount = + (record.calibrationGroundResolveCount || 0) + 1; + const revision = (record.calibrationGroundRevision || 0) + 1; + record.calibrationGroundRevision = revision; + const point = { lat: record.camera.lat, lon: record.camera.lon }; + + rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + if (isGroundResolved(record)) return; + + resolveGroundFloorCells([point]).then(() => { + if (layerState._recordById.get(record.camera.id) !== record) return; + if (record.calibrationGroundRevision !== revision) return; + if (record.camera.lat !== point.lat || record.camera.lon !== point.lon) + return; + rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + }); + } + + /** + * Task 5: batches every catalog camera's coords through the Re:Earth + * ellipsoidal ground resolver (`/api/terrain/heights` proxy — network-cached, + * chunked, geoid fallback; NOT a scene query). The catalog's orthometric + * `groundElevationM` rides along as `sourceOrthometricM` so the geoid + * fallback chain is meaningful where the catalog value is real (Caltrans). + * Never rejects — a total failure resolves null and geometry stays on + * catalog fallbacks (no worse than pre-Task-5). + * @param {Object[]} catalog - Camera objects (post-ensureCameraPose). + * @returns {Promise|null>} + */ + + async function resolveGroundPriors(catalog) { + try { + const coords = catalog.map((camera) => { + const ortho = Number(camera.groundElevationM); + return { + lat: camera.lat, + lon: camera.lon, + ...(Number.isFinite(ortho) ? { sourceOrthometricM: ortho } : {}), + }; + }); + return await resolveEllipsoidalGround(coords); + } catch (error) { + console.warn( + '[Data:CCTV] ground-prior batch failed (keeping catalog fallbacks):', + error?.message || error, + ); + return null; + } + } + + /** + * Task 5: applies a LATE-arriving ground-prior batch (init's bounded race + * lost — cold proxy cache / slow upstream). Pure recomputes only, no scene + * queries: + * - terrain-globe regime: the prior IS the resolution → re-run the + * resolution (updateRecordGeometry latches it) for every record. + * - google-3d regime: records still awaiting a shared floor move from the + * catalog fallback onto the exact prior; records already holding a shared + * mesh/DEM floor keep it untouched. + * Guarded per record against a torn-down/re-inited layer (records are only + * touched while they are still the live catalog entries). + * @param {Object[]} records - The record array captured at init time. + * @param {Array<{ellipsoid:number, source:string}>} priors - Aligned by index. + */ + + function applyLateGroundPriors(records, priors) { + if (!Array.isArray(records) || !Array.isArray(priors)) return; + let applied = 0; + for (let i = 0; i < records.length && i < priors.length; i++) { + const record = records[i]; + const prior = priors[i]; + if (!record || !prior || !Number.isFinite(prior.ellipsoid)) continue; + // Stale-record guard: init() may have re-run (destroy/init cycle) while + // the batch was in flight — only touch records still live in the map. + if (layerState._recordById.get(record.camera.id) !== record) continue; + record.groundPrior = prior; + // Keep the cheap pre-enable altitude consistent for records whose + // geometry hasn't been applied yet (applyFrustumGeometry overwrites it). + if (!record.frustumPositions) { + record.camera.absoluteHeightM = + prior.ellipsoid + record.camera.mountHeightM; + } + const regime = currentSurfaceRegime(); + if (regime === 'terrain-globe') { + // Prior IS the resolution — re-latch onto the fresh value. + parts.geometry.updateRecordGeometry(record, { sampleGround: false }); + applied += 1; + } else if (!Number.isFinite(record.groundSamples['google-3d'])) { + // Still awaiting the shared floor: snap interim geometry onto the exact + // prior (pure recompute; the shared cell may refine later). + parts.geometry.applyFrustumGeometry(record, prior.ellipsoid); + applied += 1; + } + } + if (applied) parts.presentation.notifyListeners(); + } + + /** + * Task 5: surface-regime change handler ('gev:map-stack-changed' + * CustomEvent, dispatched by main.js from MapStackController.onChange). The + * surface HEIGHT at a camera differs between regimes (a photogrammetric + * deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so + * on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and + * no-ops): + * 1. every record's geometry recomputes IMMEDIATELY from the new regime's + * resolution — cached sample if that regime has one, else the Re:Earth + * prior (never blank, zero scene queries); + * 2. entering google-3d re-arms the one-shot tiles-ready completion latch so + * update()'s existing event-driven machinery refines records that never + * took their sample, through the same staggered queue. + * Event-driven only — never called on a timer. + */ + + function handleMapStackChanged() { + if (!layerState._viewer || !layerState._records.length) return; + const regime = currentSurfaceRegime(); + if (regime === layerState._lastAppliedRegime) return; + layerState._lastAppliedRegime = regime; + + for (const record of layerState._records) { + if (regime === 'terrain-globe') { + // Prior IS the resolution — latch it (zero scene queries). + record.groundSamples['terrain-globe'] = groundPriorAltFor(record); + record.groundResolved['terrain-globe'] = true; + } + const ground = groundAltFor(record, regime); + // Skip the entity rewrite when the applied ground already matches (e.g. + // entering google-3d before any sample: prior → prior is a no-op). + if ( + record.frustumGeometry && + Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001 + ) { + continue; + } + parts.geometry.applyFrustumGeometry(record, ground); + } + + if (regime === 'google-3d') { + // Fresh google-3d session: let update()'s ONE-SHOT completion pass + // re-enqueue records without an accepted sample once the (re-shown) + // tileset reports tilesLoaded. Records already sampled in a previous + // google-3d session keep their cached resolution — 0 new samples, well + // under the ≤1-per-(camera, session) ceiling. + layerState._tilesReadyReenqueued = false; + } + parts.presentation.notifyListeners(); + } + return { + surfaceRegimeKey, + currentSurfaceRegime, + groundPriorAltFor, + isGroundResolved, + groundAltFor, + rearmGroundResolution, + resolveCommittedGroundAnchor, + resolveGroundPriors, + applyLateGroundPriors, + handleMapStackChanged, + }; +} diff --git a/src/layers/cctv/health.js b/src/layers/cctv/health.js new file mode 100644 index 000000000..979e997cf --- /dev/null +++ b/src/layers/cctv/health.js @@ -0,0 +1,41 @@ +import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js'; + +export function createHealth({ state: layerState, services, parts, source }) { + /** + * Fetches per-camera health status from the backend and updates _healthById. + * Rate-limited to HEALTH_SYNC_INTERVAL_MS unless forced. + * @param {boolean} [force=false] - Bypass the interval check. + */ + + async function syncHealthState(force = false) { + const now = Date.now(); + if (!force && now - layerState._lastHealthSyncAt < HEALTH_SYNC_INTERVAL_MS) + return; + layerState._lastHealthSyncAt = now; + + try { + const signal = layerState._sourceAbort?.signal; + const data = await source.getHealth({ signal }); + signal?.throwIfAborted(); + const rows = Array.isArray(data?.cameras) ? data.cameras : []; + const next = new Map(); + for (const row of rows) { + const id = String(row?.id || '').trim(); + if (!id) continue; + next.set(id, { + status: String(row.status || '').toLowerCase() || 'unknown', + sourceKind: String( + row.sourceKind || row.feedType || '', + ).toLowerCase(), + label: String(row.label || row.provider || ''), + message: String(row.message || ''), + updatedAt: parts.model.safeNumber(row.updatedAt, now), + }); + } + layerState._healthById = next; + } catch { + // keep previous health map + } + } + return { syncHealthState }; +} diff --git a/src/layers/cctv/hover.js b/src/layers/cctv/hover.js new file mode 100644 index 000000000..cdd3375bf --- /dev/null +++ b/src/layers/cctv/hover.js @@ -0,0 +1,127 @@ +import { + CCTV_CARD_FETCH_BURST_LIMIT, + frameFetchDue, +} from '../../data/cctvCards.js'; +import { staticFrameRefreshMs } from '../../data/cctvLod.js'; +import { HOVER_PICK_THROTTLE_MS, HOVER_RELEASE_MS } from './policy.js'; + +export function createHover({ state: layerState, services, parts, source }) { + /** + * Throttled MOUSE_MOVE hover pass (owner round 2, item B): pointing at a + * camera icon that has no card summons its card immediately. This is + * EVENT-DRIVEN picking on a user gesture, not steady-state work — the + * ≥120 ms throttle caps it at ~8 scene.pick calls/s while the pointer is + * actually moving (a still pointer costs nothing), so it can never approach + * per-frame cost. Skipped while the camera is in motion, while ADJUST mode + * owns the pointer (gizmo drags), and while the layer is disabled. + * @param {Cesium.Cartesian2} position - Pointer position (CSS px). + */ + + function handleHoverMove(position) { + if ( + !layerState._enabled || + layerState._cameraMoving || + layerState._calibrationMode || + !position + ) + return; + if (!layerState._viewer || layerState._viewer.isDestroyed()) return; + const now = Date.now(); + if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return; + layerState._hoverLastPickAt = now; + let picked = null; + try { + picked = layerState._viewer.scene.pick(position); + } catch { + picked = null; + } + const cameraId = parts.selection.extractPickedCameraId(picked); + if (cameraId && cameraId === layerState._hoverCardId) { + // Still on the hovered camera — keep the card alive. + cancelHoverRelease(); + return; + } + const record = cameraId ? layerState._recordById.get(cameraId) : null; + // Hovering the active camera or a camera that already has a card is a + // no-op; video feeds stay icon-only until activated (ambient tier is + // stills-only — same rule as the LOD selection). + const eligible = + !!record && + cameraId !== layerState._activeCameraId && + !layerState._cardIds.has(cameraId) && + !parts.model.isVideoFeedType( + parts.model.normalizeFeedType(record.camera.feedType), + ); + if (eligible) { + cancelHoverRelease(); + layerState._hoverCardId = cameraId; + // Immediacy: the pinned entry publishes now — chrome may paint before + // the first frame arrives (documented exception in cctvCards.js). + parts.cards.pushAmbientCardEntries(); + hoverFetchCardFrame(record); + } else if (layerState._hoverCardId) { + // Pointer left the hovered icon: linger ~1 s, then release. + scheduleHoverRelease(); + } + } + + /** Cancels a pending hover-card release. */ + + function cancelHoverRelease() { + if (layerState._hoverReleaseTimer) { + clearTimeout(layerState._hoverReleaseTimer); + layerState._hoverReleaseTimer = 0; + } + } + + /** + * Schedules the hover card's release ~1 s after unhover. On release the pin + * drops; the card stays only if the LOD selection has adopted the camera + * (it is then a normal budgeted entry in `_cardIds`). + */ + + function scheduleHoverRelease() { + if (layerState._hoverReleaseTimer) return; + layerState._hoverReleaseTimer = setTimeout(() => { + layerState._hoverReleaseTimer = 0; + layerState._hoverCardId = null; + parts.cards.pushAmbientCardEntries(); + }, HOVER_RELEASE_MS); + } + + /** Clears the hover-card state (teardown / activation of the hovered camera). */ + + function clearHoverCard() { + cancelHoverRelease(); + layerState._hoverCardId = null; + layerState._hoverLastPickAt = 0; + } + + /** + * Fast-tracked frame fetch for the hover-summoned card: launches + * immediately, bypassing the pacer's launch-spacing gate (a single + * user-gesture fetch is fine even during the geometry drain), but still + * respecting the per-camera failure backoff / freshness check + * (frameFetchDue), the no-double-fetch pending set, and the burst in-flight + * cap of 4. + * @param {Object} record - The hovered camera's record. + */ + + function hoverFetchCardFrame(record) { + const cameraId = record.camera.id; + if (layerState._cardFetchPendingIds.has(cameraId)) return; + if (layerState._cardFetchInFlightCount >= CCTV_CARD_FETCH_BURST_LIMIT) + return; + const slot = parts.cards.ensureCardFrameSlot(cameraId); + const refreshMs = staticFrameRefreshMs(record.camera); + if (!frameFetchDue(slot, refreshMs, Date.now())) return; + parts.cards.fetchCardFrame(record, slot, refreshMs, { userGesture: true }); + } + return { + handleHoverMove, + cancelHoverRelease, + scheduleHoverRelease, + clearHoverCard, + hoverFetchCardFrame, + }; +} diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js new file mode 100644 index 000000000..712783144 --- /dev/null +++ b/src/layers/cctv/index.js @@ -0,0 +1,130 @@ +import { createCalibration } from './calibration.js'; +import { createTesting } from './testing.js'; +import { createProjection } from './projection.js'; +import { createCards } from './cards.js'; +import { createModel } from './model.js'; +import { createGround } from './ground.js'; +import { createGeometry } from './geometry.js'; +import { createCatalog } from './catalog.js'; +import { createFrames } from './frames.js'; +import { createRendering } from './rendering.js'; +import { createGeometryQueue } from './geometryQueue.js'; +import { createSelection } from './selection.js'; +import { createHover } from './hover.js'; +import { createNavigation } from './navigation.js'; +import { createPresentation } from './presentation.js'; +import { createLifecycle } from './lifecycle.js'; +import { createHealth } from './health.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createCctvLayer({ services, source }) { + if ( + !['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every( + (key) => typeof source?.[key] === 'function', + ) + ) + throw new TypeError('A camera source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.calibration = createCalibration(context); + parts.testing = createTesting(context); + parts.projection = createProjection(context); + parts.cards = createCards(context); + parts.model = createModel(context); + parts.ground = createGround(context); + parts.geometry = createGeometry(context); + parts.catalog = createCatalog(context); + parts.frames = createFrames(context); + parts.rendering = createRendering(context); + parts.geometryQueue = createGeometryQueue(context); + parts.selection = createSelection(context); + parts.hover = createHover(context); + parts.navigation = createNavigation(context); + parts.presentation = createPresentation(context); + parts.lifecycle = createLifecycle(context); + parts.health = createHealth(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + calibrationPatchMovesAnchor: + parts.calibration.calibrationPatchMovesAnchor, + _pushAmbientCardEntriesForTest: + parts.testing._pushAmbientCardEntriesForTest, + _setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest, + createCctvProjectionOverlayEntry: + parts.projection.createCctvProjectionOverlayEntry, + setCctvCardPresentationOptions: + parts.cards.setCctvCardPresentationOptions, + surfaceRegimeKey: parts.ground.surfaceRegimeKey, + normalizeCoverageMode: parts.model.normalizeCoverageMode, + readCalibrationStoreV2: parts.calibration.readCalibrationStoreV2, + writeCalibrationStoreV2: parts.calibration.writeCalibrationStoreV2, + deriveCalBadge: parts.calibration.deriveCalBadge, + computeFrustumGeometry: parts.geometry.computeFrustumGeometry, + activationProbeClampRange: parts.geometry.activationProbeClampRange, + frameSignatureFromPixels: parts.frames.frameSignatureFromPixels, + _createCctvProjectionPlaneForTest: + parts.testing._createCctvProjectionPlaneForTest, + _updateCctvProjectionPlaneForTest: + parts.testing._updateCctvProjectionPlaneForTest, + applyCctvFocusDeemphasis: parts.rendering.applyCctvFocusDeemphasis, + createGeometryProgressNotifier: + parts.geometryQueue.createGeometryProgressNotifier, + processCctvGeometryQueueBatch: + parts.geometryQueue.processCctvGeometryQueueBatch, + cctvGeometryDrainPacing: parts.geometryQueue.cctvGeometryDrainPacing, + processCctvGeometryDrainBatch: + parts.geometryQueue.processCctvGeometryDrainBatch, + prioritizeActiveCctvGeometryRecord: + parts.geometryQueue.prioritizeActiveCctvGeometryRecord, + processGeometryBatch: parts.geometryQueue.processGeometryBatch, + hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals, + refreshCoverageStyles: parts.rendering.refreshCoverageStyles, + clearProbeClampOnDeactivation: + parts.geometry.clearProbeClampOnDeactivation, + cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation, + bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture, + setActiveCamera: parts.selection.setActiveCamera, + deactivateActiveCamera: parts.selection.deactivateActiveCamera, + cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects, + materializeCctvCoverageEntities: + parts.geometry.materializeCctvCoverageEntities, + materializeCctvActiveCoverageEntities: + parts.geometry.materializeCctvActiveCoverageEntities, + materializeCctvVisibleCoverageEntities: + parts.geometry.materializeCctvVisibleCoverageEntities, + _extractPickedCameraIdForTest: + parts.testing._extractPickedCameraIdForTest, + _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, + focusCctvRecord: parts.navigation.focusCctvRecord, + maybeAutoHop: parts.navigation.maybeAutoHop, + cctvCycleIndex: parts.navigation.cctvCycleIndex, + }, + ); +} +export { + CCTV_CALIBRATION_STORAGE_KEY_V1, + CCTV_CALIBRATION_STORAGE_KEY_V2, + FRUSTUM_GROUND_CLEARANCE_M, + CCTV_FOCUS_RESULT, + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export { createCctvSource } from './source.js'; + +export { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; +export { + CCTV_OVERLAY_SOURCE_ID, + createCctvThumbnailOverlayEntry, + createFrameSlot, +} from '../../data/cctvCards.js'; diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js new file mode 100644 index 000000000..0947ccdb6 --- /dev/null +++ b/src/layers/cctv/ingestion.js @@ -0,0 +1,48 @@ +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + /** + * Periodic update tick: syncs health state and runs auto-hop logic. Ground + * geometry is NOT resampled here — v2 grounds each camera once via the + * staggered load queue (see startGeometryLoadQueue/updateRecordGeometry) + * and never resamples on a timer. The ONE exception is a one-shot + * completion pass: the enable-time drain can run while 3D tiles are still + * streaming (each such pass keeps the fabricated catalog height and leaves + * the record `!groundResolved`), so the FIRST tick that sees + * projectionTilesReady() re-enqueues those records once — each then takes + * its single real sample and freezes (design §4). Guarded by a boolean + * latch (`_tilesReadyReenqueued`), NOT a timer loop: after it fires, no + * tick ever samples anything again. + */ + async update() { + if (!layerState._enabled) return; + const now = Date.now(); + layerState._lastUpdate = now; + if ( + !layerState._tilesReadyReenqueued && + parts.model.projectionTilesReady() + ) { + layerState._tilesReadyReenqueued = true; + // Per-regime resolution (Task 5): only records unresolved for the + // CURRENT surface regime need the completion pass. On globe stacks + // projectionTilesReady() is false while a (hidden) Google tileset + // exists, so this latch effectively fires for the google-3d regime — + // terrain-globe records resolve from the prior in their drain pass. + const unresolved = layerState._records.filter( + (record) => !parts.ground.isGroundResolved(record), + ); + if (unresolved.length) + parts.geometryQueue.enqueueGeometryRefresh(unresolved); + } + await parts.health.syncHealthState(); + parts.navigation.maybeAutoHop(now); + parts.presentation.notifyListeners(); + }, + }; + + return { methods }; +} diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js new file mode 100644 index 000000000..a679f3234 --- /dev/null +++ b/src/layers/cctv/lifecycle.js @@ -0,0 +1,461 @@ +import * as Cesium from 'cesium'; +import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js'; +import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js'; +import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js'; +import { + GROUND_PRIOR_INIT_WAIT_MS, + CAMERA_ICON, + IDLE_CAMERA_COLOR, +} from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { activateCctvCameraFromWorldClick } = services.activation; + const { resolvePickId, registerPickOwner, unregisterPickOwner } = + services.picking; + const { onFocusTargetAppear } = services.focus; + const { releaseContinuousRender } = services.render; + + /** Resets all module-scoped runtime state to initial values. */ + + function clearRuntimeState() { + parts.geometryQueue.stopGeometryLoadQueue(); + // Idempotent — also covers a re-init without a prior destroy(). + parts.cards.teardownAmbientCards(); + parts.projection.clearProjectionOverlay(); + layerState._records = []; + layerState._recordById = new Map(); + layerState._healthById = new Map(); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastHealthSyncAt = 0; + layerState._lastError = null; + layerState._lastFocusStyleAt = 0; + layerState._activeFocusStyleCount = 0; + // FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh + // init re-discovers against the current scene primitives. + layerState._activeTileset = null; + // Task 5: the applied-regime tracker is record-set-scoped — a fresh init + // recomputes it against the then-current scene. + layerState._lastAppliedRegime = null; + } + const methods = { + /** + * Initializes the CCTV layer: loads camera sources, builds the catalog, + * restores calibration from localStorage, creates billboards, sets up click + * handling, and performs initial health sync. Coverage entities stay lazy. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + async init(viewer) { + layerState._sourceAbort?.abort(); + const sourceAbort = new AbortController(); + layerState._sourceAbort = sourceAbort; + if (typeof document !== 'undefined') + document.addEventListener( + 'visibilitychange', + parts.cards.handleVisibilityChange, + ); + layerState._viewer = viewer; + clearRuntimeState(); + layerState._enabled = false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = false; + layerState._lastHopAt = 0; + layerState._lastViewContext = ''; + layerState._calibrationById = parts.calibration.loadCalibrationStore(); + + layerState._billboards = new Cesium.BillboardCollection(); + layerState._viewer.scene.primitives.add(layerState._billboards); + registerSpriteCollection('cctv', layerState._billboards); + + const sources = await parts.catalog.loadCameraSources(); + const catalogFromSources = parts.catalog.buildCatalogFromSources(sources); + const catalog = catalogFromSources.length + ? catalogFromSources + : parts.catalog.seedCatalog(); + + // Viewshed color identity (design §3a): golden-angle hue over the + // id-SORTED catalog index — deterministic across sessions for a stable + // catalog, maximally separated for neighboring cameras. + const hueIndexById = new Map( + catalog + .map((camera) => camera.id) + .sort() + .map((id, index) => [id, index]), + ); + + for (const camera of catalog) { + const savedEntry = layerState._calibrationById.get(camera.id); + if (savedEntry) { + camera.calibration = parts.calibration.normalizeCalibration( + savedEntry.values, + ); + camera.calSource = savedEntry.source; + } + parts.model.ensureCameraPose(camera); + } + + // Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth + // ellipsoidal ground-prior resolver (network-cached — NOT a scene query; + // the catalog's orthometric groundElevationM feeds the geoid fallback + // chain). Bounded wait: a warm proxy cache resolves in milliseconds, so + // records are normally built WITH their prior (correct first paint in + // every regime); a cold/slow upstream loses the race and the batch + // applies post-hoc via applyLateGroundPriors instead of hanging init. + const priorsPromise = parts.ground.resolveGroundPriors(catalog); + const priors = await Promise.race([ + priorsPromise, + new Promise((resolve) => + setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS), + ), + ]); + sourceAbort.signal.throwIfAborted(); + + for (let i = 0; i < catalog.length; i++) { + const camera = catalog[i]; + // Ellipsoidal ground prior (or null while the batch is still in + // flight). Geometry falls back to the catalog value only until the + // batch lands. + const groundPrior = priors?.[i] || null; + // Cheap first-pass altitude from the ellipsoidal prior (catalog value + // only as the pre-prior fallback) — the staggered geometry queue + // refines with sampled tile heights after enable so the init path + // never raycasts the scene once per camera. + const priorGround = Number.isFinite(groundPrior?.ellipsoid) + ? groundPrior.ellipsoid + : Number(camera.groundElevationM) || 0; + camera.absoluteHeightM = priorGround + camera.mountHeightM; + const position = Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + camera.absoluteHeightM, + ); + const billboard = layerState._billboards.add({ + id: camera.id, + image: CAMERA_ICON, + position, + color: IDLE_CAMERA_COLOR, + width: 24, + height: 24, + // Field-test fix (2026-07-06): always-on-top. The old finite value + // (1800 m) re-engaged the depth test at far zoom, where the COARSE + // far-LOD Google-3D mesh sits above the true ground and swallowed + // ground-anchored icons ("submerged" pills over SF). Far-side-of-globe + // icons are handled by refreshHorizonCulling() (the flights-layer + // EllipsoidalOccluder pattern), not by the depth test. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42), + }); + + const record = { + camera, + position, + billboard, + coverageEntities: [], + projection: null, + // Task 5 (height-datum fix): regime-aware ground resolution state. + // groundPrior — { ellipsoid, source } from the Re:Earth batch + // (null until a late batch lands). The prior applies in EVERY + // regime and is the terrain-globe resolution outright. + // groundResolved — PER-REGIME one-shot latch (regime key → + // boolean): true once this record's resolution completed for that + // regime; such records are excluded from the completion pass so + // their geometry freezes. Re-armed only on a genuine pose change, + // explicit user select/move, or a surface-regime change — never + // on the 10s timer. + // groundSamples — PER-REGIME resolved ground (regime key → + // metres): the accepted one-shot scene sample in google-3d, the + // mirrored prior in terrain-globe. Kept across re-arms as the + // "has ever resolved" memory for the B9c mid-stream guard. + // frustumPositions — cached Cartesians for pure recomputes (so + // plane placement never re-derives geometry it already has). + groundPrior, + groundResolved: {}, + groundSamples: {}, + frustumGeometry: null, + frustumPositions: null, + // §9.1 activation obstruction probe result: effective-range clamp so + // the far-cap plane never clips into the tiles. Null = unclamped. + // Reset + re-probed on every activation; cleared when the user takes + // the range slider (slider overrides the clamp). + probeClampRangeM: null, + // Viewshed (design §3a/§3b): per-camera color identity + the volume + // primitive handle (exists only in viewshed mode for the visible set). + viewshedColors: viewshedColors( + cameraHue(hueIndexById.get(camera.id) ?? 0), + ), + viewshedPrimitive: null, + viewshedActiveTint: false, + }; + layerState._records.push(record); + layerState._recordById.set(camera.id, record); + } + + layerState._count = layerState._records.length; + if (layerState._records.length > 0) { + // Projection runtime + first frame fetch are deferred to enable() so + // initializing the catalog stays render-cheap. + layerState._activeCameraId = layerState._records[0].camera.id; + } + + // Task 5: if the prior batch lost init's bounded race, apply it post-hoc + // when it lands (pure recomputes — applyLateGroundPriors guards against + // a torn-down/re-inited catalog). + if (!priors) { + const initRecords = layerState._records.slice(); + priorsPromise + .then((late) => { + if (late) parts.ground.applyLateGroundPriors(initRecords, late); + }) + .catch(() => {}); + } + + // Task 5: track the surface regime the initial geometry was computed for + // and listen for map-stack changes (main.js re-dispatches + // MapStackController.onChange as this CustomEvent). The handler compares + // regimes itself, so 'switching'/'error' emissions and same-regime stack + // swaps (bing→osm) no-op. + layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime(); + if (!layerState._mapStackListener && typeof window !== 'undefined') { + layerState._mapStackListener = () => + parts.ground.handleMapStackChanged(); + window.addEventListener( + 'gev:map-stack-changed', + layerState._mapStackListener, + ); + } + + // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with + // the billboards' always-on-top depth setting) + one initial pass so the + // first paint is already culled. + if (!layerState._horizonCullListener) { + // Ambient cards piggyback the same settle event: moveEnd-driven + // reselection only, never per frame (refreshAmbientCards no-ops while + // the layer is disabled). + layerState._horizonCullListener = () => { + layerState._cameraMoving = false; + parts.rendering.refreshHorizonCulling(); + parts.cards.refreshAmbientCards(); + }; + layerState._viewer.camera.moveEnd.addEventListener( + layerState._horizonCullListener, + ); + } + if (!layerState._moveStartListener) { + // Item B: hover picking pauses while the camera is in motion. + layerState._moveStartListener = () => { + layerState._cameraMoving = true; + }; + layerState._viewer.camera.moveStart.addEventListener( + layerState._moveStartListener, + ); + } + parts.rendering.refreshHorizonCulling(); + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + layerState._viewer.scene.canvas, + ); + parts.selection.bindCctvWorldClickGesture( + layerState._clickHandler, + (click) => { + if (!layerState._enabled) return; + const picked = layerState._viewer.scene.pick(click.position); + const cameraId = parts.selection.extractPickedCameraId(picked); + if (cameraId) { + activateCctvCameraFromWorldClick( + cameraId, + parts.selection.setActiveCamera, + ); + return; + } + // Any identified scene object owns this click even if its layer does not + // register a shared pick predicate. This keeps selectable siblings ahead + // of an overlapping CCTV card while ID-less globe/terrain/tile surfaces + // remain eligible for true empty-space deselection. + const pickedId = resolvePickId(picked); + if (pickedId !== null) return; + // Item A (owner round 2): the scene pick found no camera — try the + // painted ambient cards. The cards canvas is pointer-events:none (this + // handler owns the events), so a click landing on a card's rect selects + // its camera exactly like a click on the icon. Cesium click positions + // and the recorded rects are both CSS px — direct comparison. + const cardId = layerState._cctvOverlayHost.hitTest( + click.position.x, + click.position.y, + { sourceId: CCTV_OVERLAY_SOURCE_ID }, + )?.entryId; + if (cardId && layerState._recordById.has(cardId)) { + activateCctvCameraFromWorldClick( + cardId, + parts.selection.setActiveCamera, + ); + return; + } + if ( + parts.selection.cctvEmptyClickDeselects(picked, { + activeCameraId: layerState._activeCameraId, + calibrationMode: layerState._calibrationMode, + }) + ) { + parts.selection.deactivateActiveCamera(); + } + }, + { + // Item B: hover summons a card on a cardless camera icon. The gesture + // classifier owns MOUSE_MOVE too, so chain hover work through its seam + // instead of replacing the travel accumulator's handler. + onMouseMove: (movement) => + parts.hover.handleHoverMove(movement?.endPosition), + }, + ); + + await parts.health.syncHealthState(true); + parts.rendering.refreshCoverageStyles(); + parts.presentation.notifyListeners(); + restoreSpriteOrder(layerState._viewer); + console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras'); + }, + + /** + * Enables the layer: shows entities, starts the projection loop, and kicks + * the staggered geometry-load queue. Heavy work (per-camera ground + * sampling) is deferred/batched so the frame budget never collapses at + * enable time. + */ + enable() { + layerState._enabled = true; + layerState._lastUpdate = Date.now(); + // Pick-ownership (H2): camera billboards use the camera id directly; + // coverage polyline entities use `cctv--` entity ids. + registerPickOwner('cctv', (pickedId) => { + if (layerState._recordById.has(pickedId)) return true; + if ( + typeof pickedId === 'string' && + pickedId.startsWith(GIZMO_ID_PREFIX) + ) + return true; + const coverage = + /^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec( + pickedId, + ); + return Boolean(coverage && layerState._recordById.has(coverage[1])); + }); + if (!layerState._activeCameraId && layerState._records.length) { + layerState._activeCameraId = layerState._records[0].camera.id; + layerState._autoHopSuspended = false; + } + const activeRecord = parts.selection.getActiveRecord(); + if (activeRecord) { + parts.projection.ensureProjectionRuntime(activeRecord); + parts.frames.refreshProjectionImage(activeRecord, true); + } + parts.geometryQueue.startGeometryLoadQueue(); + parts.rendering.refreshCoverageStyles(); + parts.projection.startProjectionLoop(); + // The projection loop self-stops when idle; a focus target appearing + // (user starts tracking a contact) is the one edge it can't see while + // stopped, so re-arm on it. Removed on disable. + layerState._removeFocusAppearListener?.(); + layerState._removeFocusAppearListener = onFocusTargetAppear(() => + parts.projection.startProjectionLoop(), + ); + // Ambient card tier: shared host source + policy-gated frame pacer + the + // initial selection pass (moveEnd drives every later reselection). + layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, true); + parts.cards.startCardFrameLoop(); + parts.cards.refreshAmbientCards(); + parts.presentation.notifyListeners(); + restoreSpriteOrder(layerState._viewer); + }, + + /** Disables the layer: hides entities, stops the projection loop and load queue. */ + disable() { + layerState._enabled = false; + unregisterPickOwner('cctv'); + // ADJUST mode does not survive a layer toggle — predictable re-entry. + layerState._calibrationMode = false; + releaseContinuousRender('cctv-adjust'); + layerState._gizmo?.setEnabled(false); + layerState._removeFocusAppearListener?.(); + layerState._removeFocusAppearListener = null; + parts.projection.stopProjectionLoop(); + parts.geometryQueue.stopGeometryLoadQueue(); + // Ambient cards tear down COMPLETELY on disable (owner design point 6): + // source entries, pacer timer, in-flight handlers, and caches. + parts.cards.teardownAmbientCards(); + parts.rendering.hideCctvVisuals(); + parts.presentation.notifyListeners(); + }, + + /** + * Tears down the layer: destroys click handler, projection loop, coverage + * entities, billboards, and clears all runtime state and subscribers. + * @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref). + */ + destroy(viewer) { + layerState._sourceAbort?.abort(); + if (typeof document !== 'undefined') + document.removeEventListener( + 'visibilitychange', + parts.cards.handleVisibilityChange, + ); + unregisterPickOwner('cctv'); + if (layerState._mapStackListener && typeof window !== 'undefined') { + window.removeEventListener( + 'gev:map-stack-changed', + layerState._mapStackListener, + ); + layerState._mapStackListener = null; + } + const teardownViewer = viewer || layerState._viewer; + if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) { + teardownViewer.camera.moveEnd.removeEventListener( + layerState._horizonCullListener, + ); + layerState._horizonCullListener = null; + } + if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) { + teardownViewer.camera.moveStart.removeEventListener( + layerState._moveStartListener, + ); + layerState._moveStartListener = null; + } + if (layerState._gizmo) { + layerState._gizmo.destroy(); + layerState._gizmo = null; + } + layerState._calibrationMode = false; + releaseContinuousRender('cctv-adjust'); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (teardownViewer?.scene?.screenSpaceCameraController) { + teardownViewer.scene.screenSpaceCameraController.enableInputs = true; + } + parts.projection.stopProjectionLoop(); + parts.geometryQueue.stopGeometryLoadQueue(); + parts.cards.teardownAmbientCards(); + parts.geometry.destroyCoverageEntities(); + if (layerState._billboards && teardownViewer) { + teardownViewer.scene.primitives.remove(layerState._billboards); + layerState._billboards = null; + } + clearRuntimeState(); + layerState._viewer = null; + layerState._enabled = false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = false; + // Clear existing subscribers rather than replacing the Set — + // replacing would silently orphan any unsubscribe() closures + layerState._listeners.clear(); + }, + }; + + return { clearRuntimeState, methods }; +} diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js new file mode 100644 index 000000000..48c8cddd4 --- /dev/null +++ b/src/layers/cctv/model.js @@ -0,0 +1,566 @@ +import * as Cesium from 'cesium'; +import { staticFrameRefreshMs } from '../../data/cctvLod.js'; +import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js'; +import { DEFAULT_CAMERA_CALIBRATION } from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + const { focusPassIsNeeded, getFocusTarget } = services.focus; + + /** + * 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)); + } + + /** + * 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 element rather than an . + * @param {string} feedType + * @returns {boolean} + */ + + function isVideoFeedType(feedType) { + return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm'; + } + + /** + * Coerces a value to a finite number or returns the fallback. + * @param {*} value + * @param {number} [fallback=NaN] + * @returns {number} + */ + + function safeNumber(value, fallback = NaN) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + /** + * Derives a deterministic heading from a camera ID string via a simple hash. + * Produces one of 16 evenly-spaced headings (0, 22.5, 45, ..., 337.5). + * @param {string} id + * @returns {number} Heading in degrees [0, 360). + */ + + function headingFromId(id) { + const text = String(id || ''); + let acc = 0; + for (let i = 0; i < text.length; i++) { + acc = (acc * 33 + text.charCodeAt(i)) >>> 0; + } + return normalizeHeading((acc % 16) * 22.5); + } + + /** + * Rounds a value to the nearest multiple of `step`. + * @param {number} value + * @param {number} [step=0.1] + * @returns {number} + */ + + function quantize(value, step = 0.1) { + return Math.round(value / step) * step; + } + + /** + * Normalizes a coverage-mode request (viewshed design §3b). Accepts the three + * mode strings plus booleans for `setParams({showCoverage})` back-compat + * (true → 'on', false → 'off'); anything else keeps the current mode. + * @param {*} value - Requested mode ('off'|'on'|'viewshed') or boolean. + * @param {'off'|'on'|'viewshed'} current - Mode to keep when the request is invalid. + * @returns {'off'|'on'|'viewshed'} + */ + + function normalizeCoverageMode(value, current) { + if (value === true) return 'on'; + if (value === false) return 'off'; + if (value === 'off' || value === 'on' || value === 'viewshed') return value; + return current; + } + + /** + * Converts north/east metre offsets to lat/lon degree deltas at a given latitude. + * Uses the equirectangular approximation (111320 m/deg). + * @param {number} latDeg - Reference latitude (degrees). + * @param {number} northMeters - Offset northward (metres). + * @param {number} eastMeters - Offset eastward (metres). + * @returns {{ latOffset: number, lonOffset: number }} Degree deltas. + */ + + function offsetDegrees(latDeg, northMeters, eastMeters) { + const latOffset = northMeters / 111320; + const lonDivisor = Math.max(0.15, Math.cos(toRad(latDeg))); + const lonOffset = eastMeters / (111320 * lonDivisor); + return { latOffset, lonOffset }; + } + + /** + * Returns `window.localStorage` when it is safely accessible, else null. + * Split out so store IO can be exercised under plain node:test with an + * injected storage-like object (getItem/setItem/removeItem) instead. + * @returns {Storage|null} + */ + + function safeWindowLocalStorage() { + if (typeof window === 'undefined') return null; + try { + // NB: the window.localStorage property ACCESS itself throws SecurityError + // under "block all cookies", so it has to live inside the try (M11). + return window.localStorage || null; + } catch { + return null; + } + } + + /** + * Initializes or recomputes a camera's derived pose fields from its base pose + * and calibration offsets. Also sets intrinsics, extrinsics, and anchor. + * + * On first call for a camera, captures the raw values as `basePose`. + * Subsequent calls re-derive lat/lon/heading/pitch/fov/range by applying + * calibration deltas to the frozen base pose. + * + * Note: the old score-based quality system (`confidenceFromScore`, seeded + * `camera.quality.score`) is retired — panel trust signal is now the 3-state + * CAL badge (`deriveCalBadge`, driven by `calSource`/`poseSource`), not a + * fabricated confidence score. + * + * @param {Object} camera - Mutable camera record. + */ + + function ensureCameraPose(camera) { + if (!camera) return; + if (!camera.basePose) { + camera.basePose = { + lat: safeNumber(camera.lat, 0), + lon: safeNumber(camera.lon, 0), + headingDeg: normalizeHeading(safeNumber(camera.headingDeg, 0)), + pitchDeg: clamp(safeNumber(camera.pitchDeg, -17), -70, 10), + fovDeg: clamp(safeNumber(camera.fovDeg, 74), 20, 130), + rangeM: clamp(safeNumber(camera.rangeM, 700), 120, 5000), + mountHeightM: clamp(safeNumber(camera.mountHeightM, 24), 2, 240), + }; + } + + const nextCalibration = parts.calibration.normalizeCalibration( + camera.calibration || DEFAULT_CAMERA_CALIBRATION, + ); + camera.calibration = nextCalibration; + + const base = camera.basePose; + const offsets = offsetDegrees( + base.lat, + nextCalibration.offsetNorthM, + nextCalibration.offsetEastM, + ); + camera.lat = base.lat + offsets.latOffset; + camera.lon = base.lon + offsets.lonOffset; + camera.headingDeg = normalizeHeading( + base.headingDeg + nextCalibration.headingDeg, + ); + camera.pitchDeg = clamp(base.pitchDeg + nextCalibration.pitchDeg, -70, 10); + camera.fovDeg = clamp(base.fovDeg + nextCalibration.fovDeg, 20, 130); + camera.rangeM = clamp(base.rangeM * nextCalibration.rangeScale, 120, 5000); + camera.mountHeightM = clamp( + base.mountHeightM + nextCalibration.heightM, + 2, + 240, + ); + + camera.intrinsics = { + fovDeg: camera.fovDeg, + principalPoint: [0.5, 0.5], + }; + camera.extrinsics = { + headingDeg: camera.headingDeg, + pitchDeg: camera.pitchDeg, + rollDeg: 0, + heightM: camera.mountHeightM, + }; + camera.anchor = { + lat: camera.lat, + lon: camera.lon, + elevM: safeNumber(camera.groundElevationM, 0), + targetLatLon: camera.anchor?.targetLatLon || null, + }; + } + + /** + * Projects a point along a bearing from a given lat/lon by a distance. + * Uses the spherical-earth direct geodesic formula (R = 6371 km). + * @param {number} latDeg - Origin latitude (degrees). + * @param {number} lonDeg - Origin longitude (degrees). + * @param {number} bearingDeg - Azimuth from north (degrees). + * @param {number} distanceM - Distance in metres. + * @returns {{ lat: number, lon: number }} Destination in degrees. + */ + + function projectPoint(latDeg, lonDeg, bearingDeg, distanceM) { + const angular = distanceM / 6371000; + const bearing = toRad(bearingDeg); + const lat1 = toRad(latDeg); + const lon1 = toRad(lonDeg); + + const sinLat2 = + Math.sin(lat1) * Math.cos(angular) + + Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing); + const lat2 = Math.asin(sinLat2); + + const y = Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1); + const x = Math.cos(angular) - Math.sin(lat1) * sinLat2; + const lon2 = lon1 + Math.atan2(y, x); + + return { + lat: Cesium.Math.toDegrees(lat2), + lon: Cesium.Math.toDegrees(lon2), + }; + } + + /** + * Computes the great-circle distance between two points using the haversine formula. + * @param {number} lat1 - Latitude of point 1 (degrees). + * @param {number} lon1 - Longitude of point 1 (degrees). + * @param {number} lat2 - Latitude of point 2 (degrees). + * @param {number} lon2 - Longitude of point 2 (degrees). + * @returns {number} Distance in kilometres. + */ + + function haversineKm(lat1, lon1, lat2, lon2) { + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + /** + * Computes the area of a circular sector (camera FOV wedge). + * @param {number} rangeM - Radius in metres. + * @param {number} fovDeg - Field of view in degrees. + * @returns {number} Area in km^2. + */ + + function sectorAreaKm2(rangeM, fovDeg) { + const theta = toRad(clamp(fovDeg, 12, 170)); + const areaM2 = 0.5 * rangeM * rangeM * theta; + return areaM2 / 1_000_000; + } + + /** + * Returns a coarse grid key describing the viewer's current position and zoom + * level. Used to detect meaningful view changes for auto-hop camera switching. + * @returns {string} Grid key in the form "zoomBucket:latGrid:lonGrid". + */ + + function currentViewContext() { + const carto = layerState._viewer?.camera?.positionCartographic; + if (!carto) return 'none'; + const lat = Cesium.Math.toDegrees(carto.latitude); + const lon = Cesium.Math.toDegrees(carto.longitude); + const alt = carto.height || 0; + const zoomBucket = + alt < 1500 + ? 'street' + : alt < 12000 + ? 'city' + : alt < 75000 + ? 'regional' + : 'global'; + const grid = + zoomBucket === 'street' + ? 0.045 + : zoomBucket === 'city' + ? 0.24 + : zoomBucket === 'regional' + ? 1.0 + : 4.5; + return `${zoomBucket}:${Math.floor(lat / grid)}:${Math.floor(lon / grid)}`; + } + + /** + * Reports whether the active Google Photorealistic 3D Tileset (if any) has + * finished loading the tiles in view. Shared mesh-floor sampling is gated on + * this so a one-shot cell never bakes in a miss from still-streaming tiles. + * Discovers + caches the tileset lazily from scene + * primitives (the CCTV module holds only a `_viewer` reference). When no + * tileset is present (OSM fallback) this returns true so ground sampling is + * not permanently blocked. + * + * Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`, + * i.e. a globe stack is active) must NOT report ready — Cesium 1.138's + * the shared sampler can only inspect *visible* 3D tilesets, so a sample taken + * against the hidden Google tileset would silently miss. + * + * @returns {boolean} True when tiles are loaded AND visible (or no tileset + * exists to wait on). + */ + + function projectionTilesReady() { + if ( + !layerState._activeTileset || + layerState._activeTileset.isDestroyed?.() + ) { + layerState._activeTileset = null; + const primitives = layerState._viewer?.scene?.primitives; + if (primitives && typeof primitives.get === 'function') { + for (let i = 0; i < primitives.length; i++) { + const p = primitives.get(i); + if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) { + layerState._activeTileset = p; + break; + } + } + } + } + if (!layerState._activeTileset) return true; + if (layerState._activeTileset.show === false) return false; + return layerState._activeTileset.tilesLoaded === true; + } + + /** + * Orientation quaternion for the monitor plane entity: local +Z is the plane + * normal, pointing BACK along the view axis toward the mount so the textured + * front face reads correctly from the natural viewpoint (focusCamera flies the + * viewer to look along the camera heading). Local +X = viewer-right, +Y = + * frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry — + * computed only on slider/save/activation, never per frame (§2b). + * @param {Object} camera - Camera pose. + * @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF. + * @returns {Cesium.Quaternion} + */ + + function planeOrientationFor(camera, capCenterPos) { + const frame = parts.geometry.frustumFrameEcef(camera, capCenterPos); + const right = Cesium.Cartesian3.cross( + frame.dir, + frame.up, + new Cesium.Cartesian3(), + ); + const normal = Cesium.Cartesian3.negate(frame.dir, new Cesium.Cartesian3()); + const m = new Cesium.Matrix3( + right.x, + frame.up.x, + normal.x, + right.y, + frame.up.y, + normal.y, + right.z, + frame.up.z, + normal.z, + ); + return Cesium.Quaternion.fromRotationMatrix(m); + } + + /** + * Starts the requestAnimationFrame loop that drives projection canvas updates + * (frame draw + texture swap) for the active camera. + */ + /** + * The projection rAF has real work only while a camera is actively projected + * or a focus fade is in flight — otherwise it burned a wakeup + style poll + * every rendered frame for the whole enabled lifetime of the layer. The tick + * self-stops when idle; every state edge that creates work re-arms it + * (enable, setActiveCamera, showProjection, focus-target appearance). + * (perf wave 1) + * @returns {boolean} Whether the loop currently has work. + */ + + function projectionLoopIsNeeded() { + if (!layerState._enabled || !layerState._viewer) return false; + if (layerState._showProjection && parts.selection.getActiveRecord()) + return true; + return focusPassIsNeeded( + getFocusTarget(), + layerState._activeFocusStyleCount, + ); + } + + /** + * Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per + * tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill + * — any selected card still missing its FIRST frame — bursts up to 4 + * in-flight fetches at 250 ms spacing so arriving in a new area populates + * in a few seconds instead of 16-32 s; once every selected card has a first + * frame the layer drops back to the salvaged steady-state gate (single + * flight, one request per second). Priority: frameless cards first in ring + * order (nearest-first — they're what makes a card appear at all), then the + * stalest refresh-overdue card by its source cadence (staticFrameRefreshMs). + * Failures back off per camera (frameFetchDue) instead of hammering a dead + * source, so a dead upstream never eats the burst slots. + */ + + function cardFrameTick() { + if ( + !layerState._enabled || + (!layerState._cardIds.size && + !(layerState._activeCameraCardEnabled && layerState._activeCameraId)) + ) + return; + const now = Date.now(); + let frameless = null; + let stalest = null; + let coldFill = false; + const consider = (id) => { + const record = layerState._recordById.get(id); + if (!record) return; + const slot = parts.cards.ensureCardFrameSlot(id); + if (layerState._cardFetchPendingIds.has(id)) { + // An in-flight first-frame fetch keeps cold-fill mode active without + // being re-launchable. + if (!(slot.stamp > 0)) coldFill = true; + return; + } + const refreshMs = staticFrameRefreshMs(record.camera); + if (!frameFetchDue(slot, refreshMs, now)) return; + if (!(slot.stamp > 0)) { + coldFill = true; + if (!frameless) frameless = { record, slot, refreshMs }; + return; + } + if (!stalest || slot.stamp < stalest.slot.stamp) { + stalest = { record, slot, refreshMs }; + } + }; + // The optional protected active card stays outside the 40-card ambient + // quota and uses the same source-owned pacing/retry/cache lifecycle. + if (layerState._activeCameraCardEnabled && layerState._activeCameraId) + consider(layerState._activeCameraId); + for (const id of layerState._cardIds) consider(id); + const policy = cardFetchPolicy({ + // The cold-fill burst yields to the staggered geometry drain: 4 concurrent + // image fetch+decodes mid-drain starve the mesh-floor queue on weak GPUs + // (qa-cctv-v2 drain-budget regression). Steady 1/s trickle still runs; + // the burst fires the moment the drain completes. + coldFill: coldFill && !layerState._geoLoading, + inFlight: layerState._cardFetchInFlightCount, + sinceLastLaunchMs: + layerState._cardLastFetchAt > 0 + ? now - layerState._cardLastFetchAt + : Infinity, + }); + layerState._cardFetchMode = policy.mode; + if (!policy.launch) return; + const pick = frameless || stalest; + if (pick) + parts.cards.fetchCardFrame(pick.record, pick.slot, pick.refreshMs); + } + + /** + * Hidden-state gate (perf wave 2): detach in-flight card frame decodes when + * the document hides — a hidden canvas has no reader, and image decode is + * the expensive half. New fetches are gated at fetchCardFrame; the steady + * pacer refills naturally on return. Owned by the initialized layer and detached on destruction. + */ + + /** + * Reports whether selecting a record must run the full activation path. + * Disable re-arms the still-active record so its obstruction probe runs again + * on its next real activation after the temporary clamp is cleared. + * @param {string} cameraId Requested camera ID. + * @param {string|null} activeCameraId Current active camera ID. + * @param {Object|null} record Requested camera runtime record. + * @returns {boolean} Whether activation work must run. + */ + + function cctvRecordNeedsActivation(cameraId, activeCameraId, record) { + return cameraId !== activeCameraId || record?.activationDone !== true; + } + return { + toRad, + normalizeHeading, + clamp, + normalizeSignedAngle, + angularDeltaAbs, + normalizeFeedType, + isVideoFeedType, + safeNumber, + headingFromId, + quantize, + normalizeCoverageMode, + offsetDegrees, + safeWindowLocalStorage, + ensureCameraPose, + projectPoint, + haversineKm, + sectorAreaKm2, + currentViewContext, + projectionTilesReady, + planeOrientationFor, + projectionLoopIsNeeded, + cardFrameTick, + cctvRecordNeedsActivation, + }; +} diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js new file mode 100644 index 000000000..6c576bb4f --- /dev/null +++ b/src/layers/cctv/navigation.js @@ -0,0 +1,157 @@ +import * as Cesium from 'cesium'; +import { CCTV_FOCUS_RESULT } from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Finds the camera closest to the Cesium viewer's current position. + * @returns {string|null} Camera ID of the nearest camera, or null. + */ + + function nearestCameraIdToViewer() { + const carto = layerState._viewer?.camera?.positionCartographic; + if (!carto || !layerState._records.length) return null; + const lat = Cesium.Math.toDegrees(carto.latitude); + const lon = Cesium.Math.toDegrees(carto.longitude); + + let best = null; + for (const record of layerState._records) { + const distKm = parts.model.haversineKm( + lat, + lon, + record.camera.lat, + record.camera.lon, + ); + if (!best || distKm < best.distKm) { + best = { id: record.camera.id, distKm }; + } + } + return best?.id || null; + } + + /** + * Flies the Cesium viewer camera to frame the specified CCTV camera, + * looking along its heading from above. + * @param {Cesium.Viewer|null} viewer Cesium viewer that owns the camera. + * @param {Object|null} record CCTV camera runtime record. + * @param {number} [duration=2.2] - Flight duration in seconds. + * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. + */ + + function focusCctvRecord(viewer, record, duration = 2.2) { + if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA; + if ( + typeof document !== 'undefined' && + document.body?.classList.contains('cockpit-mode') + ) { + console.debug('[Data:CCTV] focus ignored while cockpit owns the camera'); + return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE; + } + if (viewer.trackedEntity) { + console.debug( + '[Data:CCTV] focus ignored while a tracked entity owns the camera', + ); + return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; + } + const { camera } = record; + const range = Math.max(280, camera.rangeM * 1.18); + viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + record.position, + Math.max(40, camera.rangeM * 0.36), + ), + { + offset: new Cesium.HeadingPitchRange( + parts.model.toRad(camera.headingDeg), + parts.model.toRad(-22), + range, + ), + duration: Math.max(0.2, duration || 0), + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + ); + return CCTV_FOCUS_RESULT.FOCUSED; + } + + function focusCamera(cameraId, duration = 2.2) { + return focusCctvRecord( + layerState._viewer, + layerState._recordById.get(cameraId), + duration, + ); + } + + /** + * Advances to the next camera if auto-hop is enabled and the hop interval + * has elapsed. If the viewer has panned to a new region since the last hop, + * snaps to the nearest camera instead of cycling sequentially. + * @param {number} nowMs - Current timestamp in milliseconds. + */ + + function maybeAutoHop(nowMs) { + if ( + !layerState._autoHop || + layerState._autoHopSuspended || + !layerState._enabled || + layerState._records.length < 2 + ) + return; + if (nowMs - layerState._lastHopAt < layerState._autoHopSec * 1000) return; + + const viewKey = parts.model.currentViewContext(); + const viewChanged = viewKey !== layerState._lastViewContext; + layerState._lastViewContext = viewKey; + + if (viewChanged) { + const nearest = nearestCameraIdToViewer(); + if (nearest && nearest !== layerState._activeCameraId) { + // Use setActiveCamera so the full activation path runs (obstruction + // probe, projection runtime, geometry rewrite) — previously bypassed + // with a bare assignment + parts.selection.setActiveCamera(nearest); + layerState._lastHopAt = nowMs; + return; + } + } + + const nextIdx = cctvCycleIndex( + layerState._records.findIndex( + (record) => record.camera.id === layerState._activeCameraId, + ), + 1, + layerState._records.length, + ); + parts.selection.setActiveCamera(layerState._records[nextIdx].camera.id); + layerState._lastHopAt = nowMs; + } + + /** + * Resolves a catalog cycle target, including the explicit no-selection state. + * NEXT from null selects the first record; PREV selects the last. + * @param {number} currentIdx + * @param {number} step + * @param {number} count + * @returns {number} + */ + + function cctvCycleIndex(currentIdx, step, count) { + const total = Number.isFinite(count) ? Math.floor(count) : 0; + if (total <= 0) return -1; + const delta = Number.isFinite(step) ? Math.trunc(step) : 1; + if (!Number.isFinite(currentIdx) || currentIdx < 0) { + return delta < 0 ? total - 1 : 0; + } + return (((Math.floor(currentIdx) + delta) % total) + total) % total; + } + return { + nearestCameraIdToViewer, + focusCctvRecord, + focusCamera, + maybeAutoHop, + cctvCycleIndex, + }; +} diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js new file mode 100644 index 000000000..ff9aa2465 --- /dev/null +++ b/src/layers/cctv/policy.js @@ -0,0 +1,481 @@ +import * as Cesium from 'cesium'; +import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; +import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; + +// --------------------------------------------------------------------------- +// API endpoints +// --------------------------------------------------------------------------- + +export const FRAME_ENDPOINT = '/api/cctv/frame'; + +export const SOURCE_ENDPOINT = '/api/cctv/sources'; + +export const HEALTH_ENDPOINT = '/api/cctv/health'; + +export const MEDIA_ENDPOINT = '/api/cctv/media'; + +// --------------------------------------------------------------------------- +// Timing and geometry constants +// --------------------------------------------------------------------------- + +export const DEFAULT_UPDATE_INTERVAL_MS = 10000; + +export const MIN_AUTO_HOP_SEC = 8; + +export const MAX_AUTO_HOP_SEC = 90; + +export const HEALTH_SYNC_INTERVAL_MS = 7000; + +export const ACTIVE_FRAME_REFRESH_MS = 10000; + +export const IDLE_FRAME_REFRESH_MS = 60000; + +export const PROJECTION_ACTIVE_REFRESH_MS = 10000; + +export const PROJECTION_IDLE_REFRESH_MS = 60000; + +export const PROJECTION_CANVAS_WIDTH = 1920; + +export 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. + +export const FRAME_SIGNATURE_W = 64; + +export const FRAME_SIGNATURE_H = 36; + +export const COVERAGE_NEIGHBOR_LIMIT = 14; + +export 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. + +export const GEO_LOAD_BATCH_SIZE = 4; + +export const GEO_LOAD_BATCH_DELAY_MS = 120; + +export const GEO_TRACKING_BATCH_SIZE = 2; + +export const GEO_TRACKING_BATCH_DELAY_MS = 250; + +export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300; + +export 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. + +export 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). + +export const PROJECTION_TEXTURE_SWAP_MS = 1000; + +export 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', +}); + +// §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). + +export const PROBE_CLEARANCE_M = 4; + +export 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. + +export const GROUND_PRIOR_INIT_WAIT_MS = 8000; + +/** Default calibration offsets — all zeroed, range scale 1x. */ + +export const DEFAULT_CAMERA_CALIBRATION = Object.freeze({ + offsetNorthM: 0, + offsetEastM: 0, + headingDeg: 0, + pitchDeg: 0, + fovDeg: 0, + rangeScale: 1, + heightM: 0, +}); + +/** Base64-encoded SVG camera icon for billboard rendering. */ + +export 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. + */ + +export 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 +// --------------------------------------------------------------------------- + +export const IDLE_CAMERA_COLOR = + Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88); + +export const ACTIVE_CAMERA_COLOR = + Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); + +export const IDLE_COVERAGE_COLOR = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); + +export const IDLE_COVERAGE_CENTER_MUTED = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2); + +export const IDLE_COVERAGE_EDGE_MUTED = + Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18); + +export const ACTIVE_COVERAGE_EDGE = + Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58); + +export 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. + +export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL = + Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18); + +export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL = + Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26); + +export const PLANE_OUTLINE_COLOR = + Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55); + +/** + * Card budget while the staggered geometry drain is running — the raised + * 20/28/40 tiers resume when loading completes (see refreshAmbientCards). + */ + +export 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. + +export 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). + +export const CARD_VIEW_MARGIN = 0.06; + +/** Card leader gap: clears the 24px icon (12px half + breathing room). */ + +export const CARD_GAP_PX = 16; + +export 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, +}); + +// 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). */ + +export const HOVER_PICK_THROTTLE_MS = 120; + +/** How long the hover card lingers after the pointer leaves the icon. */ + +export const HOVER_RELEASE_MS = 1_000; diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js new file mode 100644 index 000000000..6a8d632e4 --- /dev/null +++ b/src/layers/cctv/presentation.js @@ -0,0 +1,207 @@ +import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; +import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; + +export function createPresentation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Builds a single-line summary string for the active camera, including city, + * heading, FOV, coverage area, overlap count, projection mode, alignment + * confidence, source type, and view context. + * @returns {string} Summary text separated by mid-dots. + */ + + function buildSummaryText() { + const active = parts.selection.getActiveRecord(); + if (!active) { + return layerState._records.length + ? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE` + : 'No cameras available in catalog.'; + } + + const area = parts.model.sectorAreaKm2( + active.camera.rangeM, + active.camera.fovDeg, + ); + const overlapCount = parts.geometry.coverageNeighborCount(active); + const viewKey = parts.model.currentViewContext(); + const viewBand = viewKey.split(':')[0] || 'global'; + const health = layerState._healthById.get(active.camera.id) || null; + const calBadge = parts.calibration.deriveCalBadge(active.camera); + + return [ + `${active.camera.city.toUpperCase()} CCTV`, + `${active.camera.name.toUpperCase()}`, + `HDG ${Math.round(active.camera.headingDeg)}°`, + `FOV ${Math.round(active.camera.fovDeg)}°`, + `COVERAGE ${area.toFixed(2)}km²`, + overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', + `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, + layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null, + `CAL ${calBadge.replace('-', ' ').toUpperCase()}`, + health?.sourceKind + ? `SRC ${String(health.sourceKind).toUpperCase()}` + : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, + `${viewBand.toUpperCase()} CONTEXT`, + ] + .filter(Boolean) + .join(' · '); + } + + /** + * Builds a public-facing camera state object for UI consumption. + * Includes all pose, calibration, CAL badge, projection, and feed metadata. + * @param {Object} record - Camera record. + * @param {string|null} [activeId=null] - Active camera ID for the `active` flag. + * @returns {Object} Public camera state. + */ + + function getPublicCameraState(record, activeId = null) { + const resolvedActiveId = + activeId || parts.selection.getActiveRecord()?.camera.id || null; + const camera = record.camera; + const health = layerState._healthById.get(camera.id) || null; + const isActive = camera.id === resolvedActiveId; + const refreshMs = isActive + ? ACTIVE_FRAME_REFRESH_MS + : IDLE_FRAME_REFRESH_MS; + return { + id: camera.id, + name: camera.name, + city: camera.city, + provider: camera.provider, + lat: camera.lat, + lon: camera.lon, + headingDeg: camera.headingDeg, + pitchDeg: camera.pitchDeg, + fovDeg: camera.fovDeg, + rangeM: camera.rangeM, + elevationM: camera.absoluteHeightM, + mountHeightM: camera.mountHeightM, + active: isActive, + feedType: camera.feedType, + sourceKind: + health?.sourceKind || + camera.sourceKind || + (camera.feedConfigured ? 'configured' : 'seed'), + sourceStatus: health?.status || 'unknown', + sourceMessage: health?.message || '', + sourceLabel: health?.label || camera.provider || '', + calibration: { + ...parts.calibration.normalizeCalibration(camera.calibration), + }, + // Save-gated persistence (design §3e): true while the live pose carries + // edits that have not been SAVEd (or RESET). Drives the CAL · EDITED chip. + calDirty: !!record.calDirty, + // Deterministic QA seam: counts commit-grade anchor resolutions (E/N drag + // release, numeric E/N edit, or reset), never transient gizmo moves. + groundResolveCount: record.calibrationGroundResolveCount || 0, + // Per-record QA seam for proving transient gizmo moves never enter the + // shared mesh-floor sampler while unrelated catalog cells finish. + groundMeshSampleRequestCount: record.groundMeshSampleRequestCount || 0, + // Datum QA seam: expose the immutable Re:Earth ellipsoidal prior + // separately from the currently applied frustum ground. Google-3D may + // legitimately refine the latter to the rendered mesh, so callers must + // not infer the prior by subtracting mount height from live geometry. + groundPriorM: Number.isFinite(record.groundPrior?.ellipsoid) + ? record.groundPrior.ellipsoid + : null, + intrinsics: camera.intrinsics ? { ...camera.intrinsics } : null, + extrinsics: camera.extrinsics ? { ...camera.extrinsics } : null, + anchor: camera.anchor ? { ...camera.anchor } : null, + // Panel-only trust signal (design §3b, amended by LOCKED §9.2/§9.3): no + // in-world rendering reads this, no score-based quality math backs it. + calBadge: parts.calibration.deriveCalBadge(camera), + poseSource: camera.poseSource || null, + basePose: camera.basePose ? { ...camera.basePose } : null, + frameUrl: parts.frames.frameUrlFor(camera, refreshMs), + mediaUrl: parts.frames.mediaUrlFor(camera), + }; + } + + /** + * Assembles the full UI state payload containing layer toggles, camera list, + * active camera details, summary text, and error state. + * @returns {Object} Complete UI state for subscribers. + */ + + function uiState() { + const active = parts.selection.getActiveRecord(); + const activeId = active?.camera.id || null; + const payload = { + enabled: layerState._enabled, + // Compat boolean + the full tri-state (viewshed design §3b). + showCoverage: layerState._coverageMode !== 'off', + coverageMode: layerState._coverageMode, + showProjection: layerState._showProjection, + calibrationMode: layerState._calibrationMode, + autoHop: layerState._autoHop, + autoHopSuspended: layerState._autoHopSuspended, + autoHopSec: layerState._autoHopSec, + count: layerState._count, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + loading: { + active: layerState._geoLoading, + loaded: Math.min(layerState._geoLoadDone, layerState._geoLoadTotal), + total: layerState._geoLoadTotal, + }, + // Ambient card tier telemetry (QA harnesses assert the fetch pacing — + // minFrameFetchSpacingMs reads together with fetchMode: cold-fill bursts + // legitimately reach ~250 ms, steady state stays >=1000 ms). + ambientCards: { + count: layerState._cardIds.size, + limit: CCTV_AMBIENT_CARD_MAX, + frameFetches: layerState._cardFetchCount, + minFrameFetchSpacingMs: layerState._cardMinFetchSpacingMs, + fetchMode: layerState._cardFetchMode, + fetchesInFlight: layerState._cardFetchInFlightCount, + // Item B QA seam: the hover-summoned pinned card, if any. + hoverId: layerState._hoverCardId, + }, + activeCameraId: activeId, + activeCamera: active ? getPublicCameraState(active, activeId) : null, + cameras: layerState._records.map((record) => + getPublicCameraState(record, activeId), + ), + summary: buildSummaryText(), + }; + return payload; + } + + /** Dispatches the current UI state to all registered subscriber callbacks. */ + + function notifyListeners() { + const payload = uiState(); + for (const callback of layerState._listeners) { + try { + callback(payload); + } catch (error) { + console.warn('[Data:CCTV] listener error:', error); + } + } + } + + /** + * Throttled notifyListeners for transient (mid-drag) calibration patches — + * the panel re-render is DOM-heavy, so live gizmo drags publish state at + * ≤10 Hz while the in-world geometry still tracks every processed move. + */ + + function notifyListenersThrottled() { + const now = Date.now(); + if (now - layerState._lastTransientNotifyAt < 100) return; + layerState._lastTransientNotifyAt = now; + notifyListeners(); + } + return { + buildSummaryText, + getPublicCameraState, + uiState, + notifyListeners, + notifyListenersThrottled, + }; +} diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js new file mode 100644 index 000000000..1ea937961 --- /dev/null +++ b/src/layers/cctv/projection.js @@ -0,0 +1,385 @@ +import * as Cesium from 'cesium'; +import { + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, + PLANE_OUTLINE_COLOR, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, +} from './policy.js'; + +export function createProjection({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + /** + * 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. + */ + + 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, + }; + } + + /** + * Re-derives the monitor plane entity's placement (position, orientation, + * dimensions) + label from the record's current frustum geometry, so the plane + * always caps the wireframe exactly (corner rays terminate on its corners). + * No-op when the record has no plane runtime (idle neighbors have no plane). + * @param {Object} record - Camera record. + */ + + function updatePlanePlacement(record) { + const runtime = record?.projection; + if (!runtime?.planeEntity) return; + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + runtime.planeEntity.position = positions.capCenter; + runtime.planeEntity.orientation = parts.model.planeOrientationFor( + record.camera, + positions.capCenter, + ); + if (runtime.planeEntity.plane) { + runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2( + geometry.halfW * 2, + geometry.halfH * 2, + ); + } + if (runtime.labelPosition) { + Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); + } + } + + /** + * Clear the active monitor-plane label source and ownership marker. + */ + + function clearProjectionOverlay() { + layerState._cctvOverlayHost.clearSource(CCTV_PROJECTION_OVERLAY_SOURCE_ID); + layerState._cctvOverlayHost.setVisible( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + false, + ); + layerState._projectionOverlayOwnerId = null; + } + + /** + * Shows/hides the monitor plane and its associated shared-host label. + * @param {Object} runtime - Projection runtime. + * @param {boolean} visible + */ + + function setPlaneVisible(runtime, visible) { + if (!runtime) return; + if (runtime.planeEntity) runtime.planeEntity.show = !!visible; + if (visible && runtime.overlayEntry && runtime.cameraId) { + if (layerState._projectionOverlayOwnerId !== runtime.cameraId) { + layerState._cctvOverlayHost.setEntries( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + [runtime.overlayEntry], + CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, + ); + layerState._cctvOverlayHost.setVisible( + CCTV_PROJECTION_OVERLAY_SOURCE_ID, + true, + ); + layerState._projectionOverlayOwnerId = runtime.cameraId; + } + } else if (layerState._projectionOverlayOwnerId === runtime.cameraId) { + clearProjectionOverlay(); + } + } + + /** Create the native monitor plane plus its cached host-label presentation. */ + + function createProjectionPlane(record, runtime, geometry, positions) { + runtime.labelPosition ||= new Cesium.Cartesian3(); + Cesium.Cartesian3.clone(positions.label, runtime.labelPosition); + runtime.cameraId = String(record.camera.id); + runtime.overlayEntry = createCctvProjectionOverlayEntry({ + cameraId: runtime.cameraId, + name: record.camera.name, + position: () => runtime.labelPosition, + }); + runtime.planeEntity = layerState._viewer.entities.add({ + id: `cctv-${record.camera.id}-plane`, + properties: { cctvCameraId: record.camera.id }, + show: false, + position: positions.capCenter, + orientation: parts.model.planeOrientationFor( + record.camera, + positions.capCenter, + ), + plane: { + plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0), + dimensions: new Cesium.Cartesian2( + geometry.halfW * 2, + geometry.halfH * 2, + ), + material: runtime.planeMaterial, + outline: true, + outlineColor: PLANE_OUTLINE_COLOR, + }, + }); + return runtime.planeEntity; + } + + /** + * Creates the projection runtime for a camera record: an offscreen canvas, + * the monitor plane plus associated host label, and either an + * or element depending on the feed type. + * + * The plane is the only projection representation (v2): the frustum's far cap, + * perpendicular to the view axis (§2b — never billboarded; a wall primitive + * can't pitch, the plane can). It is textured with the live frame: video + * element direct, canvas double-buffer otherwise. + * + * @param {Object} record - Camera record. + * @returns {Object|null} Projection runtime, or null if no viewer. + */ + + function createProjectionRuntime(record) { + if (!layerState._viewer) return null; + const canvas = document.createElement('canvas'); + canvas.width = PROJECTION_CANVAS_WIDTH; + canvas.height = PROJECTION_CANVAS_HEIGHT; + const ctx = canvas.getContext('2d', { alpha: true }); + + const feedType = parts.model.normalizeFeedType(record.camera.feedType); + const mode = parts.model.isVideoFeedType(feedType) ? 'video' : 'image'; + const runtime = { + mode, + canvas, + ctx, + image: null, + video: null, + planeEntity: null, + cameraId: String(record.camera.id), + labelPosition: new Cesium.Cartesian3(), + overlayEntry: null, + planeMaterial: null, + buffers: null, + bufferIndex: 0, + lastTextureSwapAt: 0, + lastImageRefreshAt: 0, + imageReady: false, + imageLoading: false, + imageStamp: 0, + drawnImageStamp: -1, + // Signature of the pixels currently ON the canvas, plus the reused 64x36 + // scratch used to compute it. null = "nothing known", which always redraws. + lastFrameSignature: null, + signatureCanvas: null, + signatureCtx: null, + lastPlaceholderPaintAt: 0, + // canvasStamp increments on every canvas write (frame blit / placeholder + // paint); lastSwappedCanvasStamp trails it so refreshProjectionTextures + // only re-uploads the plane texture when there is genuinely new content. + canvasStamp: 1, + lastSwappedCanvasStamp: 0, + }; + + parts.frames.paintProjectionPlaceholder(ctx, record.camera); + + if (mode === 'video') { + const video = document.createElement('video'); + video.muted = true; + video.loop = true; + video.autoplay = true; + video.playsInline = true; + video.crossOrigin = 'anonymous'; + video.preload = 'auto'; + video.src = parts.frames.mediaUrlFor(record.camera); + video.addEventListener('canplay', () => { + video.play().catch(() => {}); + }); + runtime.video = video; + } else { + const img = new Image(); + img.decoding = 'async'; + img.crossOrigin = 'anonymous'; + img.onload = () => { + runtime.imageLoading = false; + runtime.imageReady = true; + runtime.imageStamp = Date.now(); + }; + img.onerror = () => { + runtime.imageLoading = false; + runtime.imageReady = false; + }; + runtime.image = img; + } + + // Monitor plane = the frustum's far cap: video feeds bind the video element + // directly (Cesium updates video-backed entity materials per frame); image + // feeds start on the placeholder canvas and switch to double-buffer swaps + // at <=1Hz. + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + runtime.planeMaterial = new Cesium.ImageMaterialProperty({ + image: mode === 'video' && runtime.video ? runtime.video : canvas, + transparent: true, + color: Cesium.Color.WHITE.withAlpha(0.95), + }); + createProjectionPlane(record, runtime, geometry, positions); + + return runtime; + } + + /** + * Lazily initializes the projection runtime for a record if it doesn't exist yet. + * @param {Object} record - Camera record. + * @returns {Object|null} The record's projection runtime. + */ + + function ensureProjectionRuntime(record) { + if (!record) return null; + if (record.projection) return record.projection; + const runtime = createProjectionRuntime(record); + record.projection = runtime; + if (runtime) { + layerState._projectionEntities.push(runtime); + } + return runtime; + } + + /** + * Tears down a projection runtime: stops video playback, removes the monitor + * plane, and clears its host label if it owns the active source. + * @param {Object} runtime - Projection runtime to destroy. + */ + + function destroyProjectionRuntime(runtime) { + if (!runtime) return; + if (runtime.video) { + runtime.video.pause(); + runtime.video.removeAttribute('src'); + runtime.video.load(); + } + if (runtime.planeEntity && layerState._viewer) { + layerState._viewer.entities.remove(runtime.planeEntity); + runtime.planeEntity = null; + } + if (layerState._projectionOverlayOwnerId === runtime.cameraId) + clearProjectionOverlay(); + runtime.overlayEntry = null; + runtime.labelPosition = null; + runtime.planeMaterial = null; + } + + function startProjectionLoop() { + if (layerState._projectionRaf) return; + if (!parts.model.projectionLoopIsNeeded()) return; + // The armed projection loop uploads video textures / runs focus fades per + // frame — the scene must render continuously while it runs. Released when + // the tick self-stops. (perf wave 2) + holdContinuousRender('cctv-projection'); + + const tick = () => { + if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) { + layerState._projectionRaf = 0; + releaseContinuousRender('cctv-projection'); + return; + } + + parts.rendering.refreshCctvFocusStyles(performance.now()); + + const active = parts.selection.getActiveRecord(); + if (layerState._enabled && layerState._showProjection && active) { + ensureProjectionRuntime(active); + if (active.projection?.video) { + active.projection.video.play().catch(() => {}); + } + if (active.projection) { + parts.frames.drawProjectionFrame(active); + parts.frames.refreshProjectionTextures(active); + } + } + + layerState._projectionRaf = requestAnimationFrame(tick); + }; + + layerState._projectionRaf = requestAnimationFrame(tick); + } + + /** Cancels the projection animation loop. */ + + function stopProjectionLoop() { + if (layerState._projectionRaf) { + cancelAnimationFrame(layerState._projectionRaf); + layerState._projectionRaf = 0; + } + releaseContinuousRender('cctv-projection'); + } + + /** + * Pauses video playback on all non-active camera projections and resumes + * the active one (if projection is enabled). + * @param {string|null} activeId - ID of the currently active camera. + */ + + function pauseInactiveProjectionFeeds(activeId) { + for (const record of layerState._records) { + if (!record.projection?.video) continue; + if ( + record.camera.id === activeId && + layerState._enabled && + layerState._showProjection + ) { + record.projection.video.play().catch(() => {}); + } else { + record.projection.video.pause(); + } + } + } + return { + createCctvProjectionOverlayEntry, + updatePlanePlacement, + clearProjectionOverlay, + setPlaneVisible, + createProjectionPlane, + createProjectionRuntime, + ensureProjectionRuntime, + destroyProjectionRuntime, + startProjectionLoop, + stopProjectionLoop, + pauseInactiveProjectionFeeds, + }; +} diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js new file mode 100644 index 000000000..8c4dbb9be --- /dev/null +++ b/src/layers/cctv/rendering.js @@ -0,0 +1,312 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { + ACTIVE_CAMERA_COLOR, + IDLE_CAMERA_COLOR, + ACTIVE_COVERAGE_CENTER, + IDLE_COVERAGE_CENTER_MUTED, + ACTIVE_COVERAGE_CENTER_DEPTHFAIL, + ACTIVE_COVERAGE_EDGE, + IDLE_COVERAGE_EDGE_MUTED, + ACTIVE_COVERAGE_EDGE_DEPTHFAIL, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + /** + * Focus modulation rides the layer's existing animation loop; no additional + * scene listener is installed. Only camera-icon alpha changes here — coverage + * geometry and monitor-plane styling retain their established cadence. + */ + + function refreshCctvFocusStyles(nowMs) { + nowMs = focusNowMs(nowMs); + const target = getFocusTarget(); + if ( + !layerState._enabled || + !layerState._viewer || + !focusPassIsNeeded(target, layerState._activeFocusStyleCount) + ) + return; + if (nowMs - layerState._lastFocusStyleAt < 80) return; + layerState._lastFocusStyleAt = nowMs; + const scene = layerState._viewer.scene; + const camera = layerState._viewer.camera; + const result = applyCctvFocusDeemphasis({ + records: layerState._records, + target, + previousActiveCount: layerState._activeFocusStyleCount, + nowMs, + screenPositionFor: (position) => + Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + position, + layerState._scratchFocusScreen, + ), + cameraDistanceFor: (position) => + Cesium.Cartesian3.distance(camera.positionWC, position), + baseColorFor: (record) => + record.camera.id === layerState._activeCameraId + ? ACTIVE_CAMERA_COLOR + : IDLE_CAMERA_COLOR, + }); + layerState._activeFocusStyleCount = result.activeCount; + } + + /** + * Apply the gated CCTV focus pass through the production color path. + * @param {object} input + * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} + */ + + function applyCctvFocusDeemphasis({ + records, + target, + previousActiveCount = 0, + nowMs, + screenPositionFor, + cameraDistanceFor, + baseColorFor, + 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; + if (!bb) continue; + const position = bb.position; + // CCTV never publishes a tracked focus target, so every icon is ambient. + const focus = advanceSpriteFocus(bb, { + // Keep hidden icons in the state/release pass so the active count cannot + // drop while a stale dim alpha remains waiting to reappear. + screenPosition: + bb.show === false || !position ? null : screenPositionFor(position), + cameraDistance: position ? cameraDistanceFor(position) : Number.NaN, + nowMs, + target, + params, + spriteHalfWidthPx: (bb.width || 24) * (bb.scale || 1) * 0.5, + spriteHalfHeightPx: (bb.height || 24) * (bb.scale || 1) * 0.5, + }); + transitioning ||= focus.transitioning; + if (focus.active) activeCount += 1; + const base = baseColorFor(record); + const alpha = base.alpha * focus.factor; + if (focusAlphaNeedsWrite(bb.color?.alpha, alpha, params)) { + // Order-independent narrow amendment to always-visible icons: CCTV + // contacts retain a non-zero floor while yielding near the tracked target. + bb.color = base.withAlpha(alpha); + writes += 1; + } + } + return { writes, transitioning, activeCount, ran: true }; + } + + /** + * Updates visual styles (colors, widths, visibility) for all camera billboards, + * coverage polylines, viewshed volumes, and projection entities based on which + * camera is active, whether the layer is enabled, and the current + * coverage-mode/projection toggle states. + */ + /** + * Field-test fix (2026-07-06): horizon-culls camera billboards, mirroring the + * flights layer's EllipsoidalOccluder pass. With the Cesium globe hidden + * (Google-3D regime) nothing writes far-side depth, and the billboards are now + * always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge + * fix), so without this pass London's cluster would shine through the planet + * from a US viewpoint. Pure math over ≤ catalog-size points; runs on + * camera.moveEnd + init only (event-driven — no steady-state work). + */ + + function refreshHorizonCulling() { + if ( + !layerState._viewer || + layerState._viewer.isDestroyed() || + !layerState._records.length + ) + return; + const occluder = horizonOccluder(layerState._viewer.camera); + for (const record of layerState._records) { + const bb = record.billboard; + if (!bb) continue; + const visible = occluder.isPointVisible(bb.position); + if (bb.show !== visible) bb.show = visible; + } + } + + /** + * Hides every per-record CCTV visual without recomputing coverage membership or + * styles. Disabling makes every visibility branch false, so a direct sweep is + * sufficient; live viewshed primitives still require explicit destruction. + * + * @param {Object[]} records CCTV runtime records. + * @param {(record: Object) => void} destroyVolume Viewshed teardown callback. + * @param {string|null} [activeCameraId=null] Active camera whose activation probe must be re-armed. + */ + + function hideCctvRecordVisuals( + records, + destroyVolume, + activeCameraId = null, + ) { + for (const record of Array.isArray(records) ? records : []) { + if (record) { + record.probeClampRangeM = null; + if (record.camera?.id === activeCameraId) record.activationDone = false; + } + for (const entity of record?.coverageEntities || []) entity.show = false; + if (record?.viewshedPrimitive) destroyVolume?.(record); + if (record?.projection?.planeEntity) + record.projection.planeEntity.show = false; + } + } + + function hideCctvVisuals() { + hideCctvRecordVisuals( + layerState._records, + parts.geometry.destroyViewshedVolume, + layerState._activeCameraId, + ); + parts.projection.clearProjectionOverlay(); + if (layerState._billboards) layerState._billboards.show = false; + parts.projection.pauseInactiveProjectionFeeds(null); + } + + /** Applies coverage visibility/style state and lazily builds eligible sets. */ + + function refreshCoverageStyles() { + const activeRecord = parts.selection.getActiveRecord(); + parts.geometry.ensureActiveCoverageEntities(activeRecord); + const activeId = activeRecord?.camera.id || null; + const coverageVisible = + parts.geometry.buildCoverageVisibleSet(activeRecord); + const coverageOn = layerState._coverageMode !== 'off'; + const viewshedOn = layerState._coverageMode === 'viewshed'; + if (coverageOn) { + parts.geometry.ensureVisibleCoverageEntities( + layerState._records, + coverageVisible, + ); + } + for (const record of layerState._records) { + const isActive = record.camera.id === activeId; + if (record.billboard) { + record.billboard.color = isActive + ? ACTIVE_CAMERA_COLOR + : IDLE_CAMERA_COLOR; + record.billboard.scale = isActive ? 1.25 : 1.0; + // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard + // (set at creation) — see the field-test far-zoom submerge fix there. + } + + if (layerState._enabled && layerState._showProjection && isActive) { + parts.projection.ensureProjectionRuntime(record); + } + // One live plane in the world at a time (§2c): only the active camera's + // far cap carries the monitor plane; idle neighbors get the faint + // wireframe only. + const planeShowing = !!( + layerState._enabled && + layerState._showProjection && + isActive + ); + + const inVisibleSet = coverageVisible.has(record.camera.id); + for (const entity of record.coverageEntities || []) { + // The frustum wireframe is part of the projection representation — + // force it on for the active camera and let it read through geometry + // via depthFailMaterial (polylines have no disableDepthTestDistance). + entity.show = !!( + layerState._enabled && + ((coverageOn && inVisibleSet) || planeShowing) + ); + if (!entity.polyline) continue; + // Viewshed mode swaps the cyan/green scheme for the camera's own hue so + // adjacent cones read as distinct coverage claims (design §3b); the + // active camera keeps its width/alpha emphasis in both schemes. + const hue = viewshedOn ? record.viewshedColors : null; + if (entity._coverageRole === 'cap') { + entity.polyline.material = hue + ? isActive + ? hue.lineActive + : hue.line + : isActive + ? ACTIVE_COVERAGE_CENTER + : IDLE_COVERAGE_CENTER_MUTED; + entity.polyline.width = isActive ? 2.2 : 1.0; + entity.polyline.depthFailMaterial = planeShowing + ? hue + ? hue.line.withAlpha(0.26) + : ACTIVE_COVERAGE_CENTER_DEPTHFAIL + : undefined; + } else { + entity.polyline.material = hue + ? isActive + ? hue.lineActive + : hue.line.withAlpha(0.6) + : isActive + ? ACTIVE_COVERAGE_EDGE + : IDLE_COVERAGE_EDGE_MUTED; + entity.polyline.width = isActive ? 1.8 : 0.9; + entity.polyline.depthFailMaterial = planeShowing + ? hue + ? hue.line.withAlpha(0.18) + : ACTIVE_COVERAGE_EDGE_DEPTHFAIL + : undefined; + } + } + + // Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the + // visible set. Rebuild on active-tint flips (rare); otherwise leave the + // primitive alone so idle refreshes never churn geometry. + const wantVolume = !!( + layerState._enabled && + viewshedOn && + inVisibleSet && + record.frustumPositions + ); + if (wantVolume) { + if ( + !record.viewshedPrimitive || + record.viewshedActiveTint !== isActive + ) { + parts.geometry.rebuildViewshedVolume(record, isActive); + } + } else if (record.viewshedPrimitive) { + parts.geometry.destroyViewshedVolume(record); + } + + if (record.projection) { + parts.projection.setPlaneVisible(record.projection, planeShowing); + } + } + + if (layerState._billboards) + layerState._billboards.show = !!layerState._enabled; + parts.projection.pauseInactiveProjectionFeeds(activeId); + } + return { + refreshCctvFocusStyles, + applyCctvFocusDeemphasis, + refreshHorizonCulling, + hideCctvRecordVisuals, + hideCctvVisuals, + refreshCoverageStyles, + }; +} diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js new file mode 100644 index 000000000..c89937a9a --- /dev/null +++ b/src/layers/cctv/selection.js @@ -0,0 +1,258 @@ +import { + bindTrackingClickGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import * as Cesium from 'cesium'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { CCTV_ACTIVATION_RESULT } = services.activation; + const { resolvePickId } = services.picking; + + /** + * Returns the camera record for the currently active camera. A stale ID falls + * back to the first record, but an intentional null remains an honest + * deselected state. + * @returns {Object|null} Active camera record, or null when none is active. + */ + + function getActiveRecord() { + if (!layerState._activeCameraId) return null; + if (layerState._recordById.has(layerState._activeCameraId)) { + return layerState._recordById.get(layerState._activeCameraId); + } + // Sync _activeCameraId when falling back to first record to prevent ID mismatch + const fallback = layerState._records[0] || null; + if (fallback && fallback.camera?.id) { + layerState._activeCameraId = fallback.camera.id; + } + return fallback; + } + + /** + * Bind CCTV activation to clean taps while preserving the layer's hover-move + * callback on the shared Cesium handler. Drag-like and long-press gestures do + * not reach camera activation or focus dispatch. + * @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler. + * @param {(click: Object) => void} onClick - Accepted CCTV click callback. + * @param {Object} [options] - Gesture test seams and optional onMouseMove hook. + * @returns {void} + */ + + function bindCctvWorldClickGesture(handler, onClick, options = {}) { + bindTrackingClickGesture( + handler, + (click, gesture) => { + if (!isTrackingClickGesture(gesture)) return; + onClick(click); + }, + options, + ); + } + + /** + * Sets the active camera by ID, initializes its projection runtime, refreshes + * its frame, and updates styles. + * @param {string} cameraId - ID of the camera to activate. + * @returns {'activated'|'unchanged'|'not-found'} Discriminated activation result. + */ + + function setActiveCamera(cameraId) { + if (!cameraId || !layerState._recordById.has(cameraId)) + return CCTV_ACTIVATION_RESULT.NOT_FOUND; + const record = layerState._recordById.get(cameraId); + const previousActiveRecord = getActiveRecord(); + // Re-selecting the already-active camera is a no-op: re-running the + // activation path re-probes and rewrites the plane entity's geometry, and + // that async primitive rebuild visibly flashes the monitor plane (owner + // field test 2026-07-04 — every click ON the plane picks its own camera). + // `activationDone` distinguishes a real activation from the enable()-time + // default `_activeCameraId` assignment, which never ran this path. + if ( + !parts.model.cctvRecordNeedsActivation( + cameraId, + layerState._activeCameraId, + record, + ) + ) { + return CCTV_ACTIVATION_RESULT.UNCHANGED; + } + layerState._activeCameraId = cameraId; + layerState._autoHopSuspended = false; + // A real activation creates projection work — wake the self-stopping loop. + parts.projection.startProjectionLoop(); + if (previousActiveRecord && previousActiveRecord !== record) { + parts.geometry.clearProbeClampOnDeactivation( + previousActiveRecord, + (previous) => { + parts.geometry.applyFrustumGeometry( + previous, + parts.ground.groundAltFor(previous), + ); + }, + ); + } + // The clicked camera leaves the ambient quota immediately (never graced). + // Shipped behavior publishes no card for it because the monitor plane is + // now the active representation; the opt-in protected-card path republishes + // it synchronously through refreshAmbientCards() below. + layerState._cardIds.delete(cameraId); + layerState._cardGraceState.delete(cameraId); + // Activating the hovered camera consumes the transient pin; the active + // monitor plane replaces it from this moment. + if (layerState._hoverCardId === cameraId) parts.hover.clearHoverCard(); + // If the record's geometry refinement is still queued, jump it to the front + // so the newly active camera resolves before idle neighbors. + const queueIdx = layerState._geoQueue.indexOf(record); + if (queueIdx > 0) { + layerState._geoQueue.splice(queueIdx, 1); + layerState._geoQueue.unshift(record); + } + // §9.1: one obstruction probe per activation, BEFORE the geometry pass so + // the range clamp lands in the same rewrite. A FIRST-EVER activation (no + // real ground sample yet) probes from the catalog-prior altitude — if that + // prior is off, the clamp is measured from a shifted origin, but a + // re-activation after the one-shot snap lands re-probes from real ground, + // so it self-corrects. + parts.geometry.runActivationObstructionProbe(record); + // Coverage is activation-lazy even while the mode is OFF: the active + // camera's projection representation must be ready without materializing + // any idle neighbor. The following geometry rewrite welds these entities to + // the current sampled positions. + parts.geometry.ensureActiveCoverageEntities(record); + parts.projection.ensureProjectionRuntime(record); + parts.frames.refreshProjectionImage(record, true); + // FIX B9b: an explicit user (re)select re-arms one real ground sample for the + // newly-active camera, then freezes. Idle neighbors keep their resolved state. + // FIX B9c: if tiles are ready this real pass applies + re-resolves + // immediately; if tiles are still streaming, updateRecordGeometry's fallback + // guard recomputes purely from the cached real ground (the pose + the probe + // clamp above still land — no scene queries) and the record stays unresolved + // until update()'s one-shot tiles-ready completion pass re-grounds it. + parts.ground.rearmGroundResolution(record); + parts.geometry.updateRecordGeometry(record); + record.activationDone = true; + parts.rendering.refreshCoverageStyles(); + // The newly active camera leaves the ambient ring (its monitor plane takes + // over); the freed slot re-fills on this same pass. + parts.cards.refreshAmbientCards(); + // ADJUST mode follows the active camera. + layerState._gizmo?.refresh(); + parts.presentation.notifyListeners(); + return CCTV_ACTIVATION_RESULT.ACTIVATED; + } + + /** + * Clears the active CCTV camera in place without moving the viewer or + * disabling the layer. The normal selection-refresh path releases the active + * projection, emphasis, and probe state while ambient cards remain available. + * @returns {boolean} True when a camera was deactivated. + */ + + function deactivateActiveCamera() { + const record = layerState._activeCameraId + ? layerState._recordById.get(layerState._activeCameraId) + : null; + if (!record) return false; + layerState._activeCameraId = null; + layerState._autoHopSuspended = true; + record.activationDone = false; + parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { + parts.geometry.applyFrustumGeometry( + previous, + parts.ground.groundAltFor(previous), + ); + }); + parts.rendering.refreshCoverageStyles(); + parts.cards.refreshAmbientCards(); + layerState._gizmo?.refresh(); + parts.presentation.notifyListeners(); + return true; + } + + /** + * True only for a clean click that is empty from CCTV's perspective: an + * active camera exists, ADJUST does not own the pointer, and the scene pick + * carries no canonical object ID. Any identified scene object is non-empty, + * including selectable siblings that do not participate in the pick registry. + * @param {Object|null} picked - `scene.pick()` result. + * @param {Object} [context] + * @param {string|null} [context.activeCameraId] + * @param {boolean} [context.calibrationMode] + * @returns {boolean} + */ + + function cctvEmptyClickDeselects( + picked, + { activeCameraId = null, calibrationMode = false } = {}, + ) { + if (!activeCameraId || calibrationMode) return false; + return resolvePickId(picked) === null; + } + + /** + * Extracts a camera ID from a Cesium pick result by checking billboard IDs, + * primitive IDs, and entity cctvCameraId properties. + * @param {Object|null} picked - Result from scene.pick(). + * @returns {string|null} Camera ID, or null if the pick is not a CCTV entity. + */ + + function extractPickedCameraId(picked) { + if (!picked) return null; + + const entity = picked.id?.properties + ? picked.id + : picked.primitive?.id?.properties + ? picked.primitive.id + : null; + const maybeProp = entity?.properties?.cctvCameraId; + if (maybeProp) { + const value = + typeof maybeProp.getValue === 'function' + ? maybeProp.getValue(Cesium.JulianDate.now()) + : maybeProp; + const record = + typeof value === 'string' ? layerState._recordById.get(value) : null; + const ownsCoverageEntity = Boolean( + record?.coverageEntities?.includes(entity), + ); + const ownsProjectionEntity = + record?.projection?.planeEntity === entity || + layerState._projectionEntities.some( + (runtime) => + runtime?.cameraId === value && runtime.planeEntity === entity, + ); + if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value; + } + + // Camera billboard IDs are intentionally the upstream camera ID, so a + // sibling may legitimately use the same string. A bare ID match is not + // ownership proof: require this layer's billboard collection or the exact + // billboard stored on the record. + const directId = + typeof picked.id === 'string' + ? picked.id + : typeof picked.primitive?.id === 'string' + ? picked.primitive.id + : null; + const record = + directId === null ? null : layerState._recordById.get(directId); + if (!record) return null; + return picked.primitive === layerState._billboards || + picked.primitive === record.billboard + ? directId + : null; + } + return { + getActiveRecord, + bindCctvWorldClickGesture, + setActiveCamera, + deactivateActiveCamera, + cctvEmptyClickDeselects, + extractPickedCameraId, + }; +} diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js new file mode 100644 index 000000000..7fba9a9ca --- /dev/null +++ b/src/layers/cctv/source.js @@ -0,0 +1,55 @@ +import { + ACTIVE_FRAME_REFRESH_MS, + FRAME_ENDPOINT, + MEDIA_ENDPOINT, +} from './policy.js'; +function safeNumber(value, fallback = NaN) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} +function frameUrlFor(camera, refreshMs = ACTIVE_FRAME_REFRESH_MS) { + const cadenceMs = Math.max( + 1000, + safeNumber(refreshMs, ACTIVE_FRAME_REFRESH_MS), + ); + const tick = Math.floor(Date.now() / cadenceMs); + const params = new URLSearchParams({ + label: camera.name, + city: camera.city, + lat: camera.lat.toFixed(6), + lon: camera.lon.toFixed(6), + heading: String(Math.round(camera.headingDeg)), + fov: String(Math.round(camera.fovDeg)), + pitch: String(Math.round(camera.pitchDeg || -10)), + ts: String(tick), + }); + return `${FRAME_ENDPOINT}/${encodeURIComponent(camera.id)}?${params.toString()}`; +} +function mediaUrlFor(camera) { + return `${MEDIA_ENDPOINT}/${encodeURIComponent(camera.id)}?ts=${Math.floor(Date.now() / 15000)}`; +} +/** Supply catalog/health records and the existing registered camera URL families. */ +export function createCctvSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + async function read(path, key, { signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(path, { cache: 'no-store', signal }); + if (!response.ok) throw new Error('Camera source HTTP ' + response.status); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.[key])) + throw new Error('Malformed camera ' + key + ' snapshot'); + return payload; + } + return { + getCatalog(options) { + return read('/api/cctv/sources', 'sources', options); + }, + getHealth(options) { + return read('/api/cctv/health', 'cameras', options); + }, + getFrameUrl: frameUrlFor, + getMediaUrl: mediaUrlFor, + }; +} diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs new file mode 100644 index 000000000..f0e3b0cc3 --- /dev/null +++ b/src/layers/cctv/source.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCctvSource, createCctvLayer } from './index.js'; + +const camera = { + id: 'pack/camera ?x', + name: 'Camera & road', + city: 'Austin', + lat: 30.267, + lon: -97.744, + headingDeg: 45, + fovDeg: 60, + pitchDeg: -12, +}; + +test('camera catalog and health use fixed source routes and caller cancellation', async () => { + const calls = []; + const source = createCctvSource({ + fetchImpl: async (path, options) => { + calls.push({ path, options }); + return new Response( + JSON.stringify( + path.endsWith('/sources') ? { sources: [] } : { cameras: [] }, + ), + ); + }, + }); + const controller = new AbortController(); + await source.getCatalog({ signal: controller.signal }); + await source.getHealth({ signal: controller.signal }); + assert.deepEqual( + calls.map((call) => call.path), + ['/api/cctv/sources', '/api/cctv/health'], + ); + for (const { options } of calls) { + assert.equal(options.signal, controller.signal); + assert.equal(options.cache, 'no-store'); + } +}); + +test('camera sources reject malformed snapshots and failures', async () => { + for (const method of ['getCatalog', 'getHealth']) { + const malformed = createCctvSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects(malformed[method](), /Malformed camera/); + const denied = createCctvSource({ + fetchImpl: async () => new Response('', { status: 403 }), + }); + await assert.rejects(denied[method](), /HTTP 403/); + } +}); + +test('cancellation while reading a camera response body prevents publication', async () => { + const controller = new AbortController(); + const source = createCctvSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { sources: [] }; + }, + }), + }); + await assert.rejects(source.getCatalog({ signal: controller.signal }), { + name: 'AbortError', + }); +}); + +test('frame and media URLs preserve registered camera identity and encoded metadata', () => { + const source = createCctvSource(); + const frame = new URL(source.getFrameUrl(camera), 'https://example.test'); + const media = new URL(source.getMediaUrl(camera), 'https://example.test'); + assert.equal( + frame.pathname, + '/api/cctv/frame/' + encodeURIComponent(camera.id), + ); + assert.equal( + media.pathname, + '/api/cctv/media/' + encodeURIComponent(camera.id), + ); + assert.equal(frame.searchParams.get('label'), camera.name); + assert.equal(frame.searchParams.get('city'), camera.city); + assert.equal(frame.searchParams.get('lat'), '30.267000'); + assert.equal(frame.searchParams.get('lon'), '-97.744000'); + assert.equal(frame.searchParams.get('heading'), '45'); + assert.equal(frame.searchParams.get('pitch'), '-12'); + assert.deepEqual([...media.searchParams.keys()], ['ts']); +}); + +test('camera construction is inert and destruction cancels a pending catalog and its visibility listener', async (t) => { + const original = globalThis.document; + const listeners = new Set(); + globalThis.document = { + addEventListener(type, handler) { + if (type === 'visibilitychange') listeners.add(handler); + }, + removeEventListener(type, handler) { + if (type === 'visibilitychange') listeners.delete(handler); + }, + }; + t.after(() => { + globalThis.document = original; + }); + const noop = () => {}; + const services = { + overlays: { + clearOverlaySource: noop, + hitTestWorldOverlay: noop, + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + }, + sprites: { registerSpriteCollection: noop }, + activation: {}, + locations: {}, + picking: { unregisterPickOwner: noop }, + terrain: {}, + ground: {}, + mesh: {}, + focus: {}, + render: { releaseContinuousRender: noop }, + }; + let resolveCatalog; + let signal; + const source = { + ...createCctvSource(), + getCatalog(options) { + signal = options.signal; + return new Promise((resolve) => { + resolveCatalog = resolve; + }); + }, + }; + const a = createCctvLayer({ services, source }); + const b = createCctvLayer({ services, source }); + assert.equal(listeners.size, 0); + const viewer = { + scene: { primitives: { add: (value) => value, remove: () => true } }, + }; + const initializing = a.init(viewer); + assert.equal(listeners.size, 1); + assert.equal(signal.aborted, false); + a.destroy(viewer); + assert.equal(listeners.size, 0); + assert.equal(signal.aborted, true); + resolveCatalog({ sources: [] }); + await assert.rejects(initializing, { name: 'AbortError' }); + assert.equal(a.getStats().count, 0); + assert.equal(b.getStats().count, 0); +}); diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js new file mode 100644 index 000000000..b78d18b73 --- /dev/null +++ b/src/layers/cctv/state.js @@ -0,0 +1,208 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { + clearOverlaySource, + hitTestWorldOverlay, + setOverlayEntries, + setOverlaySourceVisible, + } = services.overlays; + const layerState = {}; + + // --------------------------------------------------------------------------- + // Module-scoped mutable state + // --------------------------------------------------------------------------- + + layerState._viewer = null; + + layerState._billboards = null; + + layerState._records = []; + + layerState._recordById = new Map(); + + layerState._coverageEntities = []; + + layerState._projectionEntities = []; + + layerState._enabled = false; + + layerState._activeCameraId = null; + + layerState._coverageMode = 'on'; + // 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes) + + layerState._showProjection = true; + + layerState._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. + + layerState._autoHopSuspended = false; + + layerState._autoHopSec = 18; + + layerState._lastHopAt = 0; + + layerState._lastViewContext = ''; + + layerState._clickHandler = null; + + layerState._count = 0; + + layerState._lastUpdate = null; + + layerState._lastHealthSyncAt = 0; + + layerState._lastError = null; + + layerState._healthById = new Map(); + + layerState._calibrationById = new Map(); + + layerState._listeners = new Set(); + + layerState._projectionRaf = 0; + + layerState._removeFocusAppearListener = null; + + layerState._lastFocusStyleAt = 0; + + /** Icons whose animated emphasis remains outside the 1.0 deadband. */ + + layerState._activeFocusStyleCount = 0; + + layerState._scratchFocusScreen = new Cesium.Cartesian2(); + + // Staggered geometry-load queue state (see startGeometryLoadQueue). + + layerState._geoQueue = []; + + layerState._geoQueueTimer = 0; + + layerState._geoLoading = false; + + layerState._geoLoadTotal = 0; + + layerState._geoLoadDone = 0; + + layerState._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. + + layerState._tilesReadyReenqueued = false; + + // Calibration ADJUST mode (viewshed/gizmo design §3c): while true, the active + // camera renders the direct-manipulation gizmo. Reset on layer disable. + + layerState._calibrationMode = false; + + layerState._gizmo = null; + + layerState._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. + + layerState._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. + + layerState._lastAppliedRegime = null; + + // Task 5: window listener handle for the 'gev:map-stack-changed' CustomEvent + // main.js dispatches from MapStackController's onChange (removed in destroy). + + layerState._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. + + layerState._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. + + layerState._cardIds = new Set(); + + /** @type {Map} */ + + layerState._cardGraceState = new Map(); + + /** @type {Map} */ + + layerState._cardFrameSlots = new Map(); + + layerState._cardFetchTimer = 0; + + /** In-flight card-frame fetch count (burst allows up to 4, steady is 1). */ + + layerState._cardFetchInFlightCount = 0; + + /** @type {Set} in-flight fetches, detached on teardown. */ + + layerState._cardFetchImages = new Set(); + + /** @type {Set} camera ids with an in-flight fetch (no double-fetch). */ + + layerState._cardFetchPendingIds = new Set(); + + layerState._cardFetchCount = 0; + + layerState._cardLastFetchAt = 0; + + layerState._cardMinFetchSpacingMs = null; + + /** Pacer mode telemetry: 'burst' during cold fill, 'steady' after. */ + + layerState._cardFetchMode = 'steady'; + + layerState.DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({ + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + }); + + layerState._cctvOverlayHost = layerState.DEFAULT_CCTV_OVERLAY_HOST; + + layerState._projectionOverlayOwnerId = null; + + /** + * Product presentation option. Shipped behavior keeps the active camera's + * thumbnail absent because its monitor plane is the active representation. + */ + + layerState._activeCameraCardEnabled = false; + + /** Camera id currently holding the hover-summoned pinned card (or null). */ + + layerState._hoverCardId = null; + + layerState._hoverReleaseTimer = 0; + + layerState._hoverLastPickAt = 0; + + // True between camera.moveStart and moveEnd — hover picking pauses while the + // camera is in motion (picks during a flight would fight the reselection). + + layerState._cameraMoving = false; + + layerState._moveStartListener = null; + layerState._sourceAbort = null; + return layerState; +} diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js new file mode 100644 index 000000000..5c1e67956 --- /dev/null +++ b/src/layers/cctv/testing.js @@ -0,0 +1,120 @@ +import * as Cesium from 'cesium'; + +export function createTesting({ state: layerState, services, parts, source }) { + /** Test seam: republishes host entries through the real push path, so tests + * can observe the pristine module default without touching the setter. */ + + function _pushAmbientCardEntriesForTest() { + parts.cards.pushAmbientCardEntries(); + } + + /** Test seam for exercising real layer lifecycle paths without a DOM host. */ + + function _setCctvOverlayHostForTest(host = null) { + layerState._cctvOverlayHost = host + ? { ...layerState.DEFAULT_CCTV_OVERLAY_HOST, ...host } + : layerState.DEFAULT_CCTV_OVERLAY_HOST; + layerState._projectionOverlayOwnerId = null; + } + + /** + * Create the production monitor-plane/host-label pair without media setup. + * @param {Object} viewer Cesium viewer seam. + * @param {Object} record CCTV runtime record. + * @returns {Object} Projection runtime. + */ + + function _createCctvProjectionPlaneForTest(viewer, record) { + layerState._viewer = viewer; + const geometry = + record.frustumGeometry || + parts.geometry.computeFrustumGeometry( + record.camera, + parts.ground.groundAltFor(record), + record.probeClampRangeM, + ); + const positions = + record.frustumPositions || parts.geometry.frustumCartesians(geometry); + record.frustumGeometry = geometry; + record.frustumPositions = positions; + const runtime = { + cameraId: String(record.camera.id), + planeEntity: null, + labelPosition: new Cesium.Cartesian3(), + overlayEntry: null, + planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE), + }; + parts.projection.createProjectionPlane( + record, + runtime, + geometry, + positions, + ); + record.projection = runtime; + return runtime; + } + + /** + * Exercise the production geometry-to-plane-and-label cache update. + * @param {Object} record CCTV runtime record. + */ + + function _updateCctvProjectionPlaneForTest(record) { + parts.projection.updatePlanePlacement(record); + } + + /** Test-only seam for the CCTV ownership proof used by the world-click route. */ + + function _extractPickedCameraIdForTest(picked) { + return parts.selection.extractPickedCameraId(picked); + } + + /** + * Primes the minimum module state needed to exercise the production coverage + * refresh path in unit tests. + * @param {Object} [options={}] Test state values. + * @param {Object|null} [options.viewer] Viewer-like entity owner. + * @param {Object[]} [options.records] Seeded CCTV records. + * @param {string|null} [options.activeCameraId] Active record id. + * @param {boolean} [options.enabled=true] Layer enabled state. + * @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode. + * @param {boolean} [options.showProjection=false] Projection visibility. + * @returns {void} + */ + + function _setCctvCoverageStateForTest({ + viewer = null, + records = [], + activeCameraId = null, + enabled = true, + coverageMode = 'on', + showProjection = false, + } = {}) { + layerState._viewer = viewer; + layerState._records = Array.isArray(records) ? records : []; + layerState._recordById = new Map( + layerState._records + .filter((record) => record?.camera?.id) + .map((record) => [record.camera.id, record]), + ); + layerState._coverageEntities = []; + layerState._projectionEntities = []; + layerState._billboards = null; + layerState._activeCameraId = activeCameraId; + layerState._autoHopSuspended = false; + layerState._enabled = !!enabled; + layerState._coverageMode = parts.model.normalizeCoverageMode( + coverageMode, + 'on', + ); + layerState._showProjection = !!showProjection; + } + return { + _pushAmbientCardEntriesForTest, + _setCctvOverlayHostForTest, + _createCctvProjectionPlaneForTest, + _updateCctvProjectionPlaneForTest, + _extractPickedCameraIdForTest, + _setCctvCoverageStateForTest, + }; +} diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js new file mode 100644 index 000000000..f4fd12bd8 --- /dev/null +++ b/src/layers/earthquakes/index.js @@ -0,0 +1,233 @@ +import * as Cesium from 'cesium'; +import { + EARTHQUAKE_OVERLAY_SOURCE_ID, + EARTHQUAKE_OVERLAY_COHORT_LIMIT, + EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, + depthColor, + createEarthquakeOverlayEntry, + selectEarthquakeOverlayCohort, + mapAnalystRecord, +} from './model.js'; +export * from './model.js'; +export { createUsgsEarthquakeSource } from './source.js'; + +/** Own one earthquake display and its refresh lifecycle. */ +export function createEarthquakesLayer({ source, overlayHost } = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Earthquakes require a snapshot source'); + if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); + let _viewer = null; + let _request = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + + const layer = { + id: 'earthquakes', + name: 'Earthquakes (24h)', + icon: '🌋', + source: 'USGS', + updateInterval: 60000, + + init(viewer) { + if (_viewer) throw new Error('Earthquake layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('earthquakes'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + console.log('[Data:Earthquakes] Initialized'); + }, + + enable(viewer) { + _enabled = true; + // No continuous-render hold: the discs are static geometry now, so the + // layer has no per-frame animator to keep the render loop alive for. + if (_dataSource) _dataSource.show = true; + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + }, + + disable(viewer) { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + }, + + async update(viewer) { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + const nextEntities = []; + let count = 0; + const overlayEntries = []; + + for (const { + stableId, + usgsId, + lon, + lat, + depthKm, + mag, + place, + time, + } of rows) { + count++; + const baseRadius = Math.pow(2, mag) * 1000; + const color = depthColor(depthKm || 0); + const isSignificant = mag >= 5.0; + const fillAlpha = isSignificant ? 0.4 : 0.3; + const outlineAlpha = isSignificant ? 1.0 : 0.8; + + const position = Cesium.Cartesian3.fromDegrees(lon, lat); + nextEntities.push( + new Cesium.Entity({ + id: `earthquake:${stableId}`, + position, + ellipse: { + // Static axes — see the module header. A CallbackProperty here + // re-tessellates the clamped ground geometry every frame. + semiMajorAxis: baseRadius, + semiMinorAxis: baseRadius, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(fillAlpha), + ), + outline: true, + outlineColor: color.withAlpha(outlineAlpha), + outlineWidth: isSignificant ? 3 : 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + properties: { + // Analyst seam (additive): the USGS event id (e.g. "us7000abcd"). + usgsId, + mag, + place, + time, + depth: depthKm, + }, + }), + ); + overlayEntries.push( + createEarthquakeOverlayEntry({ + id: String(stableId), + position, + magnitude: mag, + accent: color.toCssColorString(), + }), + ); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + if (_enabled) { + overlayHost.setEntries( + EARTHQUAKE_OVERLAY_SOURCE_ID, + selectEarthquakeOverlayCohort(overlayEntries), + { + cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT, + collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, + moving: false, + }, + ); + } + + _count = count; + _lastUpdate = Date.now(); + _lastError = null; + console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:Earthquakes] Fetch error:', e); + _lastError = e?.message || 'Earthquake source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + _viewer = null; + _enabled = false; + overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + /** + * Snapshot the layer's in-memory earthquake records as plain JSON-safe + * objects for the analyst query engine. On-demand only (called at most + * once per spoken query) — zero per-frame cost, no listeners, no caching. + * Returns [] while the layer is disabled or empty. + * @param {number} [maxCount=2000] - Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const entities = _dataSource.entities.values; + if (!entities.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const now = Cesium.JulianDate.now(); + const result = []; + for (const entity of entities) { + if (result.length >= limit) break; + const cartesian = entity.position + ? entity.position.getValue(now) + : null; + const carto = cartesian + ? Cesium.Cartographic.fromCartesian(cartesian) + : null; + const p = entity.properties; + result.push( + mapAnalystRecord( + { + id: p?.usgsId?.getValue(now) ?? null, + mag: p?.mag?.getValue(now), + place: p?.place?.getValue(now), + time: p?.time?.getValue(now), + depth: p?.depth?.getValue(now), + lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, + lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, + }, + result.length, + ), + ); + } + return result; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js new file mode 100644 index 000000000..b679b7e35 --- /dev/null +++ b/src/layers/earthquakes/model.js @@ -0,0 +1,150 @@ +import * as Cesium from 'cesium'; +export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; +export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; +export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; + +/** + * Color by depth: + * - Shallow (<70km): Red + * - Intermediate (70-300km): Orange + * - Deep (>300km): Yellow + */ +export function depthColor(depthKm) { + if (depthKm < 70) return Cesium.Color.RED; + if (depthKm < 300) return Cesium.Color.ORANGE; + return Cesium.Color.YELLOW; +} + +/** + * Build the source-owned presentation for one ambient magnitude label. + * Magnitude formatting deliberately remains here instead of moving into the + * shared renderer. + * @param {object} input + * @param {string} input.id Stable USGS or deterministic fallback id. + * @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse. + * @param {number} input.magnitude USGS magnitude. + * @param {string} input.accent Source-owned depth-band color. + * @returns {object} + */ +export function createEarthquakeOverlayEntry({ + id, + position, + magnitude, + accent, +}) { + const mag = Number(magnitude); + return { + id: String(id), + position, + variant: 'label', + title: `M${mag.toFixed(1)}`, + accent, + priority: Math.round(mag * 1000), + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} + +/** Keep the largest events, with stable identity as the tie-break. */ +export function selectEarthquakeOverlayCohort( + entries, + limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT, +) { + const cap = Math.max( + 0, + Math.min(EARTHQUAKE_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); +} + +/** + * Map one earthquake's raw plain values to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. Falls back to an index-based id + * when the USGS event id is absent. + * @param {Object|null|undefined} raw - Plain values pulled off the entity: + * {id, mag, place, time, depth, lat, lon}. + * @param {number} [index=0] - Position in the snapshot (fallback id only). + * @returns {{id: string, magnitude: number|null, depthKm: number|null, + * lat: number|null, lon: number|null, timeMs: number|null, place: string|null}} + */ +export function mapAnalystRecord(raw, index = 0) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + return { + id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`, + magnitude: num(raw?.mag), + depthKm: num(raw?.depth), + lat: num(raw?.lat), + lon: num(raw?.lon), + timeMs: num(raw?.time), // USGS epoch ms + place: text(raw?.place), + }; +} + +/** Validate a complete feed before replacing the last good earthquake snapshot. */ +export function normalizeEarthquakeSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const [index, feature] of geojson.features.entries()) { + const coordinates = feature?.geometry?.coordinates; + const properties = feature?.properties; + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 || + !properties || + typeof properties !== 'object' || + Array.isArray(properties) || + (feature.geometry.type != null && feature.geometry.type !== 'Point') + ) + return null; + const [lon, lat, depthKm] = coordinates; + const mag = properties.mag; + if ( + !Number.isFinite(lon) || + Math.abs(lon) > 180 || + !Number.isFinite(lat) || + Math.abs(lat) > 90 || + (depthKm != null && !Number.isFinite(depthKm)) || + (mag != null && (!Number.isFinite(mag) || mag > 10)) + ) + return null; + // A missing magnitude cannot establish that this event meets M2.5+. + if (mag == null || mag < 2.5) continue; + const stableId = + feature.id == null || feature.id === '' + ? `event-${index + 1}` + : String(feature.id); + if (ids.has(stableId)) return null; + ids.add(stableId); + rows.push({ + stableId, + usgsId: feature.id ?? null, + lon, + lat, + depthKm: depthKm ?? null, + mag, + place: typeof properties.place === 'string' ? properties.place : null, + time: Number.isFinite(properties.time) ? properties.time : null, + }); + } + return rows; +} diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs new file mode 100644 index 000000000..9951935de --- /dev/null +++ b/src/layers/earthquakes/ownership.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createEarthquakesLayer } from './index.js'; +import { createUsgsEarthquakeSource } from './source.js'; +function harness(source) { + const sources = []; + const events = []; + const viewer = { + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createEarthquakesLayer({ + source, + overlayHost: { + setEntries(...args) { + events.push(args); + }, + setVisible() {}, + clearSource() {}, + }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, events }; +} +const row = { + stableId: 'event-a', + usgsId: 'event-a', + lon: 30, + lat: 20, + depthKm: 3, + mag: 4, + place: 'Fixture', + time: 1000, +}; +test('late refresh cannot publish after disable, re-enable, or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + assert.equal(h.events.length, 0); + h.layer.destroy(h.viewer); + } +}); +test('two displays own separate data sources and destruction', async () => { + const a = harness({ getSnapshot: async () => [row] }); + const b = harness({ getSnapshot: async () => [] }); + await a.layer.update(a.viewer); + await b.layer.update(b.viewer); + assert.equal(a.layer.getStats().count, 1); + assert.equal(b.layer.getStats().count, 0); + a.layer.destroy(); + assert.equal(a.sources.length, 0); + assert.equal(b.sources.length, 1); + b.layer.destroy(); +}); +test('USGS body completion honors cancellation even with an uncooperative transport', async () => { + const abort = new AbortController(); + const source = createUsgsEarthquakeSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + abort.abort(); + return { features: [] }; + }, + }), + }); + await assert.rejects(source.getSnapshot({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/earthquakes/source.js b/src/layers/earthquakes/source.js new file mode 100644 index 000000000..fe074e139 --- /dev/null +++ b/src/layers/earthquakes/source.js @@ -0,0 +1,20 @@ +import { normalizeEarthquakeSnapshot } from './model.js'; +const API_URL = + 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'; +/** Request and validate a complete USGS snapshot before it can replace displayed events. */ +export function createUsgsEarthquakeSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(API_URL, { signal }); + if (!response.ok) throw new Error(`USGS HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + const rows = normalizeEarthquakeSnapshot(payload); + if (!rows) throw new Error('Malformed USGS response'); + return rows; + }, + }; +} diff --git a/src/layers/firms/anchors.js b/src/layers/firms/anchors.js new file mode 100644 index 000000000..8b740bdab --- /dev/null +++ b/src/layers/firms/anchors.js @@ -0,0 +1,98 @@ +// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections +// (owner field finding 2026-07-21: at close/oblique zoom over high country, +// fire dots anchored at ellipsoid height 0 read as buried inside the terrain +// ~1-2 km below the visible surface). +// +// The fix rides the height-datum ship's machinery end to end: anchors read +// the SAME shared ground floor every other ground-adjacent consumer uses +// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM +// cell), and cold cells warm through the same batched, chunked, cached +// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts); +// fires are static, and floor cells latch warm for the session, so each +// rendered detection costs at most one DEM lookup EVER — and detections +// sharing a ~111 m coarse cell share that lookup. + +/** @constant {number} Metres above the resolved floor for a fire anchor. + * The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel; + * a few metres of lift biases "slightly above the visible surface" (owner + * principle) without a visible hover on the screen-sized sprite. */ +export const FIRE_ANCHOR_LIFT_M = 5; + +/** + * Synchronous anchor height for one detection: shared ground floor + lift + * when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render + * exactly as before until their floor lands). Warm-cache read only; never + * triggers network or sampling. + * @param {number} lat + * @param {number} lon + * @returns {number} Ellipsoidal anchor height in metres. + */ +export function createFireAnchors({ + cachedGroundFloor, + resolveGroundFloorCells, +}) { + function fireAnchorHeight(lat, lon) { + const floor = cachedGroundFloor(lat, lon); + return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0; + } + + /** @type {?Promise} Tail of the batch chain — batches run strictly + * sequentially so overlapping renders can't stack concurrent requests on + * the single dev-server proxy (same courtesy as terrainHeights' sequential + * chunking). Each queued batch re-filters against the warm cache when it + * actually runs, so cells resolved by an earlier batch are never refetched. */ + let _chain = null; + + /** + * Batched warm of the ground-floor cells behind a rendered detection set. + * Fire-and-forget safe (never rejects). Resolves `true` only when at least + * one requested point actually gained a warm floor — a failed resolve (proxy + * down caches only geoid fallbacks, which do NOT count as a floor) resolves + * `false`, so a render → warm → re-render chain terminates instead of + * looping; the next natural rebuild retries and the cache self-heals. + * @param {Array<{lat: number, lon: number}>} points - Rendered detections. + * @returns {Promise} Whether any requested point warmed. + */ + function warmFireAnchorFloors(points) { + const cold = collectCold(points); + if (!cold.length) return Promise.resolve(false); + const prev = _chain; + const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold); + _chain = run.then( + () => true, + () => false, + ); + return run; + } + + /** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */ + function collectCold(points) { + if (!Array.isArray(points)) return []; + const cold = []; + for (const p of points) { + if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; + if (cachedGroundFloor(p.lat, p.lon) != null) continue; + cold.push({ lat: p.lat, lon: p.lon }); + } + return cold; + } + + /** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */ + async function resolveBatch(points) { + const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null); + if (!cold.length) return false; + try { + await resolveGroundFloorCells(cold); + } catch { + /* resolver is best-effort and never throws; belt and braces */ + } + return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null); + } + + /** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */ + function _resetFireAnchorsForTest() { + _chain = null; + } + + return { fireAnchorHeight, warmFireAnchorFloors, _resetFireAnchorsForTest }; +} diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js new file mode 100644 index 000000000..6b8a72ab6 --- /dev/null +++ b/src/layers/firms/cards.js @@ -0,0 +1,135 @@ +import * as Cesium from 'cesium'; +import { + FIRMS_OVERLAY_SOURCE_ID, + FIRMS_AMBIENT_COHORT_LIMIT, +} from '../../data/firmsLabels.js'; +import { MAX_AMBIENT_LABELS, LABEL_VIEW_MARGIN_PX } from './policy.js'; + +export function createCards({ + layerState, + services, + components, + config, + feed, +}) { + const { overlayHost } = config; + + /** + * Rebuild the card entries for the shared world-overlay host: the + * selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS + * ambient cards picked by a greedy screen-space declutter — walk the + * priority-ranked candidates, project each with + * SceneTransforms.worldToWindowCoordinates, and accept only candidates + * ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose + * projection fails are dropped silently. SELECTION runs here, on LOD/ + * viewport rebuilds and camera moveEnd — never per frame; RENDERING is + * the host, which re-projects the accepted entries every frame so + * cards track the camera smoothly. + */ + + function rebuildAmbientLabels() { + if (!layerState._viewer) return; + const scene = layerState._viewer.scene; + const now = Date.now(); + const entries = []; + /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ + const accepted = []; + // worldToWindowCoordinates happily projects points on the FAR side of the + // planet, so without this the ≤18 ambient slots get spent on fires the + // overlay host will then horizon-cull at paint time (`horizonCull: true` + // in applyFirmsOverlayPolicy) — near-side fires silently lose their cards. + // Shared with the sprite pass (fireHorizonOccluder) so cards and sprites + // never disagree about which hemisphere a detection is on. + const occluder = components.rendering.fireHorizonOccluder(); + const beyondHorizon = (position) => + occluder ? occluder.isPointVisible(position) !== true : false; + layerState._fireByCardId.clear(); + + if (layerState._selectedFire) { + const selectedCard = components.model.buildSelectedFireCard( + layerState._selectedFire, + now, + ); + layerState._fireByCardId.set(selectedCard.id, layerState._selectedFire); + entries.push(selectedCard); + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + components.model.firePosition(layerState._selectedFire), + layerState.scratchWindowCoord, + ); + // Seed the accepted list so ambient cards keep clear of the detail card. + // A selected fire behind the limb is not painted, so it must not reserve + // screen space either. + if ( + screen && + !beyondHorizon( + components.model.fireCullPosition(layerState._selectedFire), + ) + ) { + accepted.push({ x: screen.x, y: screen.y }); + } + } + + const width = scene.canvas.clientWidth; + const height = scene.canvas.clientHeight; + let ambientCount = 0; + + for (const candidate of layerState._labelCandidates) { + if (ambientCount >= MAX_AMBIENT_LABELS) break; + if (candidate.fire && candidate.fire === layerState._selectedFire) + continue; + // Spend the bounded cohort only on the visible hemisphere. At global + // LOD, far-side cells can still project on-canvas; accepting those first + // starves front-side cards before the host applies its authoritative cull. + if (beyondHorizon(candidate.cullPosition || candidate.position)) continue; + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + candidate.position, + layerState.scratchWindowCoord, + ); + if (!screen) continue; // projection failed (e.g. behind camera) — drop silently + if ( + screen.x < -LABEL_VIEW_MARGIN_PX || + screen.x > width + LABEL_VIEW_MARGIN_PX || + screen.y < -LABEL_VIEW_MARGIN_PX || + screen.y > height + LABEL_VIEW_MARGIN_PX + ) + continue; + if (!components.model.screenSeparated(accepted, screen)) continue; + accepted.push({ x: screen.x, y: screen.y }); + ambientCount += 1; + const card = candidate.fire + ? components.model.buildFireCard(candidate, now) + : components.model.buildCellCard(candidate, now); + if (candidate.fire) layerState._fireByCardId.set(card.id, candidate.fire); + entries.push(card); + } + + overlayHost.setEntries( + FIRMS_OVERLAY_SOURCE_ID, + entries.map((entry) => { + const card = components.model.applyFirmsOverlayPolicy( + entry, + layerState._labelLodDistance, + ); + if (!card.interactive) return card; + return { + ...card, + accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`, + activate: () => { + const fire = layerState._fireByCardId.get(card.id); + if (!fire) return false; + components.selection.selectAndFocusFire(fire); + return true; + }, + }; + }), + { + cohortLimit: FIRMS_AMBIENT_COHORT_LIMIT, + collisionCapacity: FIRMS_AMBIENT_COHORT_LIMIT, + moving: false, + }, + ); + } + return { rebuildAmbientLabels }; +} diff --git a/src/layers/firms/index.js b/src/layers/firms/index.js new file mode 100644 index 000000000..080755872 --- /dev/null +++ b/src/layers/firms/index.js @@ -0,0 +1,42 @@ +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createRendering } from './rendering.js'; +import { createCards } from './cards.js'; +import { createSelection } from './selection.js'; +import { createViewport } from './viewport.js'; +import { createLifecycle } from './lifecycle.js'; +import { createQueries } from './queries.js'; +import { createFirmsState } from './state.js'; + +export function createFirmsHelpers({ services }) { + const layerState = createFirmsState({ services, config: {} }); + return createModel({ layerState, services, config: {}, components: {} }); +} + +/** Compose one fire layer with explicit source and scene operations. */ +export function createFirmsHeatmapLayer({ services, feed, ...config }) { + if (typeof feed?.getSnapshot !== 'function') + throw new TypeError('Fires require a snapshot source'); + const layerState = createFirmsState({ services, config }); + const components = {}; + const context = { layerState, services, config, components, feed }; + components.model = createModel(context); + components.ingestion = createIngestion(context); + components.rendering = createRendering(context); + components.cards = createCards(context); + components.selection = createSelection(context); + components.viewport = createViewport(context); + components.lifecycle = createLifecycle(context); + components.queries = createQueries(context); + return Object.assign( + {}, + components.queries.methods, + components.lifecycle.methods, + components.ingestion.methods, + ); +} + +export { createFirmsSource } from './source.js'; +export { createFireAnchors, FIRE_ANCHOR_LIFT_M } from './anchors.js'; +export * from '../../data/firmsAdapt.js'; +export * from '../../data/firmsLabels.js'; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js new file mode 100644 index 000000000..e363fe72b --- /dev/null +++ b/src/layers/firms/ingestion.js @@ -0,0 +1,111 @@ +import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; +import { fireDetectionKey } from '../../data/firmsLabels.js'; + +export function createIngestion({ + layerState, + services, + components, + config, + feed, +}) { + const { clearSelectedEntityContextForLayer } = services.context; + const { id } = config; + + /** Replace a validated snapshot while preserving source freshness and selection identity. */ + + async function loadHeatmap() { + if (!layerState._dataSource || !layerState._enabled) return; + layerState.request?.abort(); + const request = new AbortController(); + layerState.request = request; + layerState._loading = true; + + try { + const payload = await feed.getSnapshot({ signal: request.signal }); + if ( + request.signal.aborted || + layerState.request !== request || + !layerState._enabled + ) + return; + if (payload.keyRequired) { + layerState._keyRequired = true; + layerState._error = null; + layerState._stale = false; + return; + } + layerState._keyRequired = false; + layerState._error = null; + layerState._stale = Boolean(payload?.stale); + const previousSelection = layerState._selectedFire; + layerState._selectedFire = null; + layerState._fires = adaptFirmsRecords(payload?.fires); + layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells + layerState._firesByFrp = [...layerState._fires].sort( + (a, b) => b.frp - a.frp, + ); + layerState._count = layerState._fires.length; + // Data age, not response age: a stale proxy payload truthfully reads old. + layerState._lastUpdate = Number.isFinite(payload?.fetchedAt) + ? payload.fetchedAt + : Date.now(); + // Settle the previous selection BEFORE the LOD rebuild. renderCurrentLod + // runs refreshContextRegistrations(), which deletes every context record + // not in the new top-N — including the one the store still points at. + // Clearing after that deletion fails the ownership guard inside + // clearSelectedEntityContextForLayer and emits nothing at all, so an + // eviction-aware readout never hears that its subject is gone. + const reselected = findMatchingFire(previousSelection); + if (!reselected && previousSelection) { + // The selected detection is not in the new payload: it left the feed + // rather than being deselected. Eviction-aware readouts hold their + // last-known values for this instead of tearing down. + clearSelectedEntityContextForLayer(id, { evicted: true }); + } + components.rendering.renderCurrentLod(true); + if (reselected) components.selection.selectFire(reselected, false); + } catch (error) { + if ( + request.signal.aborted || + layerState.request !== request || + !layerState._enabled + ) + return; + console.warn(`[Data:${id}] FIRMS live load failed:`, error); + layerState._error = 'live feed unavailable'; + } finally { + if (layerState.request === request) { + layerState.request = null; + layerState._loading = false; + } + } + } + + /** + * Find the record in the freshly-loaded set matching a previous selection. + * Identity is the detection itself (lat/lon/acquisition time) — indices + * are regenerated every fetch, so object/index identity cannot be used. + * @param {?Object} previous - Previously selected fire record. + * @returns {?Object} Matching new record. + */ + + function findMatchingFire(previous) { + if (!previous) return null; + const key = fireDetectionKey(previous); + return ( + layerState._fires.find((fire) => fireDetectionKey(fire) === key) || null + ); + } + const methods = { + async update() { + if (layerState._destroyed || !layerState._enabled || layerState._loading) + return; + // Scheduled 10-minute poll (and the manager's immediate first update): + // refetch live fires through the proxy and re-render. Viewport-driven + // re-renders between polls are handled by the LOD watcher. + await loadHeatmap(); + }, + }; + + return { loadHeatmap, findMatchingFire, methods }; +} diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js new file mode 100644 index 000000000..7e0b42a00 --- /dev/null +++ b/src/layers/firms/lifecycle.js @@ -0,0 +1,119 @@ +import * as Cesium from 'cesium'; +import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; + +export function createLifecycle({ + layerState, + services, + components, + config, + feed, +}) { + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { restoreSpriteOrderOnEnable } = services.sprites; + const { clearSelectedEntityContextForLayer } = services.context; + const { id, overlayHost } = config; + + const methods = { + init(viewer) { + if (layerState._destroyed) return; + layerState._viewer = viewer; + layerState._dataSource = new Cesium.CustomDataSource(id); + layerState._dataSource.show = false; + viewer.dataSources.add(layerState._dataSource); + }, + + async enable(viewer) { + if (layerState._destroyed) return; + layerState._enabled = true; + layerState._viewer = viewer; + if (!layerState._dataSource) this.init(viewer); + if (layerState._dataSource) layerState._dataSource.show = true; + if (layerState._billboards) { + // The camera can have moved anywhere while the layer was off: moveEnd + // was not being listened to, and the preRender watcher is inert while + // disabled AND while the (untimed) refetch below is in flight. So the + // retained per-sprite show flags describe the OLD viewpoint. Re-cull + // BEFORE the collection becomes visible — otherwise re-enabling at a + // new location flashes far-side fires through the planet (and keeps + // near-side ones hidden) until the fetch resolves. + components.rendering.refreshHorizonCulling(); + layerState._billboards.show = true; + } + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + components.viewport.installLodWatcher(); + components.viewport.installMoveEndWatcher(); + components.selection.installClickHandler(); + registerPickOwner(id, (pickedId) => + layerState._pickIndexById.has(pickedId), + ); + if (!layerState._fires.length && !layerState._loading) + await components.ingestion.loadHeatmap(); + if ( + layerState._enabled && + !layerState._destroyed && + layerState._viewer === viewer + ) + restoreSpriteOrderOnEnable('firms', viewer); + }, + + disable() { + layerState.request?.abort(); + layerState.request = null; + layerState._loading = false; + layerState._enabled = false; + components.selection.clearFireSelection(); + if (layerState._dataSource) layerState._dataSource.show = false; + if (layerState._billboards) layerState._billboards.show = false; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + clearSelectedEntityContextForLayer(id); + components.selection.removeClickHandler(); + components.viewport.removeMoveEndWatcher(); + unregisterPickOwner(id); + components.viewport.removeLodWatcher(); + }, + + destroy(viewer = layerState._viewer) { + layerState.request?.abort(); + layerState.request = null; + layerState._loading = false; + if (layerState._destroyed) return; + layerState._destroyed = true; + layerState._enabled = false; + components.viewport.removeLodWatcher(); + components.viewport.removeMoveEndWatcher(); + components.selection.removeClickHandler(); + unregisterPickOwner(id); + if (layerState._dataSource && viewer) { + viewer.dataSources.remove(layerState._dataSource, true); + } + layerState._dataSource = null; + components.rendering.removeDetectionCollections(viewer); + components.selection.clearContextRegistrations(); + clearSelectedEntityContextForLayer(id); + layerState._fires = []; + layerState._firesByFrp = []; + layerState._cellCacheByGrid.clear(); + layerState._count = 0; + layerState._cellCount = 0; + layerState._lastUpdate = null; + layerState._keyRequired = false; + layerState._stale = false; + layerState._error = null; + layerState._currentLodId = null; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + layerState._currentLodIndex = -1; + layerState._lastViewRect = null; + layerState._selectedFire = null; + layerState._labelCandidates = []; + layerState._labelLodDistance = 0; + layerState._pickIndexById.clear(); + layerState._fireByCardId.clear(); + layerState._cullPositions.length = 0; + layerState._camSnapValid = false; + }, + }; + + return { methods }; +} diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js new file mode 100644 index 000000000..563a2d735 --- /dev/null +++ b/src/layers/firms/model.js @@ -0,0 +1,532 @@ +import * as Cesium from 'cesium'; +import { + satelliteShortName, + fireDetectionKey, + accentForSeverity, +} from '../../data/firmsLabels.js'; +import { + VIEW_PADDING, + DETECTION_COLOR_STOPS, + CULL_LIFT_THRESHOLD_M, + CULL_LIFT_M, + LABEL_MIN_SEP_PX, +} from './policy.js'; + +export function createModel({ + layerState, + services, + components, + config, + feed, +}) { + const { fireAnchorHeight } = services.anchors; + + /** + * Map one internal fire record (firmsAdapt.js shape) to a plain JSON-safe + * analyst record (analyst query engine seam). Pure — no Cesium types. + * Missing/unknown fields are null, never NaN/undefined. The id reuses the + * layer's FIRE-##### pick-id convention (index keys pick ids and + * context-store ids, so the two stay consistent). + * @param {Object|null|undefined} fire - Internal fire record. + * @returns {{id: string, lat: number|null, lon: number|null, frp: number|null, + * confidence: number|null, satellite: string|null, acqTime: number|null}} + */ + + function mapAnalystRecord(fire) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + return { + id: `FIRE-${String(fire?.index ?? 0).padStart(5, '0')}`, + lat: num(fire?.lat), + lon: num(fire?.lon), + frp: num(fire?.frp), + confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence) + satellite: text(fire?.satellite) || text(fire?.sensor), + acqTime: + Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null + }; + } + + /** + * Expand a view rectangle by VIEW_PADDING per side and convert to degree + * bounds with an explicit anti-meridian wrap flag. Returns null for + * near-global views where clipping would be pointless. + * @param {Cesium.Rectangle} rect - Camera view rectangle (radians). + * @returns {?{west: number, south: number, east: number, north: number, wraps: boolean}} + */ + + function paddedDegreeBounds(rect) { + const width = Cesium.Rectangle.computeWidth(rect); + const height = Cesium.Rectangle.computeHeight(rect); + if (width >= Math.PI * 1.85) return null; + const padLon = width * VIEW_PADDING; + const padLat = height * VIEW_PADDING; + const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat); + const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat); + let west = rect.west - padLon; + let east = rect.east + padLon; + let wraps; + if (width + padLon * 2 >= Cesium.Math.TWO_PI) { + west = -Math.PI; + east = Math.PI; + wraps = false; + } else { + west = Cesium.Math.negativePiToPi(west); + east = Cesium.Math.negativePiToPi(east); + wraps = west > east; + } + return { + west: Cesium.Math.toDegrees(west), + south: Cesium.Math.toDegrees(south), + east: Cesium.Math.toDegrees(east), + north: Cesium.Math.toDegrees(north), + wraps, + }; + } + + /** Point-in-bounds test in degrees, anti-meridian aware. */ + + function boundsContainPoint(bounds, lat, lon) { + if (lat < bounds.south || lat > bounds.north) return false; + if (bounds.wraps) return lon >= bounds.west || lon <= bounds.east; + return lon >= bounds.west && lon <= bounds.east; + } + + /** Cell-rectangle/bounds intersection test in degrees, anti-meridian aware. */ + + function cellIntersectsBounds(cell, gridDegrees, bounds) { + if ( + cell.latCell + gridDegrees < bounds.south || + cell.latCell > bounds.north + ) + return false; + const west = cell.lonCell; + const east = cell.lonCell + gridDegrees; + if (bounds.wraps) return east >= bounds.west || west <= bounds.east; + return east >= bounds.west && west <= bounds.east; + } + + function heatScore(cell) { + return ( + cell.intensity + cell.count * 0.8 + cell.night * 0.6 + cell.maxFrp * 0.12 + ); + } + + function heatColor(value, alpha) { + if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); + if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); + return Cesium.Color.YELLOW.withAlpha(alpha); + } + + /** + * Pick a sprite color stop from FRP + confidence, reusing the same + * yellow → orange → red thresholds as the aggregated heat cells. + * @param {Object} fire - Detection record. + * @returns {{name: string, color: Cesium.Color}} + */ + + function detectionColorStop(fire) { + const heat = Math.min( + 1, + Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, + ); + if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; + if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; + return DETECTION_COLOR_STOPS[2]; + } + + /** FRP → core marker pixel size, clamped to 8..28px. */ + + function frpPixelSize(frp) { + return Math.max( + 8, + Math.min(28, Math.round(8 + Math.sqrt(Math.max(0, frp)) * 2)), + ); + } + + /** Quantize a core size to a 2px bucket so the sprite cache stays tiny. */ + + function sizeBucket(coreSize) { + return Math.max(8, Math.min(28, Math.round(coreSize / 2) * 2)); + } + + /** + * Build (and cache) a radial-glow sprite: hot near-white center fading + * through the stop color to a transparent edge. The glow lives in the + * sprite itself because global bloom defaults OFF. + * @param {{name: string, color: Cesium.Color}} stop - Color stop. + * @param {number} corePx - Bucketed core size in pixels. + * @returns {string} PNG data URL. + */ + + function glowSprite(stop, corePx) { + const key = `${stop.name}:${corePx}`; + const cached = layerState.glowSpriteCache.get(key); + if (cached) return cached; + + const dimension = corePx * 2; + const canvas = document.createElement('canvas'); + canvas.width = dimension; + canvas.height = dimension; + const context = canvas.getContext('2d'); + if (!context) return ''; + const radius = dimension / 2; + const rgb = [ + Math.round(stop.color.red * 255), + Math.round(stop.color.green * 255), + Math.round(stop.color.blue * 255), + ].join(','); + const gradient = context.createRadialGradient( + radius, + radius, + 0, + radius, + radius, + radius, + ); + gradient.addColorStop(0, 'rgba(255,255,235,0.95)'); + gradient.addColorStop(0.25, `rgba(${rgb},0.9)`); + gradient.addColorStop(0.55, `rgba(${rgb},0.35)`); + gradient.addColorStop(1, `rgba(${rgb},0)`); + context.fillStyle = gradient; + context.fillRect(0, 0, dimension, dimension); + + const dataUrl = canvas.toDataURL('image/png'); + layerState.glowSpriteCache.set(key, dataUrl); + return dataUrl; + } + + /** + * Lazily-cached Cartesian3 anchor at the shared ground floor (DEM/mesh cell + * + lift) when the floor is warm, else ellipsoid height 0 (owner field + * finding 2026-07-21: height-0 anchors read as buried under high terrain at + * close/oblique zoom). Warm-cache read only — floors are warmed in batch by + * renderDetections for the close band; everything else (cell-band context + * registrations, detectable objects) just rides whatever is already warm. + * Fires are static and floor cells latch, so each detection re-allocates at + * most once per floor state (cold 0 → warm DEM). Rendering stays depth-test + * free either way (contacts are ALWAYS visible — never below the surface, + * slightly above is fine). + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function firePosition(fire) { + const height = fireAnchorHeight(fire.lat, fire.lon); + if (!fire.position || fire.positionHeight !== height) { + fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height); + fire.positionHeight = height; + } + return fire.position; + } + + /** + * Occlusion-test anchor for one detection. Returns the render position + * outright for anchors comfortably above the ellipsoid (the common case) and + * a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This + * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} + * is what the sprite and the card are drawn at. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireCullPosition(fire) { + const position = firePosition(fire); + if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; + if (!fire.cullPosition) { + fire.cullPosition = Cesium.Cartesian3.fromDegrees( + fire.lon, + fire.lat, + CULL_LIFT_M, + ); + } + return fire.cullPosition; + } + + /** + * Occlusion-test anchor for an aggregated heat cell. Cell centers are built at + * height 0 (exactly on the ellipsoid), which the occluder treats as a limb + * boundary case, so they get the same lift as sub-ellipsoid fire anchors. + * @param {number} lon - Cell center longitude in degrees. + * @param {number} lat - Cell center latitude in degrees. + * @returns {Cesium.Cartesian3} + */ + + function cellCullPosition(lon, lat) { + return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M); + } + + /** + * Horizon-cull a billboard collection against an ellipsoid occluder. + * + * The Cesium globe is hidden in this app (Google 3D Tiles render the planet), + * so nothing writes far-side depth — and these sprites are additionally + * always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never + * swallowed by terrain it sits on). Without an explicit occluder pass, fires on + * the opposite side of the Earth shine through the planet at tilted mid-altitude + * views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes. + * + * Pure and allocation-free: reads `billboard.position`, writes `billboard.show` + * only when it actually flips (assigning `show` dirties the collection's vertex + * buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without + * a WebGL scene. + * + * @param {?{length: number, get: function(number): (Object|undefined)}} billboards + * Billboard collection (or collection-shaped stub). + * @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder. + * @param {?Array} [cullPositions=null] Index-aligned lifted + * occlusion-test anchors (see {@link fireCullPosition}); falls back to the + * billboard's own render position wherever an entry is absent. + * @returns {number} Count of billboards left visible (0 when nothing to test). + */ + + function applyHorizonCull(billboards, occluder, cullPositions = null) { + if (!billboards || typeof billboards.get !== 'function') return 0; + if (typeof occluder?.isPointVisible !== 'function') return 0; + const total = Number(billboards.length) || 0; + let visibleCount = 0; + for (let i = 0; i < total; i += 1) { + const billboard = billboards.get(i); + if (!billboard) continue; + const point = (cullPositions && cullPositions[i]) || billboard.position; + const visible = occluder.isPointVisible(point) === true; + if (billboard.show !== visible) billboard.show = visible; + if (visible) visibleCount += 1; + } + return visibleCount; + } + + /** + * True when `screen` is at least LABEL_MIN_SEP_PX away from every accepted + * screen position (greedy declutter accept test). + * @param {Array<{x: number, y: number}>} accepted - Accepted label positions. + * @param {Cesium.Cartesian2} screen - Candidate window coordinates. + * @returns {boolean} + */ + + function screenSeparated(accepted, screen) { + const minSq = LABEL_MIN_SEP_PX * LABEL_MIN_SEP_PX; + 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; + } + + /** + * Card model for a click-selected fire — the full-detail card, drawn last + * (on top) and never distance-faded by the overlay. + * Exported for unit tests. + * @param {Object} fire - Detection record. + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildSelectedFireCard(fire, nowMs) { + const meta = [`${confidenceBucket(fire.confidence)} conf`]; + if (fire.acqMs > 0) { + const age = formatAge(nowMs - fire.acqMs); + if (age) meta.push(`${age} ago`); + } + const sat = satelliteShortName(fire.satellite); + meta.push( + sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a', + ); + return { + id: `selected-fire:${fireDetectionKey(fire)}`, + actionable: true, + position: firePosition(fire), + // Host-side horizon test uses this instead of the render anchor. + cullPosition: fireCullPosition(fire), + gapPx: frpPixelSize(fire.frp), + accent: accentForSeverity(detectionColorStop(fire).name), + title: `FIRE · ${formatFrp(fire.frp)} MW`, + details: [ + meta.join(' · '), + formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''), + ], + selected: true, + priority: Number.MAX_SAFE_INTEGER, + }; + } + + /** + * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail + * "high · 14h · N20". Ages are computed against each detection's acquisition + * time — with the live feed everything reads under 24 h (and a stale cache + * reads truthfully old). Exported for unit tests. + * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildFireCard(candidate, nowMs) { + const fire = candidate.fire; + const meta = [confidenceBucket(fire.confidence)]; + if (fire.acqMs > 0) { + const age = formatAge(nowMs - fire.acqMs); + if (age) meta.push(age); + } + const sat = satelliteShortName(fire.satellite) || fire.sensor; + if (sat) meta.push(sat); + return { + id: `fire:${fireDetectionKey(fire)}`, + actionable: true, + position: candidate.position, + cullPosition: candidate.cullPosition || candidate.position, + gapPx: frpPixelSize(fire.frp), + accent: accentForSeverity(detectionColorStop(fire).name), + title: `▲ ${formatFrp(fire.frp)} MW`, + details: [meta.join(' · ')], + selected: false, + priority: Number(fire.frp) || 0, + }; + } + + /** + * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail + * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized + * score is only known at renderCells time). Exported for unit tests. + * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate + * @param {number} nowMs - Current epoch milliseconds. + * @returns {Object} firmsLabels entry. + */ + + function buildCellCard(candidate, nowMs) { + const cell = candidate.cell; + const noun = cell.count === 1 ? 'FIRE' : 'FIRES'; + const parts = [`max ${formatFrp(cell.maxFrp)} MW`]; + if (cell.newestAcqMs > 0) { + const age = formatAge(nowMs - cell.newestAcqMs); + if (age) parts.push(`new ${age}`); + } + return { + id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`, + position: candidate.position, + cullPosition: candidate.cullPosition || candidate.position, + gapPx: 10, + accent: candidate.accent || accentForSeverity('yellow'), + title: `${cell.count} ${noun}`, + details: [parts.join(' · ')], + selected: false, + priority: Number(cell.maxFrp) || 0, + }; + } + + /** + * Add the host-owned layout/fade/collision fields to a source-formatted FIRMS + * card. Selected fires share the collision domain so their protected rect + * excludes ambient cards, but bypass both the 18-card cohort and distance fade. + * @param {Object} card Source-formatted card. + * @param {number} fadeDistance Current LOD fade distance in metres. + * @returns {Object} + */ + + function applyFirmsOverlayPolicy(card, fadeDistance) { + const selected = card?.selected === true; + const rawGap = Number(card?.gapPx) || 10; + const gapPx = Math.max(12, rawGap + 8); + return { + ...card, + variant: selected ? 'selected' : 'card', + protected: selected, + collisionGroup: 'ambient-card', + cardStyle: 'tactical', + gapPx, + leaderOffsetPx: Math.max(2, gapPx - 6), + verticalOnly: true, + viewportMargin: 4, + maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance, + distanceFadeStartRatio: 0.7, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + // Aggregate cells omit actionable because they do not identify one fire. + interactive: card?.actionable === true, + }; + } + + /** + * Heat-normalized cell score → severity accent, mirroring heatColor's + * red/orange/yellow thresholds. + * @param {number} normalized - 0..1 normalized heat score. + * @returns {string} "r, g, b" accent string. + */ + + function cellAccent(normalized) { + if (normalized > 0.72) return accentForSeverity('red'); + if (normalized > 0.42) return accentForSeverity('orange'); + return accentForSeverity('yellow'); + } + + /** Coordinate line like "30.512°N 75.831°E". */ + + function formatLatLon(lat, lon) { + const latPart = `${Math.abs(lat).toFixed(3)}°${lat >= 0 ? 'N' : 'S'}`; + const lonPart = `${Math.abs(lon).toFixed(3)}°${lon >= 0 ? 'E' : 'W'}`; + return `${latPart} ${lonPart}`; + } + + function formatFrp(frp) { + return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1); + } + + /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ + + function formatAge(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; + const hours = deltaMs / 3600000; + if (hours < 1) return '<1h'; + if (hours < 48) return `${Math.round(hours)}h`; + return `${Math.round(hours / 24)}d`; + } + + /** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */ + + function formatAgoMinutes(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now'; + const minutes = Math.floor(deltaMs / 60000); + if (minutes < 1) return '<1m ago'; + if (minutes < 90) return `${minutes}m ago`; + return `${Math.round(minutes / 60)}h ago`; + } + + /** Normalized 0..1 confidence → low/nominal/high display bucket. */ + + function confidenceBucket(confidence) { + if (confidence >= 0.75) return 'high'; + if (confidence >= 0.45) return 'nominal'; + return 'low'; + } + return { + mapAnalystRecord, + paddedDegreeBounds, + boundsContainPoint, + cellIntersectsBounds, + heatScore, + heatColor, + detectionColorStop, + frpPixelSize, + sizeBucket, + glowSprite, + firePosition, + fireCullPosition, + cellCullPosition, + applyHorizonCull, + screenSeparated, + buildSelectedFireCard, + buildFireCard, + buildCellCard, + applyFirmsOverlayPolicy, + cellAccent, + formatLatLon, + formatFrp, + formatAge, + formatAgoMinutes, + confidenceBucket, + }; +} diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js new file mode 100644 index 000000000..673aab871 --- /dev/null +++ b/src/layers/firms/policy.js @@ -0,0 +1,97 @@ +import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; +import * as Cesium from 'cesium'; + +/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ + +export const REFRESH_INTERVAL_MS = 600_000; + +/** + * LOD bands keyed by camera height. `cells` bands render aggregated + * ground-clamped heat rectangles; `detections` bands (< ~750km plus the + * local band) render individual fire detections as glow-sprite billboards. + * Labels in every band go through the shared screen-space greedy declutter + * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. + */ + +export const LOD_LEVELS = [ + { + id: 'global', + minHeight: 9000000, + mode: 'cells', + gridDegrees: 2.0, + maxCells: 1800, + labelDistance: 12000000, + }, + { + id: 'regional', + minHeight: 3000000, + mode: 'cells', + gridDegrees: 1.0, + maxCells: 3600, + labelDistance: 8500000, + }, + { + id: 'local', + minHeight: 750000, + mode: 'detections', + maxDetections: 2500, + labelDistance: 4500000, + }, + { + id: 'close', + minHeight: 0, + mode: 'detections', + maxDetections: 3000, + labelDistance: 1800000, + }, +]; + +export const LOD_CHECK_MS = 650; + +/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ + +export const LOD_HYSTERESIS = 0.1; + +/** Padding fraction applied to the camera view rectangle before clipping. */ + +export const VIEW_PADDING = 0.3; + +/** Number of top-FRP detections registered in the shared context store. */ + +export const CONTEXT_TOP_N = 50; + +/** Hard cap on ambient (non-selected) labels, regardless of zoom/density. */ + +export const MAX_AMBIENT_LABELS = FIRMS_AMBIENT_COHORT_LIMIT; + +/** Min screen-space separation between accepted labels, in CSS pixels. */ + +export const LABEL_MIN_SEP_PX = 150; + +/** Candidates projecting outside the canvas by more than this are skipped. */ + +export const LABEL_VIEW_MARGIN_PX = 16; + +/** + * Anchors below this height get a separate LIFTED point for occlusion tests + * (never for rendering). Mirrors the flights layer's `cullPosition` idiom + * (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor + * + lift, and in negative-geoid coastal regions that lands a few tens of + * metres BELOW the WGS84 ellipsoid — EllipsoidalOccluder then judges such a + * point "beyond the horizon" while its true-surface neighbours stay visible, + * so near-limb fires would blink out for a datum reason. + */ + +export const CULL_LIFT_THRESHOLD_M = 10; + +/** Height of the lifted occlusion-test point (flights uses the same 12 m). */ + +export const CULL_LIFT_M = 12; + +/** Color stops shared by cell heat fills and detection glow sprites. */ + +export const DETECTION_COLOR_STOPS = [ + { name: 'red', color: Cesium.Color.RED }, + { name: 'orange', color: Cesium.Color.ORANGE }, + { name: 'yellow', color: Cesium.Color.YELLOW }, +]; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js new file mode 100644 index 000000000..d1dcb3f5e --- /dev/null +++ b/src/layers/firms/queries.js @@ -0,0 +1,174 @@ +import { fireDetectionKey } from '../../data/firmsLabels.js'; +import { REFRESH_INTERVAL_MS } from './policy.js'; + +export function createQueries({ + layerState, + services, + components, + config, + feed, +}) { + const { id, name, icon, source } = config; + + const methods = { + /** Return the selected detection as a plain record for application consumers. */ + getSelectedInfo() { + const fire = layerState._selectedFire; + if (!fire) return null; + return { + id: fireDetectionKey(fire), + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, + latitude: fire.lat, + longitude: fire.lon, + frp: fire.frp, + confidencePct: Number.isFinite(fire.confidence) + ? fire.confidence * 100 + : null, + observedAt: + Number.isFinite(fire.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, + source: fire.sensor || fire.satellite || null, + }; + }, + + id, + + name, + + icon, + + source, + + // Live layer: the manager calls update() every 10 minutes while enabled, + // which refetches through the /api/firms proxy (the proxy's 30 min TTL — + // not this interval — is what protects the upstream FIRMS quota). + updateInterval: REFRESH_INTERVAL_MS, + + /** + * Layer stats for the data panel. Degraded feed states surface through + * `error` (established qa-failstate pattern: a dead feed must never look + * like a healthy empty layer) with a matching human `loadingLabel`: + * 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy + * served past-TTL cache, 'KEY REQUIRED' keyless. + */ + getStats() { + const now = Date.now(); + const staleText = layerState._lastUpdate + ? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}` + : 'STALE'; + let loadingLabel = ''; + if (layerState._loading) { + loadingLabel = layerState._fires.length + ? 'refreshing...' + : 'loading...'; + } else if (layerState._keyRequired) { + loadingLabel = 'KEY REQUIRED'; + } else if (layerState._stale) { + loadingLabel = staleText; + } else if (layerState._error) { + loadingLabel = layerState._error; + } else if (layerState._lastUpdate) { + loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`; + } + return { + count: layerState._count, + cells: layerState._cellCount, + lastUpdate: layerState._lastUpdate, + loading: layerState._loading, + stale: layerState._stale, + error: layerState._keyRequired + ? 'KEY REQUIRED' + : layerState._stale + ? staleText + : layerState._error, + loadingLabel, + }; + }, + + /** + * Strongest currently-loaded detection (by FRP) for voice targeting + * ("take me to the biggest fire"). + * @returns {{latitude: number, longitude: number, frp: number, label: string}|null} + */ + getStrongestFire() { + const strongest = layerState._firesByFrp.length + ? layerState._firesByFrp[0] + : null; + if (!strongest) return null; + return { + latitude: strongest.lat, + longitude: strongest.lon, + frp: strongest.frp, + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, + }; + }, + + /** + * Detection-overlay seam, shaped like the other layers' detectable + * objects (traffic/flights). NOT yet registered in initDetection's layer + * list — wiring fires through the detection/label-arbiter pipeline is a + * deliberate post-PR#1 task; when that happens the arbiter replaces this + * layer's greedy declutter as the SELECTOR and the shared overlay remains + * the renderer. Walks the FRP-sorted index so the strongest fires come first. + * @param {{maxCount?: number}} [options] + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || !layerState._firesByFrp.length) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : 250; + const result = []; + for (const fire of layerState._firesByFrp) { + result.push({ + position: components.model.firePosition(fire), + id: `FIRE-${String(fire.index).padStart(5, '0')}`, + type: 'FIRE', + }); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory fire records as plain JSON-safe + * objects for the analyst query engine. Walks the FRP-sorted index so + * truncation keeps the STRONGEST fires (200k+ detections can be live — + * the cap is load-bearing, not cosmetic). 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 (!layerState._enabled || !layerState._firesByFrp.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const fire of layerState._firesByFrp) { + result.push(components.model.mapAnalystRecord(fire)); + if (result.length >= limit) break; + } + return result; + }, + + /** Test seam that binds the production click path and indexes. */ + _bindInteractionForTest(viewer, fires = []) { + layerState._viewer = viewer; + layerState._enabled = true; + layerState._fires = fires; + layerState._firesByFrp = [...fires]; + layerState._pickIndexById.clear(); + layerState._labelLodDistance = 1e7; + layerState._labelCandidates = fires.map((fire) => { + layerState._pickIndexById.set(`firms-${fire.index}`, fire); + return { fire, position: components.model.firePosition(fire) }; + }); + components.cards.rebuildAmbientLabels(); + components.selection.installClickHandler(); + return layerState._clickHandler; + }, + }; + + return { methods }; +} diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js new file mode 100644 index 000000000..8ae445f91 --- /dev/null +++ b/src/layers/firms/rendering.js @@ -0,0 +1,355 @@ +import * as Cesium from 'cesium'; +import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; + +export function createRendering({ + layerState, + services, + components, + config, + feed, +}) { + const { warmFireAnchorFloors } = services.anchors; + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { overlayHost } = config; + + /** + * Rebuild the render for the current LOD band and viewport. Skips work + * when neither the (hysteresis-damped) LOD band nor the padded view + * rectangle changed meaningfully. + * @param {boolean} force - Rebuild even if LOD/view appear unchanged. + * @returns {boolean} True when a rebuild actually ran. Callers use this to + * avoid a second horizon walk in the same tick: a rebuild already culls + * its freshly-added sprites, an early-out does not. + */ + + function renderCurrentLod(force = false) { + if ( + !layerState._dataSource || + !layerState._fires.length || + !layerState._viewer + ) + return false; + const lodIndex = components.viewport.selectLodIndex( + components.viewport.cameraHeight(), + ); + const lod = LOD_LEVELS[lodIndex]; + const viewRect = components.viewport.computeViewRect(); + if ( + !force && + lodIndex === layerState._currentLodIndex && + !components.viewport.viewChangedEnough(viewRect) + ) + return false; + layerState._currentLodIndex = lodIndex; + layerState._currentLodId = lod.id; + layerState._lastViewRect = viewRect + ? Cesium.Rectangle.clone(viewRect) + : null; + const bounds = viewRect + ? components.model.paddedDegreeBounds(viewRect) + : null; + + if (lod.mode === 'detections') { + renderDetections(lod, bounds); + } else { + renderCells(aggregateFires(lod, bounds), lod, bounds); + } + return true; + } + + /** + * Bin all detections into grid cells, then viewport-clip BEFORE the + * top-N cap so fires in view never lose their slot to higher-scoring + * cells on other continents. `bounds === null` (sky/horizon or + * near-global view) falls back to the global top-N behavior. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {Array} Cells sorted by heat score, capped. + */ + + function aggregateFires(lod, bounds) { + const gridDegrees = lod.gridDegrees; + + let sorted = layerState._cellCacheByGrid.get(gridDegrees); + if (!sorted) { + const cells = new Map(); + for (const fire of layerState._fires) { + const latCell = Math.floor(fire.lat / gridDegrees) * gridDegrees; + const lonCell = Math.floor(fire.lon / gridDegrees) * gridDegrees; + const key = `${latCell.toFixed(3)}:${lonCell.toFixed(3)}`; + // confidence is normalized 0..1 — weight ×4 preserves the old 0..100×0.04 scale. + const intensity = Math.max( + 1, + fire.frp * 0.18 + fire.confidence * 4 + fire.brightness * 0.01, + ); + const existing = cells.get(key) || { + latCell, + lonCell, + count: 0, + intensity: 0, + maxFrp: 0, + night: 0, + newestAcqMs: 0, + }; + existing.count += 1; + existing.intensity += intensity; + existing.maxFrp = Math.max(existing.maxFrp, fire.frp); + existing.newestAcqMs = Math.max(existing.newestAcqMs, fire.acqMs); + if (fire.night) existing.night += 1; + cells.set(key, existing); + } + // Heat-sorted once; per-render clipping below preserves the order. + sorted = [...cells.values()].sort( + (a, b) => components.model.heatScore(b) - components.model.heatScore(a), + ); + layerState._cellCacheByGrid.set(gridDegrees, sorted); + } + + if (!bounds) return sorted.slice(0, lod.maxCells); + const clipped = []; + for (const cell of sorted) { + if (!components.model.cellIntersectsBounds(cell, gridDegrees, bounds)) + continue; + clipped.push(cell); + if (clipped.length >= lod.maxCells) break; + } + return clipped; + } + + /** + * Render aggregated heat cells (global/regional bands) as ground-clamped + * rectangles. Cell labels do NOT live on the entities anymore — they go + * through the same unclamped, screen-space-decluttered label pipeline as + * detections (see {@link rebuildAmbientLabels}). + * @param {Array} cells - Aggregated cells, heat-sorted descending. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderCells(cells, lod, bounds) { + ensureDetectionCollections(); + layerState._dataSource.entities.removeAll(); + if (layerState._billboards) layerState._billboards.removeAll(); + layerState._pickIndexById.clear(); + layerState._cullPositions.length = 0; + layerState._cellCount = cells.length; + const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + + layerState._labelCandidates = []; + layerState._labelLodDistance = lod.labelDistance; + + for (const cell of cells) { + const score = components.model.heatScore(cell); + const normalized = Math.min(1, Math.sqrt(score / maxScore)); + const alpha = 0.16 + normalized * 0.5; + const color = components.model.heatColor(normalized, alpha); + const centerLon = cell.lonCell + lod.gridDegrees / 2; + const centerLat = cell.latCell + lod.gridDegrees / 2; + const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); + + layerState._dataSource.entities.add({ + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees( + cell.lonCell, + cell.latCell, + cell.lonCell + lod.gridDegrees, + cell.latCell + lod.gridDegrees, + ), + material: new Cesium.ColorMaterialProperty(color), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + position, + properties: { + count: cell.count, + intensity: score, + maxFrp: cell.maxFrp, + night: cell.night, + }, + }); + + // Cells arrive heat-sorted, so candidate order doubles as label priority. + // Accent is resolved here because `normalized` only exists in this walk. + layerState._labelCandidates.push({ + position, + cullPosition: components.model.cellCullPosition(centerLon, centerLat), + fire: null, + cell, + accent: components.model.cellAccent(normalized), + }); + } + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + components.selection.topFiresWithinBounds(bounds), + ); + } + + /** + * Render individual detections (local/close bands) as pre-baked + * radial-glow sprite billboards sized by FRP, capped per viewport by + * highest FRP, depth test disabled. The 3D Tiles mesh is never sampled + * per point; instead, the CLOSE band (where terrain parallax is actually + * visible) batch-warms the shared cached DEM floor for its rendered + * subset and re-renders once floors land, so anchors sit on the terrain + * (firePosition). The local band keeps cold anchors at height 0 — at + * ≥750 km camera height the divergence is invisible and warming a + * continent-wide viewport would waste the DEM proxy. + * Labels are not built here; candidates feed {@link rebuildAmbientLabels}. + * @param {Object} lod - Active LOD descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderDetections(lod, bounds) { + ensureDetectionCollections(); + layerState._dataSource.entities.removeAll(); + if (!layerState._billboards) return; + layerState._billboards.removeAll(); + layerState._pickIndexById.clear(); + layerState._cullPositions.length = 0; + + let candidates; + if (bounds) { + // Walk the pre-sorted FRP index and early-exit at the cap — the old + // collect-then-sort touched and sorted every in-view detection per + // rebuild (tens of thousands mid-zoom; field-test round 1 chug). + candidates = []; + for (const fire of layerState._firesByFrp) { + if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon)) + continue; + candidates.push(fire); + if (candidates.length >= lod.maxDetections) break; + } + } else { + // Sky/horizon view: fall back to the globally strongest detections. + candidates = layerState._firesByFrp.slice(0, lod.maxDetections); + } + + layerState._cellCount = candidates.length; + layerState._labelCandidates = []; + layerState._labelLodDistance = lod.labelDistance; + + for (const fire of candidates) { + const coreSize = components.model.frpPixelSize(fire.frp); + const position = components.model.firePosition(fire); + const cullPosition = components.model.fireCullPosition(fire); + const pickId = `firms-${fire.index}`; + layerState._pickIndexById.set(pickId, fire); + layerState._cullPositions.push(cullPosition); + layerState._billboards.add({ + id: pickId, + position, + image: components.model.glowSprite( + components.model.detectionColorStop(fire), + components.model.sizeBucket(coreSize), + ), + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + // FRP-sorted walk: candidate order doubles as label priority. + layerState._labelCandidates.push({ + position, + cullPosition, + fire, + cell: null, + }); + } + + // Freshly-added sprites default to show=true; cull the far side now so a + // rebuild never flashes through-the-planet detections while the camera + // sits still (the moveEnd/preRender hooks only fire on camera motion). + refreshHorizonCulling(); + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + function ensureDetectionCollections() { + if (!layerState._viewer || layerState._billboards) return; + layerState._billboards = new Cesium.BillboardCollection({ + scene: layerState._viewer.scene, + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + layerState._billboards.show = layerState._enabled; + layerState._viewer.scene.primitives.add(layerState._billboards); + registerSpriteCollection('firms', layerState._billboards); + restoreSpriteOrder(layerState._viewer); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled); + } + + function removeDetectionCollections(viewer) { + const scene = viewer?.scene || layerState._viewer?.scene; + if (scene && !scene.isDestroyed?.()) { + try { + if (layerState._billboards) + scene.primitives.remove(layerState._billboards); + } catch { + /* already torn down */ + } + } + layerState._billboards = null; + overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); + } + + /** + * Shared horizon occluder positioned at the current camera, or null when + * there is no usable camera yet (init order / headless stubs). + * `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium; + * both are probed so a partial stub (either shape) still gets a real + * occluder rather than silently degrading to "nothing is ever occluded". + * @returns {?Cesium.EllipsoidalOccluder} + */ + + function fireHorizonOccluder() { + const camera = layerState._viewer?.camera?.positionWC + ? layerState._viewer.camera + : layerState._viewer?.scene?.camera; + if (!camera?.positionWC) return null; + return horizonOccluder(camera); + } + + /** + * Hide detection sprites that sit beyond the ellipsoid horizon (see + * {@link applyHorizonCull}). Runs on the cadences that already exist in this + * layer — the throttled LOD preRender watcher (camera-moved only), camera + * moveEnd, and each detection rebuild — never as a new per-frame pass. + * No-ops in the `cells` bands, where the collection is empty. + */ + + function refreshHorizonCulling() { + if (!layerState._billboards || !layerState._billboards.length) return; + components.model.applyHorizonCull( + layerState._billboards, + fireHorizonOccluder(), + layerState._cullPositions, + ); + } + return { + renderCurrentLod, + aggregateFires, + renderCells, + renderDetections, + ensureDetectionCollections, + removeDetectionCollections, + fireHorizonOccluder, + refreshHorizonCulling, + }; +} diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js new file mode 100644 index 000000000..4cec52c69 --- /dev/null +++ b/src/layers/firms/selection.js @@ -0,0 +1,251 @@ +import { + FIRMS_OVERLAY_SOURCE_ID, + fireDetectionKey, +} from '../../data/firmsLabels.js'; +import * as Cesium from 'cesium'; +import { CONTEXT_TOP_N } from './policy.js'; + +export function createSelection({ + layerState, + services, + components, + config, + feed, +}) { + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { requestWorldFocus } = services.focus; + const { + selectEntityContext, + clearSelectedEntityContextForLayer, + getContextStore, + registerEntityContext, + } = services.context; + const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config; + + /** + * Install the LEFT_CLICK handler for fire selection (enable-time only, + * mirroring the flight layers). Clicking a fire sprite selects it and + * surfaces it in the shared context store; clicking empty space clears + * the selection. Picks owned by sibling layers are left alone. + */ + + function installClickHandler() { + if (layerState._clickHandler || !layerState._viewer) return; + layerState._clickHandler = screenSpaceEventHandlerFactory( + layerState._viewer, + ); + layerState._clickHandler.setInputAction((click) => { + const picked = layerState._viewer.scene.pick(click.position); + const fire = pickedFire(picked); + if (fire) { + selectAndFocusFire(fire); + return; + } + // A pick that belongs to a sibling layer (e.g. an aircraft) is not + // "empty space" — leave the selection alone and let that layer handle it. + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer(id, pickedId)) return; + } + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { + sourceId: FIRMS_OVERLAY_SOURCE_ID, + }, + ); + if (cardHit) { + const carded = layerState._fireByCardId.get(cardHit.entryId); + if (carded) selectAndFocusFire(carded); + return; + } + clearFireSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + } + + /** Select one stable detection and request one UI-owned camera transfer. */ + + function selectAndFocusFire(fire) { + selectFire(fire); + requestWorldFocus({ + kind: 'fire', + id: fireDetectionKey(fire), + label: 'FIRE', + position: components.model.firePosition(fire), + }); + } + + /** + * Resolve a scene pick to one of this layer's fire records, or null. + * BillboardCollection picks surface our id either on picked.primitive.id + * or directly on picked.id depending on the CesiumJS pick path. + * @param {*} picked - Result of scene.pick(). + * @returns {?Object} Fire record. + */ + + function pickedFire(picked) { + if (!picked) return null; + const primitiveId = picked.primitive?.id; + if ( + typeof primitiveId === 'string' && + layerState._pickIndexById.has(primitiveId) + ) { + return layerState._pickIndexById.get(primitiveId); + } + if ( + typeof picked.id === 'string' && + layerState._pickIndexById.has(picked.id) + ) { + return layerState._pickIndexById.get(picked.id); + } + return null; + } + + /** + * Select a fire: show its detail label and mark it selected in the shared + * context store so voice "what's selected" resolves to it. + * @param {Object} fire - Detection record. + */ + + function selectFire(fire, publishSelection = true) { + layerState._selectedFire = fire; + try { + registerFireContext(fire); + layerState._contextIds.add(fireDetectionKey(fire)); + if (publishSelection) selectEntityContext(fire.contextEntity); + } catch { + // context store unavailable — the selection label still works + } + components.cards.rebuildAmbientLabels(); + } + + function clearFireSelection() { + if (!layerState._selectedFire) return; + layerState._selectedFire = null; + clearSelectedEntityContextForLayer(id); + components.cards.rebuildAmbientLabels(); + } + + /** + * Register the top detections of the current render set in the shared + * context store (voice "what's burning here") and drop stale entries + * from the previous rebuild. + * @param {Array} fires - FRP-ranked detections to register. + */ + + function refreshContextRegistrations(fires) { + let store = null; + try { + store = getContextStore(); + } catch { + return; + } + + const nextIds = new Set(); + for (const fire of fires) { + nextIds.add(registerFireContext(fire)); + } + // The click-selected fire must stay queryable ("what's selected") even + // when it falls outside the top-N context slice. + if (layerState._selectedFire) { + nextIds.add(registerFireContext(layerState._selectedFire)); + } + + for (const staleId of layerState._contextIds) { + if (!nextIds.has(staleId)) store.entities.delete(staleId); + } + layerState._contextIds = nextIds; + } + + /** + * Register one fire in the shared context store (idempotent) and return + * its record id. + * @param {Object} fire - Detection record. + * @returns {string} Context record id. + */ + + function registerFireContext(fire) { + const recordId = fireDetectionKey(fire); + if (!fire.contextEntity) { + fire.contextEntity = { + show: true, + }; + } + // Refreshed every registration: the anchor re-grounds onto the DEM when + // its floor cell warms, and voice targeting must project the same point + // the sprite renders at. + fire.contextEntity.__localBaseCartesian = + components.model.firePosition(fire); + registerEntityContext(fire.contextEntity, { + id: recordId, + layerId: id, + layerName: name, + source: 'NASA FIRMS', + dataSource: layerState._dataSource, + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, + latitude: fire.lat, + longitude: fire.lon, + properties: { + frp: fire.frp, + confidence: components.model.confidenceBucket(fire.confidence), + age: + fire.acqMs > 0 + ? components.model.formatAge(Date.now() - fire.acqMs) + : 'unknown', + sensor: fire.sensor || 'unknown', + }, + }); + return recordId; + } + + function clearContextRegistrations() { + if (!layerState._contextIds.size) return; + try { + const store = getContextStore(); + for (const recordId of layerState._contextIds) + store.entities.delete(recordId); + } catch { + // store unavailable — nothing to clean + } + layerState._contextIds = new Set(); + } + + /** + * Top-FRP detections within the given bounds (or globally when bounds is + * null). Walks the pre-sorted FRP index so it stays a single cheap pass. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @param {number} limit - Max detections returned. + * @returns {Array} + */ + + function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) { + if (!bounds) return layerState._firesByFrp.slice(0, limit); + const top = []; + for (const fire of layerState._firesByFrp) { + if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon)) + continue; + top.push(fire); + if (top.length >= limit) break; + } + return top; + } + return { + installClickHandler, + removeClickHandler, + selectAndFocusFire, + pickedFire, + selectFire, + clearFireSelection, + refreshContextRegistrations, + registerFireContext, + clearContextRegistrations, + topFiresWithinBounds, + }; +} diff --git a/src/layers/firms/source.js b/src/layers/firms/source.js new file mode 100644 index 000000000..9ee4ebc6c --- /dev/null +++ b/src/layers/firms/source.js @@ -0,0 +1,29 @@ +/** Construct the existing live-fire endpoint without making a request. */ +export function createFirmsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/firms', { + signal, + cache: 'no-store', + }); + let payload; + try { + payload = await response.json(); + } catch { + /* status below remains authoritative */ + } + signal?.throwIfAborted(); + if (!response.ok) { + if (response.status === 503 && payload?.error === 'no_key') + return { keyRequired: true }; + throw new Error(`FIRMS HTTP ${response.status}`); + } + if (!Array.isArray(payload?.fires)) + throw new Error('Malformed fire snapshot'); + return payload; + }, + }; +} diff --git a/src/layers/firms/source.test.mjs b/src/layers/firms/source.test.mjs new file mode 100644 index 000000000..f67bcdd9b --- /dev/null +++ b/src/layers/firms/source.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFirmsSource } from './source.js'; + +test('a malformed successful response is never accepted as an empty fire snapshot', async () => { + for (const payload of [{}, { fires: null }, { fires: {} }]) { + const source = createFirmsSource({ + fetchImpl: async () => ({ ok: true, json: async () => payload }), + }); + await assert.rejects(source.getSnapshot(), /Malformed fire snapshot/); + } +}); +test('optional-key guidance is distinct from denial or upstream failure', async () => { + for (const status of [401, 403, 429, 500, 503]) { + const source = createFirmsSource({ + fetchImpl: async () => ({ + ok: false, + status, + json: async () => ({ error: 'no_key' }), + }), + }); + if (status === 503) + assert.deepEqual(await source.getSnapshot(), { keyRequired: true }); + else + await assert.rejects( + source.getSnapshot(), + new RegExp(`FIRMS HTTP ${status}`), + ); + } +}); +test('response-body completion honors cancellation without replacing records', async () => { + const abort = new AbortController(); + const source = createFirmsSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + abort.abort(); + return { fires: [] }; + }, + }), + }); + await assert.rejects(source.getSnapshot({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js new file mode 100644 index 000000000..48ae11db2 --- /dev/null +++ b/src/layers/firms/state.js @@ -0,0 +1,115 @@ +import * as Cesium from 'cesium'; + +export function createFirmsState({ services, config }) { + const layerState = {}; + + /** Pre-baked radial-glow sprites keyed by `:`. */ + + layerState.glowSpriteCache = new Map(); + + layerState.scratchViewRect = new Cesium.Rectangle(); + + layerState.scratchCenterA = new Cesium.Cartographic(); + + layerState.scratchCenterB = new Cesium.Cartographic(); + + layerState.scratchWindowCoord = new Cesium.Cartesian2(); + + layerState._viewer = null; + + layerState._dataSource = null; + + layerState._billboards = null; + + layerState._enabled = false; + + layerState._destroyed = false; + + layerState._loading = false; + + /** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */ + + layerState._keyRequired = false; + + /** True when the proxy served a cached payload past TTL (upstream failing). */ + + layerState._stale = false; + + /** Surfaced error string when the live fetch failed outright. */ + + layerState._error = null; + + layerState._fires = []; + + layerState._firesByFrp = []; + + layerState._count = 0; + + layerState._cellCount = 0; + + layerState._lastUpdate = null; + + layerState._currentLodId = null; + + layerState._currentLodIndex = -1; + + layerState._lastViewRect = null; + + layerState._preRenderRemover = null; + + layerState._moveEndSettleRemover = null; + + layerState._lastLodCheck = 0; + + layerState._contextIds = new Set(); + + layerState._clickHandler = null; + + layerState._moveEndRemover = null; + + layerState._selectedFire = null; + + /** Priority-ordered label candidates from the last rebuild (detections or cells). */ + + layerState._labelCandidates = []; + + layerState._labelLodDistance = 0; + + /** pick id string -> fire record, for the currently rendered sprites. */ + + layerState._pickIndexById = new Map(); + + /** Actionable card id -> current detection record painted for that id. */ + + layerState._fireByCardId = new Map(); + + /** + * Occlusion-test anchors index-aligned with the billboard collection (the + * collection is cleared and refilled in one ordered pass per rebuild, and + * nothing else adds to it). Lifted where the render anchor sits at/below + * the ellipsoid — see {@link fireCullPosition}. + * @type {Array} + */ + + layerState._cullPositions = []; + + /** + * gridDegrees -> heat-sorted full cell list. The full-grid aggregation is + * viewport-independent but walks every detection (~200k live), so it is + * computed once per grid size per data refresh; renders only clip + cap + * (field-test round 1: intermediate-zoom chug during LOD rebuilds). + * @type {Map>} + */ + + layerState._cellCacheByGrid = new Map(); + + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ + + layerState._camSnapValid = false; + + layerState._camPos = new Cesium.Cartesian3(); + + layerState._camDir = new Cesium.Cartesian3(); + layerState.request = null; + return layerState; +} diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js new file mode 100644 index 000000000..932fe2e75 --- /dev/null +++ b/src/layers/firms/viewport.js @@ -0,0 +1,216 @@ +import * as Cesium from 'cesium'; +import { + LOD_LEVELS, + LOD_HYSTERESIS, + VIEW_PADDING, + LOD_CHECK_MS, +} from './policy.js'; + +export function createViewport({ + layerState, + services, + components, + config, + feed, +}) { + const { governorRequestRender } = services.render; + + /** + * Ambient labels are placed via screen-space projection, so they need a + * re-declutter after the camera settles — on moveEnd, never per frame. + */ + + function installMoveEndWatcher() { + if (layerState._moveEndRemover || !layerState._viewer) return; + layerState._moveEndRemover = + layerState._viewer.camera.moveEnd.addEventListener(() => { + if (!layerState._enabled) return; + // Settled-camera exactness for the horizon pass (the throttled preRender + // watcher can last have run up to LOD_CHECK_MS before the camera + // stopped). Runs ahead of the `_loading` gate on purpose: the sprites on + // screen during a refresh are the ones that need culling. + components.rendering.refreshHorizonCulling(); + if (layerState._loading) return; + components.cards.rebuildAmbientLabels(); + }); + } + + function removeMoveEndWatcher() { + if (layerState._moveEndRemover) { + layerState._moveEndRemover(); + layerState._moveEndRemover = null; + } + } + + function cameraHeight() { + return ( + layerState._viewer?.camera?.positionCartographic?.height ?? + Number.POSITIVE_INFINITY + ); + } + + /** Raw LOD index for a camera height (0 = global ... 3 = close). */ + + function rawLodIndex(height) { + const index = LOD_LEVELS.findIndex((level) => height >= level.minHeight); + return index === -1 ? LOD_LEVELS.length - 1 : index; + } + + /** + * LOD selection with ±10% hysteresis around band floors: a band switch + * only happens once the camera clears the boundary by the margin, so a + * slow zoom oscillating on an edge cannot trigger a rebuild every check. + * @param {number} height - Camera height in meters. + * @returns {number} LOD index to render. + */ + + function selectLodIndex(height) { + const raw = rawLodIndex(height); + if (layerState._currentLodIndex < 0 || raw === layerState._currentLodIndex) + return raw; + let index = layerState._currentLodIndex; + // Zooming out: enter a coarser band only after clearing its floor by +10%. + while ( + index > raw && + height >= LOD_LEVELS[index - 1].minHeight * (1 + LOD_HYSTERESIS) + ) + index -= 1; + // Zooming in: leave the current band only after dropping 10% below its floor. + while ( + index < raw && + height <= LOD_LEVELS[index].minHeight * (1 - LOD_HYSTERESIS) + ) + index += 1; + return index; + } + + /** Current camera view rectangle, or null when looking at sky/horizon. */ + + function computeViewRect() { + try { + const rect = layerState._viewer?.camera?.computeViewRectangle( + layerState._viewer?.scene?.globe?.ellipsoid, + layerState.scratchViewRect, + ); + return rect || null; + } catch { + return null; + } + } + + /** + * True when the camera view rectangle drifted far enough from the last + * rendered one that the padding is at risk of being consumed (pan) or + * the footprint changed notably (zoom within a band). + * @param {?Cesium.Rectangle} viewRect - Current view rectangle. + * @returns {boolean} + */ + + function viewChangedEnough(viewRect) { + if (!viewRect) return false; // sky/horizon — keep the current render + if (!layerState._lastViewRect) return true; // previous render was global fallback + const lastWidth = Cesium.Rectangle.computeWidth(layerState._lastViewRect); + const lastHeight = Cesium.Rectangle.computeHeight(layerState._lastViewRect); + const width = Cesium.Rectangle.computeWidth(viewRect); + const height = Cesium.Rectangle.computeHeight(viewRect); + if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true; + if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true; + const lastCenter = Cesium.Rectangle.center( + layerState._lastViewRect, + layerState.scratchCenterA, + ); + const center = Cesium.Rectangle.center(viewRect, layerState.scratchCenterB); + const latShift = Math.abs(center.latitude - lastCenter.latitude); + let lonShift = Math.abs(center.longitude - lastCenter.longitude); + if (lonShift > Math.PI) lonShift = Cesium.Math.TWO_PI - lonShift; + return ( + latShift > lastHeight * VIEW_PADDING * 0.6 || + lonShift > lastWidth * VIEW_PADDING * 0.6 + ); + } + + function installLodWatcher() { + if (layerState._preRenderRemover || !layerState._viewer) return; + // The LOD sweep is throttle-gated inside preRender. When the camera + // settles just inside the throttle window, the final rebuild would wait + // for a frame that idle mode never produces — schedule one. (perf wave 2) + if (!layerState._moveEndSettleRemover) { + layerState._moveEndSettleRemover = + layerState._viewer.camera.moveEnd.addEventListener(() => { + if (!layerState._enabled) return; + setTimeout( + () => governorRequestRender('firms-lod-settle'), + LOD_CHECK_MS + 40, + ); + }); + } + layerState._preRenderRemover = + layerState._viewer.scene.preRender.addEventListener(() => { + // NOTE: `_loading`/empty-data are deliberately NOT part of this guard. + // A refresh has no timeout, and sprites from the previous payload stay + // on screen throughout it — so the horizon pass below must keep running + // while a fetch is in flight. Only the rebuild is gated on fresh data. + if (!layerState._enabled) return; + const now = performance.now(); + if (now - layerState._lastLodCheck < LOD_CHECK_MS) return; + layerState._lastLodCheck = now; + // Idle camera: two scratch compares and out — no view-rect math, no + // aggregation, no allocation while the user is parked. + const camera = layerState._viewer.camera; + if ( + layerState._camSnapValid && + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + layerState._camPos, + 0, + 0.5, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + layerState._camDir, + 0, + 1e-7, + ) + ) { + return; + } + Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos); + Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir); + layerState._camSnapValid = true; + // The camera moved, so the horizon moved. A rebuild culls its own fresh + // sprites (renderDetections); an early-out — same LOD band and view + // rect, or a refresh in flight — does not, so cull here instead. + // Exactly ONE occlusion walk per tick either way, bounded by the + // idle-gate + LOD_CHECK_MS throttle above: a parked camera costs + // nothing, a moving one pays one ≤maxDetections show-flip walk at + // ~1.5 Hz. + const rebuilt = + layerState._fires.length && !layerState._loading + ? components.rendering.renderCurrentLod(false) + : false; + if (!rebuilt) components.rendering.refreshHorizonCulling(); + }); + } + + function removeLodWatcher() { + if (layerState._preRenderRemover) { + layerState._preRenderRemover(); + layerState._preRenderRemover = null; + } + if (layerState._moveEndSettleRemover) { + layerState._moveEndSettleRemover(); + layerState._moveEndSettleRemover = null; + } + } + return { + installMoveEndWatcher, + removeMoveEndWatcher, + cameraHeight, + rawLodIndex, + selectLodIndex, + computeViewRect, + viewChangedEnough, + installLodWatcher, + removeLodWatcher, + }; +} diff --git a/src/layers/flights/controller.js b/src/layers/flights/controller.js new file mode 100644 index 000000000..5e04c53e1 --- /dev/null +++ b/src/layers/flights/controller.js @@ -0,0 +1,26 @@ +import * as Cesium from 'cesium'; + +export function createController({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _abortActiveUpdates() { + for (const controller of flightState._activeUpdateControllers) + controller.abort(); + flightState._activeUpdateControllers.clear(); + } + + function _flightQuery(viewer) { + const cartographic = viewer?.camera?.positionCartographic; + return cartographic + ? { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + } + : {}; + } + return { _abortActiveUpdates, _flightQuery }; +} diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js new file mode 100644 index 000000000..935fb301e --- /dev/null +++ b/src/layers/flights/enrichment.js @@ -0,0 +1,240 @@ +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import * as Cesium from 'cesium'; +import { + ENRICH_MAX_INFLIGHT, + ENRICH_DISPATCH_GAP_MS, + ENRICH_AMBIENT_BUDGET_CEIL, + ENRICH_AMBIENT_REFILL_TOKENS, + ENRICH_AMBIENT_REFILL_WINDOW_MS, + ENRICH_AMBIENT_PER_SWEEP, +} from './policy.js'; + +export function createEnrichment({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _enqueueEnrich(key, query, onData, priority = false) { + if (flightState.lifetime.signal.aborted) return; + if (flightState._enrichSeen.has(key)) return; + flightState._enrichSeen.add(key); + const job = { query, onData }; + // Priority (tracked / model-eligible) goes to the FRONT so a deep ambient + // backlog can never delay the plane the user just clicked or zoomed into. + if (priority) flightState._enrichQueue.unshift(job); + else flightState._enrichQueue.push(job); + _drainEnrich(); + } + + function _drainEnrich() { + if (flightState.lifetime.signal.aborted) return; + while ( + flightState._enrichActive < ENRICH_MAX_INFLIGHT && + flightState._enrichQueue.length + ) { + // Drip: at most one dispatch per ENRICH_DISPATCH_GAP_MS. When the gap + // hasn't elapsed yet, park a single wake-up timer and stop — completions + // and enqueues in the meantime re-enter here harmlessly. + const wait = + ENRICH_DISPATCH_GAP_MS - + (Date.now() - flightState._enrichLastDispatchMs); + if (wait > 0) { + if (!flightState._enrichDripTimer) { + flightState._enrichDripTimer = setTimeout(() => { + flightState._enrichDripTimer = null; + _drainEnrich(); + }, wait); + } + return; + } + flightState._enrichLastDispatchMs = Date.now(); + const job = flightState._enrichQueue.shift(); + flightState._enrichActive += 1; + const lifetime = flightState.lifetime; + Promise.resolve() + .then(() => { + lifetime.signal.throwIfAborted(); + return flightState._source.getEnrichment?.(job.query, { + signal: lifetime.signal, + }); + }) + .then((data) => { + if (!lifetime.signal.aborted && data && data.found) job.onData(data); + }) + .catch(() => { + /* enrichment never surfaces errors */ + }) + .finally(() => { + if (lifetime.signal.aborted) return; + flightState._enrichActive -= 1; + _drainEnrich(); + }); + } + } + + function _requestTypeEnrichment(icao24, priority = false) { + if (!/^[0-9a-f]{6}$/i.test(icao24)) return; + _enqueueEnrich( + `t:${icao24}`, + { kind: 'type', id: icao24.toLowerCase() }, + (data) => { + const meta = flightState._flightData.get(icao24); + if (!meta) return; // evicted while the lookup was in flight + meta.typeCode = data.typeCode || meta.typeCode; + meta.typeName = data.typeName || meta.typeName; + meta.registration = data.registration || meta.registration; + if (meta.typeCode) { + const klass = classifyAircraft({ + typeCode: meta.typeCode, + category: meta.category, + }); + if (klass !== meta.klass) { + meta.klass = klass; + const bb = flightState._billboards.get(icao24); + if (bb) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + // Hangar fleet: the class's GLB/scale may have changed — resync the + // live model, any in-flight load, and the tracked standalone model. + parts.rendering._syncModelToClass(icao24); + } + } + if (icao24 === flightState._trackedIcao && flightState._trackedEntity) + parts.tracking._updateTrackedLabelModel(icao24); + }, + priority, + ); + } + + function _requestRouteEnrichment(icao24) { + const cs = String(flightState._flightData.get(icao24)?.callsign || '') + .trim() + .toUpperCase(); + if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve + _enqueueEnrich( + `r:${cs}`, + { kind: 'route', id: cs }, + (data) => { + const meta = flightState._flightData.get(icao24); + if (!meta) return; + meta.airline = data.airline || meta.airline; + if (data.origin && data.destination) + meta.route = { origin: data.origin, destination: data.destination }; + if (icao24 === flightState._trackedIcao && flightState._trackedEntity) + parts.tracking._updateTrackedLabelModel(icao24); + }, + true, + ); // route lookups only fire for the TRACKED plane — front of the queue + } + + /** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the + * bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens, + * windowMs} — they cannot wait out a real 5-minute window. Read lazily each + * refill so a pre-boot override (or a mid-run windowMs swap) applies. + * Production never sets this; the constants above are the defaults. */ + + function _ambientBudgetKnobs() { + const o = + (typeof window !== 'undefined' && window.__GEV_ENRICH_AMBIENT_QA) || null; + return { + ceil: + Number.isFinite(o?.ceil) && o.ceil > 0 + ? o.ceil + : ENRICH_AMBIENT_BUDGET_CEIL, + refillTokens: + Number.isFinite(o?.refillTokens) && o.refillTokens > 0 + ? o.refillTokens + : ENRICH_AMBIENT_REFILL_TOKENS, + windowMs: + Number.isFinite(o?.windowMs) && o.windowMs > 0 + ? o.windowMs + : ENRICH_AMBIENT_REFILL_WINDOW_MS, + }; + } + + /** Advance the token bucket: add refillTokens per FULLY elapsed window since + * the anchor, clamp at the ceiling, and move the anchor forward by the whole + * windows consumed (while the bucket sits full this still advances, so idle + * time never banks more than one bucket's worth of burst). */ + + function _refillAmbientBudget(nowMs) { + const { ceil, refillTokens, windowMs } = _ambientBudgetKnobs(); + if (!flightState._enrichAmbientRefillAnchorMs) { + flightState._enrichAmbientRefillAnchorMs = nowMs; + return; + } + const windows = Math.floor( + (nowMs - flightState._enrichAmbientRefillAnchorMs) / windowMs, + ); + if (windows <= 0) return; + flightState._enrichAmbientBudget = Math.min( + ceil, + flightState._enrichAmbientBudget + windows * refillTokens, + ); + flightState._enrichAmbientRefillAnchorMs += windows * windowMs; + } + + function _sweepAmbientEnrichment() { + _refillAmbientBudget(Date.now()); + if ( + flightState._enrichAmbientBudget <= 0 || + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + try { + const camera = flightState._viewer.camera; + const camPos = camera.positionWC; + const occluder = horizonOccluder(camera); + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + const cand = []; + for (const [icao24, bb] of flightState._billboards) { + if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session + if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only + if (flightState._flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) + if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + if ( + cull.computeVisibility(flightState._scratchModelBS) === + Cesium.Intersect.OUTSIDE + ) + continue; // off-screen + cand.push([ + icao24, + Cesium.Cartesian3.distanceSquared(camPos, bb.position), + ]); + } + cand.sort((a, b) => a[1] - b[1]); // nearest first — what the user is looking at resolves first + const n = Math.min( + cand.length, + ENRICH_AMBIENT_PER_SWEEP, + flightState._enrichAmbientBudget, + ); + for (let i = 0; i < n; i++) { + flightState._enrichAmbientBudget -= 1; + _requestTypeEnrichment(cand[i][0]); // non-priority: fills the back of the queue + } + } catch { + /* ambient enrichment is best-effort — never disturb the poll loop */ + } + } + return { + _enqueueEnrich, + _drainEnrich, + _requestTypeEnrichment, + _requestRouteEnrichment, + _ambientBudgetKnobs, + _refillAmbientBudget, + _sweepAmbientEnrichment, + }; +} diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js new file mode 100644 index 000000000..9a6f9da66 --- /dev/null +++ b/src/layers/flights/evidence.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { FOCUS_EVIDENCE_DEV } from './policy.js'; + +export function createEvidence({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** Resolve a JSON-safe evidence position into ECEF. DEV-only caller. */ + + function _focusEvidencePosition(record) { + const cartesian = record?.cartesian; + if ( + Array.isArray(cartesian) && + cartesian.length >= 3 && + cartesian.slice(0, 3).every(Number.isFinite) + ) { + return Cesium.Cartesian3.fromElements( + cartesian[0], + cartesian[1], + cartesian[2], + ); + } + if ( + !Number.isFinite(record?.longitude) || + !Number.isFinite(record?.latitude) + ) + return null; + return Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000, + ); + } + + /** Replace the real fleet with deterministic explicit-position contacts. */ + + function _setFocusEvidenceAircraft(records = []) { + if ( + !FOCUS_EVIDENCE_DEV || + !flightState._billboardCollection || + !flightState._viewer + ) + return { ok: false, count: 0 }; + if (flightState._trackedIcao) parts.tracking._clearTracking(); + parts.rendering._releaseModels(); + for (const bb of flightState._billboards.values()) + flightState._billboardCollection.remove(bb); + flightState._billboards.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._missingPolls.clear(); + flightState._focusEvidenceIds.clear(); + + for (const record of Array.isArray(records) ? records : []) { + const id = String(record?.id || '') + .trim() + .toLowerCase(); + const position = _focusEvidencePosition(record); + if (!id || !position) continue; + const klass = record.klass || 'airliner'; + const altitudeM = Number.isFinite(record.altitudeM) + ? record.altitudeM + : Cesium.Cartographic.fromCartesian(position)?.height || 3_000; + const meta = { + callsign: String(record.callsign || id).toUpperCase(), + altitude: altitudeM, + renderAltitudeM: altitudeM, + velocity: Number.isFinite(record.velocityMps) ? record.velocityMps : 0, + true_track: Number.isFinite(record.trackDeg) ? record.trackDeg : 90, + klass, + onGround: false, + wasAirborne: true, + turnRateDps: 0, + lastContactEpochMs: Date.now(), + rawLat: record.latitude ?? null, + rawLon: record.longitude ?? null, + cullPosition: null, + }; + flightState._flightData.set(id, meta); + flightState._focusEvidenceIds.add(id); + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon(parts.rendering._iconKind(id, klass)), + width: 20, + height: 20, + scale: parts.rendering._fleetBillboardScale(id, klass), + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: parts.rendering._fleetBillboardColor(id), + sizeInMeters: false, + scaleByDistance: parts.rendering._normalBillboardScaleByDistance(), + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id, + show: true, + }); + flightState._billboards.set(id, bb); + } + flightState._count = flightState._billboards.size; + flightState._lastFleetTickMs = 0; + flightState._viewer.scene.requestRender?.(); + return { ok: true, count: flightState._count }; + } + + /** Update explicit evidence positions without rebuilding billboards. */ + + function _moveFocusEvidenceAircraft(records = []) { + if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 }; + let moved = 0; + for (const record of Array.isArray(records) ? records : []) { + const id = String(record?.id || '') + .trim() + .toLowerCase(); + if (!flightState._focusEvidenceIds.has(id)) continue; + const position = _focusEvidencePosition(record); + const bb = flightState._billboards.get(id); + if (!position || !bb) continue; + bb.position = position; + const meta = flightState._flightData.get(id); + if (meta) { + if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; + if (Number.isFinite(record.velocityMps)) + meta.velocity = record.velocityMps; + } + moved += 1; + } + flightState._lastFleetTickMs = 0; + flightState._viewer?.scene?.requestRender?.(); + return { ok: true, moved }; + } + + /** JSON-safe visual snapshot for the evidence report. */ + + function _focusEvidenceSnapshot() { + if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; + return [...flightState._focusEvidenceIds].map((id) => { + const bb = flightState._billboards.get(id); + const screen = bb?.position + ? Cesium.SceneTransforms.worldToWindowCoordinates( + flightState._viewer.scene, + bb.position, + ) + : null; + return { + id, + show: bb?.show === true, + scale: bb?.scale ?? null, + alpha: bb?.color?.alpha ?? null, + x: screen?.x ?? null, + y: screen?.y ?? null, + cameraDistanceM: bb?.position + ? Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + bb.position, + ) + : null, + }; + }); + } + return { + _focusEvidencePosition, + _setFocusEvidenceAircraft, + _moveFocusEvidenceAircraft, + _focusEvidenceSnapshot, + }; +} diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js new file mode 100644 index 000000000..c652ca45b --- /dev/null +++ b/src/layers/flights/index.js @@ -0,0 +1,41 @@ +import { createFlightState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createMotion } from './motion.js'; +import { createTracking } from './tracking.js'; +import { createController } from './controller.js'; +import { createEnrichment } from './enrichment.js'; +import { createIngestion } from './ingestion.js'; +import { createLifecycle } from './lifecycle.js'; +import { createEvidence } from './evidence.js'; +import { createTesting } from './testing.js'; +import { createQueries } from './queries.js'; +/** Compose one civil-flight layer with application-owned scene services. */ +export function createCivilFlightLayer({ + source, + services, + resolveAsset = (url) => url, +} = {}) { + const flightState = createFlightState({ source, services }); + const parts = {}; + const layer = {}; + const context = { flightState, services, parts, layer, resolveAsset }; + parts.rendering = createRendering(context); + parts.motion = createMotion(context); + parts.tracking = createTracking(context); + parts.controller = createController(context); + parts.enrichment = createEnrichment(context); + parts.ingestion = createIngestion(context); + parts.lifecycle = createLifecycle(context); + parts.evidence = createEvidence(context); + parts.testing = createTesting(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { TRACKED_MODEL_MAX_PX } from './policy.js'; diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js new file mode 100644 index 000000000..7a0a44731 --- /dev/null +++ b/src/layers/flights/ingestion.js @@ -0,0 +1,661 @@ +import * as Cesium from 'cesium'; +import { stickyNumber, stickyText } from '../../data/aircraftMeta.js'; +import { + geoidSurfaceLastResortM, + pickRenderAltitudeM, +} from '../../data/renderAltitude.js'; +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + GROUND_FLOOR_CLAMP_RADIUS_KM, + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './policy.js'; + +export function createIngestion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = + services.militaryRegistry; + const { geoidHeight } = services.geoid; + const { cachedGroundFloor, floorAltitudeM, warmGroundFloor } = + services.groundFloor; + const { sampleMeshFloorCells } = services.meshFloor; + const flightsLayer = layer; + + const methods = { + /** + * Fetch the latest aircraft state vectors from the OpenSky proxy and + * reconcile them with the billboard collection. + * + * Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors + * with exponential-ish backoff. On success, adds/updates/removes + * billboards and position history, triggers lerp blending for the + * tracked aircraft, and updates its label text. + * + * @param {Cesium.Viewer} viewer + * @returns {Promise} + */ + async update(viewer, { signal = null } = {}) { + const nowMs = Date.now(); + const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + ids: new Set(), + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + if (flightState._retryAt && nowMs < flightState._retryAt) { + flightState._backoff = true; + return; + } + + const resourceController = new AbortController(); + flightState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + const snapshot = await flightState._source.getSnapshot( + parts.controller._flightQuery(viewer || flightState._viewer), + { signal: updateSignal }, + ); + updateSignal.throwIfAborted(); + flightState._lastStatus = snapshot.status ?? 200; + const usableStates = snapshot.records; + const sourceEpochMs = snapshot.observedAtMs; + const sourceAgeMs = snapshot.ageMs; + const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; + flightState._backoff = sourceStale; + flightState._retryAt = 0; + flightState._lastError = sourceStale + ? sourceAgeMs == null + ? 'Source snapshot time unavailable' + : `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old` + : null; + flightState._lastSource = snapshot.source; + flightState._lastCoverage = snapshot.coverage; + flightsLayer.source = flightState._lastSource; + const currentIcaos = new Set(); + const acceptedSnapshotIcaos = new Set(); + const now = Cesium.JulianDate.now(); + // Field-test round 3 (2026-07-06, Austin fleet-underground): viewer + // subpoint + collected floor cells for the viewer-proximate low-contact + // clamp below — one carto read per poll, one batch warm after the loop. + const viewerCarto = + (viewer || flightState._viewer)?.camera?.positionCartographic || null; + const viewerLatDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : null; + const viewerLonDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : null; + const floorWarmPoints = []; + + // Classification and display policy consume source-independent observations. + refreshMilitaryRegistryIfStale(); + for (const observation of usableStates) { + const { + id: icao24, + callsign, + originCountry: origin_country, + longitude: lon, + latitude: lat, + baroAltitudeM: baro_alt, + onGround: on_ground, + speedMps: velocity, + courseDeg: true_track, + ellipsoidAltitudeM: geo_alt, + category, + verticalRateMps: vertical_rate, + } = observation; + acceptedSnapshotIcaos.add(icao24); + const onGround = on_ground === true; + + // Known-military aircraft: the dedicated military layer wins + // (icon + track + click) while it is enabled — suppress the + // OpenSky duplicate entirely (except a currently tracked one, + // which hands off on untrack). + const isMil = isMilitaryIcao(icao24); + if (isMil && parts.tracking._militaryLayerSuppresses(icao24)) { + const dupe = flightState._billboards.get(icao24); + if (dupe) { + flightState._billboardCollection.remove(dupe); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._missingPolls.delete(icao24); + flightState._geoidNCache.delete(icao24); + } + continue; + } + + currentIcaos.add(icao24); + flightState._missingPolls.delete(icao24); + // Sticky merge: OpenSky intermittently drops callsign/velocity/track for + // aircraft it still positions — hold last-known-good instead of + // regressing to the ICAO hex / a 0° (north) heading. Bounded by the + // MISSING_POLL_LIMIT eviction below, which deletes the whole entry. + const prevMeta = flightState._flightData.get(icao24); + // Grounded planes with no baro reading sit at 0 m, not the 10 km + // airborne default (a parked plane must never float). + // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky + // barometric/MSL altitude read by labels (FL/altitude readout), + // route-plausibility, and follow-camera range heuristics. It is + // NEVER overwritten or renamed. Where the aircraft actually RENDERS + // on the ellipsoidal globe is a SEPARATE value (renderAltitudeM, + // below) — geo_altitude when OpenSky reports it (already WGS84 + // ellipsoidal), else baro+geoid as a visual fallback, else ground + // surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM, + // never `alt` directly. + const alt = stickyNumber( + baro_alt, + prevMeta?.altitude, + onGround ? 0 : 10000, + ); + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = flightState._geoidNCache.get(icao24); + if (geoidN === undefined && flightState._geoidReady) { + geoidN = geoidHeight(lat, lon); + flightState._geoidNCache.set(icao24, geoidN); + } + + // on_ground surface prior: ONLY synchronous warm-cache reads here — + // never a per-aircraft network fetch inside the poll loop (see the + // batch resolve call below, which fills this cache for NEXT poll). A + // Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied): + // the grounded surface is the round-4 choke point and nothing else — + // rendered-mesh cell first, real (never fallback-poisoned) DEM cell + // second. The old exact-5-decimal warm chain is GONE: it minted a new + // key per parked-jitter poll for every grounded contact ON EARTH, + // hammering Re:Earth into the very failures that poisoned the cache. + let surfaceM = null; + if (onGround) { + surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell) + // Taxiing crosses into a fresh cold cell every poll — always one + // step ahead of the warm batch — so fall back to LAST poll's cell + // (warmed by last poll's batch; aprons are flat across adjacent + // 111 m cells). Round-5 verify caught taxiing contacts stuck at + // the geoid without this (round 2's lesson, at cell granularity). + if ( + surfaceM == null && + Number.isFinite(prevMeta?.rawLat) && + Number.isFinite(prevMeta?.rawLon) + ) { + surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon); + } + // Grounded contacts near the viewer feed the floor warm/sampler + // (the only ones whose exact height is visible; far contacts are + // subpixel and always-on-top anyway). + if ( + viewerLatDeg != null && + parts.queries._approxDistanceKm( + viewerLatDeg, + viewerLonDeg, + lat, + lon, + ) <= GROUND_FLOOR_CLAMP_RADIUS_KM + ) { + floorWarmPoints.push({ lat, lon }); + } + // Last synchronous resort for a BRAND-NEW grounded contact with NO + // altitude data at all (nothing warm yet, not even the coarse + // cell): the geoid surface. At the sea-level airports where most + // grounded traffic sits, geoidN IS the local ellipsoidal ground to + // within metres — instantly right — and at elevated fields it is + // far less wrong than the raw 0 m ellipsoid default for the one + // poll until the coarse cell warms. STRICTLY gated on "no geo, no + // baro": a reported baro already reflects the field elevation, and + // pickRenderAltitudeM's surfaceM branch would let this crude guess + // outrank it (caught by the ground-3d track regression). + // + // 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds + // one more gate — a contact that already HAS a render height keeps + // it. Leaving surfaceM null routes it through the sentinel path + // below, which holds that height. + if (surfaceM == null) { + surfaceM = geoidSurfaceLastResortM({ + geoAltM: geo_alt, + baroAltM: baro_alt, + priorRenderM: prevMeta?.renderAltitudeM, + geoidN, + }); + } + } + + const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null; + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: Number.isFinite(baro_alt) ? baro_alt : null, + onGround, + surfaceM, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks, + // in priority order: + // (1) hold the previous geoid-corrected render height if we have one — + // a one-poll baro dropout must NOT snap the plane down by the geoid + // undulation N (~46 m in London) and back up next poll. `alt` stays + // sticky for labels, so holding the last render height keeps the two + // layers consistent through the gap. + // (2) otherwise the SAME default policy `alt` already uses, so the two + // never disagree on the genuine "no data yet" case (a never-reported + // aircraft has no prior render height, so it lands here unchanged). + let renderAltitudeM; + if (pickedAltM != null) { + renderAltitudeM = pickedAltM; + } else if (Number.isFinite(prevMeta?.renderAltitudeM)) { + renderAltitudeM = prevMeta.renderAltitudeM; + } else { + renderAltitudeM = alt; + } + // Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3): + // floor a low airborne contact's render height at the local coarse + // ground so it can never dive below the mesh. Round 3 (Austin + // fleet-underground): baro can read BELOW an elevated field — SWA696 + // showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi + // traffic that OpenSky hasn't flagged on_ground yet renders from that + // baro, so the whole fleet sat buried at AUS in 2D. Clamping every + // global contact would need unbounded terrain resolution; instead the + // clamp covers the TRACKED contact (always) plus every low contact + // within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones + // whose burial is visible. Cells warm in one batch after the loop. + // Airborne only (grounded planes keep the surface-cache path above). + if ( + !onGround && + renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M && + (icao24 === flightState._trackedIcao || + (viewerLatDeg != null && + parts.queries._approxDistanceKm( + viewerLatDeg, + viewerLonDeg, + lat, + lon, + ) <= GROUND_FLOOR_CLAMP_RADIUS_KM)) + ) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + floorWarmPoints.push({ lat, lon }); + } + + const position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + renderAltitudeM, + ); + // Landing/takeoff transition: the on_ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Either flip direction retires the model's ground snap: a departing plane + // flies free of it, a landing plane earns a fresh sample where it rolls out. + if (groundFlipped) flightState._groundSnap.forget(icao24); + + // Store flight metadata for click-to-track labels + const cat = stickyNumber(category, prevMeta?.category, null); + const meta = { + sourceReference: observation.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + altitude: alt, + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitude` — never rename/replace it (labels, + // FL readout, route-plausibility, and follow-camera range math all + // still read `altitude`/baro). + geoAltitudeM, + renderAltitudeM, + onGround, + // Round 7: sticky airborne history — the landed fast-cull only + // applies to contacts that actually flew this session. + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Round 6: lifted occlusion-test point for contacts rendering + // at/below the ellipsoid (fleet pass reads it — see the occluder + // note there). Null for the overwhelmingly common airborne case. + cullPosition: + renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) + : null, + velocity: stickyNumber(velocity, prevMeta?.velocity, 0), + true_track: stickyNumber(true_track, prevMeta?.true_track, 0), + category: cat, + // An adsbdb-enriched type code outranks the coarse OpenSky category. + klass: classifyAircraft({ + typeCode: prevMeta?.typeCode ?? null, + category: cat, + }), + turnRateDps: prevMeta?.turnRateDps || 0, + verticalRate: stickyNumber( + vertical_rate, + prevMeta?.verticalRate, + null, + ), + // Analyst seam: OpenSky origin_country (state[2]) — additive, sticky + // like callsign so a transient blank row doesn't blank the field. + originCountry: + stickyText(origin_country, prevMeta?.originCountry) || null, + // OpenSky distinguishes the last position epoch from the last + // transponder message. The fleet coast horizon uses this actual + // contact time so a temporarily old position does not hard-freeze + // while fresh velocity/track messages are still arriving. + lastContactEpochMs: stickyNumber( + observation.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + // adsbdb enrichment — written by the enrichment callbacks, carried across polls: + typeCode: prevMeta?.typeCode ?? null, + typeName: prevMeta?.typeName ?? null, + registration: prevMeta?.registration ?? null, + airline: prevMeta?.airline ?? null, + route: prevMeta?.route ?? null, + // The RAW poll fix lat/lon (this tick's OpenSky state-vector + // coords, pre-dead-reckon) — kept distinct from the continuously + // dead-reckoned billboard position for any consumer that needs the + // actual reported fix. + rawLat: lat, + rawLon: lon, + }; + flightState._flightData.set(icao24, meta); + + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch + // (time_position), not client receipt time — OpenSky positions arrive + // 5-15s stale and receipt-time stamping is what caused the + // back/forward oscillation. Only append when the fix actually + // advances, so stale repeats don't create zero-dt segments. + const fixEpochMs = + Number.isFinite(observation.positionTimeMs) && + observation.positionTimeMs > 0 + ? observation.positionTimeMs + : Date.now(); + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.velocity, + track: meta.true_track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F1) + // so the visible trail keeps growing while tracked. Ground traffic + // appends nothing — a touchdown freezes the existing trail. + // Round 2 (owner): ground traffic appends too — taxi history stays + // live after touchdown (grounded flights positions are already + // surface-clamped via the surfaceM chain, so the ground leg drapes). + if (isTracked) parts.tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = + parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + // Apply fresh kinematics only from a forward synthetic fix. Mutating + // the historical fix reprojects the entire stale interval and snaps + // the rendered aircraft when course or speed changes late. + const kinematicsChanged = + newest.velocity !== meta.velocity || + newest.track !== meta.true_track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.velocity, + track: meta.true_track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) + parts.tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Reclassify if the category resolved/changed (first extended poll); + // a ground flip (landing/takeoff) re-scales the SAME billboard in + // place — the transition is a restyle, never a removal. + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + if (!isTracked) { + // Refresh affiliation hue without clobbering the tick-owned + // freshness × focus × horizon alpha composition. + bb.color = parts.rendering + ._fleetBillboardColor(icao24) + .withAlpha(bb.color?.alpha ?? 1); + } + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Poll-path class change (category updates): same model resync rule + // as the enrichment path — the class's GLB/scale may have changed. + if (prevMeta?.klass !== meta.klass) + parts.rendering._syncModelToClass(icao24); + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon( + parts.rendering._iconKind(icao24, meta.klass), + ), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: parts.rendering._fleetBillboardScale(icao24, meta.klass), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked + ? Cesium.Color.CYAN + : parts.rendering._fleetBillboardColor(icao24), + sizeInMeters: false, + scaleByDistance: + parts.rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + + // Takeoff while TRACKED: ground traffic drew no trail, so start one + // from the fresh airborne history (touchdown needs no action — the + // append gate above simply freezes the existing trail). + if (isTracked && groundFlipped && !meta.onGround) + parts.tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + parts.tracking._updateTrackedLabelModel(icao24); + } + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // OpenSky routinely drops aircraft for a single poll; immediate removal + // made planes blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if ( + !snapshot.complete && + Date.now() - + (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < + 300000 + ) + continue; + const misses = (flightState._missingPolls.get(icao24) || 0) + 1; + const limit = parts.queries._likelyLanded(icao24) + ? LANDED_MISSING_POLL_LIMIT + : MISSING_POLL_LIMIT; + if (misses < limit) { + flightState._missingPolls.set(icao24, misses); + if ( + icao24 === flightState._trackedIcao && + flightState._trackedEntity + ) { + // Honest readout: the tracked plane has no faded billboard (its + // entity owns the visual), so refresh the label — with icao24 now + // in _missingPolls, _trackedLabelText appends the STALE cue so + // last-known velocity/altitude aren't presented as live. + parts.tracking._updateTrackedLabelModel(icao24); + } + continue; + } + flightState._missingPolls.delete(icao24); + + // If the tracked flight is truly gone, clear tracking BEFORE deleting + // its state (M3 ordering, keep it): teardown reads the maps this loop + // is about to delete (billboard restore, DR cache reset), and we must + // never leave the camera mid-follow with stale tracking state. The + // camera is then RELEASED IN PLACE — it stays where the follow left + // it, fully free (owner decision 2026-07-02: no overview flyTo). + if (icao24 === flightState._trackedIcao) { + parts.tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._geoidNCache.delete(icao24); + flightState._displayFloorState.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Ambient type enrichment: give ON-SCREEN planes real types (bounded + // sweep — see _sweepAmbientEnrichment; internally fail-silent). + parts.enrichment._sweepAmbientEnrichment(); + + // 2026-08-19: the loop above only ever collects FIX cells, but a grounded + // contact renders across every cell its dead-reckoned position drifts + // through. Add those too, so the display clamp has data where the contact + // actually is instead of silently passing. + parts.motion._collectDisplayCorridorCells( + floorWarmPoints, + viewerLatDeg, + viewerLonDeg, + ); + + // Field-test round 3: one batch warm of the viewer-proximate low-contact + // floor cells collected in the loop (fire-and-forget, single-flight; + // read synchronously by NEXT poll's clamp — the military-layer pattern). + warmGroundFloor(floorWarmPoints); + // Round 4: sample the RENDERED mesh for those same cells (one-shot per + // cell, budget-capped, viewer-proximate, google-3d regime only). Own + // billboards/models are excluded so a vertical probe can't land on an + // aircraft instead of the pavement. + sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerLatDeg, + viewerLon: viewerLonDeg, + }); + + // Round 5: the grounded exact-key warm that used to live here is gone — + // see the note where _warmGroundedAircraftSurfaceCache was removed. The + // viewer-proximate coarse warm + mesh sampler above cover everything + // whose height is actually visible. + // Round 6: re-floor STALE grounded contacts. A parked plane whose + // transponder went quiet stops receiving poll updates, so a floor that + // warms AFTER its last fix never applied — it sat frozen at the geoid + // (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever). + // Grounded contacts are static, so lifting the stored fix + billboard + // in place is safe (the DR extrapolates a zero-velocity fix). + parts.motion._refloorStaleGroundedContacts(currentIcaos); + + flightState._count = flightState._billboards.size; + // Freshness belongs to the source snapshot, not the moment this browser + // received a cached 200 response. + flightState._lastUpdate = sourceEpochMs; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'accepted', + ids: acceptedSnapshotIcaos, + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Flights update aborted', 'AbortError'); + } + console.warn('[Data:Flights] Fetch error:', e); + flightState._backoff = true; + flightState._retryAt = + Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); + flightState._lastStatus = e?.status ?? null; + if (e?.source) { + flightState._lastSource = e.source; + this.source = flightState._lastSource; + } + flightState._lastError = + e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; + } finally { + flightState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js new file mode 100644 index 000000000..9dff82f89 --- /dev/null +++ b/src/layers/flights/lifecycle.js @@ -0,0 +1,336 @@ +import * as Cesium from 'cesium'; +import { PLANE_MODEL_URL } from './policy.js'; + +export function createLifecycle({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { clearFocusTarget } = services.focus; + const { + registerSpriteCollection, + restoreSpriteOrder, + restoreSpriteOrderOnEnable, + } = services.sprites; + const { onMilitaryLayerActiveChange } = services.militaryRegistry; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (flightState._viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + flightState._source = source; + flightState._lastSource = source.label || flightState._lastSource; + this.source = flightState._lastSource; + }, + + /** + * Initialize the flights layer. + * Creates the BillboardCollection, resets all state, and installs the + * click-to-track handler on the scene canvas. + * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. + */ + init(viewer) { + if (flightState._viewer) + throw new Error('Flight layer is already initialized'); + if (typeof flightState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + if (flightState.lifetime.signal.aborted) + flightState.lifetime = new AbortController(); + clearFocusTarget('flights'); + flightState._focusEvidenceIds.clear(); + flightState._viewer = viewer; + flightState._billboardCollection = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(flightState._billboardCollection); + registerSpriteCollection('flights', flightState._billboardCollection); + flightState._modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(flightState._modelCollection); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(PLANE_MODEL_URL), + asynchronous: false, + }) + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + try { + m.destroy(); + } catch { + /* gone */ + } + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + flightState._billboards = new Map(); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map(); + flightState._positionHistory = new Map(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._backoff = false; + flightState._retryAt = 0; + flightState._lastError = null; + flightState._lastStatus = null; + flightState._lastSource = flightState._source.label || 'Aircraft'; + flightState._lastCoverage = 'worldwide upstream snapshot'; + flightState._trackedIcao = null; + parts.tracking._resetTrackedSelectionState(); + flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; + flightState._cockpitContactMode = + document.body.classList.contains('cockpit-mode'); + flightState._cockpitNearContacts = new Set(); + if (!flightState._cockpitModeListener) { + flightState._cockpitModeListener = (event) => + parts.tracking._applyCockpitState(event?.detail); + window.addEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + } + // Fresh session — full bucket, anchor re-seeded on the first sweep. + flightState._enrichAmbientBudget = + parts.enrichment._ambientBudgetKnobs().ceil; + flightState._enrichAmbientRefillAnchorMs = 0; + + parts.tracking._installClickHandler(viewer); + + // React to Military-layer toggles IMMEDIATELY (suppress/restore sweep) + // instead of waiting out the 30 s poll (M2). + if (!flightState._milActiveChangeUnsub) { + flightState._milActiveChangeUnsub = onMilitaryLayerActiveChange( + parts.tracking._onMilitaryActiveChange, + ); + } + + restoreSpriteOrder(viewer); + + console.log('[Data:Flights] Initialized with billboard icons'); + }, + + /** + * Show the billboard collection and re-install the click handler. + * @param {Cesium.Viewer} viewer + */ + enable(viewer) { + if (flightState._billboardCollection) + flightState._billboardCollection.show = true; + holdContinuousRender('flights'); // per-frame animator (perf wave 2) + if (flightState._modelCollection) + flightState._modelCollection.show = true; + parts.tracking._setCockpitContactMode( + document.body.classList.contains('cockpit-mode'), + ); + // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop + // only reads geoidHeight() synchronously after this resolves (guarded by + // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. + if (!flightState._geoidReady) { + const lifetime = flightState.lifetime; + ensureGeoidReady() + .then(() => { + if (!lifetime.signal.aborted) flightState._geoidReady = true; + }) + .catch(() => { + /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ + }); + } + parts.tracking._installClickHandler(viewer); + registerPickOwner('flights', (pickedId) => + flightState._billboards.has(pickedId), + ); + // Force a fresh rotation pass on the first tick after re-enable + flightState._lastCamPoseSig = ''; + if (!flightState._preRenderRemove && viewer?.scene) { + flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering._fleetTick, + ); + } + if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { + flightState._trackedModelPreUpdateRemove = + viewer.scene.preUpdate.addEventListener( + parts.rendering._updateTrackedModel, + ); + } + if (!flightState._moveEndRemove && viewer?.camera) { + // Arrival polish (field test 2026-07-03: "planes look weird when you first + // come to them"): when a camera move SETTLES (voice fly-to, fast pan + // release), force a full rotation pass on the very next frame. The + // pose-signature gate alone can eat the settle — the final easing frames + // of a flight land inside one quantization bucket (10 m / 0.06°), leaving + // every icon wearing its last mid-flight rotation for up to + // ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs + // on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one + // extra rotation pass per completed camera gesture — nothing per-frame. + flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( + () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }, + ); + } + restoreSpriteOrderOnEnable('flights', viewer); + }, + + /** + * Hide all flight billboards and tear down click/keyboard handlers. + * Also clears any active flight tracking so the camera is released. + * @param {Cesium.Viewer} viewer + */ + disable(viewer) { + parts.controller._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._billboardCollection) + flightState._billboardCollection.show = false; + releaseContinuousRender('flights'); + parts.rendering._releaseModels(); + if (flightState._modelCollection) + flightState._modelCollection.show = false; + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + // Remove click handler + keydown listener while disabled to avoid + // intercepting input when the layer is off + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + unregisterPickOwner('flights'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + }, + + /** + * Fully tear down the flights layer — remove primitives, handlers, + * tracked entities, and clear all internal state maps. + * @param {Cesium.Viewer} viewer + */ + destroy(viewer) { + flightState.lifetime.abort(); + flightState._enrichActive = 0; + flightState._enrichLastDispatchMs = 0; + parts.controller._abortActiveUpdates(); + releaseContinuousRender('flights'); // direct-destroy path (perf wave 2 fix) + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + if (flightState._milActiveChangeUnsub) { + flightState._milActiveChangeUnsub(); + flightState._milActiveChangeUnsub = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + if (flightState._cockpitModeListener) { + window.removeEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + flightState._cockpitModeListener = null; + } + unregisterPickOwner('flights'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + parts.rendering._releaseModels(); + if (flightState._billboardCollection) { + viewer.scene.primitives.remove(flightState._billboardCollection); + flightState._billboardCollection = null; + } + if (flightState._modelCollection) { + viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models + flightState._modelCollection = null; + } + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + try { + flightState._preloadModel.destroy(); + } catch { + /* gone */ + } + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._detectionObjects.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._displayFloorState.clear(); + flightState._enrichQueue.length = 0; + flightState._enrichSeen.clear(); + if (flightState._enrichDripTimer) { + clearTimeout(flightState._enrichDripTimer); + flightState._enrichDripTimer = null; + } + flightState._missingPolls.clear(); + flightState._focusEvidenceIds.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._cockpitContactMode = false; + flightState._cockpitNearContacts = new Set(); + flightState._cockpitSubjectId = null; + flightState._trackingRefreshEpoch += 1; + flightState._lastTrackingRefreshOutcome = { + epoch: flightState._trackingRefreshEpoch, + status: 'destroyed', + ids: new Set(), + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling + flightState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js new file mode 100644 index 000000000..42de84532 --- /dev/null +++ b/src/layers/flights/motion.js @@ -0,0 +1,1108 @@ +import * as Cesium from 'cesium'; +import { + COURSE_HOLD_SPEED_MPS, + lerpAngleDeg, + speedRamp, + courseBetweenCartesians, + staleCoastLimitSeconds, + arcOffsetEnu, + courseSlewCapDps, + limitCourseStep, + corridorPathLatLon, +} from '../../data/motionModel.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + modelVisualAnchor, + modelAnchorWorld, +} from '../../data/modelVisualAnchor.js'; +import { + FOCUS_EVIDENCE_DEV, + RENDER_DELAY_SEC, + TRACKED_MODEL_MAX_PX, + TRACKED_MODEL_MIN_PX, + TRACKED_BILLBOARD_SCALE_BY_DISTANCE, + DR_CORRECTION_MS, + COURSE_SLEW_DT_MAX_SEC, + COURSE_MAX_DPS, + HELD_FLOOR_MAX_DRIFT_KM, + NEIGHBOR_FLOOR_PROBE_MS, + FLOOR_SEED_GRACE_MS, + FLOOR_EASE_MAX_STEP, + FLOOR_EASE_TAU_MS, + FLOOR_EASE_EPSILON_M, + DISPLAY_CORRIDOR_RADIUS_KM, + DISPLAY_CORRIDOR_LOOKAHEAD_SEC, + DISPLAY_CORRIDOR_CELL_BUDGET, + DISPLAY_CORRIDOR_FAIR_SHARE, +} from './policy.js'; + +export function createMotion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + cachedGroundFloor, + GROUND_FLOOR_LIFT_M, + neighborFloorM, + stickyFloorCell, + displayFloorHeightM, + coarseFloorCoord, + corridorFloorCells, + allocateCorridorCells, + } = services.groundFloor; + const { trackedModelScaleForPixelCap } = services.camera; + const { + nearFarScalarValueAtDistance, + clearFocusTarget, + publishFocusTargetFromCachedPosition, + } = services.focus; + + // Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal + // warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS + // jitter minted fresh keys every poll → thousands of upstream points per + // minute → Re:Earth proxy failures → geoid-fallback POISON cached at + // sea-level heights → sunken sprites/trails and rejected mesh samples. The + // coarse ~111 m floor cells (viewer-proximate, collected in the poll loop) + // are the only DEM warm the layer needs. + + /** + * Round 6: lifts STALE grounded contacts onto floors that warmed after + * their last feed fix. A parked plane whose transponder went quiet keeps + * coasting on its final meta — if that fix predated the floor warm, it sat + * frozen underground forever. Runs once per poll over the (bounded) flight + * map; touches only grounded, feed-absent, demonstrably-below-floor + * contacts, and patches the stored fix + billboard in place (zero-velocity + * DR renders the patched fix verbatim). + * @param {Set} currentIcaos - Contacts present in THIS poll (already + * floored by the live path — skipped here). + */ + + function _refloorStaleGroundedContacts(currentIcaos) { + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + info.cullPosition = null; // above the ellipsoid now (or floors say otherwise next poll) + const position = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const history = flightState._positionHistory.get(icao24); + const newest = history?.[history.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone(position, newest.position); + const bb = flightState._billboards.get(icao24); + if (bb) bb.position = position; + } + } + + /** + * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. + * + * Projects forward from the last known API fix using the aircraft's ground + * velocity and true_track heading. The ENU transform avoids repeated lat/lon + * trig, keeping the per-frame cost low. + * + * If this aircraft is the tracked target and a lerp is in progress (new API + * fix just arrived), the function first blends from the old dead-reckoned + * position toward the corrected fix before projecting forward. + * + * @param {string} icao24 - ICAO 24-bit transponder address of the aircraft. + * @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists. + */ + + function _deadReckon(icao24, result) { + const info = flightState._flightData.get(icao24); + if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { + const position = flightState._billboards.get(icao24)?.position; + flightState._drCourseDeg = info?.true_track || 0; + flightState._drSpeedMps = info?.velocity || 0; + flightState._drCourseHold = + flightState._drSpeedMps < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = false; + return position + ? Cesium.Cartesian3.clone(position, result || new Cesium.Cartesian3()) + : null; + } + const history = flightState._positionHistory.get(icao24); + if (!history || history.length === 0) { + flightState._drCourseDeg = null; + flightState._drSpeedMps = null; + flightState._drCourseHold = false; + flightState._drExtrapolating = false; + return null; + } + + const out = result || new Cesium.Cartesian3(); + // Render one poll interval behind real time so we interpolate between + // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); + + // Bracketing pair: interpolate — no extrapolation error, no snap-back. + for (let i = history.length - 1; i >= 1; i--) { + const a = history[i - 1]; + const b = history[i]; + if ( + Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && + Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) { + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + // Course of the DISPLAYED motion. The chord is only trustworthy when the + // segment covers real ground (at hover its direction is GPS jitter; on a + // slow tight turn it STEPS the whole per-segment turn at each boundary), + // so it is blended against the reported per-fix track by displayed + // ground speed — and the track is TIME-INTERPOLATED between the fixes so + // a slow turner's nose advances continuously through the segment instead + // of snapping once per poll. Helicopters always use the reported track + // (rotorcraft chords are noise-dominated at their typical speeds). + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const segSpeed = + span > 0 ? chordLenM / span : (info && info.velocity) || 0; + const fallbackTrack = (info && info.true_track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); + const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); + const chordCourse = + w > 0 ? courseBetweenCartesians(a.position, b.position) : null; + flightState._drCourseDeg = + chordCourse != null + ? lerpAngleDeg(trackCourse, chordCourse, w) + : trackCourse; + flightState._drSpeedMps = segSpeed; + flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = false; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + } + + const newest = history[history.length - 1]; + const elapsedSec = Cesium.JulianDate.secondsDifference( + renderTime, + newest.time, + ); + if (elapsedSec <= 0) { + // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking + // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing + // pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind + // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history + // fills, renderTime advances toward the first fix and the icon glides FORWARD into + // the bracketing interpolation above with NO freeze and NO backward snap. (Holding + // the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made + // the icon jump back ~one poll interval the instant interpolation took over.) + const oldest = history[0]; + const lookbackSec = Cesium.JulianDate.secondsDifference( + oldest.time, + renderTime, + ); // ≥ 0 + return _extrapolateFix( + oldest, + info, + -Math.min(lookbackSec, 60), + out, + (info && info.turnRateDps) || 0, + ); + } + + // Newest POSITION is older than renderTime. OpenSky can still be receiving + // fresh contact/kinematic messages for that aircraft; freezing at a hard + // 60 s-after-position boundary produced the visible stop → catch-up → stop + // cadence. Coast through the latest real contact plus a bounded grace + // window, with an absolute cap so a stale cached feed cannot drift forever. + const coastLimitSec = staleCoastLimitSeconds({ + // `epochMs` is captured once when the poll is normalized. Avoid allocating + // a Date per aircraft on every 12 Hz fleet tick. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, + // Permit one minute of contact grace but cap any cached-feed drift at + // five minutes. Source backoff is exposed separately as a STALE cue. + minimumSec: 60, + maximumSec: 300, + }); + return _extrapolateFix( + newest, + info, + Math.min(elapsedSec, coastLimitSec), + out, + (info && info.turnRateDps) || 0, + ); + } + + /** + * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a + * constant-rate-turn arc when `turnRateDps` is significant (straight line + * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` + * projects BACKWARD (before the fix) — used for warm-up, estimating where the + * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, + * up = +Z; heading 0 deg = north, 90 deg = east. Sets `_drCourseDeg` to the + * arc's instantaneous end course on every path. + * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). + */ + + function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { + const speed = Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.velocity) || 0; + const heading = Number.isFinite(fix.track) + ? fix.track + : (info && info.true_track) || 0; + flightState._drSpeedMps = speed; + flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = true; + if (speed === 0 || dt === 0) { + flightState._drCourseDeg = heading; + return Cesium.Cartesian3.clone(fix.position, out); + } + // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a + // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. + arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); + flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; + Cesium.Cartesian3.fromElements( + flightState._scratchArc.east, + flightState._scratchArc.north, + 0, + flightState._scratchOffset, + ); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchEnu, + ); + return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); + } + + /** + * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) + * predates its oldest real fix — i.e. _deadReckon is extrapolating backward, with no + * real history yet sitting BEHIND the displayed icon. The trail must draw nothing in + * this window (every accumulated point is ahead of the icon). + * @returns {boolean} + */ + + function _isTrackWarmingUp() { + if (!flightState._trackedIcao) return false; + const history = flightState._positionHistory.get(flightState._trackedIcao); + if (!history || history.length === 0) return true; + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + return Cesium.JulianDate.lessThan(renderTime, history[0].time); + } + + /** Resolve the selected aircraft's actual rendered square extent this frame. */ + + function _trackedFocusSizePx(icao24, position) { + const camera = flightState._viewer?.camera; + const scene = flightState._viewer?.scene; + if (!camera?.positionWC || !position || !scene) return 28; + const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); + if (parts.rendering._modelOwnsVisual(icao24)) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(icao24)?.klass, + ); + const scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM, + viewportHeightPx: scene.canvas.clientHeight, + fovyRad: camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + const focalLengthPx = + scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + const projectedDiameterPx = + (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; + return Math.max( + TRACKED_MODEL_MIN_PX, + Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx), + ); + } + + const billboard = flightState._trackedEntity?.billboard; + const time = flightState._viewer.clock.currentTime; + const width = billboard?.width?.getValue(time) ?? 28; + const height = billboard?.height?.getValue(time) ?? 28; + const scale = + billboard?.scale?.getValue(time) ?? + (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + const scaleByDistance = + billboard?.scaleByDistance?.getValue(time) ?? + TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); + return Math.max(width, height) * scale * distanceScale; + } + + /** + * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium + * frame number so the position / rotation / trail-head callbacks share ONE computation + * (and one reconciliation-state update) per frame. Returns a stable module holder, or + * null when the aircraft has no fix. + * @param {string} icao24 + * @returns {Cesium.Cartesian3|null} + */ + + function _trackedDisplayPosition(icao24) { + const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frame === flightState._cachedDRFrame && + icao24 === flightState._drReconcileIcao + ) + return flightState._cachedDRPosition; + + // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting + // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) + const sameTrack = + flightState._drReconcileValid && flightState._drReconcileIcao === icao24; + const raw = _deadReckon(icao24, flightState._scratchDrRaw); + flightState._cachedDRCourse = flightState._drCourseDeg; + flightState._cachedDRSpeedMps = flightState._drSpeedMps; + flightState._cachedDRHold = flightState._drCourseHold; + flightState._cachedDRFrame = frame; + flightState._drReconcileIcao = icao24; + if (!raw) { + flightState._cachedDRPosition = null; + flightState._drReconcileValid = false; + clearFocusTarget('flights', icao24); + return null; + } + + const nowMs = Date.now(); + const info = flightState._flightData.get(icao24); + if (sameTrack) { + const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); + const speed = (info && info.velocity) || 0; + // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. + const plausible = speed * dtSec * 4 + 25; + if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { + // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. + Cesium.Cartesian3.subtract( + flightState._drPrevDisplay, + raw, + flightState._drCorrection, + ); + flightState._drCorrectionStartMs = nowMs; + } + } else { + Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); + flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed + } + + const elapsed = nowMs - flightState._drCorrectionStartMs; + const factor = + elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; + let display = Cesium.Cartesian3.multiplyByScalar( + flightState._drCorrection, + factor, + flightState._trackedPosHolder, + ); + Cesium.Cartesian3.add(raw, display, display); + // Same display floor the fleet pass applies — otherwise selecting a correctly + // floored grounded billboard swapped it for an unfloored tracked entity and + // dropped the cyan target back under the mesh. Applied HERE, at the single + // point every VISUAL consumer's per-frame position is computed, so the + // readout anchor, detection bracket, trail head and follow-camera all read + // the one floored value (the anti-jitter contract forbids recomputing per + // consumer). NOT the single point for DATA: `_describeFlight` deliberately + // reports sensor truth — see the note there. Skipped while a tracked 3D model + // owns the visual: it rides groundSnap's one-shot sample and moving its input + // would force a re-sample (T7). + display = _floorGroundedDisplayPosition( + icao24, + info, + display, + parts.rendering._modelOwnsVisual(icao24), + nowMs, + ); + + Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); + Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); + flightState._drPrevMs = nowMs; + flightState._drReconcileValid = true; + flightState._cachedDRPosition = display; + const focusSizePx = _trackedFocusSizePx( + icao24, + flightState._cachedDRPosition, + ); + // Publish only the exact per-frame display cache the tracked entity/camera + // consumes. Re-running DR from a later frame phase recreates the historical + // target-vs-camera jitter bug. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'flights', + id: icao24, + scene: flightState._viewer?.scene, + camera: flightState._viewer?.camera, + displayPosition: flightState._cachedDRPosition, + widthPx: focusSizePx, + heightPx: focusSizePx, + }); + return display; + } + + /** The tracked plane's current display position WITHOUT recomputing — the exact value the + * follow-camera already settled on this frame (in the Viewer _onTick). getDetectableObjects + the + * readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would + * re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters + * against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when + * there's no valid fix for the tracked aircraft, so callers fall back to the billboard position. */ + + function _trackedDisplayCached() { + return flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao + ? flightState._cachedDRPosition + : null; + } + + /** + * The position the tracked aircraft is VISUALLY at this frame — the translation its + * 3D model is actually rendering with when the model owns the visual, otherwise the + * cached dead-reckoned position the billboard uses. + * + * This exists because a grounded plane's model rides a one-shot ground snap while its + * billboard deliberately stays at the reported (buried) altitude — a ~100 m vertical + * split at an inland airport. Anything anchored to the display position while the model + * is what you can see drifts below the aircraft and only converges as the coarse floor + * cell warms ("the buoy"), sometimes never. + * + * It reads `modelMatrix`, which `_updateTrackedModel` already wrote this frame — no + * sampling, no `_modelDisplayPosition` call from postRender, and no new dead reckoning, + * so the follow-camera anti-jitter contract on `gevDisplayPosition` is untouched. + */ + + function _trackedVisualCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + return modelVisualAnchor( + flightState._trackedModel.modelMatrix, + spec.visualCenterNative, + Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale, + flightState._trackedVisualPos, + ); + } + return _trackedDisplayCached(); + } + + /** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on + * the visual centre; only the trail moves to the model's lower-centre hardpoint. */ + /** The tracked model's rendered bounding radius (m), or 0 when no model of this + * contact is drawing. Carries Cesium's effective `computedScale`, which may be + * above `scale` to satisfy minimumPixelSize — the trail head has to be judged + * against the size the operator SEES, not the nominal one. */ + /** World-space origin of the tracked model, or null when no model of this + * contact is drawing. This is the centre the rendered bounding sphere is + * measured from, so the trail clip and the envelope agree on one frame. */ + + function _trackedModelCenterWorld() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return null; + return Cesium.Matrix4.getTranslation( + flightState._trackedModel.modelMatrix, + flightState._scratchTrailClip, + ); + } + + function _trackedModelEnvelopeM() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return 0; + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const scale = Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale; + return spec.nativeRadiusM * scale; + } + + function _trackedTrailCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + // Through the model's OWN render chain (modelVisualAnchor's hand-rolled + // half-correction put this offset on the lateral axis — see + // modelAnchorWorld). + return modelAnchorWorld( + flightState._trackedModel, + spec.trailAnchorNative, + flightState._trackedTrailPos, + ); + } + return _trackedDisplayCached(); + } + + /** Smoothed world course for the tracked aircraft this frame. Reads the + * frame-cached course (set by _trackedDisplayPosition — the follow-camera's + * own computation), NEVER re-runs _deadReckon. Safe to call more than once + * per frame: the second call sees dt≈0 and the limiter is a no-op. + * + * The smoothed value lives in the SHARED per-icao _displayCourse entry (see + * its declaration): on click the limiter continues from whatever nose the + * fleet pass was displaying, and on untrack the fleet pass continues from + * whatever nose this path last wrote — the tracked and fleet consumers of + * the same aircraft can never disagree across the handoff. */ + + function _trackedDisplayCourse() { + const info = flightState._flightData.get(flightState._trackedIcao); + const fallback = (info && info.true_track) || 0; + const cacheValid = + flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao && + flightState._cachedDRCourse != null; + const raw = cacheValid ? flightState._cachedDRCourse : fallback; + const nowMs = Date.now(); + const dt = flightState._trackedCourseMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._trackedCourseMs) / 1000, + ) + : 0; + flightState._trackedCourseMs = nowMs; + const prev = flightState._displayCourse.get(flightState._trackedIcao); + // Hover hold: at near-zero displayed speed both the chord and the reported + // track are noise — keep the last stable nose direction instead of chasing. + if (cacheValid && flightState._cachedDRHold && prev != null) return prev; + const cap = courseSlewCapDps( + cacheValid + ? flightState._cachedDRSpeedMps + : ((info && info.velocity) ?? NaN), + COURSE_MAX_DPS, + ); + const course = limitCourseStep(prev, raw, cap, dt); + flightState._displayCourse.set(flightState._trackedIcao, course); + return course; + } + + /** Reset reconciliation + per-frame cache when tracking stops or switches + * target. Deliberately does NOT touch _displayCourse: the smoothed course + * entry belongs to the AIRCRAFT (not to the tracked session) and must + * survive the handoff back to the fleet pass. */ + + function _resetTrackedDisplay() { + flightState._drReconcileValid = false; + flightState._drReconcileIcao = null; + flightState._cachedDRFrame = -1; + flightState._cachedDRPosition = null; + flightState._cachedDRCourse = null; + flightState._cachedDRSpeedMps = null; + flightState._cachedDRHold = false; + flightState._trackedCourseMs = 0; + } + + /** + * The floor to stand a grounded contact on while its own cell is unresolved. + * + * Owner directive (2026-08-21, after a Re:Earth outage buried a parked contact + * at a Texas field): "hold the last known altitude until a fresh one comes in. + * Never render otherwise." Two tiers, strongest first: + * own — a floor this contact's OWN cell resolved to while it stood there. + * Nothing weaker can improve on it, and its cell warming again is picked + * up by the live read, not here. Valid only within + * HELD_FLOOR_MAX_DRIFT_KM of where it was measured. + * neighbor — the LOWEST of at least two resolved ADJACENT cells (~111 m away, + * the same apron). Two at least, because one reading cannot be checked + * against anything, and the LOWEST because a high reading beside a cold + * cell is as likely to be a terminal roof as ground — see neighborFloorM. + * + * BOTH are validated measurements out of the shared floor cache. A third tier + * that read the rendered mesh directly, where no DEM existed to check it, was + * built and then REMOVED: run against a real GPU with the proxy down it + * recorded a coarse-LOD 20.6 m for ground that is really ~122 m (see the note + * in meshFloorSampler.js). Nothing in this chain is a guess. + * + * A neighbour hold keeps re-probing at the throttle so it can UPGRADE: a parked + * contact that first answered from one neighbour would otherwise keep it after + * a better one warmed. A held value is never dropped for nothing — a failed + * probe leaves the previous answer standing — and a re-probe that lands LOWER + * eases rather than steps (see the ease note in the clamp below). + * + * A REHYDRATED SEED is the one exception to the tier order. A floor parked by + * `_retireDisplayFloorState` and picked back up on a later re-ground was + * measured while the contact stood somewhere it no longer necessarily is: it is + * a memory, not a reading, and it outranks nothing. So `seeded` demotes an + * `own` floor below the neighbour tier — when two fresh adjacent cells can + * answer, they answer, and the seed is discarded. It serves only while nothing + * fresh contradicts it, which is exactly the flap case it exists for (a rotation + * outruns its own cells, so nothing nearby is warm either). Without this a + * contact that re-grounded 0.5 km away kept a 200 m floor while its new + * neighbourhood read 100 m and 105 m, and rendered 100 m in the air. + * + * @param {object} state - The contact's `_displayFloorState` entry (mutated). + * @param {{lat: number, lon: number}} cell - Cell the display is reading now. + * @param {number} nowMs - Tick clock. + * @returns {number|null} Floor to use, or null when nothing anywhere can answer. + */ + + function _heldDisplayFloorM(state, cell, nowMs) { + if ( + state.heldTier && + !( + Number.isFinite(state.heldM) && + state.heldCell && + parts.queries._approxDistanceKm( + cell.lat, + cell.lon, + state.heldCell.lat, + state.heldCell.lon, + ) <= HELD_FLOOR_MAX_DRIFT_KM + ) + ) { + // Out of range: the held value is no longer a measurement of anywhere this + // contact has been. Drop it rather than stretch it. + _dropHeldFloor(state); + } + if (state.heldTier === 'own' && !state.seeded) return state.heldM; + if ( + state.probeMs != null && + nowMs - state.probeMs < NEIGHBOR_FLOOR_PROBE_MS + ) + return state.heldM; + state.probeMs = nowMs; + const near = neighborFloorM(cell); + if (near != null) return _adoptHeldFloorM(state, near, cell, 'neighbor'); + return state.heldM; + } + + /** Records a held floor and the tier it came from; returns it. Every caller + * passes a floor measured for the cell the contact is reading NOW, so an + * adoption always clears the seed flag: live evidence has arrived. */ + + function _adoptHeldFloorM(state, floorM, cell, tier) { + state.heldM = floorM; + state.heldCell = cell; + state.heldTier = tier; + state.seeded = false; + return floorM; + } + + /** Forgets the held floor and everything that describes where it came from, + * leaving the rest of the contact's display state alone. */ + + function _dropHeldFloor(state) { + state.heldM = null; + state.heldCell = null; + state.heldTier = null; + state.seeded = false; + } + + /** Whether a parked seed has been away longer than the grace window. + * + * ONE judgement, asked from BOTH sides of the park, because neither side sees + * the whole story on its own. While the contact keeps reporting, the retire + * path asks it and drops the entry. But a contact can be parked and then make + * no calls at all — off the poll for a long-haul cruise, out of the corridor + * radius, tab hidden — and then re-ground; nothing ran in between, so an + * expiry checked only on the retire path never fires and an arbitrarily old + * measurement walks back in (measured: parked 198 s, still reused). The + * rehydration side therefore asks the same question against the wall clock + * before it clears `retiredMs`. + * @param {object} state @param {number} nowMs - Tick clock. */ + + function _seedExpired(state, nowMs) { + return ( + state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS + ); + } + + /** Retires a contact's display-floor state. Called the moment it stops being a + * grounded billboard — airborne, model-owned, or gone. + * + * The floor itself is kept as a rehydration seed for FLOOR_SEED_GRACE_MS (see + * above) and MARKED as one; everything that describes the contact's CURRENT + * rendering is cleared, so a re-ground recomputes from scratch and cannot be + * mistaken for a hold release. Nothing visual is touched, so the T7 + * model-ownership gate is unaffected. + * @param {string} icao24 @param {number} nowMs - Tick clock. */ + + function _retireDisplayFloorState(icao24, nowMs) { + const state = flightState._displayFloorState.get(icao24); + if (!state) return; + if (state.retiredMs == null) { + state.retiredMs = nowMs; + state.seeded = true; // what it answers with next is a memory, not a reading + state.heldActive = false; // a later landing is an arrival, not a release + state.easedM = null; + state.easeMs = null; + state.probeMs = null; // re-ground may probe immediately + state.out = null; + state.effectiveM = null; + Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO, state.in); // invalidate the memo + } else if (_seedExpired(state, nowMs)) { + flightState._displayFloorState.delete(icao24); // airborne long enough to be anywhere + } + } + + /** + * Floors a GROUNDED contact's DISPLAYED position onto the local ground. + * + * `renderAltitudeM` is chosen once per poll from the floor of the FIX's coarse + * cell. The position that renders is the dead-reckoned one, which drifts away + * from that fix for the whole segment — and for up to 300 s / several hundred + * metres while a ground contact coasts through its stale-feed grace. Across a + * graded apron (KAUS spans ~119–140 m ellipsoidal) that drift buries the + * sprite under the mesh it is now over; `scripts/qa-floor-verify.mjs` measured + * −15.5 m. A second, smaller share comes from the fix-time floor itself: a + * taxiing contact whose current cell is still cold falls back to the PREVIOUS + * fix's cell (see the grounded `surfaceM` chain) and nothing revisits that + * height once the cell warms — the stale re-floor sweep deliberately skips + * contacts present in the poll. Both are cured by reading the floor at the + * coordinate actually being displayed. + * + * Discipline: + * - READ-ONLY against the shared floor cache. No latch, no heal, no sampling. + * Keeping cold cells rare is `_collectDisplayCorridorCells`'s job, not this + * one's. A cold cell used to mean NO clamp at all, which was only safe while + * the un-clamped height was a real reading — and for a grounded contact with + * no altitude data it is not: the poll path's last resort is the geoid, tens + * of metres under the mesh at an inland field. When the cell cannot answer, + * `_heldDisplayFloorM` holds the last floor that DID (owner, 2026-08-21). + * Still never an invented surface: every tier is a measurement, and when + * none exists the position passes through as before. + * - Grounded contacts only. Airborne heights are the fix-time clamp's job. + * - NEVER when a 3D model owns the visual (T7): the model rides groundSnap's + * one-shot tileset sample and the billboard hides behind it, so clamping the + * hidden billboard would put a SECOND ground chain on one contact — and the + * one the operator is not looking at. (The original rationale was narrower: + * lifting the datum dragged groundSnap's input past its 50 m + * move-invalidation and forced a re-sample every frame. groundSnap now + * measures that distance on the ellipsoid, so a purely vertical change costs + * nothing; the gate stays for the reason above.) Same gate the military + * layer's grounded billboard lift uses. + * + * @param {string} icao24 - Contact key (owns one `_displayFloorState` entry). + * @param {object|null|undefined} info - `_flightData` record for this contact. + * @param {Cesium.Cartesian3|null} pos - Dead-reckoned display position. + * @param {boolean} modelOwnsVisual - Whether a 3D model is drawing this contact. + * @param {number} [nowMs] - Tick clock, passed by both callers so the release + * ease advances on the same clock the rest of the tick uses. + * @returns {Cesium.Cartesian3|null} `pos` itself when nothing moves (the common + * case — no allocation, no rebuild), otherwise the lifted position. + */ + + function _floorGroundedDisplayPosition( + icao24, + info, + pos, + modelOwnsVisual, + nowMs = Date.now(), + ) { + if (!pos || !info?.onGround || modelOwnsVisual) { + _retireDisplayFloorState(icao24, nowMs); + return pos; + } + const state = flightState._displayFloorState.get(icao24); + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchDisplayCarto, + ); + // Boundary hysteresis: a position jittering across a cell edge would flip + // floors at fleet-tick rate (see stickyFloorCell). + const cell = stickyFloorCell( + Cesium.Math.toDegrees(carto.latitude), + Cesium.Math.toDegrees(carto.longitude), + state?.cell, + ); + const floor = cachedGroundFloor(cell.lat, cell.lon); + const next = state || { + cell, + in: new Cesium.Cartesian3(), + out: null, + effectiveM: null, + heldM: null, + heldCell: null, + heldTier: null, + heldActive: false, + seeded: false, + probeMs: null, + easedM: null, + easeMs: null, + retiredMs: null, + }; + // Back on the ground. Judge the seed's AGE here, before `retiredMs` is + // cleared: a contact that made no calls while it was away never reached the + // retire path's own expiry branch, so this is the only place that can tell an + // hour-old measurement from a three-poll-old one. + if (_seedExpired(next, nowMs)) _dropHeldFloor(next); + // The seed (if any survived) is live again, and the drift bound plus the + // neighbour tier in the hold chain decide whether it still describes ground + // this contact is on. + next.retiredMs = null; + // The floor to clamp against: this cell when it has one, otherwise the last + // one that resolved for this contact (never the geoid the poll path fell to). + // Snapshot what the contact was standing on BEFORE the chain overwrites it — + // the ease decision below needs the previous value, and `_heldDisplayFloorM` + // adopts into the same fields. + const wasHeld = next.heldActive; + const stoodOnM = next.heldM; + let effective = floor; + if (Number.isFinite(effective)) { + _adoptHeldFloorM(next, effective, cell, 'own'); + next.heldActive = false; + } else { + effective = _heldDisplayFloorM(next, cell, nowMs); + next.heldActive = Number.isFinite(effective); + } + // The floor moved DOWN under a contact that was standing on a BORROWED one. + // Dropping it by that difference in a single tick is the snap the owner asked + // not to have, so approach it instead. Two ways in, and both need it: + // - the real floor arrives below the hold (releasing the hold); + // - a re-probe finds a LOWER neighbour than the one being held, which the + // 12 m spread bound can make a large step on a mesa edge (200 m held, a + // 120 m neighbour warms, the bounded answer is 132 m) — and can happen + // AGAIN while the first approach is still running. + // Scoped to a borrowed floor on purpose: an ordinary cell-to-cell change + // between two resolved floors is the existing path and keeps its timing. + if ( + next.easedM == null && + wasHeld && + Number.isFinite(stoodOnM) && + Number.isFinite(effective) && + effective < stoodOnM + ) { + next.easedM = stoodOnM; // start from where the contact is actually drawn + next.easeMs = nowMs; + } + if (next.easedM != null) { + if (!Number.isFinite(effective) || effective >= next.easedM) { + // Nothing to approach, or the floor rose: take it whole and stop. + next.easedM = null; + next.easeMs = null; + } else { + // Exponential approach from the DISPLAYED value. The target may have + // moved since last tick; that changes only where this is heading, never + // where it is, so there is no seam to jump across. + const dtMs = Math.max(0, nowMs - next.easeMs); + next.easeMs = nowMs; + const closed = Math.min( + FLOOR_EASE_MAX_STEP, + 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS), + ); + let value = next.easedM + (effective - next.easedM) * closed; + if (Math.abs(value - effective) <= FLOOR_EASE_EPSILON_M) { + value = effective; // arrive exactly, so a parked contact stops rebuilding + next.easedM = null; + next.easeMs = null; + } else { + next.easedM = value; + } + effective = value; + } + } + // Same input position AND the same EFFECTIVE floor ⇒ the same answer as last + // tick, so skip the rebuild. A clamped stationary contact (parked, coasting + // on a zero-velocity fix) hits this every tick; without it the identical + // Cartesian was rebuilt at ~12 Hz forever. The test is deliberately on the + // OUTPUT of the hold chain, not on its inputs: keying it to the raw cell + // floor let a parked contact whose cell never warmed return a memoized + // unresolved answer forever, so an adjacent-cell floor warming later was + // never adopted. One owned entry per grounded + // contact — O(grounded), dropped on eviction, on destroy, and the moment the + // contact stops being a grounded billboard. + if ( + state && + state.effectiveM === effective && + Cesium.Cartesian3.equals(pos, state.in) + ) { + return state.out || pos; + } + const lifted = displayFloorHeightM(carto.height, effective); + next.cell = cell; + next.effectiveM = effective; + Cesium.Cartesian3.clone(pos, next.in); + if (lifted == null) { + next.out = null; + } else { + // The cache OWNS its output: returning a shared scratch would let the next + // contact in the fleet loop overwrite a position already handed out. + next.out = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + lifted, + Cesium.Ellipsoid.WGS84, + next.out || new Cesium.Cartesian3(), + ); + } + if (!state) flightState._displayFloorState.set(icao24, next); + return next.out || pos; + } + + /** + * Adds the cells each grounded contact's DISPLAY is about to render over to + * this poll's floor warm/sample batch. + * + * The poll loop otherwise collects FIX cells only, so the clamp above has data + * exactly where the contact ISN'T. A contact taxiing at 10 m/s crosses a + * ~111 m cell every ~11 s while the batch runs once per 30 s poll, so it stays + * permanently ahead of its own floor data (the "taxiing cache" failure mode, at + * cell granularity) and the clamp silently passes. + * + * The corridor therefore follows the direction the display is actually MOVING, + * which is not always toward the fix: + * - INTERPOLATING between two fixes — the display is walking to the newest + * fix, so that fix is the endpoint (exact, no projection error). + * - EXTRAPOLATING — coasting past the newest fix on a stale feed, or the + * pre-history warm-up — the display travels along its course AWAY from that + * fix. Aiming at the fix here warms the BACKTRAIL while the contact stays + * one sampling cycle ahead and buried, so the endpoint is the position its + * own kinematics put it at two poll intervals from now. + * + * Runs EVERY poll, warm cells included, for the same reason the fix cells do: + * `warmGroundFloor` skips cells that already have a real DEM, but the mesh + * sampler needs to see a cell again AFTER its DEM prior lands, since a sample + * without that prior is rejected. Offering a cell once would leave it DEM-only + * for the session — at fields where the photogrammetric mesh sits well above + * bare earth that is still metres of burial. + * + * Budgeting is need-ranked and dedupe-first: cells this poll already collected + * cost NOTHING (a parked contact's corridor is its own fix cell, so it never + * competes), candidates are ordered by how many of their cells are actually + * cold, and each takes at most DISPLAY_CORRIDOR_FAIR_SHARE before anyone takes + * seconds. Insertion-order spending starved the contacts that needed it most. + * + * Purely additive to the existing batch: same fire-and-forget DEM resolve, same + * one-shot DEM-validated mesh sampler. No latch, no heal. + * + * @param {Array<{lat: number, lon: number}>} out - This poll's warm points. + * @param {number|null} viewerLat @param {number|null} viewerLon - Viewer subpoint. + */ + + function _collectDisplayCorridorCells(out, viewerLat, viewerLon) { + if (viewerLat == null || viewerLon == null) return; + // Cells the poll already collected (grounded + low-airborne fix cells). + const seen = new Set(); + for (const p of out) { + const c = coarseFloorCoord(p.lat, p.lon); + seen.add(`${c.lat},${c.lon}`); + } + + const candidates = []; + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround) continue; + // T7: a contact whose 3D model is the visual never reads a display floor. + if (parts.rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + if ( + parts.queries._approxDistanceKm( + viewerLat, + viewerLon, + info.rawLat, + info.rawLon, + ) > DISPLAY_CORRIDOR_RADIUS_KM + ) + continue; + const dr = _deadReckon(icao24, flightState._scratchCorridorPos); + if (!dr) continue; + // Read the sibling scratches IMMEDIATELY, before any other _deadReckon call. + const extrapolating = flightState._drExtrapolating; + const speedMps = Number.isFinite(flightState._drSpeedMps) + ? flightState._drSpeedMps + : info.velocity || 0; + const courseDeg = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : info.true_track || 0; + const c = Cesium.Cartographic.fromCartesian( + dr, + Cesium.Ellipsoid.WGS84, + flightState._scratchCorridorCarto, + ); + const lat = Cesium.Math.toDegrees(c.latitude); + const lon = Cesium.Math.toDegrees(c.longitude); + const cells = corridorFloorCells( + corridorPathLatLon({ + extrapolating, + displayLat: lat, + displayLon: lon, + courseDeg, + speedMps, + // Same turn the dead-reckon integrates — a sustained-turn taxi leaves a + // straight tangent within a few hundred metres. + turnRateDps: info.turnRateDps || 0, + fixLat: info.rawLat, + fixLon: info.rawLon, + lookaheadSec: DISPLAY_CORRIDOR_LOOKAHEAD_SEC, + }), + ); + let cold = 0; + for (const cell of cells) { + if (cachedGroundFloor(cell.lat, cell.lon) == null) cold += 1; + } + candidates.push({ cells, cold, speedMps }); + } + flightState._corridorEpoch += 1; + for (const cell of allocateCorridorCells( + candidates, + seen, + DISPLAY_CORRIDOR_CELL_BUDGET, + DISPLAY_CORRIDOR_FAIR_SHARE, + flightState._corridorEpoch, + )) { + out.push(cell); + } + } + return { + _refloorStaleGroundedContacts, + _deadReckon, + _extrapolateFix, + _isTrackWarmingUp, + _trackedFocusSizePx, + _trackedDisplayPosition, + _trackedDisplayCached, + _trackedVisualCached, + _trackedModelCenterWorld, + _trackedModelEnvelopeM, + _trackedTrailCached, + _trackedDisplayCourse, + _resetTrackedDisplay, + _heldDisplayFloorM, + _adoptHeldFloorM, + _dropHeldFloor, + _seedExpired, + _retireDisplayFloorState, + _floorGroundedDisplayPosition, + _collectDisplayCorridorCells, + }; +} diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs new file mode 100644 index 000000000..c951616bb --- /dev/null +++ b/src/layers/flights/ownership.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCivilFlightLayer } from './index.js'; +import { createFlightState } from './state.js'; +import { createEnrichment } from './enrichment.js'; + +function services() { + const names = [ + 'picking', + 'sprites', + 'trails', + 'aircraftPresentation', + 'camera', + 'militaryRegistry', + 'labels', + 'groundFloor', + 'meshFloor', + 'geoid', + 'focus', + 'readout', + 'context', + 'render', + 'recession', + ]; + return { + ...Object.fromEntries(names.map((name) => [name, {}])), + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + }; +} + +test('constructing independent flight layers performs no source request and isolates selection', () => { + let requests = 0; + const source = { + label: 'Fixture aircraft', + getSnapshot() { + requests++; + }, + }; + const first = createCivilFlightLayer({ source, services: services() }); + const second = createCivilFlightLayer({ source, services: services() }); + first.testing._armFlightTrackingRestoreForTest('abc123'); + assert.equal(first.testing._pendingFlightTrackingRestoreForTest(), 'abc123'); + assert.equal(second.testing._pendingFlightTrackingRestoreForTest(), null); + first.testing._setFlightTrackingRefreshOutcomeForTest({ ids: ['abc123'] }); + assert.notEqual(first.testing, second.testing); + assert.equal(first.getStats().source, 'Fixture aircraft'); + assert.equal(requests, 0); +}); + +test('a layer without a source fails before touching the viewer', () => { + const layer = createCivilFlightLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); +}); + +test('flight state owns distinct mutable records, scratch objects and floor samplers', () => { + const first = createFlightState({ services: services() }); + const second = createFlightState({ services: services() }); + for (const key of [ + '_flightData', + '_billboards', + '_positionHistory', + '_displayFloorState', + '_groundSnap', + '_scratchCarto', + '_models', + '_activeUpdateControllers', + 'lifetime', + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test('aborted enrichment cannot write into a later lifecycle or decrement its active count', async () => { + let resolve; + let signal; + const source = { + getEnrichment(query, options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }; + const flightState = createFlightState({ source, services: services() }); + const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + let writes = 0; + enrichment._enqueueEnrich( + 'test', + { kind: 'type', id: 'abc123' }, + () => writes++, + ); + await Promise.resolve(); + assert.equal(flightState._enrichActive, 1); + flightState.lifetime.abort(); + flightState.lifetime = new AbortController(); + flightState._enrichActive = 0; + resolve({ found: true }); + await new Promise((done) => setImmediate(done)); + assert.equal(signal.aborted, true); + assert.equal(writes, 0); + assert.equal(flightState._enrichActive, 0); +}); + +test('a synchronous enrichment failure stays bounded and releases its slot', async () => { + const flightState = createFlightState({ + services: services(), + source: { + getEnrichment() { + throw new Error('fixture failure'); + }, + }, + }); + const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + assert.doesNotThrow(() => + enrichment._enqueueEnrich('test', {}, () => assert.fail()), + ); + await new Promise((done) => setImmediate(done)); + assert.equal(flightState._enrichActive, 0); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js new file mode 100644 index 000000000..6ea4f0e77 --- /dev/null +++ b/src/layers/flights/policy.js @@ -0,0 +1,447 @@ +import * as Cesium from 'cesium'; + +export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; + +/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ + +export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); + +// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes +// taxiing and landing") ----------------------------------------------------------- +// Present-but-grounded planes are RENDERED instead of being skipped: same class +// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata +// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles +// the existing billboard in place, never a removal. Ground planes draw no trails +// and are excluded from the ambient enrichment sweep (click-to-enrich still +// works). In 3D mode they take model slots like airborne planes (owner decision +// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap +// (see _modelDisplayPosition). +// +// TINT: full-strength, same pipeline as airborne (white / amber-military / +// cyan-tracked). Day 1 shipped a slate-gray 50%-alpha "muted" ground tint; the +// owner killed it the same day ("just leave them as white, dude … in NYC I can +// barely see them, extremely grayed out"). "On the ground" reads from the ×0.8 +// scale + missing trail; "feed-dropped, coasting" stays the 45%-alpha stale +// fade — a full-alpha ground icon can never be confused with it. +/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ + +export const GROUND_SCALE = 0.8; + +// --- 3D model rendering (B3) --------------------------------------------------------- +// When enabled, aircraft render as 3D glTF models once the camera is below MODEL_ALT_CEIL_M +// (zoomed in); higher up they stay flat billboards. Eligibility is FRUSTUM-based (on-screen), and +// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each +// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). + +export const PLANE_MODEL_URL = '/models/airplane.glb'; + +export const MODEL_ALT_CEIL_M = 800000; +// m: below this camera altitude, draw 3D models (raised so it's easy to trigger) + +export const MODEL_MIN_PX = 24; +// floor so distant models stay visible WITHOUT ballooning into a giant +// min-pixel blob (was 54 — far planes at the All radius became white +// star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent + +export const TRACKED_MODEL_MIN_PX = 40; +// keep the glTF silhouette comparable to the selected 2D glyph at handoff + +export const TRACKED_MODEL_MAX_PX = 200; +// owner-selected close-range tracked-target feel + +export const MODEL_NATIVE_RADIUS_M = 34.41; + +export const MODEL_SCALE = 1; +// airplane.glb is transform-applied and baked to real-world meters +// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. + +export const MODEL_MAX = 150; +// 'proximity' cap (the planes immediately around you) + +export const MODEL_MAX_ALL = 350; +// 'all' cap (everything out to ~the horizon) +// Per-mode ADD / KEEP radii. The two modes differ by RADIUS, not just cap — otherwise they look +// IDENTICAL whenever fewer than a cap's worth of planes are in range (field bug: Proximity and All +// rendered the same). 'proximity' = a tight ring; 'all' = roughly to the horizon (state-scale). Each +// band has hysteresis (KEEP > ADD) so a plane doesn't release+reload its model when it straddles the +// add edge (the zoom/pan flicker). Beyond ADD a model would force-clamp to minimumPixelSize into a +// giant floating blob (the old 422 km airport-cluster bug), so far planes stay 2D dots; the cap + +// on-screen priority then spend the model slots on planes you can actually see. + +export const MODEL_PROX_ADD_M = 150000; +// proximity: model NEW planes within 150 km + +export const MODEL_PROX_KEEP_M = 185000; +// proximity: KEEP modeled planes out to 185 km + +export const COCKPIT_MODEL_MAX = 60; +// max concurrent GLBs in cockpit (never raises the map cap) + +export const MODEL_ALL_ADD_M = 400000; +// all: model NEW planes within 400 km (~to the horizon) + +export const MODEL_ALL_KEEP_M = 450000; +// all: KEEP modeled planes out to 450 km + +export const MODEL_HEADING_OFFSET_DEG = 180; +// airplane.glb nose is opposite Cesium heading-0 +// Owner launch-polish direction: models should read as clean light silhouettes, +// with only a weak diffuse contribution from the existing approved textures. + +export const MODEL_COLOR_BLEND_AMOUNT = 0.94; + +// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its +// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as +// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m +// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled +// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB +// by modelScale.test.mjs. + +export const MODEL_BELLY_OFFSET_NATIVE = 6.719; + +export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); + +/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */ + +export const ERROR_BACKOFF_INTERVAL = 20000; +// transient error retry +/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */ + +export const POSITION_HISTORY_LIMIT = 5; + +export const COCKPIT_CONTACT_SIZE_PX = 6; + +export const COCKPIT_CIVILIAN_COLOR = + Cesium.Color.fromCssColorString('#DCEEFF'); + +export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 3.0, + 8000000, + 0.5, +); + +// --------------------------------------------------------------------------- +// Track-history trail state (PRD WS-F F1/F4): a fading polyline behind the +// tracked aircraft. The accumulation array is intentionally SEPARATE from +// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) +// so the visible trail can grow to TRAIL_MAX_POINTS fixes. +// --------------------------------------------------------------------------- + +/** @constant {string} Civilian trail hue (PRD F4, pinned). */ + +export const TRAIL_COLOR = '#00d4ff'; + +/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +// --------------------------------------------------------------------------- +// Render-behind smoothing (PRD WS-C C2 — approved product decision): +// the fleet renders at now - RENDER_DELAY_SEC so positions interpolate +// BETWEEN two known fixes instead of extrapolating ahead and snapping back +// when the next poll lands. All consumers (labels, HUD, detection, +// frame_overhead) share this delayed clock; removing the delay reintroduces +// the back/forward oscillation and is a regression. +// --------------------------------------------------------------------------- + +/** @constant {number} Display latency in seconds (= one poll interval). */ + +export const RENDER_DELAY_SEC = 30; + +/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */ + +export const MISSING_POLL_LIMIT = 3; + +// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes +// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS +// the actual landing, so a landed plane's last airborne-classified fixes show +// it low + slow on the runway; when such a plane then drops out of the poll, +// it has landed (the feed reclassified it to ground traffic we filter out), +// not hit a transient gap — evict after ONE missed poll instead of the full +// grace. Thresholds: below ~150 m baro (MSL, so this only fires near +// sea-level-ish fields — deliberately conservative; a high-elevation airport +// ghost just falls back to the normal grace) AND below ~45 kts ground speed +// (≈23 m/s — rollout/taxi; nothing in normal FLIGHT is this slow, so cruise +// planes always keep the full grace that absorbs real feed gaps). +/** @constant {number} Max baro altitude (m, MSL) for the landed fast cull. */ + +export const LANDED_ALT_MAX_M = 150; + +/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ + +export const LANDED_SPEED_MAX_MPS = 23; + +/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ + +export const LANDED_MISSING_POLL_LIMIT = 1; + +// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only +// contacts rendering below the alt ceiling are ever clamped/warmed (terrain +// tops out well under it outside the extreme Himalaya; cruise traffic can't +// be below ground and costs zero lookups). The radius bounds the FLEET clamp +// to viewer-visible traffic — clamping thousands of global contacts would +// need unbounded terrain resolution (the tracked contact clamps regardless). +/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ + +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */ + +export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150; + +// --------------------------------------------------------------------------- +// Icon orientation (2026-06-10 playtest fix): rotation is computed by +// projecting each aircraft's course vector into WINDOW coordinates +// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera +// pitch/heading, including tracked-entity orbit mode. Rotation passes run on +// the fleet tick only when the camera pose changed (or 1s drift catch-up). +// The same tick horizon-culls billboards: with the Cesium globe hidden there +// is no far-side depth, so planes otherwise show through the planet. +// --------------------------------------------------------------------------- + +/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ + +export const FLEET_DR_INTERVAL_MS = 80; + +/** @constant {number} Max ms between rotation passes while the camera is idle. */ + +export const ROTATION_REFRESH_MS = 1000; + +/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary + * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ + +export const COURSE_MAX_DPS = 60; + +/** Never spend a long render stall's full elapsed time in one visible course step. */ + +export const COURSE_SLEW_DT_MAX_SEC = 0.25; + +// --------------------------------------------------------------------------- +// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 +// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart +// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally +// caches per-key on disk forever, negative results included, so repeat +// sessions never re-hit adsbdb). Each key is requested at most once per +// session. Priority jobs (tracked plane, model-eligible planes) jump the +// queue; the ambient fleet sweep (below) fills the back at poll cadence. +// --------------------------------------------------------------------------- + +export const ENRICH_MAX_INFLIGHT = 4; + +/** Min ms between request dispatches — the drip that bounds the fan-out to ≤5/s. */ + +export const ENRICH_DISPATCH_GAP_MS = 200; + +// --------------------------------------------------------------------------- +// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live +// category field is 0/"no info" for ~94% of planes, so ambient classification +// defaulted nearly the whole fleet to the airliner silhouette). Each poll, +// ON-SCREEN planes — same horizon-occluder + frustum tests the fleet tick's +// model-eligibility pass uses; no new per-plane raycast — that haven't been +// requested this session are enqueued NEAREST-TO-CAMERA FIRST, bounded by: +// - the shared queue's 4-concurrent / 200 ms-drip dispatch (above), +// - ≤ ENRICH_AMBIENT_PER_SWEEP new enqueues per poll (= one poll interval +// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and +// - a ROLLING token-bucket budget (below) bounding the sustained ambient +// request rate (repeat sessions resolve instantly from the proxy's +// permanent disk cache). +// Fail-silent by contract: the sweep never throws into the poll loop, never +// blocks rendering, and never touches tracking state. When a type answer +// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale +// in place (bb.scale composes multiplicatively with scaleByDistance). +// --------------------------------------------------------------------------- +// Rolling ambient budget (2026-07-03 field fix). The old ONE-SHOT session cap +// (300, refilled only in init) burned out in the first two polls of a busy +// region and never recovered — an hours-long session showed airliner +// monoculture in every NEW region until planes were clicked (the tracked path +// is uncapped). Token bucket instead: starts full at the ceiling, refills +// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped +// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains +// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with +// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL +// politeness bound on adsbdb; the 300 ceiling preserves the old first-look +// burst so a fresh region still classifies quickly. +/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */ + +export const ENRICH_AMBIENT_BUDGET_CEIL = 300; + +/** Tokens added back per refill window. */ + +export const ENRICH_AMBIENT_REFILL_TOKENS = 150; + +/** Refill window length (ms). */ + +export const ENRICH_AMBIENT_REFILL_WINDOW_MS = 5 * 60 * 1000; + +/** Max new ambient enqueues per poll sweep (≈ rate × poll interval). */ + +export const ENRICH_AMBIENT_PER_SWEEP = 150; + +// --------------------------------------------------------------------------- +// Tracked-display reconciliation. _deadReckon gives the RAW position from real +// fixes; at the warm-up→interpolation handoff (and on feed glitches / backfill +// splices) that raw value can step discontinuously. We absorb a step into a +// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked +// icon, camera, and trail head never visibly jump — with ZERO steady-state lag +// (the correction stays ~0 whenever motion is already continuous). +// --------------------------------------------------------------------------- + +export const DR_CORRECTION_MS = 900; + +/** Bounded on-demand loading for the tracked model. The tracked regime is + * DEFAULT-ON and its driver runs every `scene.preUpdate`, so a missing or + * corrupt GLB — or a dead network — would otherwise spin load→reject at frame + * rate for as long as the contact stays selected. Failures are counted PER + * SELECTION: a short backoff absorbs a transient blip, then the layer stops + * asking until the operator selects something else. The billboard is the + * visual throughout (the handoff only fades it once a model actually renders), + * so a latched failure degrades to exactly the pre-3D presentation. */ + +export const TRACKED_MODEL_MAX_LOAD_FAILS = 3; + +export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; + +/** @constant {number} Cap on NEW cells the display corridors may add to one + * poll's warm/sample batch — a view full of ground traffic must not balloon + * it. Cells the poll already collected are free (deduped before budgeting). */ + +export const DISPLAY_CORRIDOR_CELL_BUDGET = 64; + +/** @constant {number} Cells any single contact may claim in the first pass, so + * one long corridor cannot spend the whole budget while other contacts get + * nothing. Leftovers are handed out in a second pass. */ + +export const DISPLAY_CORRIDOR_FAIR_SHARE = 4; + +/** @constant {number} How far ahead a COASTING contact's corridor reaches: + * two poll intervals, so the ground it covers before the next batch lands is + * already warm. */ + +export const DISPLAY_CORRIDOR_LOOKAHEAD_SEC = RENDER_DELAY_SEC * 2; + +/** @constant {number} Corridors are only collected this close to the viewer: + * the mesh sampler ignores anything past 15 km, and a far contact's exact + * datum is subpixel. */ + +export const DISPLAY_CORRIDOR_RADIUS_KM = 25; + +/** @constant {number} How far a contact may travel from the cell that supplied + * its held floor before that floor stops describing the ground under it. + * + * Sized to the same worst-case ground segment `CORRIDOR_MAX_CELLS` is sized + * for — a 27 m/s rollout covers ~810 m in one poll interval — with headroom + * for a couple of polls of coasting. That is ~9 cells: aprons, taxiways and + * runways really are flat at that scale. An early draft used 5 km, which is + * ~45 cells and can leave the airfield entirely — the KAUS note in this file + * records a 21 m spread across the field alone — so the bound is the distance + * the contact can actually have travelled since the measurement rather than a + * comfortable-looking number. */ + +export const HELD_FLOOR_MAX_DRIFT_KM = 1; + +/** @constant {number} Minimum gap between adjacent-cell probes for one contact + * whose held floor is missing or came from a borrowed tier. A contact standing + * on its own resolved floor never probes at all. + * + * This per-contact throttle is the ONLY rationing on the probe path, and that + * is deliberate. A probe is eight synchronous `Map` reads against the shared + * floor cache — no fetch, no `sampleHeight`, nothing async — so it cannot + * queue work anywhere; every DEM request is driven by `warmGroundFloor` from + * the poll loop, already bounded by DISPLAY_CORRIDOR_CELL_BUDGET and the + * resolver's single-flight chain. Measured worst case (`scripts/qa-floorhold- + * probe-cost.mjs`): 200 synchronized all-cold contacts probing on the SAME + * tick cost 2.1 ms, 2.6% of one 80 ms fleet tick, and 2.1 ms per second of + * wall clock sustained under this throttle. An earlier draft added a global + * per-tick budget with a fairness queue on top of that; it protected ~2 ms and + * cost two starvation defects, so it was deleted. Nothing can starve here + * because there is no shared resource to be starved of. */ + +export const NEIGHBOR_FLOOR_PROBE_MS = 500; + +/** @constant {number} Time constant of the downward floor ease. A floor that + * drops UNDER a contact standing on a borrowed one is approached + * exponentially: each tick closes `1 - e^(-dt/TAU)` of the remaining gap, so + * at the 80 ms fleet cadence a tick moves ~20% of what is left and the value + * is within a few centimetres inside ~1.6 s. FLOOR_EASE_MAX_STEP caps that + * fraction so a delayed tick cannot close more. + * + * Exponential rather than a fixed-duration interpolation because the target + * MOVES: a second, lower neighbour can warm mid-ease. A from/duration ease + * re-evaluated against a new target jumps by the eased fraction of the change + * (measured: a 100 m single-tick drop late in an ease). Approaching from the + * CURRENTLY DISPLAYED value has no such seam — retargeting is just a different + * destination for the same continuous follow, and the per-tick bound holds + * however often the target moves. + * + * Rises are never eased: up is the safe direction, and easing up would park + * the contact under the mesh for the duration — the exact failure this whole + * path exists to prevent. + * + * Reachability note (2026-08-21, after neighborFloorM moved to a low lean): + * the ordinary arrival flows no longer produce a downward move at all — a + * borrowed floor is now the apron rather than a roof, so the contact rises + * once and stays (`scripts/qa-floorhold-staircase.mjs`: one step, zero float, + * in every scenario that settles). This machinery still guards the re-latch + * paths — a lower neighbour warming later, or an own-cell resolve below a + * hold — which the pins exercise directly. Whether those paths are worth the + * code is a follow-up judgement, deliberately not made in this change. */ + +export const FLOOR_EASE_TAU_MS = 360; + +/** @constant {number} Hard ceiling on the fraction of the remaining gap ONE + * tick may close, whatever its dt. The exponential alone is timing-dependent: + * it closes 19.9% at the 80 ms fleet cadence but 28.4% at 120 ms and 75% after + * a 500 ms stall (a hidden tab, a long frame, a GC pause), which would turn a + * delayed tick back into the snap this approach exists to prevent. Clamping + * the fraction rather than dt keeps the never-snap property a property of the + * code instead of a property of the schedule; a stalled tick simply resumes + * the approach at full rate rather than jumping most of the way. */ + +export const FLOOR_EASE_MAX_STEP = 0.22; + +/** @constant {number} Distance from the target at which the ease finishes + * exactly, so a parked contact stops rebuilding its position. Two orders of + * magnitude under GROUND_FLOOR_LIFT_M — invisible. */ + +export const FLOOR_EASE_EPSILON_M = 0.02; + +/** @constant {number} How long a retired hold stays usable as a rehydration + * seed — three poll intervals. + * + * Deleting the state outright was the first cut, and an owner sighting found + * what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good + * ground, then suddenly popped below the ground, then popped back up". + * OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and + * the fix's own height source switches at the same moment, from the resolved + * surface to baro + geoid N, which at a sea-level field IS the geoid and sits + * below the runway. A single airborne poll therefore wiped the only thing that + * was hiding that: the contact came back grounded with no prior, outrunning + * its own floor cells at 23 m/s, and rendered at the geoid until something + * ahead of it warmed (`scripts/qa-floorhold-staircase.mjs` §F1: 12 of 22 + * grounded ticks below the runway, and not recovering). + * + * So a retired hold is PARKED, not destroyed, and a contact that re-grounds + * soon after picks it back up. What makes that safe is the bound already in + * `_heldDisplayFloorM`: a seed only answers within HELD_FLOOR_MAX_DRIFT_KM of + * where it was measured AND only while no fresh neighbour contradicts it, so a + * genuine departure-and-landing-elsewhere still starts clean — the grace window + * is belt to those braces, retiring the seed outright once a contact has been + * airborne long enough to have gone anywhere. */ + +export const FLOOR_SEED_GRACE_MS = 90_000; + +/** Flip the whole 3D fleet's boost state by dropping models so the eligibility + * pass reloads them with creation-time boost options (both directions — a + * boosted model must not stay flat white back in Normal). Destroying 350 + * GPU-backed models synchronously inside the style handler stalls the render + * thread (review P1; same failure the cockpit path documents), so the release + * is BATCHED through the fleet tick: each tick drops a bounded slice, showing + * each plane's billboard first (gap-proof per plane, no double-image window). + * Models are tagged with the boost state they loaded under, so queue entries + * whose model already matches the current state (rapid style cycling, or a + * reload that already happened) are skipped. In-flight loads are invalidated + * immediately (cheap gen bumps); the tracked model is a single primitive and + * reloads synchronously. */ + +export const IR_RELOAD_BATCH = 40; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js new file mode 100644 index 000000000..83657ee3d --- /dev/null +++ b/src/layers/flights/queries.js @@ -0,0 +1,954 @@ +import { displayedKinematics } from '../../data/motionModel.js'; +import * as Cesium from 'cesium'; +import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; +import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; +import { + rankContactMatch, + contactMatchWins, + CONTACT_MATCH_TIER, +} from '../../data/contactMatch.js'; +import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; +import { + LANDED_ALT_MAX_M, + LANDED_SPEED_MAX_MPS, + FOCUS_EVIDENCE_DEV, + FLEET_DR_INTERVAL_MS, +} from './policy.js'; + +export function createQueries({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation; + const { formatFlightLevel } = services.labels; + const { isMilitaryIcao } = services.militaryRegistry; + const { applyTrackedCameraFrame } = services.camera; + const { + setFocusDeemphasisParams, + getFocusDeemphasisParams, + setFocusEvidenceNowMs, + advanceFocusEvidenceNowMs, + } = services.focus; + const { setAircraftRecessionParams, getAircraftRecessionParams } = + services.recession; + + /** + * Cheap equirectangular distance (km) — plenty accurate for the ~150 km + * ground-floor clamp gate; runs once per contact per poll, so no trig-heavy + * haversine needed. + * @param {number} lat1 @param {number} lon1 @param {number} lat2 @param {number} lon2 + * @returns {number} Approximate great-circle distance in km. + */ + + function _approxDistanceKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111.32; + const dLon = + (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLon); + } + + /** + * True when the aircraft's latest metadata reads "on or about the runway" + * (low + slow) — see the landed fast-cull rationale above. Both gates must + * hold, so a plane missing either datum keeps the normal grace. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {boolean} + */ + + function _likelyLanded(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return false; + // Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes + // never heal"): the fast cull only applies to contacts that were AIRBORNE + // this session — its original target, the post-LANDING ghost. OpenSky's + // ground coverage flaps constantly, so fast-culling every grounded contact + // put parked planes in an evict/re-enter churn: each re-entry was a + // brand-new contact with cold floor state (geoid-height first poll), so + // the apron looked half-empty AND perpetually sunken. First-seen-grounded + // contacts now ride the normal MISSING_POLL_LIMIT grace and keep their + // identity (and warmed floors) across feed flaps. + if (info.wasAirborne !== true) return false; + // Was airborne, then grounded, then VANISHED from the poll — landed ghost. + if (info.onGround) return true; + return ( + Number.isFinite(info.altitude) && + info.altitude < LANDED_ALT_MAX_M && + Number.isFinite(info.velocity) && + info.velocity < LANDED_SPEED_MAX_MPS + ); + } + + /** + * Normalize a value to a trimmed string. A whitespace-only field (" ") is + * truthy, so every label chain must trim FIRST and then fall through. + * @param {*} value - Any value (typically a metadata string or null). + * @returns {string} Trimmed string, or '' if falsy. + */ + + function _toCleanText(value) { + return String(value || '').trim(); + } + + /** + * The layer's ONE label convention: spoken callsign → tail registration → raw + * ICAO hex. Mirrors militaryFlights.js so the same aircraft reads identically + * in both layers. + * + * Registration is aircraft IDENTITY, not route, so unlike the origin/destination + * line it is NOT plausibility-gated — an adsbdb tail number describes the + * airframe itself and cannot go stale the way a leg can. + * + * This is a DISPLAY string only. Identity everywhere in this layer is `icao24` + * (the `_billboards`/`_flightData` key, the `sourceId` detection declutter hashes, + * the `id` that trackById/Context cohorts resolve) — never the label. + * @param {string} icao24 - ICAO 24-bit transponder address (the identity key). + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @returns {string} Display label; never empty. + */ + + function _contactLabel(icao24, info) { + return ( + _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24 + ); + } + + /** + * Build a public descriptor for one aircraft using its best current position + * (dead-reckoned when history exists, billboard position otherwise). + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Descriptor with a cloned position, or null if the aircraft is unknown. + */ + + function _describeFlight(icao24) { + // DATA, NOT PIXELS — deliberately UNFLOORED (2026-08-19). The display floor + // lifts grounded contacts onto the visible mesh so the sprite you see is not + // buried; that is a rendering correction, not a measurement. This descriptor + // feeds query/analyst/subject APIs — `findByQuery` (voice track-by-name), + // `getTrackedInfo` (cockpit + readout altitude), `getTrackedSubject` + // (proximity counts and distances) — where the honest answer is what the + // aircraft REPORTED, not where its icon was nudged to avoid clipping tiles. + // `altitudeM` is therefore the barometric/aviation value and `renderAltitudeM` + // the fix-time datum, neither of them the floored display height. No + // user-visible surface renders `position` as the plane's on-screen location — + // every visual consumer reads the floored per-frame cache instead (see + // `_trackedDisplayPosition`). If that ever changes, floor this path too. + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + const basePos = + parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + if (!basePos) return null; + const displayed = displayedKinematics({ + derivedSpeedMps: flightState._drSpeedMps, + derivedTrackDeg: flightState._drCourseDeg, + reportedSpeedMps: info?.velocity, + reportedTrackDeg: info?.true_track, + }); + const carto = Cesium.Cartographic.fromCartesian( + basePos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return null; + return { + icao24, + callsign: String(info?.callsign || '').trim() || null, + position: Cesium.Cartesian3.clone(basePos), + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + // The cockpit instrument reports aviation altitude, not the Cesium + // ellipsoid height of the camera/ground-clamped render position. The + // latter can be slightly negative over terrain near the surface. + altitudeM: Number.isFinite(info?.altitude) ? info.altitude : carto.height, + renderAltitudeM: Number.isFinite(info?.renderAltitudeM) + ? info.renderAltitudeM + : carto.height, + onGround: info?.onGround === true, + velocityMps: displayed.speedMps, + track: displayed.trackDeg, + stale: Boolean( + flightState._missingPolls.get(icao24) || flightState._backoff, + ), + airline: info?.airline ?? null, + // CLASS label follows the TR-3B conversion so every downstream card + // (cockpit, Contacts, analyst) agrees with the triangle on screen. + typeName: tr3bTypeLabel(icao24, info?.typeName ?? null), + typeCode: tr3bTypeLabel(icao24, info?.typeCode ?? null), + // IDENTITY, deliberately NOT converted: registration is the airframe's tail + // number and feeds `_contactLabel`'s callsign → registration → hex chain, so + // a converted contact keeps the label convention every other contact uses. + // Trimmed like `callsign` above so every consumer (cockpit readout, voice + // narration, getTrackedSubject) can use it as a label link without + // re-guarding a whitespace-only enrichment value. + registration: _toCleanText(info?.registration) || null, + origin: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.origin.code + : null, + destination: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.destination.code + : null, + route: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? { + origin: { ...info.route.origin }, + destination: { ...info.route.destination }, + } + : null, + }; + } + + function _normalizeTrackedIcao(candidate) { + const normalized = String(candidate ?? '') + .trim() + .toLowerCase(); + return normalized || null; + } + + /** + * Map one aircraft's internal poll record to a plain JSON-safe analyst + * record (analyst query engine seam). Pure — no Cesium types, no fetches; + * enrichment fields read the CACHED adsbdb values only. Missing/unknown + * fields are null, never NaN/undefined. The route-plausibility verdict is + * computed by the CALLER (it needs the billboard position) and passed in, + * so an implausible cached route is never surfaced as fact. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @param {{military?: boolean, routeOk?: boolean}} [flags] - Shared-registry + * military flag + route-plausibility verdict. + * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, + * lon: number|null, altitudeM: number|null, speedMps: number|null, + * heading: number|null, verticalRateMps: number|null, onGround: boolean, + * military: boolean, aircraftClass: string|null, originCountry: string|null, + * operator: string|null, routeOrigin: string|null, routeDestination: string|null}} + */ + + function mapAnalystRecord( + icao24, + info, + { military = false, routeOk = false } = {}, + ) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const callsign = text(info?.callsign); + return { + // Display identity for the narration layer. `id` is NOT a queryable field + // (see ANALYST_LAYERS) and follow-ups carry whole records, so this is a + // label, not a key — the engine keys on `icao24` below. + id: callsign || text(info?.registration) || icao24, + icao24, + callsign, + lat: num(info?.rawLat), + lon: num(info?.rawLon), + altitudeM: num(info?.altitude), // barometric/MSL — the aviation field, not the render height + speedMps: num(info?.velocity), + heading: num(info?.true_track), + verticalRateMps: num(info?.verticalRate), + onGround: info?.onGround === true, + military, + // A converted contact reports the class it RENDERS as, so an analyst + // filter/superlative agrees with the triangle on screen. + aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), + originCountry: text(info?.originCountry), + operator: text(info?.airline), + routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, + routeDestination: routeOk ? text(info?.route?.destination?.code) : null, + }; + } + const methods = { + mapAnalystRecord, + + id: 'flights', + + name: 'Live Flights', + + icon: '✈️', + + source: flightState._lastSource, + + // Browser-harness seam: isolates synthetic display-floor scenarios without + // changing any production lifecycle or cache policy. + _clearDisplayFloorStateForTest: + parts.testing._clearDisplayFloorStateForTest, + + /** @type {number} Polling interval (ms) between update() calls */ + updateInterval: 30000, + + /** + * Live layer params. + * `models3d` toggles 3D glTF model rendering for the FLEET (altitude-gated): when on, + * surrounding aircraft become 3D models once the camera is zoomed in past MODEL_ALT_CEIL_M. + * The TRACKED contact is NOT gated by this — it takes its 3D model by camera distance + * regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). + * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). + * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedFlightsTrackingId?: string|null}} params + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedFlightsTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + if ( + typeof params.models3d === 'boolean' && + params.models3d !== flightState._models3dEnabled + ) { + flightState._models3dEnabled = params.models3d; + if (!flightState._models3dEnabled) { + parts.rendering._releaseModels(); + parts.tracking._syncTracked2dRotation(); + // Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the + // tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it + // here would double-image the tracked plane when 3D is turned off mid-track. + if (flightState._billboardCollection) + for (const [icao, bb] of flightState._billboards) { + if (icao !== flightState._trackedIcao) bb.show = true; + } + } + } + if ( + (params.models3dMode === 'proximity' || + params.models3dMode === 'all') && + params.models3dMode !== flightState._models3dMode + ) { + // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. + flightState._models3dMode = params.models3dMode; + if (flightState._cockpitContactMode) { + parts.tracking._refreshCockpitNearContacts(); + flightState._lastFleetTickMs = 0; + } + } + if ( + typeof params.irBoost === 'boolean' && + params.irBoost !== flightState._irBoost + ) { + flightState._irBoost = params.irBoost; + parts.rendering._reloadModelsForIrBoost(); + // Sprites don't reload with the models — swap the TR-3B glyph between its + // cold and thermal-reactive variants directly. Bounded by the operator's + // own conversions, so this never touches the ordinary fleet. + parts.tracking._refreshTr3bForStyle(); + } + if (Object.hasOwn(params, 'selectedFlightsTrackingId')) { + const requested = _normalizeTrackedIcao( + params.selectedFlightsTrackingId, + ); + if (requested === flightState._trackedIcao) { + flightState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++flightState._trackingIntentGeneration; + flightState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + getParams() { + return { + models3d: flightState._models3dEnabled, + models3dMode: flightState._models3dMode, + irBoost: flightState._irBoost, + selectedFlightsTrackingId: flightState._trackedIcao, + }; + }, + + /** + * Re-render a contact whose TR-3B conversion just flipped (Easter egg). + * Callers own the registry write; this only re-derives what renders. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when this layer owns the contact. + */ + refreshTr3b(icao24) { + return parts.tracking._refreshTr3bContact(icao24); + }, + + /** + * Return a subsample of currently visible aircraft for detection overlay + * rendering (e.g. bounding boxes drawn on-screen by the CCTV detection layer). + * + * Uses a deterministic stride + seed to select a spatially distributed + * subset without sorting or shuffling. + * + * @param {object} [options] + * @param {number} [options.maxCount] - Maximum number of objects to return. + * @param {number} [options.seed] - Deterministic offset into the stride pattern. + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + // Compute a stride that evenly samples the billboard map. + // seed shifts the starting offset so successive calls can sample + // different aircraft without shuffling the underlying Map order. + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : flightState._billboards.size; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max( + 1, + Math.ceil(flightState._billboards.size / maxCount), + ); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [icao24, bb] of flightState._billboards) { + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if ( + flightState._cockpitContactMode && + icao24.toLowerCase() === flightState._cockpitSubjectId + ) + continue; + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so the detection box + // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + const model = flightState._models.get(icao24); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!isTracked && !bb.show && !modelOwnsVisual) continue; + const info = flightState._flightData.get(icao24); + let object = flightState._detectionObjects.get(icao24); + if (!object) { + object = { + sourceId: icao24, + type: 'AIR', + _weldPos: new Cesium.Cartesian3(), + }; + flightState._detectionObjects.set(icao24, object); + } + // WELD: anchor to whatever actually owns the visual. A model-owned contact is + // read straight off the translation the fleet tick already wrote into its + // modelMatrix, so bracket and label sit on the aircraft you can see instead of + // on the buried billboard position, which for a grounded plane is ~100 m below + // and rises only as the coarse ground-floor cell warms. Zero extra sampling and + // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep + // `bb.position` — sprite and bracket are co-located there, so association holds. + const spec = modelOwnsVisual + ? parts.rendering._modelSpec(info?.klass) + : null; + const pos = isTracked + ? parts.motion._trackedVisualCached() || bb.position + : modelOwnsVisual + ? modelVisualAnchor( + model.modelMatrix, + spec.visualCenterNative, + Number.isFinite(model.computedScale) + ? model.computedScale + : spec.scale, + object._weldPos || (object._weldPos = new Cesium.Cartesian3()), + ) + : bb.position; + if (!pos) continue; + object.position = pos; + object.skipLabel = isTracked; + // Card text only — declutter/cohort identity is `object.sourceId` (icao24). + const id = _contactLabel(icao24, info); + if (object.id !== id) object.id = id; + const altitude = info?.altitude; + if (object._altitude !== altitude) { + object._altitude = altitude; + object.metric = formatFlightLevel(altitude); // altitude is metres + } + result.push(object); + if (result.length >= maxCount) break; + } + if ( + flightState._detectionObjects.size > + flightState._billboards.size + 512 + ) { + for (const icao24 of flightState._detectionObjects.keys()) { + if (!flightState._billboards.has(icao24)) + flightState._detectionObjects.delete(icao24); + } + } + return result; + }, + + /** + * Find a single aircraft by free-text query. + * Match priority: exact icao24 hex, exact callsign, callsign prefix, + * then callsign substring (all case-insensitive, trimmed). + * @param {string} query - ICAO24 hex or full/partial callsign. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Best match with a cloned, dead-reckoned position, or null if none. + */ + findByQuery(query) { + if (!flightState._flightData || flightState._flightData.size === 0) + return null; + const q = String(query || '') + .trim() + .toLowerCase(); + if (!q) return null; + + // Registration is searched alongside callsign because it is what the + // operator SEES: a callsign-less contact reads as its tail number on the + // card, in the analyst's answer, and in the Contacts list. Matching only + // callsigns meant "follow 6606" — and the analyst → track_entity handoff + // the tool instructions prescribe — answered "nothing matched" for the + // very identity the app had just shown. Ranking is shared with the + // military layer (contactMatch.js) so the two cannot disagree, and it is + // strictly tiered so a registration can never out-rank a real callsign on + // feed order alone. + let best = null; + for (const [icao24, info] of flightState._flightData) { + const candidate = { + tier: rankContactMatch({ + query: q, + hex: icao24, + callsign: info?.callsign, + registration: info?.registration, + }), + id: icao24, + }; + if (!contactMatchWins(candidate, best)) continue; + best = candidate; + if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; + } + return best ? _describeFlight(best.id) : null; + }, + + /** + * Find aircraft near a given ECEF position, sorted ascending by distance. + * Return shape mirrors militaryFlightsLayer.getNearby (id/icao24/position/distance). + * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates. + * @param {number} range - Maximum distance in meters (Infinity if not finite). + * @param {number} [maxCount=50] - Maximum number of results to return. + * @param {object} [options] Query membership options. + * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. + * @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>} + */ + getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { + if ( + !center || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 50; + const maxRange = + Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; + + const now = Cesium.JulianDate.now(); + const nearby = []; + + for (const [icao24, bb] of flightState._billboards) { + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + if ( + !aircraftIncludedInNearby({ + isTracked, + billboardShown: bb.show, + modelRendering: parts.rendering._modelOwnsVisual(icao24), + includeHidden, + }) + ) + continue; + + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const pos = trackedPos || bb.position; + if (!pos) continue; + + const distance = Cesium.Cartesian3.distance(center, pos); + if (distance > maxRange) continue; + + const info = flightState._flightData.get(icao24); + const callsign = info?.callsign?.trim() || null; + nearby.push({ + // Label. Callers that need identity read `icao24` (Context cohorts do). + id: _contactLabel(icao24, info), + icao24, + callsign, + position: pos, + distance, + // Filter surface: the cockpit next/previous path matches on THIS field + // (militaryAwareness.aircraftClassMatchesFilter), so a converted contact + // has to report the class it renders as or a `tr3b` filter skips it. + aircraftClass: tr3bAircraftClass( + icao24, + String(info?.klass || '') + .trim() + .toLowerCase() || null, + ), + altitudeM: info?.altitude ?? null, + velocityMps: info?.velocity ?? null, + track: info?.true_track ?? null, + }); + } + + nearby.sort((a, b) => a.distance - b.distance); + return nearby.slice(0, limit); + }, + + /** + * Return id/label/position for up to maxCount currently rendered aircraft. + * Cheap snapshot for voice-tool framing — billboard positions only, no + * dead reckoning and no cloning. + * @param {number} [maxCount=500] - Maximum number of entries to return. + * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} + */ + /** + * Whether this layer still carries a contact, in O(1). + * + * Presence consumers must not infer absence from `getAllPositions`: it stops + * at its cap, and this layer routinely carries ~11k contacts against a + * 1,000-row cap, so "not in the returned rows" is not "gone". Id matching + * mirrors trackById: exact key first, then lowercase. + * A disabled layer keeps its records but hides the collection, so it must + * decline rather than answer from data the user can no longer see — + * otherwise a preserved subject reads as fresh off stale hidden state. + * @param {string} icao24 Contact identifier. + * @returns {boolean|null} Presence, or null when the layer is disabled or + * holds no data and therefore cannot answer. + */ + hasContact(icao24) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._billboards.size === 0 + ) + return null; + if (!icao24) return false; + const id = String(icao24).trim(); + return ( + flightState._billboards.has(id) || + flightState._billboards.has(id.toLowerCase()) + ); + }, + + getAllPositions(maxCount = 500) { + if ( + !flightState._billboardCollection || + flightState._billboards.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + + const result = []; + for (const [icao24, bb] of flightState._billboards) { + const pos = bb.position; + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) continue; + const info = flightState._flightData.get(icao24); + result.push({ + id: icao24, // identity (trackById resolves this) + label: _contactLabel(icao24, info), + position: pos, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + airline: info?.airline ?? null, + typeName: info?.typeName ?? null, + typeCode: info?.typeCode ?? null, + registration: info?.registration ?? null, + origin: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.origin.code + : null, + destination: + info?.route && parts.tracking._routeIsPlausible(icao24, info.route) + ? info.route.destination.code + : null, + }); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory 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, no + * enrichment fetches (cached adsbdb values only). 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 ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._flightData.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const [icao24, info] of flightState._flightData) { + const routeOk = + !!info?.route && parts.tracking._routeIsPlausible(icao24, info.route); + result.push( + mapAnalystRecord(icao24, info, { + military: isMilitaryIcao(icao24), + routeOk, + }), + ); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Start camera-tracking an aircraft by ICAO24 address. + * @param {string} icao24 - ICAO 24-bit transponder address. + * @returns {boolean} True if the aircraft exists and tracking started. + */ + trackById(icao24, { origin = 'programmatic' } = {}) { + if (!icao24) return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._billboards.has(id)) return false; + if (parts.tracking._isExplicitTrackingOrigin(origin)) + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao === id) + return parts.tracking._publishTrackedSelection(id, origin); + parts.tracking._trackFlight(id, { origin }); + return true; + }, + + /** Resolve a shared Follow target only against the latest accepted refresh. */ + async resolveTrackingRestoreTarget( + icao24, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = _normalizeTrackedIcao(icao24); + if (!id) return { status: 'missing', reason: 'invalid-target' }; + const outcome = flightState._lastTrackingRefreshOutcome; + if (outcome.status !== 'accepted') { + return { + status: 'source-unavailable', + reason: 'OpenSky snapshot unavailable', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + }; + } + if (!outcome.ids.has(id)) { + return { + status: 'missing', + reason: 'target-absent-from-snapshot', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + }; + } + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const followed = this.trackById(id, { origin }); + return followed + ? { + status: 'found', + refreshEpoch: outcome.epoch, + source: outcome.source, + coverage: outcome.coverage, + } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }, + + /** Reapply the canonical follow frame without recreating the selected flight. */ + refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { + if ( + !icao24 || + flightState._cockpitContactMode || + !flightState._viewer || + !flightState._trackedEntity + ) + return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if ( + id !== flightState._trackedIcao || + !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) + return false; + flightState._trackedCameraFrameStop?.(); + flightState._viewer.camera.cancelFlight(); + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + parts.tracking._publishTrackedSelection(id, origin); + return true; + }, + + /** + * Stop tracking the currently followed aircraft (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Describe the currently tracked aircraft at its dead-reckoned position. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Tracked aircraft info, or null when nothing is tracked. + */ + getTrackedInfo() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described) return null; + const { position, ...rest } = described; + return rest; + }, + + /** + * Return the current aircraft as a Context subject without changing + * tracking or camera ownership. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached subject descriptor, or null when no aircraft is tracked. + */ + getTrackedSubject() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described?.position) return null; + return { + layerId: 'flights', + id: described.icao24, + // Same label chain as getNearby/getDetectableObjects: a callsign-less + // contact reads as its registration, never as the raw ICAO hex. + label: + described.callsign || + _toCleanText(described.registration) || + described.icao24, + position: Cesium.Cartesian3.clone(described.position), + }; + }, + + ...(FOCUS_EVIDENCE_DEV + ? { + __focusEvidence: Object.freeze({ + setAircraft: parts.evidence._setFocusEvidenceAircraft, + moveAircraft: parts.evidence._moveFocusEvidenceAircraft, + snapshot: parts.evidence._focusEvidenceSnapshot, + setTuning({ focus = {}, horizon = {} } = {}) { + return { + focus: setFocusDeemphasisParams(focus), + horizon: setAircraftRecessionParams(horizon), + }; + }, + getTuning() { + return { + focus: { ...getFocusDeemphasisParams() }, + horizon: { ...getAircraftRecessionParams() }, + }; + }, + takeFrameClock(startMs = 1_000_000_000) { + if (!flightState._viewer || !Number.isFinite(startMs)) + return { ok: false, nowMs: null }; + flightState._viewer.useDefaultRenderLoop = false; + setFocusEvidenceNowMs(startMs); + flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; + // Cross a browser task boundary, then close Cesium's pending-loop + // latch. Any already-queued callback still observes the false gate, + // while manual evidence renders can begin without waiting on VSYNC. + return new Promise((resolve) => + setTimeout(() => { + if (flightState._viewer?._cesiumWidget) + flightState._viewer._cesiumWidget._renderLoopRunning = false; + resolve({ ok: true, nowMs: startMs }); + }, 0), + ); + }, + advanceFrameClock(deltaMs = FLEET_DR_INTERVAL_MS) { + return advanceFocusEvidenceNowMs(deltaMs); + }, + releaseFrameClock() { + setFocusEvidenceNowMs(null); + if (flightState._viewer) + flightState._viewer.useDefaultRenderLoop = true; + }, + }), + } + : {}), + + /** + * Return layer health/status for the HUD stats chip. + * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} + */ + getStats() { + const retryInSec = flightState._retryAt + ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) + : 0; + return { + count: flightState._count, + lastUpdate: flightState._lastUpdate, + stale: flightState._backoff, + error: flightState._lastError, + status: flightState._lastStatus, + retryInSec, + source: flightState._lastSource, + coverage: flightState._lastCoverage, + }; + }, + }; + + return { + _approxDistanceKm, + _likelyLanded, + _toCleanText, + _contactLabel, + _describeFlight, + _normalizeTrackedIcao, + mapAnalystRecord, + methods, + }; +} diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js new file mode 100644 index 000000000..761d47536 --- /dev/null +++ b/src/layers/flights/rendering.js @@ -0,0 +1,1216 @@ +import * as Cesium from 'cesium'; +import { + CLASS_SCALE_2D, + CLASS_MODEL_REAL, + CLASS_SCALE_3D, + CLASS_MODEL_URL, +} from '../../data/aircraftClass.js'; +import { + visualCenterForModel, + trailAnchorForModel, +} from '../../data/modelVisualAnchor.js'; +import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { + cameraPoseSignature, + horizonOccluder, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { + MIL_TINT, + GROUND_SCALE, + MODEL_COLOR_BLEND_AMOUNT, + MODEL_SCALE, + PLANE_MODEL_URL, + MODEL_NATIVE_RADIUS_M, + MODEL_BELLY_OFFSET_NATIVE, + COCKPIT_CONTACT_SIZE_PX, + COCKPIT_CIVILIAN_COLOR, + MODEL_ALT_CEIL_M, + MODEL_MAX_ALL, + MODEL_MAX, + COCKPIT_MODEL_MAX, + MODEL_ALL_ADD_M, + MODEL_PROX_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_PROX_KEEP_M, + MODEL_HEADING_OFFSET_DEG, + IR_RELOAD_BATCH, + MODEL_MIN_PX, + TRACKED_MODEL_MIN_PX, + TRACKED_MODEL_MAX_PX, + FLEET_DR_INTERVAL_MS, + COURSE_SLEW_DT_MAX_SEC, + ROTATION_REFRESH_MS, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createRendering({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { isMilitaryIcao } = services.militaryRegistry; + const { tr3bIconKind, isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { + focusNowMs, + getFocusTarget, + nearFarScalarValueAtDistance, + advanceProjectedSpriteFocus, + } = services.focus; + const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = + services.recession; + + /** Fleet (untracked) billboard tint: amber for known-military, white otherwise. + * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ + + function _fleetBillboardColor(icao24) { + return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + } + + /** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ + + function _fleetBillboardScale(icao24, klass) { + return ( + (CLASS_SCALE_2D[klass] || 1) * + (flightState._flightData.get(icao24)?.onGround ? GROUND_SCALE : 1) + ); + } + + /** Depth-test policy for aircraft billboards. Round 5 (owner directive + * 2026-07-06: "I just want the planes and their lines to ALWAYS be + * visible... evenly applied"): EVERY contact renders depth-test-free at + * every distance — grounded, low, and airborne alike. The photoreal mesh + * writes depth and residual baro/floor error will always leave some sprite + * geometry at or below it; a uniform rule beats the grounded-only / + * low-AGL-only conditions that kept leaving classes of contacts buried + * (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field + * case). Far-side planes are still removed by the fleet tick's horizon + * occluder, which never depended on depth. Kept as a function so the + * callers' restyle sites stay diff-stable. */ + + function _groundDepthDistance() { + return Number.POSITIVE_INFINITY; + } + + function _modelSpec(klass) { + const cached = flightState._specCache.get(klass); + if (cached) return cached; + const real = CLASS_MODEL_REAL[klass]; + let spec; + if (real) { + spec = { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } else { + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; + spec = { + url, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(url), + trailAnchorNative: trailAnchorForModel(url), + }; + } + flightState._specCache.set(klass, spec); + return spec; + } + + function _normalBillboardScaleByDistance() { + // Preserve the established close-range 3× scale. Any smaller owner-visible + // default belongs in a separate evidence-backed proposal. + return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); + } + + function _cockpitBillboardScaleByDistance() { + return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); + } + + /** Sprite kind for one contact's billboard. Identity for every aircraft except + * the ones the operator converted into a TR-3B (Easter egg), which draw the + * black-triangle glyph — its thermal-reactive variant while an IR style owns + * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a + * conversion survive the poll reconciler and the two-tier raster swap. */ + + const _iconKind = (icao24, klass) => + tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); + + /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ + + function _applyFleetBillboardPresentation(icao24, bb) { + if (!bb) return; + const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; + const isCockpitContact = + flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; + const isCockpitNear = + isCockpitContact && flightState._cockpitNearContacts.has(icao24); + if (isCockpitContact && !isCockpitNear) { + const freshnessAlpha = bb.color?.alpha ?? 1; + bb.image = cockpitContactDotImage(); + bb.width = COCKPIT_CONTACT_SIZE_PX; + bb.height = COCKPIT_CONTACT_SIZE_PX; + bb.scale = limbScale; + bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + bb.color = ( + isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR + ).withAlpha(freshnessAlpha); + bb.rotation = 0; + return; + } + + const meta = flightState._flightData.get(icao24); + bb.image = aircraftIcon( + _iconKind(icao24, meta?.klass), + bb._gevIconLarge ? TRACKED_ICON_PX : undefined, + ); + bb.width = icao24 === flightState._trackedIcao ? 24 : 20; + bb.height = icao24 === flightState._trackedIcao ? 24 : 20; + bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale; + bb.scaleByDistance = _normalBillboardScaleByDistance(); + bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1); + } + + /** + * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, + * horizon-culls billboards beyond the limb (no far-side depth with the globe + * hidden), and refreshes screen-projected icon rotations whenever the camera + * pose changed (plus a 1s drift catch-up while idle). Driven by + * scene.preRender — NOT camera.changed, whose granularity is globally + * degraded by other layers mutating camera.percentageChanged. + * @returns {void} + */ + + /** Model tint, mirroring the billboard color rules. */ + + function _modelColor(icao24) { + if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; + return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + } + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). */ + + function _modelRegimeActive() { + if (!flightState._models3dEnabled) return false; + const h = + flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses + * at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is + // already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal + // retraversal, the cloud pass) and every model is its own draw call. + return flightState._cockpitContactMode + ? Math.min(COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */ + + function _modelAddDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_ADD_M + : MODEL_PROX_ADD_M; + } + + /** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */ + + function _modelKeepDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_KEEP_M + : MODEL_PROX_KEEP_M; + } + + /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into + * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one + * mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium + * clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at + * the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the + * scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */ + + function _modelMatrix( + pos, + headingDeg, + result = flightState._scratchModelMtx, + ) { + flightState._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + MODEL_HEADING_OFFSET_DEG, + ); + flightState._scratchModelHpr.pitch = 0; + flightState._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + flightState._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the + * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked + * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion + * matches picked-object IDs, and every billboard AND model in this layer carries its + * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the + * object itself. Built lazily — only when a sample actually fires (one-shot). */ + + function _groundSampleExclusions() { + const out = [...flightState._billboards.keys()]; + if (flightState._trackedEntity) out.push(flightState._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position + * verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near + * the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports, + * hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested + * model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides + * a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js, + * CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a + * bounded last-known and resamples), plus + * the belly offset so it sits on its gear rather than sinking to the model-origin + * fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss) + * there is no safe depth-tested placement at all: this returns null, the caller + * keeps the 2D billboard visible and the model hidden, and it retries later. Once + * a contact has resolved once, a later outage holds that measurement inside + * groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D + * because a resample is mid-backoff. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = flightState._flightData.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = flightState._groundSnap.heightFor( + flightState._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its billboard to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the billboard owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + if (!model) { + bb.show = true; + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + bb.show = true; + return false; + } + // The matrix is written BEFORE the readiness test on purpose: a model can flip + // `ready` during scene update after this tick, and a first rendered frame on a + // stale load-start matrix is the one-frame jump this ordering prevents. + _modelMatrix(displayPos, course, model.modelMatrix); + if (!model.ready) { + model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + bb.show = true; + return false; + } + beforeShow?.(); + if (!model.show) model.show = true; + if (bb.show) bb.show = false; // hand off ONLY once the model renders + return true; + } + + /** + * Whether a 3D model — not the billboard — is what the user actually SEES for + * this contact, and therefore whether the display floor should stand aside. + * + * Model EXISTENCE is not ownership. A fleet model that is still loading, or has + * no resolved ground to stand on, stays hidden while `bb.show` stays true (the + * gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model + * is retained but hidden whenever the model regime is off — 3D disabled, camera + * zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore + * suppressed the clamp in exactly the states where the BILLBOARD is the visual, + * putting the burial straight back. + * + * Cesium's own default `show === true` is the reason admission clears it + * explicitly: a fleet model registered in `_models` the instant its glTF resolves + * would otherwise claim ownership — at the identity matrix — for the frames + * between admission and the next fleet tick, while the BILLBOARD was still the + * visual. Ownership means actually rendering, and every site that sets `show` + * true does so only after committing a matrix. + * + * The two halves: + * - Fleet: the rendering test alone. Safe for the ground snap either way, + * because a fleet model is positioned from the RAW dead-reckon, not from + * the billboard. + * - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked + * model is fed from `_trackedDisplayPosition`, so once it is live the clamp + * must stand aside: two different chains would otherwise be deciding one + * contact's ground, and the billboard's is the one the operator is not + * looking at (T7). The regime check is what makes a regime + * flip take effect on the same frame rather than waiting for + * `_updateTrackedModel` to clear `show`; the rendering test is what keeps a + * null or still-loading tracked model from claiming a visual it is not + * drawing yet. + * @param {string} icao24 + * @returns {boolean} + */ + + function _modelOwnsVisual(icao24) { + if (icao24 === flightState._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(flightState._trackedModel) + ); + } + return _modelIsRendering(flightState._models.get(icao24)); + } + + /** A model is the visual only once it actually draws — loaded AND shown. + * + * `show` is sufficient evidence of a safe placement because only ONE site ever + * sets it true for a fleet model (`_driveFleetModelHandoff`, after the matrix is + * committed) and one for the tracked model (`_updateTrackedModel`, likewise); + * everything else — admission, an unresolved ground, a not-yet-ready glTF, the + * limb cull, a regime exit — only ever clears it. */ + + function _modelIsRendering(model) { + return !!model && model.ready === true && model.show === true; + } + + /** Spec identity for a LOADED model: URL and scale together (same-URL classes + * differ by scale — airliner vs quadjet both ship airplane.glb). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync (enrichment AND poll-path klass updates): when the + * aircraft's live model or in-flight load no longer matches its class's spec, + * drop it so the eligibility pass reloads the right asset at the right scale. + * Gap-proof: the fleet billboard is re-shown BEFORE the release so the + * contact never goes invisible for the tick gap; _releaseModel's generation + * bump also invalidates any pending load. The tracked standalone model gets + * the same rule (its billboard entity is always the fallback visual). */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const current = flightState._models.get(icao24); + if ( + (current && current._gevSpecKey !== key) || + (!current && flightState._modelPending.has(icao24)) + ) { + const bb = flightState._billboards.get(icao24); + if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao24); + } + if ( + icao24 === flightState._trackedIcao && + flightState._trackedModel && + flightState._trackedModel._gevSpecKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + flightState._irReloadQueue = [...flightState._models.keys()]; + for (const icao of flightState._modelPending) { + if (!flightState._models.has(icao)) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + if (!flightState._irReloadQueue) return; + const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); + for (const icao of batch) { + const model = flightState._models.get(icao); + if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state + const bb = flightState._billboards.get(icao); + if (bb && icao !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao); + } + if (flightState._irReloadQueue.length === 0) + flightState._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === flightState._trackedIcao) return; + parts.enrichment._requestTypeEnrichment(icao24, true); // model-eligible: about to render in 3D — jump the ambient backlog + if ( + flightState._models.has(icao24) || + flightState._modelPending.has(icao24) + ) + return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL. + if ( + flightState._models.size + flightState._modelPending.size >= + _modelCap() + ) + return; + const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + flightState._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START — if enrichment reclassifies the + // aircraft mid-load, the post-await admission below rejects the stale asset. + // Boost state likewise: the creation options bake it in, so a mid-load + // toggle must reject too (the reload queue only covers ADMITTED models). + const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const loadIrBoost = flightState._irBoost; + try { + const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // Launch presentation keeps the code-side tint dominant for every approved + // model; IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === flightState._modelEpoch) { + flightState._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== flightState._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + flightState._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (flightState._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() || + !flightState._flightData.has(icao24) || + icao24 === flightState._trackedIcao || + flightState._models.has(icao24) || + flightState._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + flightState._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // Keep the pick identity explicit on the resolved primitive. This also + // protects injected/custom loaders that do not copy the creation option. + model.id = icao24; + model._gevSpecKey = specKey; // class-change sync compares against this + model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this + // Admitted, not yet the visual. Cesium's default is show=true, which would let + // an unplaced primitive claim ownership from the billboard for the frames + // between admission and the next fleet tick (and draw at the identity matrix, + // i.e. the Earth's centre). The handoff turns it on once it has a matrix. + model.show = false; + flightState._modelCollection.add(model); + flightState._models.set(icao24, model); + flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). + * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ + + function _releaseModel(icao24) { + const m = flightState._models.get(icao24); + const pending = flightState._modelPending.has(icao24); + // Only bump the generation when there's something to invalidate (an in-flight load or a + // live model) — so removing never-modeled aircraft doesn't grow _modelGen. + if (m || pending) { + flightState._modelGen.set( + icao24, + (flightState._modelGen.get(icao24) || 0) + 1, + ); + } + if (m) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + flightState._models.delete(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if ( + !flightState._modelPending.has(icao24) && + !flightState._models.has(icao24) + ) + flightState._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + for (const icao of flightState._modelPending) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + for (const m of flightState._models.values()) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + } + flightState._models.clear(); + } + + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ + + function _releaseTrackedModel() { + flightState._trackedModelGen++; + flightState._trackedModelLoading = false; + if (flightState._trackedModel) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(flightState._trackedModel); + } catch { + /* gone */ + } + } + flightState._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet + * cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the + * follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed + * in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */ + + function _updateTrackedModel() { + const active = + flightState._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + flightState._modelCollection && + !flightState._modelCollection.isDestroyed(); + if (!active) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(flightState._trackedIcao) || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + if ( + !flightState._trackedModel && + !flightState._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + flightState._trackedModelLoading = true; + const gen = flightState._trackedModelGen; + const trackedSpec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedKey = _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedIrBoost = flightState._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, + colorBlendMode: Cesium.ColorBlendMode.MIX, + // The tracked aircraft uses the same dominant light tint as the fleet; + // IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) + .then((m) => { + // Untracked / re-tracked / torn down during the load → drop it. + if ( + gen !== flightState._trackedModelGen || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ) !== trackedKey || + flightState._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + flightState._trackedModelLoading = false; + return; + } + // Assign after resolution as well as in the creation options so the + // standalone primitive always exposes the tracked aircraft pick id. + m.id = flightState._trackedIcao; + m._gevSpecKey = trackedKey; // class-change sync compares against this + m.show = false; // admitted, not yet the visual — the driver shows it once placed + // Seed the world transform before the primitive enters the scene. A model + // can become ready+shown between render phases; leaving Cesium's identity + // default here produces a one-frame jump to the Earth's center. + const currentPos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (currentPos) { + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + currentPos, + flightState._scratchGroundPos, + ); + if (displayPos) + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + m.modelMatrix, + ); + } + flightState._trackedModel = m; + flightState._trackedModelLoading = false; + // A good load retires this selection's failure budget — a contact that + // recovers after a transient blip is not one attempt from giving up. + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + flightState._modelCollection.add(m); + flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up + }) + .catch((err) => { + if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure + flightState._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; // billboard carries the visual until the model is ready + } + if (flightState._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + return; + } + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + flightState._trackedModel.modelMatrix, + ); + if (!flightState._trackedModel.ready) return; + const spec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + flightState._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, + fovyRad: flightState._viewer.camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + flightState._trackedModel.show = true; + } + } + + function _fleetTick() { + if ( + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + const scene = flightState._viewer.scene; + const camera = flightState._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz + // primitive rebuild needed here anymore.) + + if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; + const tickDtSec = flightState._lastFleetTickMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._lastFleetTickMs) / 1000, + ) + : 0.08; + flightState._lastFleetTickMs = nowMs; + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (flightState._cockpitContactMode) + parts.tracking._refreshCockpitNearContacts(); + const poseSig = cameraPoseSignature(camera); + // Only the nearby Cockpit silhouettes need projected course; far dots are + // rotation-free. The per-contact gate below keeps the pip path cheap. + const doRotations = + poseSig !== flightState._lastCamPoseSig || + nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; + if (doRotations) { + flightState._lastCamPoseSig = poseSig; + flightState._lastRotPassMs = nowMs; + } + + const occluder = horizonOccluder(camera); + const focusTarget = getFocusTarget(); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if ( + !useModels && + (flightState._models.size || flightState._modelPending.size) + ) + _releaseModels(); + + // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the + // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen + // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, + // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots + // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). + // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an + // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap + // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum + // filtering dropped near off-screen planes entirely. This does both right. + let modelEligible = null; + if (useModels) { + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. + const cand = []; + for (const [icao, bb] of flightState._billboards) { + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as a billboard and can never take a model, so + // it must not occupy a CAP SLOT either — excluded here at selection time, + // not just at the handoff below, or accumulated conversions would starve + // ordinary contacts of 3D models. (The handoff guard stays as defence.) + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03: "3D mode is respected regardless of whether a plane is on the + // ground or in the air — no distinction"). The cap + nearest-first ordering + // below already bound airport clusters; grounded placement is handled by the + // one-shot ground snap in _modelDisplayPosition. + const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); // nearest first + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, + ); + flightState._lastModelCapWarnMs = nowMs; + } + modelEligible = new Set(); + // 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see). + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && flightState._models.has(icao)) modelEligible.add(icao); + } + // 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap). + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } + // 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a + // retained off-screen model can never starve an on-screen plane that wants one; dropping it is + // invisible and it re-adds the moment it's back in view). + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && flightState._models.has(icao)) modelEligible.add(icao); + } + // 4. ADD off-screen NEW inside the add radius with any leftover slots. + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + } + + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + + const info = flightState._flightData.get(icao24); + + const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + // The dead-reckoned point drifts away from the fix whose cell supplied the + // height, so a grounded contact's sprite ends up under the mesh it taxied + // (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and + // never while a 3D model owns the visual (T7). + const display = parts.motion._floorGroundedDisplayPosition( + icao24, + info, + dr, + _modelOwnsVisual(icao24), + nowMs, + ); + // Gate the write — assigning Billboard.position dirties the whole + // collection's vertex buffer, so skip sub-meter moves. + if ( + display && + Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0 + ) { + bb.position = display; + } + + // Round 6: occlusion-test a LIFTED point for contacts rendering below + // (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a + // sub-ellipsoid point near the limb "beyond the horizon" and the fleet + // pass would hide a plane that is really just low over high-N terrain + // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). + const beyondHorizon = !occluder.isPointVisible( + info?.cullPosition || bb.position, + ); + // A billboard flipping INTO view (horizon reveal while the camera idles) + // gets its rotation refreshed THIS tick even without a pose change — + // otherwise it reappears wearing its stale (often creation-north) nose for + // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false + // here; harmless — the model branch below `continue`s past the rotation.) + const revealed = !beyondHorizon && !bb.show; + if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + if (beyondHorizon) { + // Also hide any 3D model — otherwise a model that crossed the limb would keep + // rendering through the hidden globe at its last matrix. + const m = flightState._models.get(icao24); + if (m && m.show) m.show = false; + continue; + } + + // One order-independent write site composes freshness × focus × limb haze + // for alpha, and base class/ground scale × limb taper for scale. Cesium's + // locked NearFarScalar remains a separate multiplicative stage. This + // narrowly amends always-visible rendering without count culling or zero + // alpha; nearer focus behavior remains tunable rather than universal. + const cameraDistanceM = Cesium.Cartesian3.distance( + camera.positionWC, + bb.position, + ); + const distanceScale = nearFarScalarValueAtDistance( + bb.scaleByDistance, + cameraDistanceM, + ); + const focus = advanceProjectedSpriteFocus( + bb, + bb.position, + scene, + camera, + nowMs, + focusTarget, + undefined, + (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, + (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + ); + const isCockpitNear = + flightState._cockpitContactMode && + flightState._cockpitNearContacts.has(icao24); + const baseColor = + flightState._cockpitContactMode && !isCockpitNear + ? isMilitaryIcao(icao24) + ? MIL_TINT + : COCKPIT_CIVILIAN_COLOR + : _fleetBillboardColor(icao24); + const treatment = applyAircraftBillboardTreatment({ + billboard: bb, + baseScale: + flightState._cockpitContactMode && !isCockpitNear + ? 1 + : _fleetBillboardScale(icao24, info?.klass), + baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseColor, + focusFactor: focus.factor, + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }); + flightState._billboardLimbScale.set(bb, treatment.factors.scale); + // Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas + // has no mipmaps, so no single texture stays crisp across the ~25–150 + // device-px range scaleByDistance produces. Swap between the 64 px fleet + // raster and the 192 px close raster on the billboard's ACTUAL on-screen + // size — post-treatment bb.scale, so focus/limb recession counts — with + // hysteresis so zoom oscillation never thrashes the atlas. + if (!flightState._cockpitContactMode || isCockpitNear) { + const glyphDevPx = + (bb.width || 20) * + (bb.scale || 1) * + distanceScale * + (globalThis.devicePixelRatio || 1); + const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; + if (wantLarge !== !!bb._gevIconLarge) { + bb._gevIconLarge = wantLarge; + bb.image = aircraftIcon( + _iconKind(icao24, info?.klass), + wantLarge ? TRACKED_ICON_PX : undefined, + ); + } + } + + // Smoothed display course: the path direction _deadReckon just reported + // for THIS aircraft (nothing else calls _deadReckon in between), rate- + // limited so segment-boundary course steps glide instead of snapping. + // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering + // aircraft (hold flag) keeps its previous nose direction outright. + const rawCourse = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : (info && info.true_track) || 0; + const prevCourse = flightState._displayCourse.get(icao24); + const course = + flightState._drCourseHold && prevCourse != null + ? prevCourse + : limitCourseStep( + prevCourse, + rawCourse, + courseSlewCapDps( + flightState._drSpeedMps != null + ? flightState._drSpeedMps + : ((info && info.velocity) ?? NaN), + COURSE_MAX_DPS, + ), + tickDtSec, + ); + flightState._displayCourse.set(icao24, course); + + // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the + // billboard stays shown until the model is actually READY to render, so a plane is never both + // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every + // tick regardless so it's framed the instant it becomes ready. + // Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and + // there is no GLB for it, so the model handoff is suppressed rather than + // fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying + // the getNearby/getDetectableObjects `bb.show` visibility guards), so a + // converted contact still works in Contacts and Cockpit. + if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const ownsVisual = _driveFleetModelHandoff( + icao24, + model, + bb, + dr, + course, + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write — otherwise + // any alpha change would repaint the ordinary tint over the hot + // white. Boosted models also skip the recession fade: hot targets + // stay full-strength at any range (billboards keep their normal + // fade — full-opacity glyph walls read as overwhelming). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost ? 1 : treatment.alpha, + }); + }, + ); + if (ownsVisual) continue; // skip billboard rotation + } + + if ( + (!flightState._cockpitContactMode || isCockpitNear) && + (doRotations || revealed) + ) { + const rot = screenProjectedRotation( + scene, + bb.position, + course, + bb.rotation, + ); + if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { + bb.rotation = rot; + } + } + } + } + return { + _fleetBillboardColor, + _fleetBillboardScale, + _groundDepthDistance, + _modelSpec, + _normalBillboardScaleByDistance, + _cockpitBillboardScaleByDistance, + _iconKind, + _applyFleetBillboardPresentation, + _modelColor, + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; +} diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js new file mode 100644 index 000000000..cf825d648 --- /dev/null +++ b/src/layers/flights/state.js @@ -0,0 +1,501 @@ +import * as Cesium from 'cesium'; +import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; + +export function createFlightState({ source, services }) { + const { createGroundSnap } = services.groundSnap; + const flightState = { lifetime: new AbortController() }; + + flightState._source = source; + + /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs + * vertex-baked to real-world METERS in the airplane.glb axis convention, so + * they render at scale 1 with their own measured belly lift and bounding + * radius. Every other class keeps the shared-airplane.glb formula + * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap + * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in + * both branches. The code-side MIX tint dominates every existing asset so + * class silhouettes stay light without modifying third-party GLBs/textures. */ + /* Specs are static per class, and the detection weld now asks for one per + * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet + * pass — so this is memoized, as militaryFlights.js already does. */ + + flightState._specCache = new Map(); + + /** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ + + flightState._groundSnap = createGroundSnap(); + + flightState._scratchGroundCarto = new Cesium.Cartographic(); + + flightState._scratchGroundPos = new Cesium.Cartesian3(); + + /** @type {Cesium.PrimitiveCollection|null} */ + + flightState._modelCollection = null; + + /** @type {Map} icao24 → model */ + + flightState._models = new Map(); + + /** @type {Set} icao24 currently loading (async) */ + + flightState._modelPending = new Set(); + + /** @type {Map} icao24 → load generation; bumped on release to invalidate + * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ + + flightState._modelGen = new Map(); + + /** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle + * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ + + flightState._modelEpoch = 0; + + /** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs + * layer-state restoration, so this initializer — not the codec — is what the app + * actually starts with; it must stay in lockstep with the `models3d` default in + * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would + * light a button the layer has not armed. */ + + flightState._models3dEnabled = true; + + flightState._models3dMode = 'proximity'; + // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) + + flightState._lastModelCapWarnMs = 0; + // throttle the "more planes in view than the cap" console notice + // The tracked entity's billboard goes transparent (rather than hidden) once the model takes + // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its + // alpha after the GLB is preloaded so the model is ready to render the instant the billboard + // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not + // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + + flightState._planeModelLoaded = false; + + /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + + flightState._preloadModel = null; + + flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + + flightState._scratchModelMtx = new Cesium.Matrix4(); + + flightState._scratchModelBS = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1.0, + ); + // frustum-visibility test + /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ + + flightState._billboardLimbScale = new WeakMap(); + // keep last N positions per aircraft + + // --------------------------------------------------------------------------- + // Module-level state: billboard collection and per-aircraft lookup maps + // --------------------------------------------------------------------------- + + /** @type {Cesium.BillboardCollection|null} */ + + flightState._billboardCollection = null; + + /** @type {Map} icao24 -> billboard primitive */ + + flightState._billboards = new Map(); + + /** Stable lightweight records reused by the detection overlay between polls. */ + + flightState._detectionObjects = new Map(); + + /** @type {Map} */ + + flightState._flightData = new Map(); + + /** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */ + + flightState._focusEvidenceIds = new Set(); + + /** @type {Map>} */ + + flightState._positionHistory = new Map(); + + /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()) */ + + flightState._geoidReady = false; + + /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ + + flightState._geoidNCache = new Map(); + + /** @type {number} Current number of visible aircraft */ + + flightState._count = 0; + + /** @type {number|null} Epoch ms of last successful API update */ + + flightState._lastUpdate = null; + + /** @type {boolean} True while in a backoff/cooldown window */ + + flightState._backoff = false; + + /** @type {number} Epoch ms — earliest time the next fetch is allowed */ + + flightState._retryAt = 0; + + /** @type {string|null} Human-readable error string shown in stats chip */ + + flightState._lastError = null; + + flightState._activeUpdateControllers = new Set(); + + /** @type {number|null} HTTP status of the most recent API response */ + + flightState._lastStatus = null; + + /** @type {string} Source used by the latest successful snapshot. */ + + flightState._lastSource = source?.label || 'Aircraft'; + + /** @type {string} Completeness boundary for the latest successful snapshot. */ + + flightState._lastCoverage = 'worldwide upstream snapshot'; + + // --------------------------------------------------------------------------- + // Click-to-track state + // --------------------------------------------------------------------------- + + /** @type {string|null} ICAO24 of the currently tracked aircraft */ + + flightState._trackedIcao = null; + + flightState._pendingTrackingRestore = null; + + flightState._trackingIntentGeneration = 0; + + flightState._trackingRefreshEpoch = 0; + + flightState._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: flightState._lastSource, + coverage: null, + }; + + /** @type {Cesium.Entity|null} Entity used for camera tracking */ + + flightState._trackedEntity = null; + + /** Disposes the single active tracked-camera framing owner. */ + + flightState._trackedCameraFrameStop = null; + + /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a + * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's + * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on + * 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the + * model rides in _modelCollection and is driven per-frame, fully decoupled from the camera. */ + + flightState._trackedModel = null; + + /** Bumped on untrack/teardown so an in-flight tracked-model load resolves into a no-op. */ + + flightState._trackedModelGen = 0; + + flightState._trackedModelLoading = false; + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */ + + flightState._clickHandler = null; + + /** @type {Cesium.Viewer|null} Cached viewer reference */ + + flightState._viewer = null; + + /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ + + flightState._cockpitContactMode = false; + + /** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ + + flightState._cockpitNearContacts = new Set(); + + /** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ + + flightState._cockpitSubjectId = null; + + /** @type {((event: CustomEvent) => void)|null} */ + + flightState._cockpitModeListener = null; + + /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ + + flightState._trail = null; + + /** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE + * dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays + * glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */ + + flightState._trailHeadEntity = null; + + /** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ + + flightState._trailHeadSeq = 0; + + /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + + flightState._trailPositions = []; + + /** @type {number} Monotonic token — invalidates in-flight backfill responses */ + + flightState._trailBackfillToken = 0; + + /** @type {Map} icao24 -> consecutive missed polls */ + + flightState._missingPolls = new Map(); + + /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ + + flightState._lastFleetTickMs = 0; + + /** @type {string} Camera pose signature at the last rotation pass */ + + flightState._lastCamPoseSig = ''; + + /** @type {number} Epoch ms of the last full rotation pass */ + + flightState._lastRotPassMs = 0; + + /** @type {number} Last computed rotation for the tracked entity (radians) */ + + flightState._lastTrackedRotation = 0; + + /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + + flightState._preRenderRemove = null; + + flightState._trackedModelPreUpdateRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + + flightState._moveEndRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + flightState._trackedEntityChangedRemove = null; + + /** @type {(() => void)|null} militaryRegistry active-transition unsubscribe (M2 handoff sweep) */ + + flightState._milActiveChangeUnsub = null; + + // --------------------------------------------------------------------------- + // Scratch (reusable) variables — avoid per-frame heap allocation + // --------------------------------------------------------------------------- + + flightState._scratchOffset = new Cesium.Cartesian3(); + + flightState._scratchCarto = new Cesium.Cartographic(); + + flightState._scratchEnu = new Cesium.Matrix4(); + + flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; + + flightState._scratchRenderTime = new Cesium.JulianDate(); + + flightState._scratchFleetPos = new Cesium.Cartesian3(); + + flightState._scratchDrRaw = new Cesium.Cartesian3(); + + flightState._scratchWarmupTime = new Cesium.JulianDate(); + + flightState._trackedPosHolder = new Cesium.Cartesian3(); + + // --------------------------------------------------------------------------- + // Per-frame cache for the tracked entity's dead-reckoned position. + // The position, alignedAxis, and rotation CallbackProperties all fire each + // render frame; caching avoids running _deadReckon three times. + // --------------------------------------------------------------------------- + + /** @type {Cesium.Cartesian3|null} */ + + flightState._cachedDRPosition = null; + + /** @type {number} Frame number for which _cachedDRPosition is valid */ + + flightState._cachedDRFrame = -1; + + /** Course (deg) of the position `_deadReckon` most recently returned — set on + * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same + * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ + + flightState._drCourseDeg = null; + + /** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion + * `_deadReckon` just returned, and whether that motion is too slow for ANY + * course source to be trusted (hover/GPS drift — consumers HOLD their + * previous display course instead of chasing noise). */ + + flightState._drSpeedMps = null; + + flightState._drCourseHold = false; + + /** Sibling scratch: whether the position `_deadReckon` just returned came from + * EXTRAPOLATION (coasting past the newest fix, or the pre-history warm-up) + * rather than interpolation between two known fixes. The display-floor + * corridor needs it — an interpolating contact is walking TOWARD its newest + * fix, a coasting one is walking AWAY from it along its course, and warming + * the wrong end leaves a coaster permanently ahead of its floor data. */ + + flightState._drExtrapolating = false; + + /** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ + + flightState._cachedDRCourse = null; + + /** Frame-cached siblings of _cachedDRCourse (same discipline). */ + + flightState._cachedDRSpeedMps = null; + + flightState._cachedDRHold = false; + + /** Wall-clock of the tracked course limiter's last advance (dt source only — + * the course VALUE lives in the shared per-icao _displayCourse map below). */ + + flightState._trackedCourseMs = 0; + + /** Per-aircraft smoothed display course — the SINGLE source of truth for the + * nose direction an aircraft displays. The fleet pass reads/writes it at + * tick cadence for untracked planes; _trackedDisplayCourse reads/writes the + * SAME entry per frame for the tracked plane (the fleet pass skips the + * tracked icao, so exactly one writer owns an entry at a time). Sharing the + * entry — including its slew state — is what makes the tracked↔fleet + * handoff seamless (2026-07-03 field fix: separate states froze the fleet + * entry while tracked, so clicking / un-clicking a 65 kt helicopter FLIPPED + * its nose — "looks like it's going in reverse"). */ + + flightState._displayCourse = new Map(); + + flightState._enrichActive = 0; + + flightState._enrichLastDispatchMs = 0; + + /** @type {ReturnType|null} pending drip wake-up */ + + flightState._enrichDripTimer = null; + + flightState._enrichQueue = []; + + flightState._enrichSeen = new Set(); + + flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL; + + /** Epoch ms the bucket last accounted a refill window from (0 = unset). */ + + flightState._enrichAmbientRefillAnchorMs = 0; + + flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); + + flightState._drCorrectionStartMs = 0; + + flightState._drPrevRaw = new Cesium.Cartesian3(); + + flightState._drPrevDisplay = new Cesium.Cartesian3(); + + flightState._drPrevMs = 0; + + flightState._drReconcileValid = false; + + /** @type {string|null} icao the reconciliation state currently belongs to */ + + flightState._drReconcileIcao = null; + + /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ + + flightState._trackedVisualPos = new Cesium.Cartesian3(); + + flightState._trackedTrailPos = new Cesium.Cartesian3(); + + /** Scratch for the tracked model's world-space origin (the envelope centre). */ + + flightState._scratchTrailClip = new Cesium.Cartesian3(); + + /** Scratch for the shortened trail-head start. Owned by the head-segment + * callback alone, so nothing else can overwrite it mid-frame. */ + + flightState._scratchTrailHead = new Cesium.Cartesian3(); + + /** Hysteresis latch for the tracked contact's zoom regime, plus the selection it + * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER + * ceiling instead of inheriting the previous target's looser EXIT band. */ + + flightState._trackedZoomLatched = false; + + flightState._trackedZoomLatchIcao = null; + + flightState._trackedModelFailIcao = null; + + flightState._trackedModelFailCount = 0; + + flightState._trackedModelRetryAtMs = 0; + + /** @type {Cesium.Cartographic} Scratch for the grounded display-floor read. */ + + flightState._scratchDisplayCarto = new Cesium.Cartographic(); + + /** @type {Map} Per-grounded-contact + * display-floor state: the cell it is currently reading (boundary hysteresis), + * the last input position and the effective floor that produced the cached + * output (rebuild skip), the last floor that actually RESOLVED for it plus the + * tier it came from (the hold, below), whether that floor is a REHYDRATED SEED + * rather than something measured while the contact stood here (`seeded` — it + * ranks below live evidence), and the value the downward ease is currently + * displaying while it approaches a lower floor. + * Dropped with the contact, and whenever it stops being a grounded billboard. */ + + flightState._displayFloorState = new Map(); + + /** @type {number} Poll counter handed to the corridor allocator: it rotates + * runs of EQUALLY needy contacts so a tie larger than the budget cycles across + * polls instead of the same prefix winning forever. */ + + flightState._corridorEpoch = 0; + + /** @type {Cesium.Cartographic} Scratch for the corridor's display-end read. */ + + flightState._scratchCorridorCarto = new Cesium.Cartographic(); + + /** @type {Cesium.Cartesian3} Scratch for the corridor's dead-reckon probe. */ + + flightState._scratchCorridorPos = new Cesium.Cartesian3(); + + /** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles + * map LUMINANCE, so mid-gray textured models read cold and vanish into + * terrain. While a boost style is active every model renders flat white + * (hottest); per-spec color/tint restores on style exit. Driven by ui.js + * setStyle via the `irBoost` layer param. */ + + flightState._irBoost = false; + + /** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16): + * the white tint alone is applied to the MATERIAL, so Cesium still + * sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high + * sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed. + * LightingModel.UNLIT emits the flat white directly, orientation be damned. + * CRITICAL (field-verified via scene.pick): assigning customShader to an + * already-READY model is a silent no-op — the property sets but the shader + * program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD + * (see setParams), so every boosted model gets the shader AT CREATION. + * One shared shader instance — stateless, safe across models. */ + + flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }); + + flightState._irReloadQueue = null; + return flightState; +} diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js new file mode 100644 index 000000000..0c37d1152 --- /dev/null +++ b/src/layers/flights/testing.js @@ -0,0 +1,215 @@ +import * as Cesium from 'cesium'; +import { CYAN_TRANSPARENT } from './policy.js'; + +export function createTesting({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** Test seam for the display-floor clamp (unit-tested against real Cesium math + * with seeded mesh cells — the drift mechanism is otherwise only reachable + * through a live poll + render loop). */ + + function _floorGroundedDisplayPositionForTest( + info, + pos, + modelOwnsVisual, + icao24 = '__test__', + nowMs = Date.now(), + ) { + return parts.motion._floorGroundedDisplayPosition( + icao24, + info, + pos, + modelOwnsVisual, + nowMs, + ); + } + + /** Test hook: drops the per-contact display-floor state (hysteresis + rebuild + * cache) so each case starts clean. There is no cross-contact state to reset — + * the clamp is per-contact all the way down. */ + + function _clearDisplayFloorStateForTest() { + flightState._displayFloorState.clear(); + } + + /** + * Seed only the mutable state needed to exercise tracked-card refreshes through + * the production poll reconciler. Tests still call `flightsLayer.update()`; + * this seam avoids constructing the browser-only Cesium layer lifecycle. + * @param {object} state + * @param {string} state.icao24 + * @param {object} state.entity + * @param {object} state.meta + * @param {object} state.billboard + * @param {object} state.billboardCollection + * @param {object} state.viewer + * @param {Array} [state.history=[]] + * @param {boolean} [state.tracked=true] + * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed + * by icao24, for the billboard-hidden/model-shown handoff state. + */ + + function _setTrackedFlightRefreshStateForTest({ + icao24, + entity, + meta, + billboard, + billboardCollection, + viewer, + history = [], + tracked = true, + models = [], + modelCollection = null, + }) { + flightState._viewer = viewer; + flightState._modelCollection = modelCollection; + flightState._billboardCollection = billboardCollection; + flightState._billboards = new Map([[icao24, billboard]]); + flightState._models.clear(); + for (const [key, model] of models) flightState._models.set(key, model); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map([[icao24, meta]]); + flightState._positionHistory = new Map([[icao24, history]]); + flightState._missingPolls = new Map(); + flightState._displayCourse.clear(); + flightState._geoidNCache.clear(); + flightState._trackedIcao = tracked ? icao24 : null; + flightState._trackedEntity = tracked ? entity : null; + flightState._trackedModel = null; + parts.tracking._cancelPendingTrackingRestore(); + flightState._trackedModelLoading = false; + // NOTE: deliberately does NOT reset the per-selection latches. They are + // production state owned by the tracking lifecycle (_resetTrackedSelectionState), + // and clearing them here would mask exactly the deselect→re-track hole this + // seam is used to test. + flightState._backoff = false; + flightState._retryAt = 0; + } + + /** Seed the authoritative snapshot outcome used by share-Follow tests. */ + + function _setFlightTrackingRefreshOutcomeForTest({ + status = 'accepted', + ids = [], + source = 'OpenSky Network', + coverage = 'test', + } = {}) { + const epoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch, + status, + ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), + source, + coverage, + }; + } + + /** Add a cached contact so tests can model a target arriving on a later feed. */ + + function _addFlightTrackingCandidateForTest({ + icao24, + meta, + billboard, + history = [], + }) { + flightState._billboards.set(icao24, billboard); + flightState._flightData.set(icao24, meta); + flightState._positionHistory.set(icao24, history); + } + + /** Expose the military-suppression decision for the civil duplicate. */ + + function _militaryLayerSuppressesForTest(icao24) { + return parts.tracking._militaryLayerSuppresses(icao24); + } + + /** Arm the deferred restore latch directly, without a full setParams turn. */ + + function _armFlightTrackingRestoreForTest(id, origin = 'share-restore') { + flightState._pendingTrackingRestore = + id === null + ? null + : { id, generation: flightState._trackingIntentGeneration, origin }; + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingFlightTrackingRestoreForTest() { + return flightState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated feed refresh. */ + + function _applyPendingFlightTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Set the exact Cockpit subject through the production state transition for focused tests. */ + + function _setCockpitDetectionSubjectForTest(active, subjectId = null) { + parts.tracking._applyCockpitState({ active, subjectId }); + } + + /** Evaluate the TRACKED contact's zoom regime through the production predicate. + * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) + * and otherwise only observable through a live scene, so tests drive it here. */ + + function _trackedModelRegimeActiveForTest() { + return parts.tracking._trackedModelRegimeActive(); + } + + /** Run one frame of the production tracked-model driver (normally a + * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ + + function _updateTrackedModelForTest() { + return parts.rendering._updateTrackedModel(); + } + + /** Evaluate the production tracked-billboard handoff colour for focused tests. */ + + function _trackedBillboardColorForTest() { + return parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? CYAN_TRANSPARENT + : Cesium.Color.CYAN; + } + + /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + + function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { + return parts.rendering._driveFleetModelHandoff( + icao24, + flightState._models.get(icao24), + flightState._billboards.get(icao24), + position, + course, + ); + } + + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + + async function _ensureFleetModelForTest(icao24) { + await parts.rendering._ensureModel(icao24); + return flightState._models.get(icao24) || null; + } + return { + _floorGroundedDisplayPositionForTest, + _clearDisplayFloorStateForTest, + _setTrackedFlightRefreshStateForTest, + _setFlightTrackingRefreshOutcomeForTest, + _addFlightTrackingCandidateForTest, + _militaryLayerSuppressesForTest, + _armFlightTrackingRestoreForTest, + _pendingFlightTrackingRestoreForTest, + _applyPendingFlightTrackingRestoreForTest, + _setCockpitDetectionSubjectForTest, + _trackedModelRegimeActiveForTest, + _updateTrackedModelForTest, + _trackedBillboardColorForTest, + _driveFleetModelHandoffForTest, + _ensureFleetModelForTest, + }; +} diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js new file mode 100644 index 000000000..3db2ce862 --- /dev/null +++ b/src/layers/flights/tracking.js @@ -0,0 +1,1262 @@ +import * as Cesium from 'cesium'; +import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; +import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { trailHeadStart } from '../../data/modelVisualAnchor.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { routePlausible } from '../../data/routePlausible.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + bindTrackingClickGesture, + isTrackingSelectionGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import { + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + TRAIL_MAX_POINTS, + RENDER_DELAY_SEC, + TRAIL_COLOR, + CYAN_TRANSPARENT, +} from './policy.js'; + +export function createTracking({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + selectTrackedSubjectContext, + clearTrackedSubjectContext, + refreshTrackedSubjectContext, + } = services.context; + const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } = + services.aircraftPresentation; + const { createTrail } = services.trails; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = + services.groundFloor; + const { clearFocusTarget } = services.focus; + const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { applyTrackedCameraFrame } = services.camera; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const flightsLayer = layer; + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'flights', + id: icao24, + // Canonical display chain (callsign → registration → hex). Publishing a + // bare `callsign || icao24` here resurrected the pre-enrichment behavior: + // a callsign-less contact reached Context as its raw hex even once adsbdb + // had supplied a registration. Identity below stays `icao24`. + label: parts.queries._contactLabel(icao24, info), + position: Cesium.Cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; + } + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const altFt = Math.round((described.altitudeM || 0) * 3.28084); + const route = + described.route && _routeIsPlausible(icao24, described.route) + ? `${described.route.origin.code} → ${described.route.destination.code}` + : null; + return { + id: icao24, + layerId: 'flights', + layerName: 'Live Flights', + source: flightState._lastSource, + label: parts.queries._contactLabel( + icao24, + flightState._flightData.get(icao24), + ), + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: parts.queries._contactLabel( + icao24, + flightState._flightData.get(icao24), + ), + operator: described.airline || '', + callsign: described.callsign || '', + registration: described.registration || '', + type: described.typeName || described.typeCode || '', + altitude: described.onGround + ? 'on ground' + : `${altFt.toLocaleString('en-US')} ft`, + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${Math.round(described.track)}°` + : '', + route: route || '', + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; + } + + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } + + /** + * Refresh the Cockpit AIR near/far band without consulting model state. + * Near contacts keep their 2D aircraft silhouette when 3D is off, loading, or + * capped; only a ready admitted model may take that silhouette over later. + */ + + function _refreshCockpitNearContacts() { + if ( + !flightState._cockpitContactMode || + !flightState._viewer?.camera?.positionWC + ) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; + } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + Cesium.Cartesian3.distanceSquared( + flightState._viewer.camera.positionWC, + bb.position, + ), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; + } + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never destroy on entry: tearing down hundreds of live glTF instances + // synchronously blocked Chrome's renderer into Page Unresponsive. + if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + } + + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); + } + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to the original + * layer) never makes `_trackedIcao` *observably* change, so the guard never + * fires. Without the reset, a contact dropped inside the hysteresis band comes + * back as a MODEL above the ENTER ceiling, and a contact whose GLB had already + * failed out would never get its retries back. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `[Data:Flights] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the + * DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is + * what the camera is pointed at, and zooming in on a target should resolve it + * into an aircraft without the operator arming anything. The toggle keeps + * owning the FLEET (`_modelRegimeActive`), which is the draw-call budget. + * + * Thresholds + hysteresis live in trackedModelRegime.js: enter at + * TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance, + * deliberately NEARER than the fleet's 800 km ceiling this used to inherit), + * hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary + * cannot flap billboard↔model. See that module's header for why the tracked + * contact now goes 3D closer in than the fleet does. + * + * In cockpit you are sitting 7 m behind and 2.6 m above your own aircraft's + * origin, so its ~26 m airframe would fill the visor. First-person means your + * own airframe is not drawn. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._viewer?.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._viewer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push(position); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail with its head clamped to the render-behind display + * position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the + * displayed aircraft (PRD C2 delayed clock) — drawing them verbatim makes + * the trail extend in FRONT of the icon. The head is refreshed ~1Hz from + * the fleet tick so it stays glued to the moving aircraft. + */ + + function _refreshTrailDisplay() { + // The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest + // fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + // now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane. + // The cheap per-frame _trailHeadEntity segment bridges the last body point to the + // delayed dead-reckoned head, so the body primitive only rebuilds on a real fix + // (poll cadence), never at motion cadence. + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget an OpenSky track backfill. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join the trail via _appendTrailFix + // as they age past the delay. + const seedRenderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + for (const fix of history) { + if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position)); + } + } + if (!flightState._trail && flightState._viewer) { + flightState._trail = createTrail(flightState._viewer, { + color: TRAIL_COLOR, + width: 2.5, + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last fix → current dead-reckoned icon, updated every frame via + // a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the + // trail primitive which fully rebuilds on setPositions). Keeps the head glued to the + // 12 Hz icon instead of lagging ~1 s behind it. + if (!flightState._trailHeadEntity && flightState._viewer) { + flightState._trailHeadEntity = flightState._viewer.entities.add({ + // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + polyline: { + positions: new Cesium.CallbackProperty(() => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — which is ~now, AHEAD of the delayed icon — + // so the segment would draw IN FRONT of the plane. Likewise during warm-up the + // icon predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[ + flightState._trailPositions.length - 2 + ]; + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. + // See trailHeadStart. Read `head` in place and clone only on the draw + // path — a suppressed parked contact runs this every frame. + const from = trailHeadStart( + start, + head, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, Cesium.Cartesian3.clone(head)]; + }, false), + width: 2.5, + material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads). + depthFailMaterial: + Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }, + }); + } + _refreshTrailDisplay(); + + const oldestFixEpochSec = history.length + ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + /** + * Fire-and-forget OpenSky /tracks backfill (PRD F1). On success, splices + * waypoints strictly older than the oldest seeded fix AHEAD of the locally + * accumulated fine segment, capped at TRAIL_MAX_POINTS (newest kept). Any + * failure (404/429/timeout/malformed) silently keeps the local-only trail. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let path = null; + try { + const track = await flightState._source.getTrack?.( + flightState._flightData.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + path = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if ( + !path || + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // OpenSky track waypoints: [time, latitude, longitude, baro_altitude, true_track, on_ground] + // Height-datum fix (Task 6): /tracks only ever reports barometric/MSL altitude + // (no per-waypoint geo_altitude in this endpoint), so waypoint render height is + // the documented visual FALLBACK baroM + geoidHeight(waypointLat, waypointLon) + // — geometrically approximate, not exact, same honesty caveat as the live + // baro-fallback branch of pickRenderAltitudeM. + await ensureGeoidReady(); + const parsed = []; + for (const waypoint of path) { + const { + observedAtMs, + latitude: lat, + longitude: lon, + baroAltitudeM: baroAlt, + } = waypoint; + const time = observedAtMs / 1000; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAlt }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06 — mirror of + // militaryFlights.js): resolve the coarse ellipsoidal ground along the track + // and floor every waypoint at it so low baro segments never dive below the + // mesh; a no-baro waypoint (predominantly taxi/ground segments in /tracks) + // sits ON the surface when the floor is known. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + const older = []; + let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet + for (const { lat, lon, baroAlt } of parsed) { + const baroM = Number.isFinite(baroAlt) + ? baroAlt + geoidHeight(lat, lon) + : null; + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // No baro + unresolved floor: hold the previous waypoint's altitude + // (continuity — never a dive/spike to a made-up height). Leading points + // with nothing to carry keep the old 10 km airborne default. + if (altM == null) altM = lastAltM != null ? lastAltM : 10000; + lastAltM = altM; + older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if ( + flightState._trailHeadEntity && + flightState._viewer && + !flightState._viewer.isDestroyed() + ) { + try { + flightState._viewer.entities.remove(flightState._trailHeadEntity); + } catch { + /* already gone */ + } + } + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the currently followed aircraft. + * + * Restores the hidden billboard, removes the tracked Entity, resets lerp + * state, and RELEASES the camera IN PLACE — no flyTo. Deselect used to fly + * an ~80 km pulled-back overview; the owner field-ruled that wrong + * (2026-07-02: "it randomly zooms way up and loses my context"). The camera + * now simply stays at its current position/orientation, immediately free to + * orbit/zoom. Applies to every deselect path: click-empty-space, Escape, + * aged-out plane, layer disable, and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget('flights'); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget('flights', clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground/military-aware restore (a plane untracked while taxiing must come + // back muted-gray at ground scale, not white at full scale). + bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); + bb.scale = parts.rendering._fleetBillboardScale( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the + // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner + // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity + // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where + // the follow left it and the user can immediately orbit/zoom. + if (flightState._viewer && !skipViewerUntrack) { + flightState._viewer.trackedEntity = undefined; + } + if (flightState._trackedEntity) { + flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext('flights'); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'flights', + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache + reconciliation state so a same-frame re-track + // cannot read the previous aircraft's cached/smoothed position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + /** + * Whether the Military layer suppresses this civil duplicate right now. + * + * The dedicated Military layer owns icon/track/click for known-military + * contacts, so the OpenSky duplicate is dropped while that layer is on. Two + * contacts are exempt, both for the same reason — this layer still owns them: + * + * - the CURRENTLY tracked one, which hands off on untrack; and + * - a target this layer is holding on its deferred-restore latch. + * + * The second exemption is what makes a shared/local Follow of a mil-registry + * hex restorable at all. The accepted-snapshot id set is built BEFORE this + * suppression runs, so without it the target is provably present in a healthy + * feed yet has no billboard, `trackById` fails, and the restore reports + * "feed unavailable" about a feed that was perfectly fine. + * + * @param {string} icao24 - Normalized ICAO 24-bit address. + * @returns {boolean} True when the civil duplicate must be dropped. + */ + + function _militaryLayerSuppresses(icao24) { + if (!isMilitaryLayerActive()) return false; + if (icao24 === flightState._trackedIcao) return false; + if (icao24 === flightState._pendingTrackingRestore?.id) return false; + return true; + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._billboardCollection?.show || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" + + * "ORIG → DEST". The route line is gated by + * routePlausible so a wrong-leg adsbdb route is hidden, not displayed. + * While the plane is in its missed-poll grace (coasting on dead reckoning + * with sticky metadata), the first line carries a "· STALE" cue — the fleet's + * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity + * owns the visual), so without this the readout would present last-known + * velocity/altitude as live. */ + + function _trackedLabelText(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return icao24; + // A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)` + // kept it, then .trim() emptied it → the callsign slot dropped out of the + // readout. `_contactLabel` trims FIRST, then falls through registration to + // the ICAO hex, so a callsign-less enriched contact heads its readout with + // the tail number rather than raw hex. + const cs = parts.queries._contactLabel(icao24, info); + const altFt = Math.round((info.altitude || 0) * 3.28084); + const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`; + const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : ''; + const stale = + flightState._missingPolls.get(icao24) || flightState._backoff + ? 'STALE' + : ''; + const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')]; + // Converted contacts report their class as TR-3B and nothing else — the + // operator/type identity is exactly what the Easter egg is replacing. + const ident = isTr3b(icao24) + ? tr3bTypeLabel(icao24) + : [info.airline, info.typeName || info.typeCode] + .filter(Boolean) + .join(' · '); + if (ident) lines.push(ident); + if (info.route && _routeIsPlausible(icao24, info.route)) { + lines.push(`${info.route.origin.code} → ${info.route.destination.code}`); + } + return lines.join('\n'); + } + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _trackedLabelText(icao24), + '#39d0ff', + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) + return; + flightState._trackedEntity.billboard.image = aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ); + } + + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Converting drops any 3D model so the triangle owns the visual; the + * billboard image, tracked entity, and tracked card are all re-derived here. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ + + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + // Drop the 3D handoff for this contact — the fleet tick now skips it, so + // an already-loaded model would otherwise linger with the billboard hidden. + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const bb = flightState._billboards.get(id); + if (bb && id !== flightState._trackedIcao) bb.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._viewer?.scene?.requestRender?.(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; + } + + /** Re-image every converted contact this layer owns (IR style flip). */ + + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); + } + } + + /** Plausibility check anchored to the plane's billboard position (coarse is + * fine here — this gates a LABEL, and it must not touch the tracked frame + * cache). Missing data → true (never hide what we can't judge). */ + + function _routeIsPlausible(icao24, route) { + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + if (!info || !bb || !bb.position) return true; + const carto = Cesium.Cartographic.fromCartesian( + bb.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return true; + return routePlausible({ + latDeg: Cesium.Math.toDegrees(carto.latitude), + lonDeg: Cesium.Math.toDegrees(carto.longitude), + altitudeM: info.altitude ?? null, + verticalRateMps: info.verticalRate ?? null, + origin: route.origin, + destination: route.destination, + }); + } + + /** + * Begin tracking a specific aircraft by ICAO24 address. + * + * Clears any existing tracked flight, hides its billboard, and creates a + * new Entity with: + * - A CallbackProperty position driven by dead-reckoning (_deadReckon). + * - A CallbackProperty alignedAxis set to the surface normal at the + * dead-reckoned position (keeps the icon tangent to the earth). + * - A CallbackProperty rotation from the aircraft's true_track heading. + * - An explicit host presentation model with callsign, flight level, speed, + * identity, and plausible route text. + * + * The viewer's trackedEntity is set to this entity so the camera follows it. + * + * @param {string} icao24 - ICAO 24-bit transponder address to track. + */ + + function _trackFlight(icao24, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard — the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, + // with discontinuity reconciliation). Falls back to the last billboard position when + // the aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Dead-reckoning position property — smooth continuous motion between API updates. + const positionProperty = new Cesium.CallbackProperty(() => { + return getTrackedPosition(); + }, false); + + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the + // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the + // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). + // Keep Cesium's generated entity ID: re-init without destroy can temporarily + // overlap collections, and an explicit ICAO ID would throw on that duplicate. + flightState._trackedEntity = flightState._viewer.entities.add({ + position: positionProperty, + // Force Cesium's built-in EntityView and our close-range camera guard to + // use the same local frame. AUTO can select a velocity frame while the + // model matrix uses aircraft orientation; alternating between those + // frames makes the target oscillate forward/back on screen. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + CLASS_SCALE_2D[ + flightState._flightData.get(flightState._trackedIcao)?.klass + ] || 1, + // Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: new Cesium.CallbackProperty( + () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? CYAN_TRANSPARENT + : Cesium.Color.CYAN, + false, + ), + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the cyan icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: new Cesium.CallbackProperty(() => { + const tracked = flightState._flightData.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._viewer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, false), + }, + }); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `flights:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _trackedLabelText(icao24), + '#39d0ff', + ); + + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const followRange = Math.min( + Math.max((info.altitude || 1500) * 1.1 + 2500, 3000), + 30000, + ); + flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + 0, + -followRange * 0.8, + followRange * 0.55, + ); + + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the + // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which + // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it + // the way a click (idle camera) does. + // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target + // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in + // postRender (which would jitter the label against the now-stable plane). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera + // anti-jitter contract and must keep returning the cached DR position. Presentation + // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + flightState._viewer.camera.cancelFlight(); + // Camera follows the tracked entity + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + + // Track-history trail (PRD F1): seed from local history + async backfill. + // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the + // plane takes off while tracked, the on_ground→false transition in update() + // starts one. + parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue + parts.enrichment._requestRouteEnrichment(icao24); + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select. Grounded flights positions + // are already surface-clamped (the surfaceM chain), so seeds/appends drape. + _startTrail(icao24); + + _publishTrackedSelection(icao24, origin); + + console.log( + `[Data:Flights] Tracking ${parts.queries._contactLabel(icao24, info)} (${icao24})`, + ); + } + + /** + * Immediate military-suppression handoff sweep (pre-ship audit M2). + * + * The poll-time suppression branch in update() only reconciles every 30 s, so + * toggling the Military layer showed duplicate icons (military ON: both layers + * render the same aircraft ~3-4 km apart) or holes (military OFF: suppressed + * aircraft absent) for up to a full poll. Fired synchronously by the registry + * on the active-state TRANSITION: + * + * - activated → suppress known-military billboards NOW (mirror of the + * poll-time branch, tracked aircraft excluded — it hands off on untrack); + * - deactivated → the suppressed aircraft's state was deleted, so bring the + * next OpenSky poll forward instead of waiting out the interval (update() + * itself still honors _retryAt backoff). + * + * @param {boolean} active - New military-layer active state. + * @returns {void} + */ + + function _onMilitaryActiveChange(active) { + if (!flightState._viewer || !flightState._billboardCollection) return; + if (active) { + for (const [icao24, bb] of flightState._billboards) { + if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao) + continue; + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._missingPolls.delete(icao24); + } + flightState._count = flightState._billboards.size; + } else if (flightState._billboardCollection.show) { + // Fire-and-forget refresh; only while the layer is actually enabled. + void flightsLayer.update(flightState._viewer); + } + } + + /** + * Global keydown handler — Escape deselects the tracked flight. + * @param {KeyboardEvent} e + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Install a LEFT_CLICK handler on the scene canvas for flight selection. + * + * Picking logic checks both `picked.primitive` and `picked.id` because + * different CesiumJS versions surface BillboardCollection hits differently. + * Also registers a global keydown listener for the Escape key. + * + * Idempotent — returns immediately if a handler is already installed. + * + * @param {Cesium.Viewer} viewer + */ + + function _installClickHandler(viewer) { + if (flightState._clickHandler) return; // already installed + + // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our + // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity + // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch + // (viewer.trackedEntity briefly undefined) doesn't self-clear. + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if ( + flightState._trackedIcao && + flightState._viewer && + flightState._viewer.trackedEntity && + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: + flightState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + // Camera drags never select or deselect, even if they finish over a plane. + // Duration alone is allowed through so a stationary long press can still + // select/switch contacts; the destructive empty-space branch below applies + // the full travel + duration click classifier. + if (!isTrackingSelectionGesture(gesture)) return; + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference. A globe click must not fall through to the + // normal empty-space deselection path; cockpit has explicit exit controls. + if (document.body.classList.contains('cockpit-mode')) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking the tracked entity itself — ignore (don't deselect) + if (picked.id === flightState._trackedEntity) return; + + // Clicking the plane we're ALREADY tracking (its standalone 3D model or + // any pick carrying its icao) — same no-op as the 2D tracked-entity click + // above. H1: the model used to have no pick id, so this fell through to + // "empty space" and deselected the very plane being tracked. + if (flightState._trackedIcao) { + const rawPick = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if ( + picked.primitive === flightState._trackedModel || + rawPick === flightState._trackedIcao + ) + return; + } + + // For BillboardCollection picks, the billboard may be at picked.primitive or picked.id + const billboard = picked.primitive; + if ( + billboard && + billboard.id && + flightState._billboards.has(billboard.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(billboard.id, { origin: 'user' }); + return; + } + // Some CesiumJS versions surface the id as a string on picked.id instead + if ( + picked.id && + typeof picked.id === 'string' && + flightState._billboards.has(picked.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(picked.id, { origin: 'user' }); + return; + } + } + + // A pick that belongs to a sibling layer (military aircraft, satellite, + // vessel, station, CCTV camera…) is not "empty space" — leave tracking + // alone and let that layer handle it. resolvePickId String()-coerces the + // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the + // registry predicates can recognize them (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('flights', pickedId)) return; + } + + // Clicked empty space — deselect only for a clean, short click. A slow + // stationary press may select above, but cannot release existing tracking. + if (!isTrackingClickGesture(gesture)) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + }); + + document.addEventListener('keydown', _onKeyDown); + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _militaryLayerSuppresses, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackedLabelText, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _routeIsPlausible, + _trackFlight, + _onMilitaryActiveChange, + _onKeyDown, + _installClickHandler, + }; +} diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js new file mode 100644 index 000000000..1c894f5f7 --- /dev/null +++ b/src/layers/installations/controls.js @@ -0,0 +1,126 @@ +import * as Cesium from 'cesium'; +import { installationFeedback } from '../../data/installationFeedback.js'; +import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: LAYER_ID, + + name: 'Mapped Installations', + + icon: '⌖', + + source: 'OpenStreetMap + optional Google Maps Places', + + updateInterval: 0, + + statsRefreshInterval: 1000, + + /** Request a one-shot Google Maps Places search around the current map view. */ + searchNearby() { + layerState.googleSearchRequested = true; + return parts.ingestion.loadInstallations(); + }, + + getNearby(center, rangeM, maxCount = 50) { + if (!center) return []; + const range = Number.isFinite(rangeM) ? rangeM : Infinity; + const centerCartographic = Cesium.Cartographic.fromCartesian(center); + if (!centerCartographic) return []; + const nearby = []; + const approximateLimit = Number.isFinite(range) + ? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M + : Infinity; + for (const record of layerState.records) { + if (record.kind !== 'installation') continue; + if ( + parts.model.approximateSurfaceDistanceM( + centerCartographic.latitude, + centerCartographic.longitude, + record.latitude, + record.longitude, + ) > approximateLimit + ) + continue; + // The awareness disk is projected onto the ground. Confirm candidates + // with an exact ellipsoidal surface distance and reusable scratch state. + layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians( + record.longitude, + ); + layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians( + record.latitude, + ); + layerState.distanceEndpointScratch.height = 0; + layerState.distanceGeodesicScratch.setEndPoints( + centerCartographic, + layerState.distanceEndpointScratch, + ); + const distanceM = layerState.distanceGeodesicScratch.surfaceDistance; + if (!Number.isFinite(distanceM) || distanceM > range) continue; + nearby.push({ + ...record, + position: Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + parts.rendering.installationSurfaceHeightM(record), + ), + distanceM, + }); + } + nearby.sort((a, b) => a.distanceM - b.distanceM); + return nearby.slice( + 0, + Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50, + ); + }, + + /** + * Select and frame a mapped installation from another contextual UI. + * @param {string} id Source-backed installation id. + * @returns {boolean} True when an available installation was focused. + */ + focusById(id) { + const record = layerState.recordById.get(String(id)); + if (!record || !layerState.viewer) return false; + // No camera flight without a real selection: a flight plus a stale subject + // reads as success to Context navigation and strands NEXT on this item. + if (!parts.selection.selectRecord(record.id)) return false; + layerState.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + parts.rendering.installationSurfaceHeightM(record), + ), + 18000, + ), + { duration: 1.4 }, + ); + return true; + }, + + getStats() { + return { + count: layerState.records.length, + lastUpdate: layerState.lastUpdate, + stale: layerState.stale, + saturated: layerState.saturated, + error: layerState.error, + status: layerState.status, + loading: layerState.loading, + retryAt: layerState.retryAt, + retrying: layerState.loading && Boolean(layerState.failureReason), + failureReason: layerState.failureReason, + statusMessage: installationFeedback({ + ...layerState, + retrying: layerState.loading && Boolean(layerState.failureReason), + }), + loadingLabel: layerState.loading + ? 'loading mapped installation context' + : '', + }; + }, + }; + + return { methods }; +} diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js new file mode 100644 index 000000000..d8433fe12 --- /dev/null +++ b/src/layers/installations/index.js @@ -0,0 +1,43 @@ +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createIngestion } from './ingestion.js'; +import { createViewport } from './viewport.js'; +import { createSelection } from './selection.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createInstallationsLayer({ services, source }) { + if ( + typeof source?.getMappedSites !== 'function' || + typeof source?.searchNearby !== 'function' + ) + throw new TypeError('An installation source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.ingestion = createIngestion(context); + parts.viewport = createViewport(context); + parts.selection = createSelection(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + approximateSurfaceDistanceM: parts.model.approximateSurfaceDistanceM, + classifyGoogleMilitaryPlace: parts.model.classifyGoogleMilitaryPlace, + installationSourceLabel: parts.model.installationSourceLabel, + installationSurfaceHeightM: parts.rendering.installationSurfaceHeightM, + installationWithinViewport: parts.model.installationWithinViewport, + installationResponseSaturated: parts.model.installationResponseSaturated, + installationRetryDelayMs: parts.viewport.installationRetryDelayMs, + }, + ); +} +export { createInstallationSource } from './source.js'; diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js new file mode 100644 index 000000000..7564b6025 --- /dev/null +++ b/src/layers/installations/ingestion.js @@ -0,0 +1,214 @@ +import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const { governorRequestRender } = services.render; + const { resolveGroundFloorCellsBounded } = services.ground; + + /** + * Commit a status/error transition and buy the one frame it needs. + * + * With the render governor idle — Contacts has released its hold and nothing + * else animates — no frame would otherwise arrive to re-read this, so a load + * that fails after the scene went quiet would leave the last healthy readout on + * screen indefinitely. + * @param {string} status @param {?string} error + */ + + function setInstallationStatus(status, error = null) { + if (layerState.status === status && layerState.error === error) return; + layerState.status = status; + layerState.error = error; + governorRequestRender('installations-status'); + } + + async function loadInstallations() { + if (!layerState.enabled || !layerState.viewer) return; + const box = parts.viewport.viewportBox(layerState.viewer); + // Guidance, not a fault: the layer chose not to query because the view is + // unbounded (a global view, or Cockpit looking at the horizon). Keep it out + // of `error` — the manager derives refresh failures and the global status + // chip derives LOAD FAILED from that field, and a "zoom in" prompt turned + // every Cockpit refresh into a reported failure. The row and toast read the + // prompt from `statusMessage` (installationFeedback) instead. + if (!box) { + layerState.abort?.abort(); + layerState.abort = null; + layerState.loading = false; + parts.viewport.clearUnavailableRetry(); + setInstallationStatus('zoom-in'); + return; + } + layerState.abort?.abort(); + const requestAbort = new AbortController(); + layerState.abort = requestAbort; + layerState.loading = true; + parts.viewport.clearUnavailableRetry({ resetBackoff: false }); + // The previous attempt's failure is not the outcome of this new attempt. + setInstallationStatus('loading'); + try { + const fetchInstallations = (exact) => + source.getMappedSites(box, { exact, signal: requestAbort.signal }); + + let payload = await fetchInstallations(false); + // A SATURATED snapped tile was truncated upstream, so features from the + // snap's extra ring may have crowded out sites actually on screen. Re-ask + // for the exact viewport (separately keyed and cached) before rendering. + let saturated = parts.model.installationResponseSaturated(payload); + if (saturated) { + payload = await fetchInstallations(true); + saturated = parts.model.installationResponseSaturated(payload); + } + const normalized = normalizeMilitaryInstallations( + payload, + payload.retrievedAt || new Date().toISOString(), + ); + // The proxy answers a bbox at least as large as the viewport; keep only what + // was actually asked for so nothing off-screen reaches the map or the + // "current viewport only" context claim. + const records = normalized.records.filter((record) => + parts.model.installationWithinViewport(record, box), + ); + let placesError = null; + if (layerState.googleSearchRequested) { + layerState.googleSearchRequested = false; + const latitude = (box.south + box.north) / 2; + const longitude = (box.west + box.east) / 2; + const radiusM = Math.min( + 50000, + Math.max( + 1000, + Math.round( + Math.max(box.north - box.south, box.east - box.west) * 55_000, + ), + ), + ); + try { + const placesPayload = await source.searchNearby( + { latitude, longitude, radiusM }, + { signal: requestAbort.signal }, + ); + const seen = new Set( + records.map( + (record) => + `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`, + ), + ); + for (const place of Array.isArray(placesPayload?.places) + ? placesPayload.places + : []) { + if ( + !place?.id || + !place?.name || + !Number.isFinite(place.latitude) || + !Number.isFinite(place.longitude) + ) + continue; + const placeClass = parts.model.classifyGoogleMilitaryPlace(place); + const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`; + if (seen.has(signature)) continue; + seen.add(signature); + const retrievedAt = new Date().toISOString(); + records.push({ + id: `google:${place.id}`, + kind: + placeClass === 'military_land' + ? 'installation' + : 'place_candidate', + class: placeClass, + name: String(place.name).trim(), + latitude: place.latitude, + longitude: place.longitude, + footprint: null, + primaryType: place.primaryType || null, + placeTypes: Array.isArray(place.types) ? place.types : [], + sources: [ + { name: 'Google Maps Places', id: place.id, retrievedAt }, + ], + validation: 'unreviewed', + retrievedAt, + }); + } + } catch (error) { + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled || + error?.name === 'AbortError' + ) + return; + placesError = + 'Google Places search unavailable; showing mapped sites'; + } + } + await resolveGroundFloorCellsBounded( + records.map((record) => ({ + lat: record.latitude, + lon: record.longitude, + })), + ); + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled + ) + return; + layerState.records = records; + layerState.recordById = new Map( + layerState.records.map((record) => [record.id, record]), + ); + layerState.lastUpdate = Date.now(); + layerState.stale = payload.status === 'stale'; + // Even the exact-viewport retry can saturate in a dense area. Say so rather + // than implying the view is completely surveyed. + layerState.saturated = saturated; + layerState.failureReason = null; + parts.viewport.clearUnavailableRetry(); + setInstallationStatus( + layerState.records.length + ? layerState.stale + ? 'stale' + : 'ready' + : 'empty', + payload.status === 'stale' + ? 'Serving cached mapped context' + : saturated + ? 'Too many mapped sites in view to list them all' + : placesError, + ); + parts.rendering.renderRecords(); + parts.rendering.warmInstallationFloors(layerState.records); + } catch (error) { + if ( + requestAbort.signal.aborted || + layerState.abort !== requestAbort || + !layerState.enabled || + error?.name === 'AbortError' + ) + return; + layerState.failureReason = error?.failureReason || 'unavailable'; + setInstallationStatus( + 'unavailable', + error?.message || 'Installation context unavailable', + ); + parts.viewport.scheduleUnavailableRetry(); + } finally { + // An older aborted request must not clear a newer request's busy state. + if (layerState.abort === requestAbort) { + layerState.abort = null; + layerState.loading = false; + } + } + } + const methods = { + update() { + return loadInstallations(); + }, + }; + + return { setInstallationStatus, loadInstallations, methods }; +} diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/lifecycle.js new file mode 100644 index 000000000..8df912244 --- /dev/null +++ b/src/layers/installations/lifecycle.js @@ -0,0 +1,62 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { clearSelectedEntityContextForLayer } = services.context; + + const methods = { + init(viewer) { + layerState.viewer = viewer; + layerState.dataSource = new Cesium.CustomDataSource( + 'military-installations', + ); + viewer.dataSources.add(layerState.dataSource); + layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener( + parts.viewport.scheduleLoad, + ); + parts.selection.installInteraction(viewer); + }, + + enable() { + layerState.enabled = true; + registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id)); + layerState.dataSource.show = true; + // DataLayerManager invokes update() immediately after enable(), which owns + // the first fetch. Avoid racing it with a second aborting request here. + }, + + disable() { + layerState.enabled = false; + unregisterPickOwner(LAYER_ID); + parts.viewport.clearUnavailableRetry(); + clearTimeout(layerState.timer); + layerState.abort?.abort(); + layerState.abort = null; + layerState.loading = false; + if (layerState.dataSource) layerState.dataSource.show = false; + clearSelectedEntityContextForLayer(LAYER_ID); + layerState.selectedId = null; + layerState.failureReason = null; + parts.ingestion.setInstallationStatus('idle'); + }, + + destroy(viewer) { + this.disable(); + layerState.moveEndRemove?.(); + layerState.clickHandler?.destroy(); + layerState.clickHandler = null; + parts.rendering.clearRendered(); + if (layerState.dataSource && viewer) + viewer.dataSources.remove(layerState.dataSource, true); + layerState.dataSource = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/installations/model.js b/src/layers/installations/model.js new file mode 100644 index 000000000..dc72898ed --- /dev/null +++ b/src/layers/installations/model.js @@ -0,0 +1,165 @@ +import * as Cesium from 'cesium'; +import { + EARTH_MEAN_RADIUS_M, + COLOR_BY_CLASS, + GOOGLE_MILITARY_PLACE_TYPES, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + /** + * Allocation-free spherical distance used only as a conservative rejection + * pass before the exact ellipsoidal geodesic calculation. + */ + + function approximateSurfaceDistanceM( + latitudeARad, + longitudeARad, + latitudeBDeg, + longitudeBDeg, + ) { + const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg); + const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg); + const latitudeDelta = latitudeBRad - latitudeARad; + const longitudeDelta = Math.atan2( + Math.sin(longitudeBRad - longitudeARad), + Math.cos(longitudeBRad - longitudeARad), + ); + const sinLatitude = Math.sin(latitudeDelta / 2); + const sinLongitude = Math.sin(longitudeDelta / 2); + const haversine = + sinLatitude * sinLatitude + + Math.cos(latitudeARad) * + Math.cos(latitudeBRad) * + sinLongitude * + sinLongitude; + return ( + 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine))) + ); + } + + function colorFor(record) { + return Cesium.Color.fromCssColorString( + COLOR_BY_CLASS[record.class] || '#9ca6b0', + ); + } + + /** + * Classify a Places text-search result without turning a name match into a + * mapped military-land claim. Google currently has no documented military + * Places type, so ordinary results remain visually distinct candidates; the + * explicit branch is retained for any source response that does carry one. + * @param {object} place Google Places result. + * @returns {string|null} Installation class, or null when not authoritative. + */ + + function classifyGoogleMilitaryPlace(place) { + const types = new Set( + [place?.primaryType, ...(Array.isArray(place?.types) ? place.types : [])] + .map((value) => + String(value || '') + .trim() + .toLowerCase(), + ) + .filter(Boolean), + ); + return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type)) + ? 'military_land' + : 'places_candidate'; + } + + /** @param {object} record @returns {string} Human-readable source attribution. */ + + function installationSourceLabel(record) { + const names = [ + ...new Set( + (Array.isArray(record?.sources) ? record.sources : []) + .map((source) => String(source?.name || '').trim()) + .filter(Boolean), + ), + ]; + return names.join(' + ') || 'Unknown mapped source'; + } + + /** + * Whether a mapped record belongs to the REQUESTED viewport. + * + * The proxy snaps the request bbox outward onto a shared cache grid, so a + * response is a SUPERSET of what was asked for, and rendering that superset + * would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY" + * context claim. What may be tested depends on how much of a feature's geometry + * we actually hold: + * + * - A NODE is a point: its centre IS its whole geometry, so an exact + * containment test is correct and loses nothing. + * - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox + * queries return features that merely INTERSECT the box, so centre-testing + * these would drop large bases whose centre sits just outside. + * - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on + * their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without + * one, so their true extent is unknown here — and Overpass already proved + * they intersect the queried bbox. Centre-testing them would erase exactly + * the biggest installations. The honest cost is slight over-inclusion, + * bounded by one snap cell (~5.5 km) around the viewport. + * + * @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record + * @param {{south:number, west:number, north:number, east:number}} box Requested viewport. + * @returns {boolean} + */ + + function installationWithinViewport(record, box) { + if (!record || !box) return false; + const { latitude, longitude, footprint } = record; + const centreInside = + latitude >= box.south && + latitude <= box.north && + longitude >= box.west && + longitude <= box.east; + if (centreInside) return true; + if (Array.isArray(footprint) && footprint.length) { + let minLat = Infinity; + let maxLat = -Infinity; + let minLon = Infinity; + let maxLon = -Infinity; + for (const [lon, lat] of footprint) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + } + return ( + maxLat >= box.south && + minLat <= box.north && + maxLon >= box.west && + minLon <= box.east + ); + } + // Unknown extent: inclusive. Only a point feature may be excluded on centre. + return record.osmType !== 'node'; + } + + /** + * Whether a response was truncated at the upstream element cap. + * + * The proxy states this outright, but a `saturated`-less payload is NOT + * evidence of a complete answer: entries cached before the saturation guard + * shipped predate the field and live for 30 days. Fall back to deriving it from + * the element count against the cap the payload itself reports. + * @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload + * @returns {boolean} + */ + + function installationResponseSaturated(payload) { + if (typeof payload?.saturated === 'boolean') return payload.saturated; + const cap = Number(payload?.elementCap); + if (!Number.isFinite(cap) || cap <= 0) return false; + return Array.isArray(payload?.elements) && payload.elements.length >= cap; + } + return { + approximateSurfaceDistanceM, + colorFor, + classifyGoogleMilitaryPlace, + installationSourceLabel, + installationWithinViewport, + installationResponseSaturated, + }; +} diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js new file mode 100644 index 000000000..b64a3ed16 --- /dev/null +++ b/src/layers/installations/policy.js @@ -0,0 +1,21 @@ +export const LAYER_ID = 'military-installations'; + +export const REQUEST_DEBOUNCE_MS = 500; + +export const MAX_VIEWPORT_DEGREES = 10; + +export const MAX_RENDERED = 700; + +export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); + +export const COLOR_BY_CLASS = { + airfield: '#5aa9ff', + naval_base: '#48c7d5', + range: '#d9a85d', + military_land: '#9ca6b0', + places_candidate: '#c58cff', +}; + +export const EARTH_MEAN_RADIUS_M = 6371008.8; + +export const DISTANCE_PREFILTER_MARGIN_M = 5000; diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js new file mode 100644 index 000000000..3ff922f49 --- /dev/null +++ b/src/layers/installations/rendering.js @@ -0,0 +1,195 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { floorAltitudeM, cachedGroundFloor } = services.ground; + const { + removeEntityContextsForLayer, + getSelectedEntityContext, + registerEntityContext, + selectEntityContext, + } = services.context; + const { governorRequestRender } = services.render; + const { warmFireAnchorFloors } = services.anchors; + + /** + * Shared rendered-surface height for an installation anchor or footprint. + * @param {{latitude:number, longitude:number}} record Installation record. + * @returns {number} Ellipsoidal render height in metres. + */ + + function installationSurfaceHeightM(record) { + return ( + floorAltitudeM( + null, + cachedGroundFloor(record?.latitude, record?.longitude), + ) ?? 0 + ); + } + + function clearRendered() { + if (layerState.dataSource?.entities) + layerState.dataSource.entities.removeAll(); + removeEntityContextsForLayer(LAYER_ID); + } + + /** + * The records that get entities this paint: the nearest `MAX_RENDERED`, plus + * the selected one when it falls outside that window. + * + * Context navigation walks the FULL nearby cohort, which is not bounded by the + * render cap, so selecting item 701+ used to produce no entity at all — the + * camera flew, `getById` returned null, and the selection was silently dropped + * on the floor, leaving the Context subject stale so NEXT offered the same + * installation forever. One extra entity keeps every cohort item selectable + * and the cohort count honest. + * @returns {Array} Records to render this paint. + */ + + function renderableRecords() { + const rendered = layerState.records.slice(0, MAX_RENDERED); + if (!layerState.selectedId) return rendered; + if (rendered.some((record) => record.id === layerState.selectedId)) + return rendered; + const selected = layerState.recordById.get(layerState.selectedId); + return selected ? [...rendered, selected] : rendered; + } + + function renderRecords({ claimSelection = false } = {}) { + // Context navigation can select another layer without a canvas click. + // A delayed floor/data repaint must not steal that newer selection back. + const selectedContext = getSelectedEntityContext(); + if ( + !claimSelection && + layerState.selectedId && + selectedContext && + selectedContext.id !== layerState.selectedId + ) { + layerState.selectedId = null; + } + // Post-moveEnd debounced fetches commit after the camera settles; the + // rebuilt entities need one frame in idle mode. (perf wave 2 fix) + governorRequestRender('installations-render'); + clearRendered(); + for (const record of renderableRecords()) { + const color = parts.model.colorFor(record); + const surfaceHeightM = installationSurfaceHeightM(record); + const displayPosition = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + surfaceHeightM, + ); + const entity = layerState.dataSource.entities.add({ + id: record.id, + position: displayPosition, + point: { + pixelSize: record.id === layerState.selectedId ? 13 : 9, + color: + record.id === layerState.selectedId ? Cesium.Color.WHITE : color, + outlineColor: Cesium.Color.BLACK.withAlpha(0.8), + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + polygon: record.footprint + ? { + hierarchy: new Cesium.PolygonHierarchy( + record.footprint.map(([longitude, latitude]) => + Cesium.Cartesian3.fromDegrees(longitude, latitude), + ), + ), + material: color.withAlpha(0.12), + outline: true, + outlineColor: color.withAlpha(0.65), + height: surfaceHeightM, + } + : undefined, + }); + entity.gevTrackedId = `installations:${record.id}`; + entity.gevDisplayPosition = () => displayPosition; + entity.gevLabelModel = { + title: record.name || 'MAPPED INSTALLATION', + details: [ + String(record.class || 'installation') + .replaceAll('_', ' ') + .toUpperCase(), + ], + accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', + }; + registerEntityContext(entity, { + id: record.id, + layerId: LAYER_ID, + layerName: + record.kind === 'place_candidate' + ? 'Military Site Search Candidates' + : 'Mapped Military Installations', + source: parts.model.installationSourceLabel(record), + label: record.name, + latitude: record.latitude, + longitude: record.longitude, + properties: { + class: record.class, + primaryType: record.primaryType || null, + placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], + validation: record.validation, + retrievedAt: record.retrievedAt, + }, + }); + } + const selectedEntity = layerState.selectedId + ? layerState.dataSource.entities.getById(layerState.selectedId) + : null; + if (selectedEntity) selectEntityContext(selectedEntity); + else layerState.selectedId = null; + } + + /** + * Second paint for floors that missed the bounded pre-render deadline. + * + * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so + * a cold DEM can never hold the dots hostage — but the resolve keeps running + * and lands seconds later, and without this the records it covers stay pinned + * at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest + * 2026-08-18: "orange dots at the bottom"). + * + * This is the render -> warm -> re-render chain FIRMS already uses, with one + * difference the installations path forces: the trigger is whether a cell that + * was COLD AT PAINT TIME is warm now, not whether this particular batch warmed + * it. The bounded resolve above is still running against the same cells, so + * asking "did MY batch warm anything" would answer false exactly when the other + * resolve won the race — the common case. Still terminating: a set that is + * wholly cold afterwards re-renders zero times and the next camera-driven load + * retries. + * @param {Array} records Records just rendered. + * @returns {void} + */ + + function warmInstallationFloors(records) { + const cold = records + .filter( + (record) => + cachedGroundFloor(record.latitude, record.longitude) == null, + ) + .map((record) => ({ lat: record.latitude, lon: record.longitude })); + if (!cold.length) return; + warmFireAnchorFloors(cold).then(() => { + if (!layerState.enabled || !layerState.dataSource) return; + if ( + !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) + ) + return; + renderRecords(); + }); + } + return { + installationSurfaceHeightM, + clearRendered, + renderableRecords, + renderRecords, + warmInstallationFloors, + }; +} diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js new file mode 100644 index 000000000..ace9124cb --- /dev/null +++ b/src/layers/installations/selection.js @@ -0,0 +1,43 @@ +import * as Cesium from 'cesium'; +import { LAYER_ID } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { clearSelectedEntityContextForLayer } = services.context; + + function selectRecord(id) { + const record = layerState.recordById.get(id); + if (!record || !layerState.dataSource) return false; + layerState.selectedId = id; + parts.rendering.renderRecords({ claimSelection: true }); + // renderRecords drops selectedId when the record produced no entity. + return layerState.selectedId === id; + } + + function installInteraction(viewer) { + if (layerState.clickHandler) return; + layerState.clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState.clickHandler.setInputAction((click) => { + if (!layerState.enabled) return; + const picked = viewer.scene.pick(click.position); + const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; + if (id && layerState.recordById.has(id) && id !== layerState.selectedId) { + selectRecord(id); + } else if (layerState.selectedId) { + // Clicking the selected site again, empty map, or another contact + // releases this layer's selection. Clear only our shared context so a + // sibling click handler's newly selected aircraft/site stays intact. + layerState.selectedId = null; + clearSelectedEntityContextForLayer(LAYER_ID); + parts.rendering.renderRecords(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + return { selectRecord, installInteraction }; +} diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js new file mode 100644 index 000000000..ccbb799d4 --- /dev/null +++ b/src/layers/installations/source.js @@ -0,0 +1,78 @@ +/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */ +export function createInstallationSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getMappedSites(box, { exact = false, signal } = {}) { + const { south, west, north, east } = box || {}; + if ( + ![south, west, north, east].every(Number.isFinite) || + south < -90 || + north > 90 || + west < -180 || + east > 180 || + north <= south || + east <= west || + north - south > 10 || + east - west > 10 + ) + throw new TypeError('A bounded installation viewport is required'); + signal?.throwIfAborted(); + const query = new URLSearchParams( + Object.entries({ south, west, north, east }).map(([key, value]) => [ + key, + value.toFixed(5), + ]), + ); + if (exact) query.set('exact', '1'); + const response = await fetchImpl(`/api/military-installations?${query}`, { + signal, + }); + const body = await response.json(); + signal?.throwIfAborted(); + if (!response.ok) + throw Object.assign( + new Error(body?.error || `Installation feed HTTP ${response.status}`), + { + failureReason: ['rate_limited', 'timeout', 'query_failed'].includes( + body?.reason, + ) + ? body.reason + : 'unavailable', + }, + ); + if (!Array.isArray(body?.elements)) + throw new Error('Malformed installation snapshot'); + return body; + }, + async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) { + if ( + ![latitude, longitude, radiusM].every(Number.isFinite) || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 || + radiusM < 1000 || + radiusM > 50000 + ) + throw new TypeError('Invalid nearby installation search'); + signal?.throwIfAborted(); + const response = await fetchImpl( + `/api/google/text-search?${new URLSearchParams({ + q: 'military installation', + lat: latitude.toFixed(5), + lon: longitude.toFixed(5), + radiusM: String(radiusM), + })}`, + { signal }, + ); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!response.ok) + throw new Error( + payload?.error || `Google Places HTTP ${response.status}`, + ); + if (!Array.isArray(payload?.places)) + throw new Error('Malformed nearby-place snapshot'); + return payload; + }, + }; +} diff --git a/src/layers/installations/source.test.mjs b/src/layers/installations/source.test.mjs new file mode 100644 index 000000000..3aa54bbef --- /dev/null +++ b/src/layers/installations/source.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createInstallationSource } from './source.js'; + +const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 }; +test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => { + const calls = []; + const source = createInstallationSource({ + fetchImpl: async (url) => { + calls.push(new URL(url, 'https://example.test')); + return new Response(JSON.stringify({ elements: [], status: 'stale' })); + }, + }); + for (const invalid of [ + null, + { ...box, east: Infinity }, + { ...box, north: 90 }, + { ...box, south: 31 }, + ]) + await assert.rejects( + source.getMappedSites(invalid), + /bounded installation viewport/, + ); + assert.equal(calls.length, 0); + const payload = await source.getMappedSites(box, { exact: true }); + assert.equal(payload.status, 'stale'); + assert.equal(calls[0].pathname, '/api/military-installations'); + assert.equal(calls[0].searchParams.get('exact'), '1'); + assert.equal(calls[0].searchParams.get('south'), '30.10000'); +}); +test('malformed installation and place snapshots are never accepted as empty success', async () => { + const source = createInstallationSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects( + source.getMappedSites(box), + /Malformed installation snapshot/, + ); + await assert.rejects( + source.searchNearby({ latitude: 30.2, longitude: -97.7, radiusM: 1000 }), + /Malformed nearby-place snapshot/, + ); +}); +test('installation response parsing respects cancellation before any follow-on search', async () => { + const controller = new AbortController(); + const source = createInstallationSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { elements: [] }; + }, + }), + }); + await assert.rejects( + source.getMappedSites(box, { signal: controller.signal }), + { name: 'AbortError' }, + ); +}); diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js new file mode 100644 index 000000000..217d54c14 --- /dev/null +++ b/src/layers/installations/state.js @@ -0,0 +1,37 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const state = {}; + + state.distanceEndpointScratch = new Cesium.Cartographic(); + + state.distanceGeodesicScratch = new Cesium.EllipsoidGeodesic(); + + Object.assign(state, { + viewer: null, + dataSource: null, + enabled: false, + records: [], + recordById: new Map(), + selectedId: null, + lastUpdate: null, + error: null, + status: 'idle', + stale: false, + /** Whether the upstream truncated at its element cap for the current view. */ + saturated: false, + loading: false, + abort: null, + /** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */ + retryTimer: null, + /** Current backoff step for that retry; 0 = next failure starts at the minimum. */ + retryDelayMs: 0, + retryAt: 0, + failureReason: null, + moveEndRemove: null, + clickHandler: null, + timer: null, + googleSearchRequested: false, + }); + return state; +} diff --git a/src/layers/installations/viewport.js b/src/layers/installations/viewport.js new file mode 100644 index 000000000..70269257e --- /dev/null +++ b/src/layers/installations/viewport.js @@ -0,0 +1,84 @@ +import * as Cesium from 'cesium'; +import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + function viewportBox(viewer) { + const rectangle = viewer?.camera?.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + if (!rectangle) return null; + const south = Cesium.Math.toDegrees(rectangle.south); + const north = Cesium.Math.toDegrees(rectangle.north); + const west = Cesium.Math.toDegrees(rectangle.west); + const east = Cesium.Math.toDegrees(rectangle.east); + // Cross-dateline/global views require a zoom before a bounded request. + if ( + !Number.isFinite(south + north + west + east) || + east <= west || + north - south > MAX_VIEWPORT_DEGREES || + east - west > MAX_VIEWPORT_DEGREES + ) + return null; + return { south, west, north, east }; + } + + /** + * Backoff progression for the unavailable-state retry: 30 s, doubling to a + * 240 s ceiling. Pure so the progression is pinnable without booting the layer. + */ + + function installationRetryDelayMs(prevDelayMs) { + const RETRY_MIN_MS = 30000; + const RETRY_CEIL_MS = 240000; + if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS; + return Math.min(prevDelayMs * 2, RETRY_CEIL_MS); + } + + /** + * 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only + * on enable and on camera moveEnd, so a parked camera whose first request died + * (one flaky Overpass mirror is enough) stayed unavailable forever while the + * proxy sat healthy while the layer refused to show its features. While the + * layer is enabled and + * unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load, + * zoom-out, or disable cancels it. + */ + + function scheduleUnavailableRetry() { + if (!layerState.enabled) return; + clearTimeout(layerState.retryTimer); + layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs); + layerState.retryAt = Date.now() + layerState.retryDelayMs; + layerState.retryTimer = setTimeout(() => { + layerState.retryTimer = null; + layerState.retryAt = 0; + if (layerState.enabled && !layerState.loading) + parts.ingestion.loadInstallations(); + }, layerState.retryDelayMs); + } + + function clearUnavailableRetry({ resetBackoff = true } = {}) { + clearTimeout(layerState.retryTimer); + layerState.retryTimer = null; + layerState.retryAt = 0; + if (resetBackoff) layerState.retryDelayMs = 0; + } + + function scheduleLoad() { + if (!layerState.enabled) return; + // A user-driven load supersedes any pending retry; the load reschedules on + // failure, so the backoff step is kept rather than reset. + clearUnavailableRetry({ resetBackoff: false }); + clearTimeout(layerState.timer); + layerState.timer = setTimeout(() => { + parts.ingestion.loadInstallations(); + }, REQUEST_DEBOUNCE_MS); + } + return { + viewportBox, + installationRetryDelayMs, + scheduleUnavailableRetry, + clearUnavailableRetry, + scheduleLoad, + }; +} diff --git a/src/layers/launches/camera.js b/src/layers/launches/camera.js new file mode 100644 index 000000000..e6a1ef8c9 --- /dev/null +++ b/src/layers/launches/camera.js @@ -0,0 +1,375 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + REPLAY_CONTEXT_ALTITUDE_END_M, + REPLAY_CONTEXT_MAX_RANGE_M, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_GLOBE_RANGE_M, + REPLAY_ORBIT_FRAME_CENTER_BLEND, +} from './policy.js'; + +export function createCamera({ state: layerState, services, parts, source }) { + function cameraHeadingForPath(path, progress, fallback = Math.PI) { + if (!path?.length) return fallback; + const current = parts.paths.samplePath(path, progress); + const currentCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); + if (!currentCartographic) return fallback; + for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { + const next = parts.paths.samplePath(path, Math.min(1, progress + step)); + const nextCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); + if (!nextCartographic) continue; + const geodesic = new Cesium.EllipsoidGeodesic( + currentCartographic, + nextCartographic, + ); + if ( + geodesic.surfaceDistance > 10 && + Number.isFinite(geodesic.startHeading) + ) { + // HeadingPitchRange positions the camera opposite its heading vector. + // Passing the forward path heading therefore keeps the camera behind + // the vehicle, with the remaining ascent receding into the scene. + return Cesium.Math.zeroToTwoPi(geodesic.startHeading); + } + } + return fallback; + } + + /** + * Resolve the initial replay heading as a profile view of the path. + * @param {Cesium.Cartesian3[]} path Replay path. + * @returns {number} Heading perpendicular to the initial path direction. + */ + + function replayInitialCameraHeading(path) { + return Cesium.Math.zeroToTwoPi( + cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, + ); + } + + /** + * Keep ascent framing behind and slightly to one side of the vehicle, then + * widen that rear-quarter angle as the orbit becomes visible. + * @param {number} pathHeading Forward path bearing in radians. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {number} Cesium HeadingPitchRange heading. + */ + + function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + return Cesium.Math.zeroToTwoPi( + pathHeading + + Cesium.Math.lerp( + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + blend, + ), + ); + } + + /** + * Limit replay-camera yaw changes so a path heading wrap or insertion turn + * cannot swing the chase view through the front of the vehicle. + * @param {number} previous Previous Cesium heading in radians. + * @param {number} desired Desired Cesium heading in radians. + * @param {number} [maxStepRad] Maximum angular change for one rendered frame. + * @returns {number} + */ + + function smoothReplayCameraHeading( + previous, + desired, + maxStepRad = Cesium.Math.toRadians(2), + ) { + if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); + if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); + const delta = Cesium.Math.negativePiToPi(desired - previous); + const step = Cesium.Math.clamp( + delta, + -Math.abs(maxStepRad), + Math.abs(maxStepRad), + ); + return Cesium.Math.zeroToTwoPi(previous + step); + } + + /** + * Blend from a global nadir view into an oblique local 3D view as the user + * approaches a selected launch site. + * @param {number} rangeM Camera distance from the launch-site anchor. + * @returns {number} Cesium camera pitch in radians. + */ + + function missionZoomPitch(rangeM) { + const range = Math.max(0, Number(rangeM) || 0); + const blend = Cesium.Math.clamp( + (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)) / + (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)), + 0, + 1, + ); + return Cesium.Math.lerp( + Cesium.Math.toRadians(-42), + -Cesium.Math.PI_OVER_TWO, + blend, + ); + } + + /** + * Resolve the replay camera offset for either close ascent tracking or the + * orbital globe pullback. + * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. + * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. + * @returns {{range: number, pitch: number}} + */ + + function replayCameraView(state, altitudeM) { + const altitude = Math.max(0, Number(altitudeM) || 0); + const localRange = Cesium.Math.clamp( + REPLAY_INITIAL_RANGE_M + altitude * 0.7, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + ); + const rawContextBlend = Cesium.Math.clamp( + (altitude - 20000) / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), + 0, + 1, + ); + const contextBlend = + rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); + const contextRange = Cesium.Math.clamp( + 180000 + altitude * 3.8, + MISSION_CLOSE_VIEW_RANGE_M, + REPLAY_CONTEXT_MAX_RANGE_M, + ); + const ascentRange = Cesium.Math.lerp( + localRange, + contextRange, + contextBlend, + ); + const ascentPitch = Cesium.Math.lerp( + Cesium.Math.toRadians(-20), + Cesium.Math.toRadians(-34), + contextBlend, + ); + if (state?.ascending) { + return { range: ascentRange, pitch: ascentPitch }; + } + const rawBlend = Cesium.Math.clamp( + (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ); + const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); + return { + range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), + // Keep an oblique tactical view of the complete orbit rather than ending + // in a nadir view. This also prevents the camera from appearing to + // return toward the launch site after insertion. + pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), + }; + } + + /** + * Move the orbit-follow target from the vehicle toward its sub-satellite + * globe anchor, keeping Earth centered while the vehicle remains in frame. + * @param {Cesium.Cartesian3} position Animated orbital position. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Camera look-at target. + */ + + function replayOrbitGlobeAnchor(position, orbitBlend = 0) { + if (!position) return position; + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + if (blend <= 0) return Cesium.Cartesian3.clone(position); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + if (!cartographic) return Cesium.Cartesian3.clone(position); + const altitude = Math.max(0, cartographic.height || 0); + return Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + Cesium.Math.lerp(altitude, altitude * 0.1, blend), + ); + } + + /** + * Keep the orbital camera's look-at frame biased toward the moving vehicle. + * Blending completely to a whole-orbit bounding-sphere center can place the + * target at (or numerically close to) Earth's center. That frame is singular + * for a heading/pitch camera and lets compact-orbit vehicles leave the view as + * the camera rotates. Retaining a radial vehicle bias keeps the local frame + * stable while the wider camera range still contains Earth and the full orbit. + * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. + * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Stable camera look-at target. + */ + + function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { + if (!vehicleAnchor) return vehicleAnchor; + if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); + const blend = + Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) * + REPLAY_ORBIT_FRAME_CENTER_BLEND; + return Cesium.Cartesian3.lerp( + vehicleAnchor, + frameCenter, + blend, + new Cesium.Cartesian3(), + ); + } + + /** + * Build a stable orbit-relative camera pose. The camera remains on one side + * of the orbital plane and uses the vehicle radial as its visual up axis. + * Consequently the forward orbit tangent always projects toward screen-left + * instead of changing direction when local compass headings wrap near a pole. + * @param {Cesium.Cartesian3} position Current vehicle position. + * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. + * @param {Cesium.Cartesian3} target Camera look-at target. + * @param {number} range Camera distance from the target. + * @param {number} pitch Camera elevation below the local horizon. + * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} + */ + + function replayOrbitCameraPose( + position, + tangentPosition, + target, + range, + pitch, + ) { + if (!position || !tangentPosition || !target) return null; + const radial = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.clone(position), + new Cesium.Cartesian3(), + ); + const tangent = Cesium.Cartesian3.subtract( + tangentPosition, + position, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; + Cesium.Cartesian3.normalize(tangent, tangent); + const orbitNormal = Cesium.Cartesian3.cross( + radial, + tangent, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; + Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); + + const distance = Math.max(1, Number(range) || 1); + const elevation = Cesium.Math.clamp( + Math.abs(Number(pitch) || 0), + Cesium.Math.toRadians(5), + Cesium.Math.toRadians(80), + ); + const planeOffset = Cesium.Cartesian3.multiplyByScalar( + orbitNormal, + Math.cos(elevation) * distance, + new Cesium.Cartesian3(), + ); + const radialOffset = Cesium.Cartesian3.multiplyByScalar( + radial, + Math.sin(elevation) * distance, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + target, + planeOffset, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.add(destination, radialOffset, destination); + const direction = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const radialAlongView = Cesium.Cartesian3.multiplyByScalar( + direction, + Cesium.Cartesian3.dot(radial, direction), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + radial, + radialAlongView, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return { destination, direction, up }; + } + + /** + * Build one conservative frame that contains both Earth and the complete + * selected orbit. High-apogee missions cannot be composed from the vehicle's + * instantaneous altitude alone because the opposite side of the orbit may + * extend much farther from the globe. + * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. + * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. + */ + + function replayOrbitFrameSphere(orbitPath = []) { + const earth = new Cesium.BoundingSphere( + Cesium.Cartesian3.ZERO, + Cesium.Ellipsoid.WGS84.maximumRadius, + ); + if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; + const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); + return Cesium.BoundingSphere.union( + earth, + orbit, + new Cesium.BoundingSphere(), + ); + } + + /** + * Ensure high-altitude missions frame both the globe and selected vehicle. + * @param {number} baseRange Range from the normal replay camera transition. + * @param {number} altitudeM Vehicle altitude above the ellipsoid. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. + * @returns {number} Camera range in metres. + */ + + function replayOrbitGlobeRange( + baseRange, + altitudeM, + orbitBlend = 0, + frameRadiusM = 0, + ) { + const range = Math.max(0, Number(baseRange) || 0); + const altitude = Math.max(0, Number(altitudeM) || 0); + const frameRadius = Math.max(0, Number(frameRadiusM) || 0); + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + const globeAndVehicleRange = Math.max( + range, + altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, + frameRadius * 3, + ); + return Cesium.Math.lerp(range, globeAndVehicleRange, blend); + } + return { + cameraHeadingForPath, + replayInitialCameraHeading, + replayChaseCameraHeading, + smoothReplayCameraHeading, + missionZoomPitch, + replayCameraView, + replayOrbitGlobeAnchor, + replayOrbitCameraTarget, + replayOrbitCameraPose, + replayOrbitFrameSphere, + replayOrbitGlobeRange, + }; +} diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js new file mode 100644 index 000000000..1d119ebdb --- /dev/null +++ b/src/layers/launches/controls.js @@ -0,0 +1,35 @@ +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'rocket-launches', + + name: 'Space Missions (30d)', + + icon: '🚀', + + source: 'Launch Library 2', + + updateInterval: 300000, + + /** Release only Space Mission camera ownership, preserving layer and selection state. */ + releaseCameraOwnership() { + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + }, + + getStats() { + return { + count: layerState._count, + orbitMatches: layerState._orbitMatches, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + }; + }, + + attachDataManager(dataManager) { + layerState._dataManager = dataManager; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js new file mode 100644 index 000000000..8df45903e --- /dev/null +++ b/src/layers/launches/index.js @@ -0,0 +1,118 @@ +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createOrbitRendering } from './orbitRendering.js'; +import { createOverlays } from './overlays.js'; +import { createSelection } from './selection.js'; +import { createReplay } from './replay.js'; +import { createPanel } from './panel.js'; +import { createPaths } from './paths.js'; +import { createLaunchPad } from './launchPad.js'; +import { createCamera } from './camera.js'; +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createTesting } from './testing.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createRocketLaunchesLayer({ services, source }) { + if (typeof source?.getLaunches !== 'function') + throw new TypeError('A launches source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.ingestion = createIngestion(context); + parts.orbitRendering = createOrbitRendering(context); + parts.overlays = createOverlays(context); + parts.selection = createSelection(context); + parts.replay = createReplay(context); + parts.panel = createPanel(context); + parts.paths = createPaths(context); + parts.launchPad = createLaunchPad(context); + parts.camera = createCamera(context); + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.testing = createTesting(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + { + satelliteParamsForSpaceMissions: + parts.policyHelpers.satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions: + parts.policyHelpers.satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit: parts.policyHelpers.launchStatusAllowsOrbit, + missionPathPresentation: parts.policyHelpers.missionPathPresentation, + shouldRetryAfterActiveTle: parts.ingestion.shouldRetryAfterActiveTle, + releaseAircraftTracking: parts.policyHelpers.releaseAircraftTracking, + launchPadZoneVisible: parts.policyHelpers.launchPadZoneVisible, + missionAnchorHorizonVisible: parts.overlays.missionAnchorHorizonVisible, + missionAnchorVisible: parts.overlays.missionAnchorVisible, + compactLaunchSiteName: parts.overlays.compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry: + parts.overlays.createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry: + parts.overlays.createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort: + parts.overlays.selectRocketMissionMarkerOverlayCohort, + replayOverlayMode: parts.overlays.replayOverlayMode, + replayVehicleScreenRotation: parts.replay.replayVehicleScreenRotation, + smoothReplayWindowPosition: parts.replay.smoothReplayWindowPosition, + missionDataCompleteness: parts.policyHelpers.missionDataCompleteness, + missionRosterEntries: parts.policyHelpers.missionRosterEntries, + missionHoverPreviewRange: parts.panel.missionHoverPreviewRange, + createMissionRosterPreviewOwnership: + parts.panel.createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview: + parts.panel.bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus: parts.panel.captureMissionRosterFocus, + restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch: + parts.panel.resolveMissionRosterPreviewLaunch, + formatMissionEventTime: parts.policyHelpers.formatMissionEventTime, + parseMissionDurationSeconds: + parts.policyHelpers.parseMissionDurationSeconds, + replayAscentDurationSeconds: parts.replay.replayAscentDurationSeconds, + normalizeReplaySpeed: parts.replay.normalizeReplaySpeed, + replayStartAfterPause: parts.replay.replayStartAfterPause, + replayState: parts.replay.replayState, + approximateOrbitPath: parts.paths.approximateOrbitPath, + samplePath: parts.paths.samplePath, + orbitProgressAtTime: parts.paths.orbitProgressAtTime, + reconstructedAscentPath: parts.paths.reconstructedAscentPath, + buildMissionPaths: parts.paths.buildMissionPaths, + cameraHeadingForPath: parts.camera.cameraHeadingForPath, + replayInitialCameraHeading: parts.camera.replayInitialCameraHeading, + replayChaseCameraHeading: parts.camera.replayChaseCameraHeading, + smoothReplayCameraHeading: parts.camera.smoothReplayCameraHeading, + missionZoomPitch: parts.camera.missionZoomPitch, + replayCameraView: parts.camera.replayCameraView, + replayOrbitGlobeAnchor: parts.camera.replayOrbitGlobeAnchor, + replayOrbitCameraTarget: parts.camera.replayOrbitCameraTarget, + replayOrbitCameraPose: parts.camera.replayOrbitCameraPose, + replayOrbitFrameSphere: parts.camera.replayOrbitFrameSphere, + replayOrbitGlobeRange: parts.camera.replayOrbitGlobeRange, + missionMarkerColor: parts.model.missionMarkerColor, + normalizeRocketLaunches: parts.model.normalizeRocketLaunches, + _setRocketMissionOverlayHostForTest: + parts.testing._setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest: + parts.testing._setSelectedRocketMissionForTest, + }, + ); +} +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from './policy.js'; +export { createLaunchSource } from './source.js'; diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js new file mode 100644 index 000000000..e7eab0565 --- /dev/null +++ b/src/layers/launches/ingestion.js @@ -0,0 +1,268 @@ +import { MAX_POST_TLE_RETRIES, POST_TLE_RETRY_DELAY_MS } from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + /** + * Decide whether one bounded rebuild is needed after the active TLE lookup. + * @param {object} input Retry state. + * @param {boolean} input.enabled Whether Space Missions is active. + * @param {number} input.retryCount Number of post-TLE retries already used. + * @param {string|null} input.activeTleText Resolved active TLE catalog. + * @param {string|null} input.renderedTleText TLE catalog used for the last build. + * @returns {boolean} Whether to schedule a refresh. + */ + + function shouldRetryAfterActiveTle({ + enabled, + retryCount, + activeTleText, + renderedTleText, + }) { + return Boolean( + enabled && + activeTleText && + activeTleText !== renderedTleText && + retryCount < MAX_POST_TLE_RETRIES, + ); + } + + function clearPostTleRetry() { + if (layerState._retryTimer) clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + } + + function schedulePostTleRetry(token) { + if ( + layerState._retryTimer || + token !== layerState._lifecycleToken || + !shouldRetryAfterActiveTle({ + enabled: layerState._enabled, + retryCount: layerState._postTleRetryCount, + activeTleText: layerState._activeTleText, + renderedTleText: layerState._renderedTleText, + }) + ) + return; + layerState._postTleRetryCount++; + layerState._retryTimer = setTimeout(() => { + layerState._retryTimer = null; + if (layerState._enabled && token === layerState._lifecycleToken) + requestMissionUpdate(); + }, POST_TLE_RETRY_DELAY_MS); + } + + function ensureActiveTleLookup(token) { + if (layerState._activeTleText) + return Promise.resolve(layerState._activeTleText); + if ( + layerState._activeTlePromise && + layerState._activeTlePromiseToken === token + ) + return layerState._activeTlePromise; + const request = source + .getActiveTle({ signal: layerState._sourceController.signal }) + .then((text) => { + if (!layerState._enabled || token !== layerState._lifecycleToken) + return null; + layerState._activeTleText = text; + layerState._focusAfterActiveLookup = Boolean( + layerState._selectedLaunchId, + ); + schedulePostTleRetry(token); + return text; + }) + .catch((error) => { + if (layerState._enabled && token === layerState._lifecycleToken) { + console.warn( + '[Data:RocketLaunches] Active satellite lookup unavailable:', + error.message, + ); + } + return null; + }) + .finally(() => { + if (layerState._activeTlePromise === request) { + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + } + }); + layerState._activeTlePromise = request; + layerState._activeTlePromiseToken = token; + return request; + } + + async function captureSatelliteDependency() { + if (!layerState._dataManager || layerState._satelliteStateBeforeMission) + return; + layerState._satelliteStateBeforeMission = { + // Effective visibility: a user enable still mid-activation is intent ON — + // capturing settled false would restore the user's enable away on exit. + enabled: + layerState._dataManager.isEffectivelyEnabled?.('satellites') ?? + layerState._dataManager.isEnabled('satellites'), + params: parts.policyHelpers.satelliteParamsAfterSpaceMissions( + layerState._dataManager.getLayerParams('satellites'), + ), + }; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsForSpaceMissions( + layerState._satelliteStateBeforeMission.params, + ), + ); + const token = layerState._lifecycleToken; + const activation = Promise.resolve( + layerState._dataManager.setEnabled('satellites', true), + ); + layerState._satelliteActivationPromise = activation; + try { + const activated = await activation; + // Space Missions without its satellite dependency is a broken replay + // surface; fail the mission enable so the manager's fail-closed path and + // the Context rollback see an honest failure instead of a silent success. + if ( + token === layerState._lifecycleToken && + layerState._enabled && + (activated === false || + !layerState._dataManager.isEnabled('satellites')) + ) { + throw new Error( + 'Space Missions requires the satellites layer, which failed to start', + ); + } + } finally { + if ( + token === layerState._lifecycleToken && + layerState._satelliteActivationPromise === activation + ) { + layerState._satelliteActivationPromise = null; + } + } + } + + async function restoreSatelliteDependency() { + const snapshot = layerState._satelliteStateBeforeMission; + if (!snapshot || !layerState._dataManager) return; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsAfterSpaceMissions(snapshot.params), + ); + const restored = await layerState._dataManager.setEnabled( + 'satellites', + snapshot.enabled, + ); + if ( + restored === false || + layerState._dataManager.isEnabled('satellites') !== snapshot.enabled + ) { + throw new Error('Space Missions could not restore the satellites layer'); + } + } + + async function performMissionUpdate(token) { + try { + ensureActiveTleLookup(token); + const payload = await source.getLaunches({ + signal: layerState._sourceController.signal, + }); + const launches = parts.model.normalizeRocketLaunches(payload); + if ( + !layerState._enabled || + token !== layerState._lifecycleToken || + !layerState._dataSource + ) + return; + const activeTleText = layerState._activeTleText; + if (layerState._replayCameraLaunchId) parts.replay.stopMissionReplay(); + layerState._launches = launches; + parts.orbitRendering.removeMissionOrbitPrimitives(); + layerState._dataSource.entities.removeAll(); + layerState._missionOverlayRecords.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._orbitMatches = 0; + launches.forEach((launch) => + parts.rendering.addLaunchEntity(launch, activeTleText), + ); + layerState._renderedTleText = activeTleText; + if (layerState._renderedTleText === layerState._activeTleText) + clearPostTleRetry(); + layerState._count = launches.length; + parts.panel.renderMissionRoster(); + if ( + !layerState._selectedLaunchId || + !launches.some((launch) => launch.id === layerState._selectedLaunchId) + ) { + parts.selection.setSelectedMission(null, false); + } else { + parts.selection.setSelectedMission(layerState._selectedLaunchId, true); + if (layerState._focusAfterActiveLookup) { + parts.selection.focusMission( + launches.find( + (launch) => launch.id === layerState._selectedLaunchId, + ), + ); + } + } + layerState._focusAfterActiveLookup = false; + parts.panel.renderMissionPanel(); + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + } catch (error) { + if (layerState._enabled && token === layerState._lifecycleToken) { + layerState._lastError = error.message; + console.warn('[Data:RocketLaunches] Fetch error:', error); + } + } + } + + function requestMissionUpdate() { + if (!layerState._enabled) return Promise.resolve(); + const token = layerState._lifecycleToken; + layerState._updateDirty = true; + if (layerState._updatePromise && layerState._updatePromiseToken === token) + return layerState._updatePromise; + let request; + request = (async () => { + while ( + layerState._enabled && + token === layerState._lifecycleToken && + layerState._updateDirty + ) { + layerState._updateDirty = false; + await performMissionUpdate(token); + } + })().finally(() => { + if (layerState._updatePromise === request) { + layerState._updatePromise = null; + layerState._updatePromiseToken = 0; + } + }); + layerState._updatePromise = request; + layerState._updatePromiseToken = token; + return request; + } + const methods = { + update() { + return requestMissionUpdate(); + }, + }; + + return { + shouldRetryAfterActiveTle, + clearPostTleRetry, + schedulePostTleRetry, + ensureActiveTleLookup, + captureSatelliteDependency, + restoreSatelliteDependency, + performMissionUpdate, + requestMissionUpdate, + methods, + }; +} diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js new file mode 100644 index 000000000..f0a87ce6a --- /dev/null +++ b/src/layers/launches/launchPad.js @@ -0,0 +1,158 @@ +import * as Cesium from 'cesium'; +import { LAUNCH_PAD_ZONE_RADIUS_M } from './policy.js'; + +export function createLaunchPad({ + state: layerState, + services, + parts, + source, +}) { + function hideLaunchPadZone() { + if (layerState._launchPadZonePrimitive) + layerState._launchPadZonePrimitive.show = false; + } + + function removeLaunchPadZonePrimitive() { + if ( + layerState._launchPadZonePrimitive && + layerState._viewer?.scene?.primitives + ) { + layerState._viewer.scene.primitives.remove( + layerState._launchPadZonePrimitive, + ); + } + layerState._launchPadZonePrimitive = null; + layerState._launchPadZoneLaunchId = null; + } + + function createLaunchPadZonePrimitive(launch) { + removeLaunchPadZonePrimitive(); + const material = new Cesium.Material({ + fabric: { + type: 'GevLaunchPadZone', + uniforms: { + color: Cesium.Color.fromCssColorString('#22e6e6'), + fillAlpha: 0.105, + rimAlpha: 0.72, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; + float radius = length(centered); + float inside = 1.0 - smoothstep(0.985, 1.0, radius); + float rim = smoothstep(0.952, 0.985, radius) * inside; + material.diffuse = color.rgb; + material.emission = color.rgb * rim * 0.35; + material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); + return material; + }`, + }, + }); + const geometry = new Cesium.EllipseGeometry({ + center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), + semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + granularity: Cesium.Math.toRadians(0.08), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }); + layerState._launchPadZonePrimitive = + layerState._viewer.scene.primitives.add( + new Cesium.GroundPrimitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry, + id: `rocket-pad-zone:${launch.id}`, + }), + appearance: new Cesium.MaterialAppearance({ + material, + translucent: true, + closed: false, + faceForward: true, + flat: true, + // Keep the zone classified onto the photoreal surface, but bias only its + // rasterized depth toward the camera. This avoids coplanar fragments + // being intermittently buried by the launch-pad mesh at oblique angles + // without adding a world-space height that would make the ring float. + renderState: { + depthTest: { + enabled: true, + }, + depthMask: false, + polygonOffset: { + enabled: true, + factor: -1, + units: -4, + }, + blending: Cesium.BlendingState.ALPHA_BLEND, + }, + }), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + show: true, + }), + ); + layerState._launchPadZoneLaunchId = launch.id; + } + + function initLaunchPadZonePrimitive() { + if (!layerState._viewer || layerState._launchPadZoneRemover) return; + layerState._launchPadZoneRemover = + layerState._viewer.scene.preRender.addEventListener(() => { + if (!layerState._enabled || !layerState._dataSource?.show) { + hideLaunchPadZone(); + return; + } + const nowMs = Date.now(); + if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { + layerState._missionRingDate.setTime(nowMs); + parts.orbitRendering.updateMissionOrbitPrimitiveFrames( + layerState._missionRingDate, + ); + layerState._lastMissionRingRotationMs = nowMs; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const camera = layerState._viewer?.camera; + if (!launch || !camera) { + hideLaunchPadZone(); + return; + } + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + const visible = parts.policyHelpers.launchPadZoneVisible({ + layerActive: Boolean(layerState._dataSource?.show), + selectedLaunchId: layerState._selectedLaunchId, + launchId: launch.id, + cameraHeightM: camera.positionCartographic?.height, + cameraDistanceM: Cesium.Cartesian3.distance( + camera.positionWC, + launchPosition, + ), + }); + if (!visible) { + hideLaunchPadZone(); + return; + } + if (layerState._launchPadZoneLaunchId !== launch.id) + createLaunchPadZonePrimitive(launch); + layerState._launchPadZonePrimitive.show = true; + }); + } + + function destroyLaunchPadZonePrimitive() { + if (layerState._launchPadZoneRemover) layerState._launchPadZoneRemover(); + layerState._launchPadZoneRemover = null; + removeLaunchPadZonePrimitive(); + } + return { + hideLaunchPadZone, + removeLaunchPadZonePrimitive, + createLaunchPadZonePrimitive, + initLaunchPadZonePrimitive, + destroyLaunchPadZonePrimitive, + }; +} diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js new file mode 100644 index 000000000..2eea788d0 --- /dev/null +++ b/src/layers/launches/lifecycle.js @@ -0,0 +1,172 @@ +import * as Cesium from 'cesium'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + const methods = { + init(viewer) { + layerState._viewer = viewer; + layerState._enabled = false; + parts.panel.createMissionPanel(); + parts.overlays.createReplayVehicleOverlay(); + layerState._dataSource = new Cesium.CustomDataSource('rocket-launches'); + layerState._dataSource.show = false; + viewer.dataSources.add(layerState._dataSource); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastError = null; + layerState._orbitMatches = 0; + parts.overlays.clearMissionOverlaySources(); + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const entity = viewer.scene + .drillPick(movement.position, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + const launchId = parts.selection.entityLaunchId(entity); + if (!launchId) return; + parts.selection.setSelectedMission(launchId); + parts.selection.focusMission( + layerState._launches.find((launch) => launch.id === launchId), + ); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._moveHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const missionEntity = viewer.scene + .drillPick(movement.endPosition, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + // Apply horizon visibility before Cesium traverses and picks the scene. + // A postRender write is one frame late and can remain visibly stale when + // request-on-demand rendering stops after a globe camera move. + layerState._declutterHandler = viewer.scene.preRender.addEventListener( + parts.rendering.updateMissionFrame, + ); + parts.launchPad.initLaunchPadZonePrimitive(); + }, + + async enable() { + layerState._sourceController.abort(); + layerState._sourceController = new AbortController(); + layerState._enabled = true; + holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) + layerState._lifecycleToken++; + layerState._postTleRetryCount = 0; + try { + await this._enableBody(); + } catch (error) { + // Enable is a transaction: a failed dependency must not leave mission + // UI/datasource visible or the satellite snapshot retained (a retained + // snapshot makes the next enable skip dependency capture entirely). + try { + await this.disable(); + } catch (cleanupError) { + console.warn('[Missions] enable rollback failed:', cleanupError); + } + throw error; + } + }, + + async _enableBody() { + layerState._updateDirty = false; + if (layerState._dataSource) layerState._dataSource.show = true; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.selection.focusFullGlobe(layerState._viewer); + document.getElementById('cockpit-context')?.setAttribute('hidden', ''); + if (layerState._selectedLaunchId) + parts.selection.setSelectedMission( + layerState._selectedLaunchId, + layerState._explicitSelection, + ); + else parts.selection.setSelectedMission(null, false); + await parts.ingestion.captureSatelliteDependency(); + }, + + async disable() { + layerState._sourceController.abort(); + layerState._enabled = false; + releaseContinuousRender('rocket-launches'); + layerState._lifecycleToken++; + layerState._updateDirty = false; + parts.ingestion.clearPostTleRetry(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + if (layerState._dataSource) layerState._dataSource.show = false; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + if (layerState._viewer?.scene?.canvas) + layerState._viewer.scene.canvas.style.cursor = ''; + parts.launchPad.hideLaunchPadZone(); + parts.overlays.hideReplayVehicleOverlay(); + layerState._selectedLaunchId = null; + parts.overlays.clearMissionOverlaySources(); + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.renderMissionPanel(); + }, + + async destroy(viewer) { + layerState._sourceController.abort(); + releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) + layerState._enabled = false; + layerState._lifecycleToken++; + layerState._updateDirty = false; + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + parts.ingestion.clearPostTleRetry(); + if (layerState._clickHandler) layerState._clickHandler.destroy(); + layerState._clickHandler = null; + if (layerState._moveHandler) layerState._moveHandler.destroy(); + layerState._moveHandler = null; + if (layerState._declutterHandler) layerState._declutterHandler(); + layerState._declutterHandler = null; + parts.launchPad.destroyLaunchPadZonePrimitive(); + parts.orbitRendering.removeMissionOrbitPrimitives(); + parts.overlays.clearMissionOverlaySources(); + parts.overlays.destroyReplayVehicleOverlay(); + layerState._launches = []; + layerState._missionOverlayRecords.clear(); + layerState._animationStarts.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._missionPanel?.remove(); + layerState._missionPanel = null; + if (layerState._missionRoster) { + layerState._missionRoster.onclick = null; + } + layerState._missionRosterPreviewOwnership = null; + layerState._missionRoster = null; + layerState._viewer = null; + if (layerState._dataSource) + viewer.dataSources.remove(layerState._dataSource, true); + layerState._dataSource = null; + layerState._count = 0; + layerState._orbitMatches = 0; + layerState._lastUpdate = null; + layerState._activeTleText = null; + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + layerState._renderedTleText = null; + layerState._postTleRetryCount = 0; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js new file mode 100644 index 000000000..641b8d157 --- /dev/null +++ b/src/layers/launches/model.js @@ -0,0 +1,241 @@ +import * as Cesium from 'cesium'; +import { WINDOW_DAYS } from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + function finiteCoordinate(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + function normalizePayloadFlights(launch) { + const flights = + launch.rocket?.payloads || + launch.payloads || + launch.mission?.payloads || + []; + if (!Array.isArray(flights)) return []; + return flights.map((flight, index) => { + const payload = flight.payload || flight; + return { + id: String(flight.id || payload.id || `payload-${index}`), + name: payload.name || flight.name || 'Unnamed payload', + type: payload.type?.name || flight.type?.name || null, + manufacturer: payload.manufacturer?.name || null, + operator: payload.operator?.name || null, + destination: flight.destination || payload.destination || null, + amount: Number.isFinite(Number(flight.amount)) + ? Number(flight.amount) + : 1, + massKg: + (typeof payload.mass === 'number' || + (typeof payload.mass === 'string' && payload.mass.trim() !== '')) && + Number.isFinite(Number(payload.mass)) && + Number(payload.mass) >= 0 + ? Number(payload.mass) + : null, + }; + }); + } + + function normalizeLanding(stage, fallbackName, category, index) { + const landing = stage?.landing; + const location = landing?.landing_location || {}; + const launcher = stage?.launcher || stage?.spacecraft || {}; + const serial = launcher.serial_number || stage?.serial_number || null; + const stageType = stage?.type?.name || stage?.type || category; + const attempted = landing?.attempt === true; + const success = landing?.success; + const recoveryType = landing?.type?.name || null; + const launcherStatus = launcher.status?.name || null; + const status = + success === true + ? 'RECOVERED' + : success === false + ? 'LOST' + : attempted + ? 'RECOVERY ATTEMPT' + : recoveryType + ? recoveryType.toUpperCase() + : launcherStatus + ? launcherStatus.toUpperCase() + : 'NO RECOVERY DATA'; + return { + id: String(stage?.id || landing?.id || `${category}-${index}`), + category, + name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, + serial, + reused: stage?.reused === true, + flightNumber: finiteCoordinate(stage?.launcher_flight_number), + status, + attempted, + success: success === true ? true : success === false ? false : null, + recoveryType, + destination: + location.name || landing?.destination || landing?.type?.name || null, + description: landing?.description || null, + downrangeKm: finiteCoordinate(landing?.downrange_distance), + lat: finiteCoordinate(location.latitude ?? landing?.latitude), + lon: finiteCoordinate(location.longitude ?? landing?.longitude), + }; + } + + function normalizeRecoveryStages(launch, payloads) { + const rocket = launch.rocket || {}; + const launcherStages = Array.isArray(rocket.launcher_stage) + ? rocket.launcher_stage + : []; + const spacecraftStages = Array.isArray(rocket.spacecraft_stage) + ? rocket.spacecraft_stage + : []; + const stages = [ + ...launcherStages.map((stage, index) => + normalizeLanding( + stage, + `Launcher stage ${index + 1}`, + 'LAUNCHER', + index, + ), + ), + ...spacecraftStages.map((stage, index) => + normalizeLanding( + stage, + `Spacecraft stage ${index + 1}`, + 'SPACECRAFT', + index, + ), + ), + ]; + const payloadFlights = rocket.payloads || launch.payloads || []; + if (Array.isArray(payloadFlights)) { + payloadFlights.forEach((flight, index) => { + if (!flight?.landing) return; + stages.push( + normalizeLanding( + { + ...flight, + type: payloads[index]?.type || 'Payload', + serial_number: payloads[index]?.name, + }, + payloads[index]?.name || `Payload ${index + 1}`, + 'PAYLOAD', + index, + ), + ); + }); + } + return stages; + } + + /** + * Select a stable marker color from the mission operator and payload name. + * Labels stay cyan so the layer remains visually coherent. + * @param {object} launch Normalized launch record. + * @returns {Cesium.Color} + */ + + function missionMarkerColor(launch) { + const identity = + `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); + if (/nasa|national aeronautics/.test(identity)) + return Cesium.Color.fromCssColorString('#ff9f43'); + if (/starlink|spacex|space exploration/.test(identity)) + return Cesium.Color.fromCssColorString('#4cc9f0'); + if (/rocket lab/.test(identity)) + return Cesium.Color.fromCssColorString('#7bed9f'); + if (/isro|indian space/.test(identity)) + return Cesium.Color.fromCssColorString('#ff66c4'); + if (/cnsa|china national|long march/.test(identity)) + return Cesium.Color.fromCssColorString('#ffd166'); + if (/blue origin/.test(identity)) + return Cesium.Color.fromCssColorString('#a78bfa'); + if (/ula|united launch alliance/.test(identity)) + return Cesium.Color.fromCssColorString('#f97316'); + if (/arianespace|esa|european space/.test(identity)) + return Cesium.Color.fromCssColorString('#60a5fa'); + if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); + return Cesium.Color.fromCssColorString('#22e6e6'); + } + + /** + * Normalize a Launch Library 2 response into records suitable for the layer. + * Trajectory points are retained only when the upstream explicitly supplies + * them; orbital tracks must not be reconstructed from launch metadata. + * @param {object} payload Launch Library 2-compatible response. + * @param {Date} [now] Reference time used for the rolling window. + * @returns {Array} + */ + + function normalizeRocketLaunches(payload, now = new Date()) { + const launches = Array.isArray(payload) ? payload : payload?.results; + if (!Array.isArray(launches)) return []; + const cutoff = now.getTime() - WINDOW_DAYS * 86400000; + return launches + .map((launch) => { + const launchTime = + launch.net || launch.window_start || launch.pad?.location?.name; + const date = Date.parse(launchTime); + const pad = launch.pad || {}; + const location = pad.location || {}; + const coordinates = location.coordinates || ''; + const [coordinateLon, coordinateLat] = String(coordinates) + .split(',') + .map(Number); + const lat = Number.isFinite(Number(pad.latitude)) + ? Number(pad.latitude) + : coordinateLat; + const lon = Number.isFinite(Number(pad.longitude)) + ? Number(pad.longitude) + : coordinateLon; + const payloads = normalizePayloadFlights(launch); + return { + id: String( + launch.id || launch.slug || launch.name || `launch-${date}`, + ), + name: launch.name || 'Unnamed launch', + status: launch.status?.name || 'Unknown', + launchTime: Number.isFinite(date) + ? new Date(date).toISOString() + : null, + launchSite: pad.name || location.name || 'Unknown launch site', + lat: Number.isFinite(lat) ? lat : null, + lon: Number.isFinite(lon) ? lon : null, + provider: launch.launch_service_provider?.name || null, + mission: launch.mission?.description || null, + missionName: launch.mission?.name || null, + satelliteQuery: launch.mission?.name || launch.name || null, + payloads, + recoveryStages: normalizeRecoveryStages(launch, payloads), + trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], + timeline: Array.isArray(launch.timeline) + ? launch.timeline.map((event) => ({ + name: + event.type?.abbrev || + event.type?.name || + event.name || + 'Mission event', + relativeTime: event.relative_time || event.relativeTime || null, + offsetSeconds: parts.policyHelpers.parseMissionDurationSeconds( + event.relative_time || event.relativeTime, + ), + })) + : [], + orbit: launch.mission?.orbit || launch.orbit || null, + source: 'Launch Library 2', + inWindow: + Number.isFinite(date) && date >= cutoff && date <= now.getTime(), + }; + }) + .filter( + (launch) => + launch.inWindow && launch.lat !== null && launch.lon !== null, + ); + } + return { + finiteCoordinate, + normalizePayloadFlights, + normalizeLanding, + normalizeRecoveryStages, + missionMarkerColor, + normalizeRocketLaunches, + }; +} diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js new file mode 100644 index 000000000..d907904eb --- /dev/null +++ b/src/layers/launches/orbitRendering.js @@ -0,0 +1,183 @@ +import * as Cesium from 'cesium'; +import { + MISSION_ORBIT_PATTERN_GROUPS, + MISSION_ORBIT_DASHES_PER_GROUP, +} from './policy.js'; + +export function createOrbitRendering({ + state: layerState, + services, + parts, + source, +}) { + const { orbitFrameModelMatrix } = services.satellites; + + /** + * Register the selected-orbit tactical material once. Each group begins with + * one compact round dot followed by one hundred short dashes. + */ + + function ensureMissionOrbitPatternRegistered() { + if (layerState._missionOrbitPatternRegistered) return; + new Cesium.Material({ + fabric: { + type: 'GevMissionOrbitTactical', + uniforms: { + color: Cesium.Color.CYAN, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float groupPosition = fract(materialInput.st.s * groupCount); + float markPosition = groupPosition * (dashCount + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); + float dash = dashAlong * dashAcross; + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (materialInput.st.t - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + float visible = mix(dash, dot, isDot); + material.diffuse = color.rgb; + material.emission = color.rgb * mix(0.07, 0.65, isDot); + material.alpha = color.a * visible * mix(0.58, 1.0, isDot); + return material; + }`, + }, + }); + layerState._missionOrbitPatternRegistered = true; + } + + function createMissionOrbitPatternMaterial(color) { + ensureMissionOrbitPatternRegistered(); + return Cesium.Material.fromType('GevMissionOrbitTactical', { + color, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }); + } + + function missionOrbitPrimitiveVisible(launchId) { + return Boolean( + layerState._enabled && + layerState._dataSource?.show && + (!layerState._selectedLaunchId || + layerState._selectedLaunchId === launchId), + ); + } + + function syncMissionOrbitPrimitiveVisibility() { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (path.primitive) + path.primitive.show = missionOrbitPrimitiveVisible(launchId); + } + } + + function removeMissionOrbitPrimitives() { + for (const path of layerState._missionOrbitPrimitives.values()) { + if (path.primitive && layerState._viewer?.scene?.primitives) { + layerState._viewer.scene.primitives.remove(path.primitive); + } + } + layerState._missionOrbitPrimitives.clear(); + } + + function updateMissionOrbitPrimitiveFrames(nowDate) { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; + orbitFrameModelMatrix( + path.gmstAtBake, + nowDate, + path.primitive.modelMatrix, + ); + if (path.labelBakePosition && path.labelPosition) { + Cesium.Matrix4.multiplyByPoint( + path.primitive.modelMatrix, + path.labelBakePosition, + path.labelPosition, + ); + } + } + } + + function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { + if ( + !layerState._viewer || + !satelliteTrack || + !Number.isFinite(satelliteTrack.gmstAtBake) + ) + return false; + const collection = new Cesium.PolylineCollection(); + collection.add({ + positions: orbitPath, + width: 3, + material: createMissionOrbitPatternMaterial( + Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), + ), + }); + collection.show = missionOrbitPrimitiveVisible(launch.id); + orbitFrameModelMatrix( + satelliteTrack.gmstAtBake, + new Date(), + collection.modelMatrix, + ); + layerState._viewer.scene.primitives.add(collection); + layerState._missionOrbitPrimitives.set(launch.id, { + primitive: collection, + gmstAtBake: satelliteTrack.gmstAtBake, + }); + return true; + } + + function MissionOrbitPatternMaterialProperty(color) { + ensureMissionOrbitPatternRegistered(); + this._color = color; + this._definitionChanged = new Cesium.Event(); + } + Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { + isConstant: { + get() { + return true; + }, + }, + definitionChanged: { + get() { + return this._definitionChanged; + }, + }, + }); + MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { + return 'GevMissionOrbitTactical'; + }; + MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue( + time, + result, + ) { + if (!Cesium.defined(result)) result = {}; + result.color = this._color; + result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; + result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; + return result; + }; + MissionOrbitPatternMaterialProperty.prototype.equals = function equals( + other, + ) { + return this === other; + }; + return { + ensureMissionOrbitPatternRegistered, + createMissionOrbitPatternMaterial, + missionOrbitPrimitiveVisible, + syncMissionOrbitPrimitiveVisibility, + removeMissionOrbitPrimitives, + updateMissionOrbitPrimitiveFrames, + addMissionOrbitPrimitive, + MissionOrbitPatternMaterialProperty, + }; +} diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js new file mode 100644 index 000000000..600b5c781 --- /dev/null +++ b/src/layers/launches/overlays.js @@ -0,0 +1,616 @@ +import * as Cesium from 'cesium'; +import { + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createOverlays({ state: layerState, services, parts, source }) { + /** + * Resolve whether a surface mission anchor is safely on the camera-facing + * side of Earth. The small positive limb margin prevents labels anchored just + * beyond the horizon from leaking through the hidden Cesium globe. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {number} [limbMargin] Additional normalized horizon clearance. + * @returns {boolean} Whether the marker belongs on the visible hemisphere. + */ + + function missionAnchorHorizonVisible( + cameraPosition, + markerPosition, + limbMargin = 0.012, + ) { + if (!cameraPosition || !markerPosition) return false; + const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); + if ( + !Number.isFinite(cameraMagnitude) || + cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius + ) { + return false; + } + const cameraDirection = Cesium.Cartesian3.normalize( + cameraPosition, + new Cesium.Cartesian3(), + ); + const markerDirection = Cesium.Cartesian3.normalize( + markerPosition, + new Cesium.Cartesian3(), + ); + const limbThreshold = + Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; + return ( + Cesium.Cartesian3.dot(cameraDirection, markerDirection) > + limbThreshold + limbMargin + ); + } + + /** + * Resolve launch-anchor visibility for overview and selected-mission views. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {string} markerId Mission represented by this anchor. + * @param {string|null} selectedLaunchId Explicitly selected mission. + * @returns {boolean} Whether the launch anchor should render. + */ + + function missionAnchorVisible( + cameraPosition, + markerPosition, + markerId, + selectedLaunchId = null, + ) { + if (selectedLaunchId && markerId !== selectedLaunchId) return false; + return missionAnchorHorizonVisible(cameraPosition, markerPosition); + } + + function shortMissionLabel(name, maxLength = 24) { + const text = String(name || 'Unnamed mission') + .replace(/\s+/g, ' ') + .trim() + .split(' | ')[0]; + const compact = text.split(' — ')[0].trim(); + return compact.length > maxLength + ? `${compact.slice(0, maxLength - 1).trimEnd()}…` + : compact; + } + + /** + * Reduce generic launch-complex names to their identifying pad or area suffix. + * @param {string|null} launchSite Launch Library pad name. + * @returns {string|null} Compact launch-site identifier. + */ + + function compactLaunchSiteName(launchSite) { + const text = String(launchSite || '') + .replace(/\s+/g, ' ') + .trim(); + if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; + const genericPrefix = + /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; + const compact = text.replace(genericPrefix, '').trim(); + return compact || null; + } + + /** + * Build the source-owned launch-site marker presentation. Overview markers + * compete in the bounded ambient-label domain; the selected mission moves to + * the protected selected lane and gains the former launch-site detail line. + * @param {object} launch Normalized Launch Library mission. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. + * @param {boolean} [selected=false] Whether the mission owns the selected view. + * @returns {object} + */ + + function createRocketMissionMarkerOverlayEntry( + launch, + position, + selected = false, + ) { + const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); + const siteName = compactLaunchSiteName(launch?.launchSite); + const launchTimeMs = Date.parse(launch?.launchTime); + const details = selected + ? [ + siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE', + ] + : []; + return { + id: `launch:${launch?.id}`, + position, + variant: 'label', + title: mission, + details, + accent: '#22e6e6', + priority: selected + ? Number.MAX_SAFE_INTEGER + : Number.isFinite(launchTimeMs) + ? Math.floor(launchTimeMs / 1000) + : 0, + selected, + protected: selected, + paintLane: selected ? 'selected' : 'ambient-label', + collisionGroup: 'ambient-label', + interactive: false, + distanceScale: { + near: 1000, + nearValue: 1.08, + far: 20_000_000, + farValue: 0.78, + }, + gapPx: 12, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** + * Build one protected label belonging to the selected mission's trajectory, + * live payload position, or orbit. Copy is already source-formatted by the + * caller; newline semantics become explicit host detail rows. + * @param {object} input + * @param {string} input.id Stable mission-element identity. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. + * @param {string} input.text Former native-label text, including newlines. + * @param {string} input.accent Source-owned label color. + * @param {number} [input.priority=0] Protected placement order. + * @param {number} [input.gapPx=8] Anchor-to-label gap. + * @returns {object} + */ + + function createRocketMissionElementOverlayEntry({ + id, + position, + text, + accent, + priority = 0, + gapPx = 8, + }) { + const [title, ...details] = String(text || '').split('\n'); + return { + id: String(id), + position, + variant: 'label', + title, + details, + accent, + priority, + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-label', + interactive: false, + gapPx, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** Keep the newest ambient mission markers with stable identity tie-breaking. */ + + function selectRocketMissionMarkerOverlayCohort( + entries, + limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ) { + const cap = Math.max( + 0, + Math.min( + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + Math.floor(Number(limit) || 0), + ), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); + } + + function missionHoverReticleImage() { + if (layerState._missionHoverReticleImage) + return layerState._missionHoverReticleImage; + const canvas = document.createElement('canvas'); + const size = 48; + const inset = 6; + const arm = 12; + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + context.strokeStyle = '#22e6e6'; + context.lineWidth = 2; + context.lineCap = 'square'; + context.shadowColor = 'rgba(34, 230, 230, .72)'; + context.shadowBlur = 5; + context.beginPath(); + context.moveTo(inset, inset + arm); + context.lineTo(inset, inset); + context.lineTo(inset + arm, inset); + context.moveTo(size - inset - arm, inset); + context.lineTo(size - inset, inset); + context.lineTo(size - inset, inset + arm); + context.moveTo(inset, size - inset - arm); + context.lineTo(inset, size - inset); + context.lineTo(inset + arm, size - inset); + context.moveTo(size - inset - arm, size - inset); + context.lineTo(size - inset, size - inset); + context.lineTo(size - inset, size - inset - arm); + context.stroke(); + layerState._missionHoverReticleImage = canvas; + return canvas; + } + + /** + * Resolve the one permitted screen-space replay overlay state. + * @param {object} input Overlay state. + * @param {boolean} input.replayActive Whether replay owns the camera. + * @param {boolean} input.ascending Whether the replay marker is on ascent. + * @param {boolean} input.countdownActive Whether the T-minus hold is active. + * @returns {'countdown'|'ascent'|'orbit'|null} + */ + + function replayOverlayMode({ + replayActive, + ascending, + countdownActive, + preCountdownActive = false, + }) { + if (!replayActive) return null; + if (preCountdownActive) return null; + if (countdownActive) return 'countdown'; + return ascending ? 'ascent' : 'orbit'; + } + + function createReplayVehicleOverlay() { + if (layerState._replayVehicleOverlay || typeof document === 'undefined') + return; + const host = document.getElementById('cesiumContainer') || document.body; + layerState._replayVehicleOverlay = document.createElement('div'); + layerState._replayVehicleOverlay.className = + 'mission-replay-vehicle-overlay'; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true'); + layerState._replayVehicleOverlay.innerHTML = ` + + + + + + + + + + + + + + + + + + + `; + host.appendChild(layerState._replayVehicleOverlay); + } + + function hideReplayVehicleOverlay() { + if (!layerState._replayVehicleOverlay) return; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.classList.remove( + 'is-thrusting', + 'is-paused', + ); + } + + function destroyReplayVehicleOverlay() { + layerState._replayVehicleOverlay?.remove(); + layerState._replayVehicleOverlay = null; + layerState._replayVehicleOverlayText = ''; + } + + function clearMissionOverlaySources() { + layerState._selectedMissionOverlayTimeText = null; + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + } + + function syncMissionOverlayEntries() { + if (!layerState._enabled || !layerState._dataSource?.show) { + clearMissionOverlaySources(); + return; + } + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (selectedRecord) { + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + const entries = selectedRecord.elementEntryFactories.map((createEntry) => + createEntry(), + ); + if (layerState._replayCameraLaunchId !== selectedRecord.launch.id) { + entries.unshift( + createRocketMissionMarkerOverlayEntry( + selectedRecord.launch, + selectedRecord.anchorPosition, + true, + ), + ); + } + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + true, + ); + layerState._selectedMissionOverlayTimeText = selectedRecord.liveEventTime + ? parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ) + : null; + return; + } + + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + layerState._selectedMissionOverlayTimeText = null; + const entries = selectRocketMissionMarkerOverlayCohort( + Array.from(layerState._missionOverlayRecords.values(), (record) => { + const entry = createRocketMissionMarkerOverlayEntry( + record.launch, + record.anchorPosition, + ); + if (record.launch.id === layerState._hoveredRosterLaunchId) { + entry.pinned = true; + entry.priority = Number.MAX_SAFE_INTEGER - 1; + } + return entry; + }), + ); + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + true, + ); + } + + function refreshSelectedMissionOverlayText() { + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (!selectedRecord?.liveEventTime) return; + const nextText = parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ); + if (nextText !== layerState._selectedMissionOverlayTimeText) + syncMissionOverlayEntries(); + } + + function updateReplayVehicleOverlay(occluder) { + if ( + !layerState._viewer || + !layerState._replayVehicleOverlay || + !layerState._selectedLaunchId + ) { + hideReplayVehicleOverlay(); + return null; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const track = layerState._replayTracks.get(layerState._selectedLaunchId); + if (!launch || !track) { + hideReplayVehicleOverlay(); + return null; + } + const replayActive = layerState._replayCameraLaunchId === launch.id; + const state = track.getReplayState(); + const mode = replayOverlayMode({ + replayActive, + ascending: state.ascending, + countdownActive: state.countdownActive, + preCountdownActive: state.preCountdownActive, + }); + if (!mode) { + track.lastOverlayWindowPosition = null; + track.lastOverlayMode = null; + hideReplayVehicleOverlay(); + return null; + } + const path = state.ascending ? track.ascentPath : track.animatedOrbitPath; + // Do not call scene.sampleHeight() from this post-render path. A selected + // mission can remain visible from globe range, and a periodic remote height + // probe forces Google Photorealistic 3D Tiles to reconsider its refinement + // set even though the camera is stationary. That presented as a one-second + // shrink/expand pulse across the globe. Replay follows its already-built, + // surface-safe Cartesian path and therefore needs no runtime pad sampling. + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position || !occluder.isPointVisible(position)) { + hideReplayVehicleOverlay(); + return null; + } + const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + position, + ); + if (!windowPosition) { + hideReplayVehicleOverlay(); + return null; + } + const canvas = layerState._viewer.scene.canvas; + if ( + windowPosition.x < -80 || + windowPosition.y < -100 || + windowPosition.x > canvas.clientWidth + 80 || + windowPosition.y > canvas.clientHeight + 100 + ) { + hideReplayVehicleOverlay(); + return null; + } + // The replay camera already targets this same frame-cached world position. + // Applying a second temporal filter here made the DOM vehicle trail the + // camera until the snap threshold was crossed, producing a repeating + // forward/back jump. Keep smoothing only for the non-tracked close-up pad + // marker, where the user can move the camera independently. + const renderedWindowPosition = + !replayActive && track.lastOverlayMode === mode + ? parts.replay.smoothReplayWindowPosition( + track.lastOverlayWindowPosition, + windowPosition, + ) + : windowPosition; + track.lastOverlayWindowPosition = renderedWindowPosition; + track.lastOverlayMode = mode; + layerState._replayVehicleOverlay.hidden = false; + layerState._replayVehicleOverlay.dataset.mode = mode; + layerState._replayVehicleOverlay.classList.toggle( + 'is-thrusting', + replayActive && state.ascending && !state.countdownActive, + ); + layerState._replayVehicleOverlay.classList.toggle( + 'is-paused', + replayActive && layerState._replayPaused, + ); + layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; + let vehicleRotation = 0; + if (replayActive && !state.countdownActive) { + const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); + const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); + const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); + const useForward = forwardProgress > state.phaseProgress; + const tangentPosition = parts.paths.samplePath( + path, + useForward ? forwardProgress : backwardProgress, + ); + const tangentWindowPosition = tangentPosition + ? Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + tangentPosition, + ) + : null; + if (tangentWindowPosition) { + const screenDelta = Math.hypot( + tangentWindowPosition.x - windowPosition.x, + tangentWindowPosition.y - windowPosition.y, + ); + // When the path points almost directly into/out of the camera, its + // screen projection has no trustworthy direction. Keep the last valid + // path-facing pose instead of snapping the rocket to a camera-facing + // upright orientation. + if (screenDelta >= 2) { + vehicleRotation = useForward + ? parts.replay.replayVehicleScreenRotation( + windowPosition, + tangentWindowPosition, + ) + : parts.replay.replayVehicleScreenRotation( + tangentWindowPosition, + windowPosition, + ); + track.lastVehicleRotation = vehicleRotation; + } else if (Number.isFinite(track.lastVehicleRotation)) { + vehicleRotation = track.lastVehicleRotation; + } + } + } + layerState._replayVehicleOverlay.style.setProperty( + '--mission-replay-rotation', + `${Cesium.Math.toDegrees(vehicleRotation)}deg`, + ); + + const mission = shortMissionLabel(launch.name, 22).toUpperCase(); + const siteName = compactLaunchSiteName(launch.launchSite); + const siteCallout = siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE'; + let title = mission; + let detail = siteCallout; + if (mode === 'countdown') { + title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; + detail = `LAUNCH STANDBY\n${siteCallout}`; + } else if (mode === 'ascent') { + title = + state.elapsedSinceStart < 1 + ? `LIFTOFF · ${mission}` + : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'recovery') { + title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'orbit') { + title = `ORBIT REPLAY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } + if (layerState._replayPaused) title = `PAUSED · ${title}`; + const nextText = `${title}\n${detail}`; + if (nextText !== layerState._replayVehicleOverlayText) { + layerState._replayVehicleOverlayText = nextText; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-title]', + ).textContent = title; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-detail]', + ).textContent = detail; + } + return mode; + } + return { + missionAnchorHorizonVisible, + missionAnchorVisible, + shortMissionLabel, + compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort, + missionHoverReticleImage, + replayOverlayMode, + createReplayVehicleOverlay, + hideReplayVehicleOverlay, + destroyReplayVehicleOverlay, + clearMissionOverlaySources, + syncMissionOverlayEntries, + refreshSelectedMissionOverlayText, + updateReplayVehicleOverlay, + }; +} diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js new file mode 100644 index 000000000..1555b5a66 --- /dev/null +++ b/src/layers/launches/panel.js @@ -0,0 +1,543 @@ +import * as Cesium from 'cesium'; +import { + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, +} from './policy.js'; + +export function createPanel({ state: layerState, services, parts, source }) { + /** + * Preserve the user's globe scale for roster previews while avoiding an + * accidental surface-level fly-to when the list is opened from a close view. + * @param {number} cameraHeight Current camera height above the ellipsoid. + * @returns {number} Preview range in metres. + */ + + function missionHoverPreviewRange(cameraHeight) { + const height = Number(cameraHeight); + return Math.max( + MISSION_CLOSE_VIEW_RANGE_M, + Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, + ); + } + + /** + * Coordinate pointer and keyboard ownership of the temporary roster preview. + * The most recent input owns the preview until it leaves, then any remaining + * input resumes ownership. This lets keyboard focus take over from a pointer + * resting on another row without losing that pointer preview on blur. + * @param {{preview: (index: number) => void, clear: () => void}} handlers + * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, + * reset: () => void}} + */ + + function createMissionRosterPreviewOwnership({ preview, clear }) { + let pointerIndex = null; + let focusIndex = null; + let latestOwner = null; + let activeIndex = null; + + const sync = () => { + const nextIndex = + latestOwner === 'pointer' + ? (pointerIndex ?? focusIndex) + : (focusIndex ?? pointerIndex); + if (nextIndex === activeIndex) return; + activeIndex = nextIndex; + if (Number.isInteger(nextIndex)) preview(nextIndex); + else clear(); + }; + + return { + pointerEnter(index) { + pointerIndex = index; + latestOwner = 'pointer'; + sync(); + }, + pointerLeave() { + pointerIndex = null; + if (latestOwner === 'pointer') + latestOwner = focusIndex === null ? null : 'focus'; + sync(); + }, + focus(index) { + focusIndex = index; + latestOwner = 'focus'; + sync(); + }, + blur(nextIndex = null) { + focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; + if (latestOwner === 'focus') + latestOwner = + focusIndex === null + ? pointerIndex === null + ? null + : 'pointer' + : 'focus'; + sync(); + }, + reset() { + pointerIndex = null; + focusIndex = null; + latestOwner = null; + activeIndex = null; + clear(); + }, + }; + } + + /** + * Bind native keyboard focus events for one rendered roster row. + * @param {Element} button Mission roster button. + * @param {number} index Source-array mission index. + * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership + * @param {Element} roster Roster root used to reject focus targets outside the list. + */ + + function bindMissionRosterItemKeyboardPreview( + button, + index, + ownership, + roster, + ) { + button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); + button.addEventListener('mouseleave', () => ownership.pointerLeave()); + button.addEventListener('focus', () => ownership.focus(index)); + button.addEventListener('blur', (event) => { + const nextButton = event.relatedTarget?.closest?.( + '[data-mission-roster-index]', + ); + const nextIndex = + nextButton && roster.contains(nextButton) + ? Number(nextButton.dataset.missionRosterIndex) + : null; + ownership.blur(nextIndex); + }); + } + + /** Capture the exact mission identity owned by keyboard focus before a refresh. */ + + function captureMissionRosterFocus( + list, + activeElement = globalThis.document?.activeElement, + ) { + const button = activeElement?.closest?.('[data-mission-roster-id]'); + if (!button || !list?.contains(button)) return null; + const launchId = button.dataset.missionRosterId; + return launchId ? { launchId } : null; + } + + /** Restore focus by mission identity, or continue after the list if it departed. */ + + function restoreMissionRosterFocus(list, snapshot, continuation) { + if (!snapshot?.launchId) return 'none'; + const button = Array.from( + list?.querySelectorAll?.('[data-mission-roster-id]') || [], + ).find( + (candidate) => candidate.dataset.missionRosterId === snapshot.launchId, + ); + if (button) { + button.focus({ preventScroll: true }); + return 'restored'; + } + if (continuation?.focus) { + continuation.focus({ preventScroll: true }); + return 'continued'; + } + return 'none'; + } + + /** Resolve a pending preview against the current refresh, never a stale object. */ + + function resolveMissionRosterPreviewLaunch(launches, launchId) { + return ( + (launches || []).find((candidate) => candidate.id === launchId) || null + ); + } + + function missionTableRows(items, columns, emptyText) { + if (!items.length) + return `${emptyText}`; + return items.map((item) => item).join(''); + } + + function setMissionPanelField(selector, value, title = '') { + const output = layerState._missionPanel?.querySelector(selector); + if (!output) return; + const row = output.closest('[data-mission-field]'); + const available = + value !== null && value !== undefined && String(value).trim() !== ''; + if (row) row.hidden = !available; + if (!available) { + output.textContent = ''; + output.removeAttribute('title'); + return; + } + output.textContent = value; + if (title) output.title = title; + else output.removeAttribute('title'); + } + + function renderMissionPanel() { + if (!layerState._missionPanel) return; + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const index = launch ? layerState._launches.indexOf(launch) : -1; + layerState._missionPanel.hidden = !launch; + if (!launch) return; + layerState._missionPanel.querySelector('[data-mission-title]').textContent = + parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(); + setMissionPanelField('[data-mission-provider]', launch.provider); + setMissionPanelField('[data-mission-status]', launch.status); + setMissionPanelField( + '[data-mission-site]', + launch.launchSite && launch.launchSite !== 'Unknown launch site' + ? launch.launchSite + : null, + ); + setMissionPanelField('[data-mission-time]', launch.launchTime); + const pathPresentation = parts.policyHelpers.missionPathPresentation( + launch, + layerState._replayTracks.has(launch.id), + ); + setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); + layerState._missionPanel.querySelector( + '[data-mission-ascent-source]', + ).textContent = pathPresentation.ascent; + const payloadRows = launch.payloads.length + ? launch.payloads.slice(0, 5).map((payload) => { + const detail = [ + payload.manufacturer, + payload.operator && payload.operator !== payload.manufacturer + ? payload.operator + : null, + Number.isFinite(payload.massKg) + ? `${payload.massKg.toLocaleString()} KG` + : null, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; + }) + : []; + if (launch.payloads.length > 5) { + payloadRows.push( + `+${launch.payloads.length - 5} additional payload records`, + ); + } + layerState._missionPanel.querySelector( + '[data-mission-payloads]', + ).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE'); + const stageRows = launch.recoveryStages.map((stage) => { + const endpoint = stage.endpoint; + const destination = + stage.destination || + (endpoint?.accuracy === 'PAD / RTLS' + ? launch.launchSite + : 'UNAVAILABLE'); + const position = endpoint + ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` + : stage.downrangeKm > 0 + ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` + : 'POSITION UNAVAILABLE'; + const stageDetail = [ + Number.isFinite(stage.flightNumber) + ? `FLIGHT ${stage.flightNumber}` + : null, + stage.reused ? 'REUSED' : null, + stage.recoveryType, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; + }); + layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML = + missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA'); + const stageSection = layerState._missionPanel.querySelector( + '[data-mission-stages-section]', + ); + if (stageSection) stageSection.hidden = stageRows.length === 0; + updateMissionTelemetry(true); + layerState._missionPanel.querySelector('[data-mission-index]').textContent = + `${index + 1} / ${layerState._launches.length}`; + layerState._missionPanel.querySelector('[data-mission-prev]').disabled = + index <= 0; + layerState._missionPanel.querySelector('[data-mission-next]').disabled = + index < 0 || index >= layerState._launches.length - 1; + parts.replay.syncReplayButton(); + const panelScroller = layerState._missionPanel.closest( + '.global-context-panel-inner', + ); + if (panelScroller) panelScroller.scrollTop = 0; + } + + function renderMissionRoster() { + if (!layerState._missionRoster) return; + const list = layerState._missionRoster.querySelector( + '[data-mission-roster-list]', + ); + const count = layerState._missionRoster.querySelector( + '[data-mission-roster-count]', + ); + if (count) count.textContent = `${layerState._launches.length} / 30D`; + if (!list) return; + const focusSnapshot = captureMissionRosterFocus(list); + layerState._missionRosterPreviewOwnership?.reset(); + const entries = parts.policyHelpers.missionRosterEntries( + layerState._launches, + ); + if (!entries.length) { + list.innerHTML = + 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'; + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + return; + } + list.innerHTML = entries + .map(({ launch, index }) => { + const color = parts.model.missionMarkerColor(launch).toCssColorString(); + const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; + const provider = launch.provider || 'UNSPECIFIED OPERATOR'; + const label = parts.overlays + .shortMissionLabel(launch.name, 27) + .toUpperCase(); + return `${escapeMissionText(label)}${escapeMissionText(provider)} · ${escapeMissionText(date)}›`; + }) + .join(''); + list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { + const index = Number(button.dataset.missionRosterIndex); + bindMissionRosterItemKeyboardPreview( + button, + index, + layerState._missionRosterPreviewOwnership, + layerState._missionRoster, + ); + }); + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + } + + function escapeMissionText(value) { + return String(value ?? '').replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); + } + + function updateMissionTelemetry(force = false) { + if ( + !layerState._missionPanel || + !layerState._selectedLaunchId || + !layerState._dataSource + ) + return; + const now = performance.now(); + if (!force && now - layerState._lastPanelTelemetryMs < 250) return; + layerState._lastPanelTelemetryMs = now; + const satellite = layerState._dataSource.entities.getById( + `rocket-satellite:${layerState._selectedLaunchId}`, + ); + const position = satellite?.position?.getValue( + Cesium.JulianDate.now(layerState._declutterTime), + ); + const altitudeM = position + ? Cesium.Cartographic.fromCartesian(position)?.height + : null; + setMissionPanelField( + '[data-mission-distance]', + Number.isFinite(altitudeM) + ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` + : null, + ); + const speedMps = layerState._satelliteTelemetry.get( + layerState._selectedLaunchId, + )?.speedMps; + setMissionPanelField( + '[data-mission-speed]', + Number.isFinite(speedMps) + ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` + : null, + Number.isFinite(speedMps) + ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` + : '', + ); + } + + function selectMissionAt(index) { + const launch = layerState._launches[index]; + if (!launch) return; + parts.selection.setSelectedMission(launch.id, true); + parts.selection.focusMission(launch); + } + + function clearMissionRosterPreviewState() { + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._missionRosterHoverTimer = null; + const changed = layerState._hoveredRosterLaunchId !== null; + layerState._hoveredRosterLaunchId = null; + if (changed) parts.overlays.syncMissionOverlayEntries(); + } + + function clearMissionRosterHover() { + if (layerState._missionRosterPreviewOwnership) + layerState._missionRosterPreviewOwnership.reset(); + else clearMissionRosterPreviewState(); + } + + function scheduleMissionRosterPreview(index) { + const launch = layerState._launches[index]; + if (!launch || layerState._selectedLaunchId) return; + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._hoveredRosterLaunchId = launch.id; + parts.overlays.syncMissionOverlayEntries(); + layerState._missionRosterHoverTimer = setTimeout(() => { + layerState._missionRosterHoverTimer = null; + if (layerState._hoveredRosterLaunchId !== launch.id) return; + const currentLaunch = resolveMissionRosterPreviewLaunch( + layerState._launches, + launch.id, + ); + if (currentLaunch) + parts.policyHelpers.previewMissionFromRoster(currentLaunch); + }, 140); + } + + function createMissionPanel() { + if (layerState._missionPanel || typeof document === 'undefined') return; + const host = + document.getElementById('space-mission-panel-host') || + document.getElementById('right-context-rail'); + if (!host) return; + layerState._missionRoster = document.getElementById('space-mission-roster'); + if (layerState._missionRoster) { + layerState._missionRosterPreviewOwnership = + createMissionRosterPreviewOwnership({ + preview: scheduleMissionRosterPreview, + clear: clearMissionRosterPreviewState, + }); + layerState._missionRoster.onclick = (event) => { + const button = + event.target instanceof Element + ? event.target.closest('[data-mission-roster-index]') + : null; + if (!button) return; + selectMissionAt(Number(button.dataset.missionRosterIndex)); + }; + } + layerState._missionPanel = document.createElement('aside'); + layerState._missionPanel.id = 'space-mission-panel'; + layerState._missionPanel.className = 'context-space-mission-detail'; + layerState._missionPanel.setAttribute( + 'aria-label', + 'Selected Space Mission', + ); + layerState._missionPanel.innerHTML = `SELECTED SPACE MISSION×MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED · PAYLOADNAMETYPEDESTINATIONSTAGE / RE-ENTRY / RECOVERYSTAGESTATUSFINAL POSITIONREPLAY SPEED1×0.25×1×4×FOCUSREPLAY ASCENT‹ PREV—NEXT ›SHOW ALL / DESELECT`; + layerState._missionPanel + .querySelector('.mission-action-row') + .insertAdjacentHTML( + 'beforeend', + ` + Ⅱ + × + `, + ); + host.appendChild(layerState._missionPanel); + layerState._missionPanel + .querySelector('[data-mission-prev]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) - 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-next]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) + 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-close]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-show-all]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-focus]') + .addEventListener('click', () => { + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + if (launch) parts.selection.focusLaunchSite(launch); + }); + layerState._missionPanel + .querySelector('[data-mission-replay]') + .addEventListener('click', () => { + if (layerState._selectedLaunchId) + parts.replay.startMissionReplay(layerState._selectedLaunchId); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-toggle]') + .addEventListener('click', () => { + if (layerState._replayPaused) parts.replay.resumeMissionReplay(); + else parts.replay.pauseMissionReplay(); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-cancel]') + .addEventListener('click', parts.replay.stopMissionReplay); + layerState._missionPanel + .querySelector('[data-mission-replay-speed]') + .addEventListener('input', (event) => { + parts.replay.setReplaySpeed(event.currentTarget.value); + }); + parts.replay.syncReplaySpeedControl(); + } + return { + missionHoverPreviewRange, + createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus, + restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch, + missionTableRows, + setMissionPanelField, + renderMissionPanel, + renderMissionRoster, + escapeMissionText, + updateMissionTelemetry, + selectMissionAt, + clearMissionRosterPreviewState, + clearMissionRosterHover, + scheduleMissionRosterPreview, + createMissionPanel, + }; +} diff --git a/src/layers/launches/paths.js b/src/layers/launches/paths.js new file mode 100644 index 000000000..72819ffb8 --- /dev/null +++ b/src/layers/launches/paths.js @@ -0,0 +1,588 @@ +import * as Cesium from 'cesium'; +import { + EARTH_ROTATION_RAD_PER_SEC, + PROJECTED_ASCENT_ROTATION_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createPaths({ state: layerState, services, parts, source }) { + function orbitInsertionOffsetSeconds(launch) { + const events = (launch.timeline || []).filter( + (event) => + Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0, + ); + if (!events.length) return null; + const deployment = events.filter((event) => + /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test( + event.name, + ), + ); + if (deployment.length) + return Math.max(...deployment.map((event) => event.offsetSeconds)); + const engineCutoff = events.filter((event) => + /seco|second engine cutoff/i.test(event.name), + ); + if (engineCutoff.length) + return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); + return Math.max(...events.map((event) => event.offsetSeconds)); + } + + function estimatedOrbitPeriodSeconds(orbitPath) { + if (!orbitPath?.length) return 5400; + const meanRadius = + orbitPath.reduce( + (total, point) => total + Cesium.Cartesian3.magnitude(point), + 0, + ) / orbitPath.length; + return 2 * Math.PI * Math.sqrt(meanRadius ** 3 / 3.986004418e14); + } + + function approximateOrbitPath(launch) { + if ( + !launch.orbit?.name || + !Number.isFinite(launch.lat) || + !Number.isFinite(launch.lon) + ) + return null; + const orbitName = launch.orbit.name.toLowerCase(); + const altitude = + orbitName.includes('geostationary') || orbitName.includes('transfer') + ? 35786000 + : orbitName.includes('medium') + ? 20200000 + : 550000; + const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; + const longitude = Cesium.Math.toRadians(launch.lon); + const latitude = Cesium.Math.toRadians(launch.lat); + const up = new Cesium.Cartesian3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude), + ); + const east = new Cesium.Cartesian3( + -Math.sin(longitude), + Math.cos(longitude), + 0, + ); + const north = new Cesium.Cartesian3( + -Math.sin(latitude) * Math.cos(longitude), + -Math.sin(latitude) * Math.sin(longitude), + Math.cos(latitude), + ); + const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); + const isWesternNorthAmerica = + launch.lat > 20 && + launch.lat < 60 && + launch.lon > -140 && + launch.lon < -105; + const launchAzimuthDeg = isPolar + ? launch.lat >= 0 + ? 180 + : 0 + : isWesternNorthAmerica + ? 190 + : 90; + const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); + const forward = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + north, + Math.cos(launchAzimuth), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + east, + Math.sin(launchAzimuth), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + // A reconstructed orbit is an estimate, not a claim that the vehicle was + // inserted directly above the pad. Offset the orbital plane downrange by a + // small launch-to-insertion arc so a top-down view does not draw the entire + // orbit on top of the launch site. The ascent still joins this ring at its + // propagated insertion reference. + const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); + const orbitAnchor = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + up, + Math.cos(insertionArc), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + forward, + Math.sin(insertionArc), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const planeNormal = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const crossTrack = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + planeNormal, + orbitAnchor, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Array.from({ length: 97 }, (_, index) => { + const angle = (index / 96) * Math.PI * 2; + return new Cesium.Cartesian3( + radius * + (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), + radius * + (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), + radius * + (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), + ); + }); + } + + function samplePath(path, progress, result) { + if (!path?.length) return undefined; + // Degenerate returns clone into `result` when provided — handing back a + // path vertex would let an in-place caller mutate the path geometry. + if (path.length === 1) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + let distances = layerState._pathDistanceCache.get(path); + if (!distances) { + distances = new Float64Array(path.length); + for (let index = 1; index < path.length; index++) { + distances[index] = + distances[index - 1] + + Cesium.Cartesian3.distance(path[index - 1], path[index]); + } + layerState._pathDistanceCache.set(path, distances); + } + const totalDistance = distances.at(-1); + if (!(totalDistance > 0)) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; + let low = 1; + let high = distances.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (distances[middle] < targetDistance) low = middle + 1; + else high = middle; + } + const index = Math.max(0, low - 1); + const segmentDistance = distances[index + 1] - distances[index]; + const segmentProgress = + segmentDistance > 0 + ? (targetDistance - distances[index]) / segmentDistance + : 0; + return Cesium.Cartesian3.lerp( + path[index], + path[index + 1], + segmentProgress, + result || new Cesium.Cartesian3(), + ); + } + + /** + * Resolve a continuously advancing orbit fraction from a wall-clock epoch. + * Fractional seconds prevent the selected satellite marker from jumping at + * each second boundary and forcing a one-frame photoreal globe LOD pulse. + * @param {number} nowMs Wall-clock epoch in milliseconds. + * @param {number} periodSec Orbital period in seconds. + * @returns {number} Normalized progress in the range [0, 1). + */ + + function orbitProgressAtTime(nowMs, periodSec) { + const period = Math.max(1, Number(periodSec) || 1); + return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; + } + + /** + * Construct one continuous estimated climb from the pad to orbit insertion. + * Horizontal movement begins much more slowly than altitude gain, preserving + * the near-vertical launch appearance without a hard corner at 120 km. + * @param {Cesium.Cartesian3} launchPosition Launch-pad position. + * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. + * @param {number} [samples] Number of curve intervals. + * @returns {Cesium.Cartesian3[]} + */ + + function reconstructedAscentPath( + launchPosition, + insertionPosition, + samples = 512, + ) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const origin = ellipsoid.cartesianToCartographic(launchPosition); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!origin || !insertion) return [launchPosition, insertionPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(origin.longitude, origin.latitude), + new Cesium.Cartographic(insertion.longitude, insertion.latitude), + ellipsoid, + ); + return Array.from({ length: samples + 1 }, (_, index) => { + const progress = index / samples; + if (index === 0) return launchPosition; + if (index === samples) return insertionPosition; + const horizontalProgress = progress ** 4; + const cartographic = geodesic.interpolateUsingFraction( + horizontalProgress, + new Cesium.Cartographic(), + ); + // Approximate the inertial eastward lead accumulated during a ten-minute + // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the + // upper climb, and returns to the fixed insertion endpoint. + const rotationalLead = + EARTH_ROTATION_RAD_PER_SEC * + PROJECTED_ASCENT_ROTATION_SEC * + Math.sin(Math.PI * progress ** 3); + cartographic.longitude = Cesium.Math.negativePiToPi( + cartographic.longitude + rotationalLead, + ); + cartographic.height = Cesium.Math.lerp( + Math.max(0, origin.height), + Math.max(0, insertion.height), + Math.sin(progress * Cesium.Math.PI_OVER_TWO), + ); + return ellipsoid.cartographicToCartesian(cartographic); + }); + } + + function nearestOrbitIndex(orbitPath, referencePosition) { + if (!orbitPath?.length || !referencePosition) return 0; + const referenceDirection = Cesium.Cartesian3.normalize( + referencePosition, + new Cesium.Cartesian3(), + ); + let bestIndex = 0; + let bestDot = -Number.MAX_VALUE; + orbitPath.forEach((candidate, index) => { + const direction = Cesium.Cartesian3.normalize( + candidate, + new Cesium.Cartesian3(), + ); + const dot = Cesium.Cartesian3.dot(referenceDirection, direction); + if (dot > bestDot) { + bestDot = dot; + bestIndex = index; + } + }); + return bestIndex; + } + + function orbitPathFromInsertion(orbitPath, insertionIndex) { + if (!orbitPath?.length) return []; + const first = orbitPath[0]; + const last = orbitPath.at(-1); + const isClosed = + orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; + const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); + if (!core.length) return orbitPath.slice(); + const index = Cesium.Math.mod(insertionIndex, core.length); + const rotated = [...core.slice(index), ...core.slice(0, index)]; + rotated.push(rotated[0]); + return rotated; + } + + function surfaceSafeSegment(startPosition, endPosition) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = ellipsoid.cartesianToCartographic(startPosition); + const end = ellipsoid.cartesianToCartographic(endPosition); + if (!start || !end) return [startPosition, endPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(start.longitude, start.latitude), + new Cesium.Cartographic(end.longitude, end.latitude), + ellipsoid, + ); + // Replay advances in real time across this path. Keep the geometry + // surface-safe, but give the animated marker and chase camera enough + // samples that they do not visibly pause at long segment boundaries. + const steps = Cesium.Math.clamp( + Math.ceil(geodesic.surfaceDistance / 75000), + 2, + 256, + ); + const positions = []; + for (let index = 0; index <= steps; index++) { + const fraction = index / steps; + const eased = fraction * fraction * (3 - 2 * fraction); + const cartographic = geodesic.interpolateUsingFraction( + fraction, + new Cesium.Cartographic(), + ); + cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); + positions.push(ellipsoid.cartographicToCartesian(cartographic)); + } + positions[0] = startPosition; + positions[positions.length - 1] = endPosition; + return positions; + } + + function surfaceSafePath(controlPositions) { + if (!controlPositions?.length) return []; + if (controlPositions.length === 1) return controlPositions.slice(); + const path = []; + for (let index = 0; index < controlPositions.length - 1; index++) { + const segment = surfaceSafeSegment( + controlPositions[index], + controlPositions[index + 1], + ); + path.push(...(index === 0 ? segment : segment.slice(1))); + } + return path; + } + + function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { + if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) + return ascentPath; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const transferEnd = orbitPath[insertionIndex]; + const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; + const orbitTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + nextOrbit, + transferEnd, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if ( + Cesium.Cartesian3.equalsEpsilon( + orbitTangent, + Cesium.Cartesian3.ZERO, + 1e-8, + ) + ) + return ascentPath; + + // Replace a substantial final section with one cubic Bézier transition. + // Matching both endpoint tangents avoids the short corrective hook produced + // by locally pulling only the last few ascent samples toward the orbit. + const blendCount = Math.min(52, ascentPath.length - 2); + const blendStart = ascentPath.length - 1 - blendCount; + const start = ascentPath[blendStart]; + const previous = ascentPath[Math.max(0, blendStart - 1)]; + const altitudeEnvelope = ascentPath + .slice(blendStart) + .map((position) => + Math.max(0, ellipsoid.cartesianToCartographic(position)?.height || 0), + ); + const ascentTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const chordLength = Math.max( + Cesium.Cartesian3.distance(start, transferEnd), + 1000, + ); + const handleLength = chordLength * 0.28; + const controlA = Cesium.Cartesian3.add( + start, + Cesium.Cartesian3.multiplyByScalar( + ascentTangent, + handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const controlB = Cesium.Cartesian3.add( + transferEnd, + Cesium.Cartesian3.multiplyByScalar( + orbitTangent, + -handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + for (let index = 0; index <= blendCount; index++) { + const progress = index / blendCount; + const inverse = 1 - progress; + const point = new Cesium.Cartesian3(); + Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlA, + 3 * inverse ** 2 * progress, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlB, + 3 * inverse * progress ** 2, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + transferEnd, + progress ** 3, + new Cesium.Cartesian3(), + ), + point, + ); + // A Cartesian Bézier is a chord in world space and can pass through the + // ellipsoid when its orbit-tangent handle is long. Preserve the original + // climb's smooth altitude envelope while retaining the Bézier's horizontal + // curvature and tangent-aligned insertion. + const cartographic = ellipsoid.cartesianToCartographic(point); + const minimumHeight = altitudeEnvelope[index] ?? 0; + if (cartographic && cartographic.height < minimumHeight) { + cartographic.height = minimumHeight; + ascentPath[blendStart + index] = + ellipsoid.cartographicToCartesian(cartographic); + } else { + ascentPath[blendStart + index] = point; + } + } + ascentPath[ascentPath.length - 1] = transferEnd; + return ascentPath; + } + + /** + * Build a globe-safe ascent ending at the nearest point on the selected orbit. + * The returned orbit is rotated to begin at that same insertion point so the + * animated marker cannot jump between ascent and orbital phases. + * @param {Cesium.Cartesian3} launchPosition Launch-site position. + * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. + * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. + * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. + * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} + */ + + function buildMissionPaths( + launchPosition, + trajectoryPositions, + orbitPath, + insertionReference = null, + ) { + const suppliedTrajectory = trajectoryPositions || []; + const controls = [launchPosition, ...suppliedTrajectory]; + const insertionIndex = nearestOrbitIndex( + orbitPath, + insertionReference || controls.at(-1), + ); + const transferEnd = orbitPath[insertionIndex]; + const ascentPath = suppliedTrajectory.length + ? surfaceSafePath([...controls, transferEnd]) + : reconstructedAscentPath(launchPosition, transferEnd); + blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); + return { + ascentPath, + animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), + insertionIndex, + }; + } + + function landingEndpoint(stage, launch, insertionPosition) { + if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { + return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; + } + const recoveryIdentity = + `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); + if ( + /return to launch site|rtls|launch site|landing zone/.test( + recoveryIdentity, + ) + ) { + return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; + } + if (!(stage.downrangeKm > 0) || !insertionPosition) return null; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!insertion) return null; + const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); + const bearing = geodesic.startHeading; + const angularDistance = + (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; + const lat1 = start.latitude; + const lon1 = start.longitude; + const lat = Math.asin( + Math.sin(lat1) * Math.cos(angularDistance) + + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const lon = + lon1 + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), + Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), + ); + return { + lat: Cesium.Math.toDegrees(lat), + lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), + accuracy: 'EST. DOWNRANGE', + }; + } + + function stageReentryRecoveryPath( + ascentPath, + endpoint, + stageIndex, + stageCount, + ) { + if (!ascentPath?.length || !endpoint) return []; + const progress = Cesium.Math.clamp( + 0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, + 0.28, + 0.68, + ); + const separation = samplePath(ascentPath, progress); + const destination = Cesium.Cartesian3.fromDegrees( + endpoint.lon, + endpoint.lat, + 12, + ); + return surfaceSafeSegment(separation, destination); + } + + function atmosphericReentryIndex(path) { + if (!path?.length) return 0; + const ellipsoid = Cesium.Ellipsoid.WGS84; + for (let index = 1; index < path.length; index++) { + const previousHeight = ellipsoid.cartesianToCartographic( + path[index - 1], + )?.height; + const height = ellipsoid.cartesianToCartographic(path[index])?.height; + if ( + Number.isFinite(previousHeight) && + Number.isFinite(height) && + previousHeight > STAGE_REENTRY_ALTITUDE_M && + height <= STAGE_REENTRY_ALTITUDE_M + ) { + return index; + } + } + return Math.min( + Math.max(Math.round(path.length * 0.55), 0), + path.length - 1, + ); + } + return { + orbitInsertionOffsetSeconds, + estimatedOrbitPeriodSeconds, + approximateOrbitPath, + samplePath, + orbitProgressAtTime, + reconstructedAscentPath, + nearestOrbitIndex, + orbitPathFromInsertion, + surfaceSafeSegment, + surfaceSafePath, + blendAscentIntoOrbitTangent, + buildMissionPaths, + landingEndpoint, + stageReentryRecoveryPath, + atmosphericReentryIndex, + }; +} diff --git a/src/layers/launches/policy.js b/src/layers/launches/policy.js new file mode 100644 index 000000000..786aed79c --- /dev/null +++ b/src/layers/launches/policy.js @@ -0,0 +1,102 @@ +import * as Cesium from 'cesium'; + +export const WINDOW_DAYS = 30; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = + 'rocket-mission-selected'; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 12, + collisionCapacity: 0, + moving: true, + solveIntervalMs: 0, +}); + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + moving: false, +}); + +export const REPLAY_ASCENT_FALLBACK_SEC = 12; + +export const REPLAY_ASCENT_MIN_SEC = 8; + +export const REPLAY_ASCENT_MAX_SEC = 36; + +export const REPLAY_ORBIT_DURATION_SEC = 28; + +export const REPLAY_COUNTDOWN_DURATION_SEC = 10; + +export const REPLAY_TILE_SETTLE_DELAY_SEC = 5; + +export const REPLAY_INITIAL_RANGE_M = 3500; + +export const REPLAY_LOCAL_MAX_RANGE_M = 900000; + +export const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; + +export const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; + +export const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; + +export const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; + +export const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); + +export const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); + +export const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; + +export const REPLAY_SPEED_MIN = 0.25; + +export const REPLAY_SPEED_MAX = 4; + +export const REPLAY_SPEED_STEP = 0.25; + +export const MAX_POST_TLE_RETRIES = 1; + +export const POST_TLE_RETRY_DELAY_MS = 1500; + +export const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; + +export const PROJECTED_ASCENT_ROTATION_SEC = 600; + +export const STAGE_REENTRY_ALTITUDE_M = 100000; + +export const MISSION_CLOSE_VIEW_RANGE_M = 180000; + +export const MISSION_GLOBE_VIEW_RANGE_M = 5000000; + +export const MISSION_FOCUS_RANGE_M = 12000; + +export const SATELLITE_STANDALONE_DEFAULTS = { + catalog: 'core', + showPoints: true, + showOrbits: true, +}; + +export const MISSION_ORBIT_PATTERN_GROUPS = 4; + +export const MISSION_ORBIT_DASHES_PER_GROUP = 100; + +export const LAUNCH_PAD_ZONE_RADIUS_M = 500; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; + +export const TRAJECTORY_STAGE_COLORS = [ + '#ff9f43', + '#ff66c4', + '#a78bfa', + '#7bed9f', + '#ffd166', + '#60a5fa', +]; diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js new file mode 100644 index 000000000..c3cf77c07 --- /dev/null +++ b/src/layers/launches/policyHelpers.js @@ -0,0 +1,256 @@ +import * as Cesium from 'cesium'; +import { + SATELLITE_STANDALONE_DEFAULTS, + LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, + LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, +} from './policy.js'; + +export function createPolicyHelpers({ + state: layerState, + services, + parts, + source, +}) { + /** + * Derive the temporary Satellite display mode required by Space Missions. + * @param {object|null} currentParams Complete pre-mission Satellite parameters. + * @returns {object} Temporary mission-specific Satellite parameters. + */ + + function satelliteParamsForSpaceMissions(currentParams) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(currentParams || {}), + catalog: 'dense', + showPoints: false, + showOrbits: false, + }; + } + + /** + * Resolve the complete Satellite parameter set restored after mission mode. + * @param {object|null} snapshot Complete pre-mission Satellite parameters. + * @returns {object} Standalone Satellite parameters. + */ + + function satelliteParamsAfterSpaceMissions(snapshot) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(snapshot || {}), + }; + } + + /** + * Failed launch records may retain a planned orbit in Launch Library, but + * must not be represented as a live or estimated payload in orbit. + * @param {string|null} status Normalized Launch Library status name. + * @returns {boolean} Whether orbital visualization is allowed. + */ + + function launchStatusAllowsOrbit(status) { + return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test( + String(status || ''), + ); + } + + /** + * Describe only the mission paths that the selected record can actually show. + * Launch Library may retain a target orbit after a failure, but that target is + * not evidence of orbital insertion and cannot support a reconstructed replay. + * @param {object|null} launch Normalized launch record. + * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. + * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} + */ + + function missionPathPresentation(launch, replayAvailable = false) { + const orbitName = + launch?.orbit?.name || + (typeof launch?.orbit === 'string' ? launch.orbit : null); + const orbitAllowed = launchStatusAllowsOrbit(launch?.status); + const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) + ? launch.trajectory.filter( + (point) => + Number.isFinite(Number(point?.latitude)) && + Number.isFinite(Number(point?.longitude)), + ).length + : 0; + return { + orbit: orbitName + ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` + : null, + ascent: + suppliedTrajectoryPoints > 1 + ? 'SUPPLIED TRAJECTORY POINTS' + : replayAvailable + ? 'RECONSTRUCTED ESTIMATE' + : 'UNAVAILABLE', + replayAvailable: Boolean(replayAvailable), + }; + } + + /** + * Release aircraft follow state through each owning flight layer before a + * mission replay takes over the camera. + * @param {object|null} dataManager DataLayerManager instance. + * @returns {number} Number of owner APIs invoked. + */ + + function releaseAircraftTracking(dataManager) { + let released = 0; + for (const layerId of ['flights', 'military']) { + const module = dataManager?.layers?.get(layerId)?.module; + if (typeof module?.stopTracking !== 'function') continue; + module.stopTracking(); + released++; + } + return released; + } + + /** + * Decide whether the selected launch-pad zone belongs in the current view. + * Both altitude and direct camera range are bounded so an oblique close-up can + * show the effect without leaking it into regional or globe views. + * @param {object} input Visibility inputs. + * @param {boolean} input.layerActive Whether Space Missions is active. + * @param {string|null} input.selectedLaunchId Selected mission identifier. + * @param {string} input.launchId Candidate mission identifier. + * @param {number} input.cameraHeightM Camera height above the ellipsoid. + * @param {number} input.cameraDistanceM Direct camera range to the launch pad. + * @returns {boolean} + */ + + function launchPadZoneVisible({ + layerActive, + selectedLaunchId, + launchId, + cameraHeightM, + cameraDistanceM, + }) { + return Boolean( + layerActive && + selectedLaunchId && + selectedLaunchId === launchId && + Number.isFinite(cameraHeightM) && + cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M && + Number.isFinite(cameraDistanceM) && + cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, + ); + } + + /** + * Score the amount of useful mission context available for roster triage. + * @param {object} launch Normalized launch record. + * @returns {number} Completeness score. + */ + + function missionDataCompleteness(launch = {}) { + let score = 0; + const present = (value) => + value !== null && value !== undefined && value !== ''; + score += present(launch.provider) ? 1 : 0; + score += present(launch.mission) ? 2 : 0; + score += present(launch.missionName) ? 1 : 0; + score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; + score += Array.isArray(launch.payloads) + ? Math.min(launch.payloads.length, 5) * 2 + : 0; + score += Array.isArray(launch.recoveryStages) + ? Math.min(launch.recoveryStages.length, 4) * 2 + : 0; + score += Array.isArray(launch.trajectory) + ? Math.min(launch.trajectory.length, 12) + : 0; + score += Array.isArray(launch.timeline) + ? Math.min(launch.timeline.length, 6) + : 0; + return score; + } + + /** + * Build a data-rich roster while preserving the source-array index + * used by mission selection and Previous/Next navigation. + * @param {Array} launches Normalized launch records. + * @returns {Array<{launch: object, index: number}>} + */ + + function missionRosterEntries(launches) { + return (launches || []) + .map((launch, index) => ({ launch, index })) + .sort((a, b) => { + const completeness = + missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); + if (completeness !== 0) return completeness; + const aTime = Date.parse(a.launch?.launchTime); + const bTime = Date.parse(b.launch?.launchTime); + if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) + return bTime - aTime; + return b.index - a.index; + }); + } + + /** + * Format a mission epoch for compact on-globe replay labels. + * @param {string|Date|null} launchTime ISO-8601 mission time or Date. + * @returns {string} UTC timestamp or a clear unavailable state. + */ + + function formatMissionEventTime(launchTime) { + const date = new Date(launchTime); + if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; + return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; + } + + /** + * Parse the ISO-8601 durations supplied by Launch Library timeline events. + * @param {string|null} value ISO duration such as PT8M40S or -PT35M. + * @returns {number|null} Signed duration in seconds. + */ + + function parseMissionDurationSeconds(value) { + const match = String(value || '') + .trim() + .match( + /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, + ); + if (!match) return null; + const seconds = + Number(match[2] || 0) * 86400 + + Number(match[3] || 0) * 3600 + + Number(match[4] || 0) * 60 + + Number(match[5] || 0); + return match[1] ? -seconds : seconds; + } + + function previewMissionFromRoster(launch) { + if (!layerState._viewer || !launch || layerState._selectedLaunchId) return; + const range = parts.panel.missionHoverPreviewRange( + layerState._viewer.camera.positionCartographic?.height, + ); + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(position, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + -Cesium.Math.PI_OVER_TWO, + range, + ), + duration: 0.8, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + ); + } + return { + satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit, + missionPathPresentation, + releaseAircraftTracking, + launchPadZoneVisible, + missionDataCompleteness, + missionRosterEntries, + formatMissionEventTime, + parseMissionDurationSeconds, + previewMissionFromRoster, + }; +} diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js new file mode 100644 index 000000000..60e6e0070 --- /dev/null +++ b/src/layers/launches/rendering.js @@ -0,0 +1,573 @@ +import * as Cesium from 'cesium'; +import { + TRAJECTORY_STAGE_COLORS, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_TILE_SETTLE_DELAY_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } = + services.satellites; + + function setGraphicVisibility(graphic, visible, time) { + if (!graphic) return; + const next = Boolean(visible); + const current = graphic.show?.getValue?.(time); + if (current !== next) graphic.show = next; + } + + function updateMissionFrame() { + if ( + !layerState._enabled || + !layerState._viewer || + !layerState._dataSource?.show + ) { + parts.overlays.hideReplayVehicleOverlay(); + return; + } + parts.panel.updateMissionTelemetry(); + const time = Cesium.JulianDate.now(layerState._declutterTime); + layerState._declutterOccluder.cameraPosition = + layerState._viewer.scene.camera.positionWC; + parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder); + parts.overlays.refreshSelectedMissionOverlayText(); + for (const entity of layerState._dataSource.entities.values) { + if (!entity.show) continue; + if (!entity.position) continue; + const id = parts.selection.entityLaunchId(entity); + if (!id) continue; + const position = entity.position.getValue(time); + let horizonVisible = Boolean( + position && layerState._declutterOccluder.isPointVisible(position), + ); + const launchAnchor = entity.id.startsWith('rocket-launch:'); + if (launchAnchor) { + horizonVisible = parts.overlays.missionAnchorVisible( + layerState._viewer.scene.camera.positionWC, + position, + id, + layerState._selectedLaunchId, + ); + // Keep entity.show reserved for mission-selection isolation. Horizon + // state is applied to the individual graphics below so a rear-side + // anchor remains eligible for reevaluation as the camera moves. + } + if (entity.point) { + setGraphicVisibility(entity.point, horizonVisible, time); + } + if (launchAnchor && entity.billboard) { + setGraphicVisibility( + entity.billboard, + horizonVisible && id === layerState._hoveredRosterLaunchId, + time, + ); + } + } + } + + function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const overlayRecord = { + launch, + anchorPosition: position, + elementEntryFactories: [], + liveEventTime: null, + }; + layerState._missionOverlayRecords.set(launch.id, overlayRecord); + const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit( + launch.status, + ); + const coreTrack = + orbitAllowed && launch.satelliteQuery + ? getSatelliteOrbitTrack(launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null; + const satelliteTrack = + coreTrack || + (orbitAllowed && activeTleText && launch.satelliteQuery + ? findSatelliteOrbitTrackInTle(activeTleText, launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null); + const orbitPath = !orbitAllowed + ? null + : satelliteTrack?.orbitPath?.length > 1 + ? satelliteTrack.orbitPath + : parts.paths.approximateOrbitPath(launch); + launch.recoveryStages.forEach((stage) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + orbitPath?.[0] || null, + ); + }); + if (satelliteTrack) layerState._orbitMatches++; + const entity = layerState._dataSource.entities.add({ + id: `rocket-launch:${launch.id}`, + position, + point: { + pixelSize: 5, + color: parts.model.missionMarkerColor(launch), + outlineColor: parts.model.missionMarkerColor(launch), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + billboard: { + image: parts.overlays.missionHoverReticleImage(), + width: 24, + height: 24, + show: false, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + launchName: launch.name, + launchTime: launch.launchTime, + launchSite: launch.launchSite, + provider: launch.provider, + mission: launch.mission, + status: launch.status, + source: launch.source, + orbit: JSON.stringify(launch.orbit || {}), + satelliteName: satelliteTrack?.name || '', + noradId: satelliteTrack?.noradId || '', + }, + }); + const points = launch.trajectory + .filter( + (point) => + Number.isFinite(Number(point.latitude)) && + Number.isFinite(Number(point.longitude)), + ) + .map((point) => ({ + stage: String( + point.stage || + point.stage_name || + point.phase || + point.stageName || + 'trajectory', + ), + position: Cesium.Cartesian3.fromDegrees( + Number(point.longitude), + Number(point.latitude), + Number(point.altitude || 0), + ), + })); + if (points.length > 1) { + const segments = []; + points.forEach((point) => { + const previous = segments.at(-1); + if (!previous || previous.stage !== point.stage) + segments.push({ stage: point.stage, positions: [] }); + segments.at(-1).positions.push(point.position); + }); + segments.forEach((segment, index) => { + if (segment.positions.length < 2) return; + layerState._dataSource.entities.add({ + id: `rocket-trajectory:${launch.id}:${index}`, + polyline: { + positions: parts.paths.surfaceSafePath(segment.positions), + width: 2, + material: Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length], + ).withAlpha(0.8), + clampToGround: false, + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stage: segment.stage, + source: launch.source, + }, + }); + }); + } + if (orbitPath?.length > 1) { + const orbitCurrent = satelliteTrack?.current || { + longitude: launch.lon, + latitude: launch.lat, + altitude: 0, + }; + const orbitPeriodSec = + satelliteTrack?.periodSec || + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const launchEpochMs = Date.parse(launch.launchTime); + const disclosedInsertionOffsetSec = + parts.paths.orbitInsertionOffsetSeconds(launch); + const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) + ? Math.max(0, disclosedInsertionOffsetSec) + : 600; + const insertionEpochMs = Number.isFinite(launchEpochMs) + ? launchEpochMs + insertionDurationSec * 1000 + : Number.NaN; + let insertionReference = points.at(-1)?.position || null; + if ( + !insertionReference && + satelliteTrack?.positionAt && + Number.isFinite(insertionEpochMs) + ) { + const propagatedInsertion = satelliteTrack.positionAt( + new Date(insertionEpochMs), + ); + if (propagatedInsertion) { + insertionReference = Cesium.Cartesian3.fromDegrees( + propagatedInsertion.longitude, + propagatedInsertion.latitude, + propagatedInsertion.altitude, + ); + } + } + if (!insertionReference && !satelliteTrack) { + // A projected orbit has no authoritative historical phase. Start its + // plane over the launch site, advance only by the estimated powered + // ascent duration, and join the forward orbit tangent. Using the UTC + // epoch as phase sent some ascents toward the far side of Earth and + // forced a visible 180-degree corrective hook before insertion. + const insertionProgress = Cesium.Math.clamp( + insertionDurationSec / orbitPeriodSec, + 1 / 96, + 0.16, + ); + insertionReference = parts.paths.samplePath( + orbitPath, + insertionProgress, + ); + } + const { ascentPath, animatedOrbitPath } = parts.paths.buildMissionPaths( + position, + points.map((point) => point.position), + orbitPath, + insertionReference, + ); + const ascentDurationSec = parts.replay.replayAscentDurationSeconds( + launch, + ascentPath, + ); + // Cesium can evaluate CallbackProperty values multiple times while it + // traverses one frame. Camera tracking and the HTML replay overlay must + // use the exact same replay epoch as those callbacks; sampling Date.now() + // independently made the vehicle advance relative to the camera and then + // snap back on the next frame. + let replayStateForFrame = null; + const sampleReplayState = () => + parts.replay.replayState( + launch, + layerState._animationStarts.get(launch.id) || Date.now(), + ascentDurationSec, + REPLAY_ORBIT_DURATION_SEC, + orbitPeriodSec, + layerState._replaySpeed, + parts.replay.replayClockNow(launch.id), + REPLAY_TILE_SETTLE_DELAY_SEC, + layerState._replayCameraLaunchId !== launch.id, + ); + // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed + // one state there and retain it through postRender so every consumer in + // that traversal sees the same timestamp. scene.frameState.frameNumber is + // advanced between some of those phases, so it is not a reliable cache key. + const beginReplayFrame = () => { + replayStateForFrame = sampleReplayState(); + return replayStateForFrame; + }; + const getReplayState = () => { + if (!replayStateForFrame) { + replayStateForFrame = sampleReplayState(); + } + return replayStateForFrame; + }; + launch.recoveryStages.forEach((stage, stageIndex) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + ascentPath.at(-1), + ); + const path = parts.paths.stageReentryRecoveryPath( + ascentPath, + stage.endpoint, + stageIndex, + launch.recoveryStages.length, + ); + if (path.length < 2) return; + const reentryIndex = parts.paths.atmosphericReentryIndex(path); + const reentryPath = path.slice(reentryIndex); + const color = Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[ + (stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length + ], + ); + layerState._dataSource.entities.add({ + id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, + polyline: { + positions: path, + width: 2, + material: new Cesium.PolylineDashMaterialProperty({ + color: color.withAlpha(0.82), + dashLength: 12, + dashPattern: 0xaaaa, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'STAGE_REENTRY_RECOVERY', + accuracy: stage.endpoint.accuracy, + source: launch.source, + }, + }); + if (reentryPath.length > 1) { + layerState._dataSource.entities.add({ + id: `rocket-reentry:${launch.id}:${stageIndex}`, + polyline: { + positions: reentryPath, + width: 2.5, + material: new Cesium.PolylineDashMaterialProperty({ + color: + Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), + dashLength: 8, + dashPattern: 0xf0f0, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'ATMOSPHERIC_REENTRY', + altitudeM: STAGE_REENTRY_ALTITUDE_M, + source: 'Estimated 100 km atmospheric interface', + }, + }); + const reentryPosition = path[reentryIndex]; + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `reentry:${launch.id}:${stageIndex}`, + position: reentryPosition, + text: 'STAGE RE-ENTRY', + accent: '#ffd166', + priority: 700_000 - stageIndex, + gapPx: 8, + }), + ); + } + layerState._dataSource.entities.add({ + id: `rocket-recovery-end:${launch.id}:${stageIndex}`, + position: path.at(-1), + point: { + pixelSize: 4, + color, + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'RECOVERY_ENDPOINT', + source: launch.source, + }, + }); + }); + layerState._replayTracks.set(launch.id, { + ascentPath, + animatedOrbitPath, + orbitFrameSphere: + parts.camera.replayOrbitFrameSphere(animatedOrbitPath), + ascentDurationSec, + beginReplayFrame, + getReplayState, + lastCameraHeading: Math.PI, + orbitCameraWorldFrame: false, + lastVehicleRotation: 0, + lastOverlayWindowPosition: null, + lastOverlayMode: null, + }); + let livePosition = Cesium.Cartesian3.fromDegrees( + orbitCurrent.longitude, + orbitCurrent.latitude, + orbitCurrent.altitude, + ); + const liveTime = new Date(); + const liveTelemetry = { speedMps: null }; + layerState._satelliteTelemetry.set(launch.id, liveTelemetry); + let liveFrameNumber = -1; + const fallbackPeriodSec = + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const updateLiveState = () => { + const frameNumber = + layerState._viewer?.scene?.frameState?.frameNumber ?? -1; + if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; + liveFrameNumber = frameNumber; + const nowMs = Date.now(); + liveTime.setTime(nowMs); + if (satelliteTrack) { + const propagated = + satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; + if (propagated) { + Cesium.Cartesian3.fromDegrees( + propagated.longitude, + propagated.latitude, + propagated.altitude, + undefined, + livePosition, + ); + liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) + ? propagated.speedMps + : null; + } + } else { + // In-place write keeps one Cartesian3 for the mission's lifetime — + // the estimated branch previously reallocated per propagation. + livePosition = + parts.paths.samplePath( + orbitPath, + parts.paths.orbitProgressAtTime(nowMs, fallbackPeriodSec), + livePosition, + ) || livePosition; + } + }; + layerState._dataSource.entities.add({ + id: `rocket-satellite:${launch.id}`, + position: new Cesium.CallbackProperty(() => { + updateLiveState(); + return livePosition; + }, false), + point: { + pixelSize: 6, + color: Cesium.Color.fromCssColorString( + satelliteTrack ? '#7bed9f' : '#ffd166', + ), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: satelliteTrack + ? 'CURRENT_SATELLITE_POSITION' + : 'ESTIMATED_ORBIT_POSITION', + source: satelliteTrack + ? 'Satellites layer' + : 'Approximate mission orbit', + }, + }); + overlayRecord.liveEventTime = () => liveTime; + overlayRecord.elementEntryFactories.push(() => { + const name = satelliteTrack + ? parts.overlays + .shortMissionLabel(satelliteTrack.name, 22) + .toUpperCase() + : 'EST. ORBIT POSITION'; + return parts.overlays.createRocketMissionElementOverlayEntry({ + id: `payload-position:${launch.id}`, + position: () => livePosition, + text: `${name}\n${parts.policyHelpers.formatMissionEventTime(liveTime)}`, + accent: satelliteTrack ? '#7bed9f' : '#ffd166', + priority: 900_000, + gapPx: 12, + }); + }); + // Live satellite rings use a primitive collection so the core Satellite + // GMST transform can rotate their baked ECEF geometry without rebuilding + // it. Estimated launch-site-relative rings remain ordinary entities. + const primitiveOrbitAdded = parts.orbitRendering.addMissionOrbitPrimitive( + launch, + orbitPath, + satelliteTrack, + ); + if (!primitiveOrbitAdded) { + layerState._dataSource.entities.add({ + id: `rocket-orbit:${launch.id}`, + polyline: { + positions: orbitPath, + // The 3 px canvas gives the periodic dot room to read as round; the + // shader masks the ten intervening dashes down to a thin center stroke. + width: 3, + material: + new parts.orbitRendering.MissionOrbitPatternMaterialProperty( + Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), + ), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + satelliteName: '', + noradId: '', + source: 'Approximate mission orbit', + }, + }); + } + const orbitLabelBakePosition = ascentPath.at(-1); + let orbitLabelPosition = orbitLabelBakePosition; + const primitivePath = layerState._missionOrbitPrimitives.get(launch.id); + if (primitivePath) { + primitivePath.labelBakePosition = orbitLabelBakePosition; + primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( + primitivePath.primitive.modelMatrix, + orbitLabelBakePosition, + new Cesium.Cartesian3(), + ); + orbitLabelPosition = () => primitivePath.labelPosition; + } + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `orbit:${launch.id}`, + position: orbitLabelPosition, + text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', + accent: satelliteTrack ? '#22e6e6' : '#c084fc', + priority: 800_000, + gapPx: 8, + }), + ); + const replayPosition = new Cesium.CallbackProperty(() => { + const state = getReplayState(); + return state.ascending + ? parts.paths.samplePath(ascentPath, state.phaseProgress) + : parts.paths.samplePath(animatedOrbitPath, state.phaseProgress); + }, false); + layerState._dataSource.entities.add({ + id: `rocket-vehicle:${launch.id}`, + position: replayPosition, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: 'ASCENT_THEN_ORBIT', + source: satelliteTrack + ? 'Satellite trajectory animation' + : 'Approximate trajectory animation', + }, + }); + layerState._dataSource.entities.add({ + id: `rocket-transfer:${launch.id}`, + polyline: { + positions: ascentPath, + width: 1.5, + // The animated vehicle uses Cartesian interpolation between these + // exact samples. Prevent Cesium from replacing each segment with a + // geodesic arc, which would visually separate the dot from the line. + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), + dashLength: 24, + dashPattern: 0xf0f0, + }), + }, + properties: { + launchId: launch.id, + phase: 'APPROXIMATE_TRANSFER', + source: satelliteTrack + ? 'Launch site to propagated insertion' + : 'Launch site to forward projected insertion', + }, + }); + } + } + return { setGraphicVisibility, updateMissionFrame, addLaunchEntity }; +} diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js new file mode 100644 index 000000000..e6cbff98b --- /dev/null +++ b/src/layers/launches/replay.js @@ -0,0 +1,557 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_FALLBACK_SEC, + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + REPLAY_SPEED_STEP, + REPLAY_TILE_SETTLE_DELAY_SEC, + REPLAY_COUNTDOWN_DURATION_SEC, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_INITIAL_RANGE_M, +} from './policy.js'; + +export function createReplay({ state: layerState, services, parts, source }) { + /** + * Rotate an upright screen-space rocket so its nose follows a projected path. + * @param {{x: number, y: number}} from Current screen point. + * @param {{x: number, y: number}} to Forward screen point. + * @returns {number} Clockwise CSS rotation in radians. + */ + + function replayVehicleScreenRotation(from, to) { + const dx = Number(to?.x) - Number(from?.x); + const dy = Number(to?.y) - Number(from?.y); + if ( + !Number.isFinite(dx) || + !Number.isFinite(dy) || + Math.hypot(dx, dy) < 0.01 + ) + return 0; + return Math.atan2(dx, -dy); + } + + /** + * Reduce small screen-space reprojection jitter without allowing the marker + * to lag behind a camera jump or a phase transition. + * @param {{x:number,y:number}|null} previous Previous rendered position. + * @param {{x:number,y:number}} next Current path projection. + * @param {number} [alpha] Interpolation amount. + * @param {number} [snapDistance] Maximum distance to smooth. + * @returns {{x:number,y:number}} + */ + + function smoothReplayWindowPosition( + previous, + next, + alpha = 0.55, + snapDistance = 24, + ) { + if (!previous || !next) return next; + const distance = Math.hypot(next.x - previous.x, next.y - previous.y); + if (!Number.isFinite(distance) || distance > snapDistance) return next; + const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); + return { + x: Cesium.Math.lerp(previous.x, next.x, amount), + y: Cesium.Math.lerp(previous.y, next.y, amount), + }; + } + + /** + * Derive a compressed but mission-specific ascent replay duration. + * Launch Library timelines are authoritative when they expose insertion, + * SECO, or separation timing. Sparse records fall back to the reconstructed + * path length and a conservative ascent velocity estimate. + * @param {object} launch Normalized launch record. + * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. + * @returns {number} Replay duration in seconds. + */ + + function replayAscentDurationSeconds(launch, ascentPath = []) { + const disclosedSeconds = parts.paths.orbitInsertionOffsetSeconds(launch); + let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; + if (!realAscentSeconds && ascentPath.length > 1) { + const pathLength = ascentPath + .slice(1) + .reduce( + (total, point, index) => + total + Cesium.Cartesian3.distance(ascentPath[index], point), + 0, + ); + realAscentSeconds = Math.max(180, pathLength / 9000); + } + if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; + return Cesium.Math.clamp( + REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + ); + } + + /** + * Clamp and snap a replay speed multiplier to the supported slider range. + * @param {number|string} value Requested playback multiplier. + * @returns {number} Supported multiplier between 0.25x and 4x. + */ + + function normalizeReplaySpeed(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 1; + const clamped = Cesium.Math.clamp( + numeric, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + ); + return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; + } + + /** + * Preserve replay elapsed time when resuming after a pause. + * @param {number} startedAt Original replay start epoch in milliseconds. + * @param {number} pausedAt Pause epoch in milliseconds. + * @param {number} resumedAt Resume epoch in milliseconds. + * @returns {number} Shifted start epoch. + */ + + function replayStartAfterPause(startedAt, pausedAt, resumedAt) { + if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) + return startedAt; + return startedAt + Math.max(0, resumedAt - pausedAt); + } + + function replayState( + launch, + startedAt, + ascentDurationSec, + orbitDurationSec, + orbitPeriodSec, + speed = 1, + nowMs = Date.now(), + preCountdownDurationSec = 0, + loop = true, + ) { + const animationDurationSec = ascentDurationSec + orbitDurationSec; + const realSecondsSinceStart = (nowMs - startedAt) / 1000; + const preCountdownDuration = Math.max( + 0, + Number(preCountdownDurationSec) || 0, + ); + const preCountdownActive = + preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration; + const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; + const countdownSeconds = countdownActive + ? Math.ceil(-realSecondsSinceStart) + : 0; + const elapsedSinceStart = Math.max( + 0, + realSecondsSinceStart * normalizeReplaySpeed(speed), + ); + const elapsed = loop + ? elapsedSinceStart % animationDurationSec + : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); + const insertionOffsetSec = parts.paths.orbitInsertionOffsetSeconds(launch); + const ascending = elapsed < ascentDurationSec; + const phaseProgress = ascending + ? elapsed / ascentDurationSec + : (elapsed - ascentDurationSec) / orbitDurationSec; + const missionOffsetSec = + insertionOffsetSec === null + ? null + : ascending + ? phaseProgress * insertionOffsetSec + : insertionOffsetSec + phaseProgress * orbitPeriodSec; + const launchEpoch = Date.parse(launch.launchTime); + const eventTime = + Number.isFinite(launchEpoch) && missionOffsetSec !== null + ? new Date(launchEpoch + missionOffsetSec * 1000) + : null; + return { + ascending, + phaseProgress, + eventTime, + elapsedSinceStart, + countdownActive, + preCountdownActive, + countdownSeconds, + }; + } + + function syncReplayButton() { + const button = layerState._missionPanel?.querySelector( + '[data-mission-replay]', + ); + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + const speedControl = layerState._missionPanel?.querySelector( + '.mission-replay-speed-control', + ); + if (!button) return; + const active = Boolean( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId === layerState._selectedLaunchId, + ); + const replayAvailable = Boolean( + layerState._selectedLaunchId && + layerState._replayTracks.has(layerState._selectedLaunchId), + ); + if (speedControl) speedControl.hidden = !replayAvailable; + button.hidden = active || !replayAvailable; + button.disabled = !replayAvailable; + button.textContent = 'REPLAY ASCENT'; + button.classList.remove('active'); + button.setAttribute('aria-pressed', String(active)); + button.title = 'Replay the estimated ascent with a following camera'; + if (transport) { + transport.hidden = !active; + transport.classList.toggle( + 'is-paused', + active && layerState._replayPaused, + ); + const toggleButton = transport.querySelector( + '[data-mission-replay-toggle]', + ); + if (toggleButton) { + toggleButton.disabled = !active; + toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ'; + toggleButton.setAttribute( + 'aria-label', + layerState._replayPaused ? 'Resume replay' : 'Pause replay', + ); + toggleButton.title = layerState._replayPaused + ? 'Resume replay' + : 'Pause replay'; + } + } + } + + function syncReplayCountdownButton(state) { + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + if (!transport || !layerState._replayCameraLaunchId) return; + const phase = state.countdownActive + ? `T minus ${state.countdownSeconds}` + : state.preCountdownActive + ? 'Preparing launch site' + : state.elapsedSinceStart < 1 + ? 'Liftoff' + : state.ascending + ? 'Ascent replay' + : 'Orbit replay'; + transport.setAttribute( + 'aria-label', + `${phase}${layerState._replayPaused ? ', paused' : ''}`, + ); + } + + function syncReplaySpeedControl() { + const input = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed]', + ); + const output = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed-output]', + ); + if (input) { + input.value = String(layerState._replaySpeed); + const progress = + ((layerState._replaySpeed - REPLAY_SPEED_MIN) / + (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * + 100; + input.style.setProperty('--replay-speed-progress', `${progress}%`); + } + if (output) + output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`; + } + + function setReplaySpeed(value) { + const nextSpeed = normalizeReplaySpeed(value); + const previousSpeed = layerState._replaySpeed; + if (nextSpeed === previousSpeed) { + syncReplaySpeedControl(); + return; + } + const now = + layerState._replayPaused && Number.isFinite(layerState._replayPausedAtMs) + ? layerState._replayPausedAtMs + : Date.now(); + for (const [launchId, startedAt] of layerState._animationStarts) { + if (!Number.isFinite(startedAt)) continue; + const elapsedMissionMs = (now - startedAt) * previousSpeed; + layerState._animationStarts.set( + launchId, + now - elapsedMissionMs / nextSpeed, + ); + } + layerState._replaySpeed = nextSpeed; + syncReplaySpeedControl(); + } + + function replayClockNow(launchId) { + if ( + layerState._replayPaused && + layerState._replayCameraLaunchId === launchId && + Number.isFinite(layerState._replayPausedAtMs) + ) { + return layerState._replayPausedAtMs; + } + return Date.now(); + } + + function pauseMissionReplay() { + if (!layerState._replayCameraLaunchId || layerState._replayPaused) + return false; + layerState._replayPausedAtMs = Date.now(); + layerState._replayPaused = true; + syncReplayButton(); + return true; + } + + function resumeMissionReplay() { + if ( + !layerState._replayCameraLaunchId || + !layerState._replayPaused || + !Number.isFinite(layerState._replayPausedAtMs) + ) { + return false; + } + const resumedAt = Date.now(); + const startedAt = layerState._animationStarts.get( + layerState._replayCameraLaunchId, + ); + if (Number.isFinite(startedAt)) { + layerState._animationStarts.set( + layerState._replayCameraLaunchId, + replayStartAfterPause( + startedAt, + layerState._replayPausedAtMs, + resumedAt, + ), + ); + } + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + syncReplayButton(); + return true; + } + + function stopMissionReplay() { + const stoppedLaunchId = layerState._replayCameraLaunchId; + layerState._replayCameraToken++; + if (layerState._replayCameraRemover) layerState._replayCameraRemover(); + layerState._replayCameraRemover = null; + layerState._viewer?.camera?.cancelFlight(); + if (layerState._viewer?.camera) + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + layerState._replayCameraLaunchId = null; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + if (stoppedLaunchId) + layerState._animationStarts.set(stoppedLaunchId, Date.now()); + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + } + + function startMissionReplay(launchId) { + const launch = layerState._launches.find((item) => item.id === launchId); + const track = layerState._replayTracks.get(launchId); + if (!layerState._viewer || !launch || !track) return false; + if (layerState._replayCameraLaunchId === launchId) { + stopMissionReplay(); + return false; + } + + parts.selection.stopMissionZoomAnchor(); + stopMissionReplay(); + layerState._replayCameraLaunchId = launchId; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + const token = ++layerState._replayCameraToken; + const ascentDurationSec = track.ascentDurationSec; + // Start broadside to the ascent/orbit direction so the launch profile is + // visible. The chase heading then eases toward the path tangent after the + // camera is established. + const initialHeading = parts.camera.replayInitialCameraHeading( + track.ascentPath, + ); + track.lastCameraHeading = initialHeading; + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); + layerState._viewer.trackedEntity = undefined; + layerState._viewer.camera.cancelFlight(); + layerState._animationStarts.set( + launchId, + Date.now() + + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, + ); + let cameraReady = false; + let lastCameraUpdateMs = null; + + // Move the chase camera before scene traversal. Mutating it in preRender + // makes Cesium discover a new view after 3D-tile refinement, which can cause + // a self-sustaining refinement loop and visible stutter during slow replay. + layerState._replayCameraRemover = + layerState._viewer.scene.preUpdate.addEventListener(() => { + if ( + token !== layerState._replayCameraToken || + layerState._replayCameraLaunchId !== launchId + ) + return; + const state = track.beginReplayFrame(); + syncReplayCountdownButton(state); + if (!cameraReady) return; + const path = state.ascending + ? track.ascentPath + : track.animatedOrbitPath; + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position) return; + const pathHeading = parts.camera.cameraHeadingForPath( + path, + state.phaseProgress, + track.lastCameraHeading, + ); + const orbitBlend = !state.ascending + ? Cesium.Math.clamp( + (Number(state.phaseProgress) || 0) / + REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ) + : 0; + const desiredHeading = parts.camera.replayChaseCameraHeading( + pathHeading, + orbitBlend, + ); + if (state.ascending) track.orbitCameraWorldFrame = false; + const nowMs = performance.now(); + const frameDurationMs = Number.isFinite(lastCameraUpdateMs) + ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) + : 1000 / 60; + lastCameraUpdateMs = nowMs; + track.lastCameraHeading = parts.camera.smoothReplayCameraHeading( + track.lastCameraHeading, + desiredHeading, + (Cesium.Math.toRadians(2) * frameDurationMs) / (1000 / 60), + ); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + const altitude = Math.max(0, cartographic?.height || 0); + const cameraView = parts.camera.replayCameraView(state, altitude); + const defaultOrbitTarget = !state.ascending + ? parts.camera.replayOrbitGlobeAnchor(position, orbitBlend) + : position; + const cameraTarget = !state.ascending + ? parts.camera.replayOrbitCameraTarget( + defaultOrbitTarget, + track.orbitFrameSphere?.center, + orbitBlend, + ) + : defaultOrbitTarget; + const cameraRange = !state.ascending + ? parts.camera.replayOrbitGlobeRange( + cameraView.range, + altitude, + orbitBlend, + track.orbitFrameSphere?.radius, + ) + : cameraView.range; + if (state.ascending) { + layerState._viewer.camera.lookAt( + cameraTarget, + new Cesium.HeadingPitchRange( + track.lastCameraHeading, + cameraView.pitch, + cameraRange, + ), + ); + } else { + const tangentStep = Math.max( + 0.002, + 0.8 / Math.max(2, path.length - 1), + ); + const tangentProgress = Math.min( + 1, + state.phaseProgress + tangentStep, + ); + const tangentPosition = parts.paths.samplePath( + path, + tangentProgress > state.phaseProgress + ? tangentProgress + : Math.max(0, state.phaseProgress - tangentStep), + ); + const orbitPose = parts.camera.replayOrbitCameraPose( + position, + tangentPosition, + cameraTarget, + cameraRange, + cameraView.pitch, + ); + if (orbitPose) { + if (!track.orbitCameraWorldFrame) { + layerState._viewer.camera.lookAtTransform( + Cesium.Matrix4.IDENTITY, + ); + track.orbitCameraWorldFrame = true; + } + layerState._viewer.camera.setView({ + destination: orbitPose.destination, + orientation: { + direction: orbitPose.direction, + up: orbitPose.up, + }, + }); + } + } + if ( + state.elapsedSinceStart >= + ascentDurationSec + REPLAY_ORBIT_DURATION_SEC + ) { + stopMissionReplay(); + } + }); + + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(track.ascentPath[0], 0), + { + offset: new Cesium.HeadingPitchRange( + initialHeading, + Cesium.Math.toRadians(-20), + REPLAY_INITIAL_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + cameraReady = true; + }, + cancel: () => { + if ( + token === layerState._replayCameraToken && + layerState._replayCameraLaunchId === launchId + ) + stopMissionReplay(); + }, + }, + ); + return true; + } + return { + replayVehicleScreenRotation, + smoothReplayWindowPosition, + replayAscentDurationSeconds, + normalizeReplaySpeed, + replayStartAfterPause, + replayState, + syncReplayButton, + syncReplayCountdownButton, + syncReplaySpeedControl, + setReplaySpeed, + replayClockNow, + pauseMissionReplay, + resumeMissionReplay, + stopMissionReplay, + startMissionReplay, + }; +} diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js new file mode 100644 index 000000000..813b7875f --- /dev/null +++ b/src/layers/launches/selection.js @@ -0,0 +1,239 @@ +import * as Cesium from 'cesium'; +import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { getKeyholeGeometry } = services.geometry; + + function focusFullGlobe(viewer, duration = 2.4) { + const canvas = viewer?.scene?.canvas; + const height = canvas?.clientHeight || canvas?.height; + const width = canvas?.clientWidth || canvas?.width; + const cartographic = viewer?.camera?.positionCartographic; + const fovy = viewer?.camera?.frustum?.fovy; + if ( + !(width > 0) || + !(height > 0) || + !cartographic || + !Number.isFinite(fovy) || + fovy <= 0 || + fovy >= Math.PI + ) + return; + const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; + const keyholeRadius = getKeyholeGeometry(width, height).radius; + const targetScreenRadius = keyholeRadius * 0.61; + const angularRadius = Math.atan( + (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5), + ); + const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); + const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + altitude, + ), + orientation: { + heading: viewer.camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + duration, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }); + } + + function entityLaunchId(entity) { + if (!entity?.id || typeof entity.id !== 'string') return null; + const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); + return match?.[1] || null; + } + + function setSelectedMission(launchId, isolate = true) { + if (launchId) parts.panel.clearMissionRosterHover(); + if ( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId !== launchId + ) + parts.replay.stopMissionReplay(); + if ( + layerState._missionZoomAnchorId && + layerState._missionZoomAnchorId !== launchId + ) + stopMissionZoomAnchor(); + layerState._selectedLaunchId = launchId; + layerState._explicitSelection = Boolean(launchId && isolate); + if (launchId) layerState._animationStarts.set(launchId, Date.now()); + if (!launchId && layerState._viewer) { + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = undefined; + } + if (!layerState._dataSource) return; + for (const entity of layerState._dataSource.entities.values) { + const relatedId = entityLaunchId(entity); + entity.show = launchId + ? relatedId === launchId + : entity.id.startsWith('rocket-launch:'); + } + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.overlays.syncMissionOverlayEntries(); + parts.panel.renderMissionPanel(); + } + + function stopMissionZoomAnchor() { + if (layerState._missionZoomAnchorRemover) + layerState._missionZoomAnchorRemover(); + layerState._missionZoomAnchorRemover = null; + layerState._missionZoomAnchorId = null; + if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) { + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } + } + + function startMissionZoomAnchor(launch, initialRange) { + if (!layerState._viewer || !launch || layerState._replayCameraLaunchId) + return; + stopMissionZoomAnchor(); + const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const range = Math.max( + 2000, + Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M, + ); + layerState._missionZoomAnchorId = launch.id; + layerState._viewer.camera.lookAt( + target, + new Cesium.HeadingPitchRange( + 0, + parts.camera.missionZoomPitch(range), + range, + ), + ); + } + + function focusMission(launch) { + if (!layerState._viewer || !launch) return; + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + const orbitEntity = layerState._dataSource.entities.getById( + `rocket-orbit:${launch.id}`, + ); + const orbitPositions = orbitEntity?.polyline?.positions?.getValue( + Cesium.JulianDate.now(), + ); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + let range = 18000000; + if (orbitPositions?.length > 1) { + const canvas = layerState._viewer.scene.canvas; + const fovy = + layerState._viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); + const aspect = Math.max( + 0.1, + (canvas.clientWidth || canvas.width) / + Math.max(1, canvas.clientHeight || canvas.height), + ); + const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); + const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; + const orbitExtent = orbitPositions.reduce( + (largest, point) => + Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), + 0, + ); + range = Math.max( + orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), + 18000000, + ); + } + const targetNormal = Cesium.Cartesian3.normalize( + launchPosition, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + launchPosition, + Cesium.Cartesian3.multiplyByScalar( + targetNormal, + range, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const direction = Cesium.Cartesian3.negate( + targetNormal, + new Cesium.Cartesian3(), + ); + let right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Z, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { + right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Y, + right, + ); + } + Cesium.Cartesian3.normalize(right, right); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + layerState._viewer.camera.flyTo({ + destination, + orientation: { direction, up }, + duration: 1.1, + complete: () => { + if (layerState._selectedLaunchId === launch.id) + startMissionZoomAnchor(launch, range); + }, + }); + } + + function focusLaunchSite(launch) { + if (!layerState._viewer || !launch) return; + parts.replay.stopMissionReplay(); + stopMissionZoomAnchor(); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(launchPosition, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + Cesium.Math.toRadians(-42), + MISSION_FOCUS_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + if (layerState._selectedLaunchId === launch.id) { + startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); + } + }, + }, + ); + } + return { + focusFullGlobe, + entityLaunchId, + setSelectedMission, + stopMissionZoomAnchor, + startMissionZoomAnchor, + focusMission, + focusLaunchSite, + }; +} diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js new file mode 100644 index 000000000..39a49e36c --- /dev/null +++ b/src/layers/launches/source.js @@ -0,0 +1,25 @@ +/** Read launch records and their optional active-orbit catalog with explicit cancellation. */ +export function createLaunchSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getLaunches({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/launches', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload) && !Array.isArray(payload?.results)) + throw new Error('Malformed launch snapshot'); + return payload; + }, + async getActiveTle({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/celestrak/active', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + signal?.throwIfAborted(); + return text; + }, + }; +} diff --git a/src/layers/launches/source.test.mjs b/src/layers/launches/source.test.mjs new file mode 100644 index 000000000..916ad291e --- /dev/null +++ b/src/layers/launches/source.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLaunchSource } from './source.js'; +import { createRocketLaunchesLayer } from './index.js'; + +test('launch sources preserve last-good eligibility by rejecting malformed snapshots', async () => { + for (const payload of [{}, { results: null }]) { + const source = createLaunchSource({ + fetchImpl: async () => new Response(JSON.stringify(payload)), + }); + await assert.rejects(source.getLaunches(), /Malformed launch snapshot/); + } +}); + +test('launch and active-orbit responses reject cancellation during parsing', async () => { + for (const method of ['getLaunches', 'getActiveTle']) { + const controller = new AbortController(); + const source = createLaunchSource({ + fetchImpl: async () => ({ + ok: true, + async json() { + controller.abort(); + return { results: [] }; + }, + async text() { + controller.abort(); + return 'late orbit'; + }, + }), + }); + await assert.rejects(source[method]({ signal: controller.signal }), { + name: 'AbortError', + }); + } +}); + +test('launch factories construct independently without starting a scene or source request', () => { + const source = { + getLaunches() { + assert.fail('construction fetched launches'); + }, + getActiveTle() { + assert.fail('construction fetched orbits'); + }, + }; + const services = { satellites: {}, geometry: {}, overlays: {}, render: {} }; + const first = createRocketLaunchesLayer({ source, services }); + const second = createRocketLaunchesLayer({ source, services }); + first._setSelectedRocketMissionForTest('first'); + assert.notEqual(first, second); + assert.equal(first.getStats().count, 0); + assert.equal(second.getStats().count, 0); +}); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js new file mode 100644 index 000000000..0f0194221 --- /dev/null +++ b/src/layers/launches/state.js @@ -0,0 +1,138 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const state = {}; + + state.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + state._dataSource = null; + + state._count = 0; + + state._lastUpdate = null; + + state._lastError = null; + + state._orbitMatches = 0; + + state._clickHandler = null; + + state._moveHandler = null; + + state._viewer = null; + + state._declutterHandler = null; + + state._dataManager = null; + + state._retryTimer = null; + + state._postTleRetryCount = 0; + + state._updatePromise = null; + + state._updatePromiseToken = 0; + + state._updateDirty = false; + + state._lifecycleToken = 0; + + state._enabled = false; + + state._selectedLaunchId = null; + + state._explicitSelection = false; + + state._launches = []; + + state._missionPanel = null; + + state._missionRoster = null; + + state._missionRosterHoverTimer = null; + + state._hoveredRosterLaunchId = null; + + state._missionRosterPreviewOwnership = null; + + state._missionHoverReticleImage = null; + + state._replayVehicleOverlay = null; + + state._replayVehicleOverlayText = ''; + + state._animationStarts = new Map(); + + state._satelliteTelemetry = new Map(); + + state._lastPanelTelemetryMs = 0; + + state._lastMissionRingRotationMs = 0; + + state._activeTleText = null; + + state._activeTlePromise = null; + + state._activeTlePromiseToken = 0; + + state._renderedTleText = null; + + state._focusAfterActiveLookup = false; + + state._satelliteStateBeforeMission = null; + + state._satelliteActivationPromise = null; + + state._replayCameraRemover = null; + + state._replayCameraLaunchId = null; + + state._replayCameraToken = 0; + + state._replayPaused = false; + + state._replayPausedAtMs = null; + + state._replaySpeed = 1; + + state._replayTracks = new Map(); + + state._missionOverlayRecords = new Map(); + + state._missionOverlayHost = state.DEFAULT_OVERLAY_HOST; + + state._selectedMissionOverlayTimeText = null; + + state._missionZoomAnchorRemover = null; + + state._missionZoomAnchorId = null; + + state._launchPadZonePrimitive = null; + + state._launchPadZoneRemover = null; + + state._launchPadZoneLaunchId = null; + + state._missionOrbitPrimitives = new Map(); + + state._declutterTime = new Cesium.JulianDate(); + + state._declutterOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + new Cesium.Cartesian3(), + ); + + state._missionRingDate = new Date(0); + + state._missionOrbitPatternRegistered = false; + + state._pathDistanceCache = new WeakMap(); + state._sourceController = new AbortController(); + return state; +} diff --git a/src/layers/launches/testing.js b/src/layers/launches/testing.js new file mode 100644 index 000000000..ad14633ac --- /dev/null +++ b/src/layers/launches/testing.js @@ -0,0 +1,19 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** Test seam for real layer lifecycle coverage with a recording host. */ + + function _setRocketMissionOverlayHostForTest(host = null) { + layerState._missionOverlayHost = host + ? { ...layerState.DEFAULT_OVERLAY_HOST, ...host } + : layerState.DEFAULT_OVERLAY_HOST; + } + + /** Test seam that exercises the real selection/deselection path. */ + + function _setSelectedRocketMissionForTest(launchId = null) { + parts.selection.setSelectedMission(launchId, Boolean(launchId)); + } + return { + _setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest, + }; +} diff --git a/src/layers/military/controller.js b/src/layers/military/controller.js new file mode 100644 index 000000000..d1f172819 --- /dev/null +++ b/src/layers/military/controller.js @@ -0,0 +1,14 @@ +export function createController({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _abortActiveUpdates() { + for (const controller of flightState._activeUpdateControllers) + controller.abort(); + flightState._activeUpdateControllers.clear(); + } + return { _abortActiveUpdates }; +} diff --git a/src/layers/military/index.js b/src/layers/military/index.js new file mode 100644 index 000000000..c972bebb1 --- /dev/null +++ b/src/layers/military/index.js @@ -0,0 +1,37 @@ +import { createFlightState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createMotion } from './motion.js'; +import { createTracking } from './tracking.js'; +import { createController } from './controller.js'; +import { createIngestion } from './ingestion.js'; +import { createLifecycle } from './lifecycle.js'; +import { createTesting } from './testing.js'; +import { createQueries } from './queries.js'; +/** Compose one military-flight layer with application-owned scene services. */ +export function createMilitaryFlightLayer({ + source, + services, + resolveAsset = (url) => url, +} = {}) { + const flightState = createFlightState({ source, services }); + const parts = {}; + const layer = {}; + const context = { flightState, services, parts, layer, resolveAsset }; + parts.rendering = createRendering(context); + parts.motion = createMotion(context); + parts.tracking = createTracking(context); + parts.controller = createController(context); + parts.ingestion = createIngestion(context); + parts.lifecycle = createLifecycle(context); + parts.testing = createTesting(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { TRACKED_MODEL_MAX_PX } from './policy.js'; diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js new file mode 100644 index 000000000..1dbc8e0ff --- /dev/null +++ b/src/layers/military/ingestion.js @@ -0,0 +1,547 @@ +import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; +import * as Cesium from 'cesium'; +import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; +import { classifyAircraft, CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + BILLBOARD_SCALE, + GROUND_SCALE, + TRACKED_ICON_COLOR, + MIL_ICON_COLOR, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './policy.js'; + +export function createIngestion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { geoidHeight } = services.geoid; + const { + floorAltitudeM, + cachedGroundFloor, + warmGroundFloor, + GROUND_FLOOR_LIFT_M, + } = services.groundFloor; + const { sampleMeshFloorCells } = services.meshFloor; + const { registerMilitaryIcaos } = services.militaryRegistry; + const militaryFlightsLayer = layer; + + const methods = { + /** + * Fetch the latest military aircraft positions from adsb.lol and reconcile + * with the billboard collection. Handles error backoff, feed-time-stamped + * position history updates, and grace-period removal of absent aircraft. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * @returns {Promise} + */ + async update(viewer, { signal = null } = {}) { + const nowMs = Date.now(); + const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + ids: new Set(), + source: flightState._lastSource, + }; + if (flightState._retryAt && nowMs < flightState._retryAt) { + flightState._backoff = true; + return; + } + + const resourceController = new AbortController(); + flightState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + const snapshot = await flightState._source.getSnapshot( + {}, + { signal: updateSignal }, + ); + updateSignal.throwIfAborted(); + flightState._lastStatus = snapshot.status ?? 200; + flightState._lastSource = snapshot.source; + militaryFlightsLayer.source = flightState._lastSource; + const usableAircraft = snapshot.records; + flightState._backoff = + snapshot.stale || snapshot.freshness === 'unknown'; + flightState._retryAt = 0; + flightState._lastError = + snapshot.reason || + (snapshot.freshness === 'unknown' + ? 'Source snapshot time unavailable' + : null); + const currentIcaos = new Set(); + const receiptNowMs = snapshot.observedAtMs; + // Field-test fix (RS46): coarse floor cells to warm for the below-ground + // clamp — collected during the loop (low airborne contacts only), + // batch-resolved once after it. Never a fetch inside the loop. + const _floorWarmPoints = []; + + for (const aircraft of usableAircraft) { + const { id: icao24, longitude: lon, latitude: lat } = aircraft; + + currentIcaos.add(icao24); + flightState._missingPolls.delete(icao24); + + const prevMeta = flightState._flightData.get(icao24); + // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no + // separate boolean). Grounded planes fall back to their last known + // altitude (field elevation is unknowable here), else 0 m — never the + // 3 km airborne default (a parked plane must not float). + const onGround = aircraft.onGround; + const altitudeFt = + aircraft.baroAltitudeM == null + ? null + : aircraft.baroAltitudeM / 0.3048; + const altitudeM = + aircraft.baroAltitudeM ?? + (onGround + ? Number.isFinite(prevMeta?.altitudeFt) + ? prevMeta.altitudeFt * 0.3048 + : 0 + : 3048); + const track = aircraft.courseDeg || 0; + const speedMps = aircraft.speedMps ?? 0; + const verticalRateMps = aircraft.verticalRateMps; + const callsign = aircraft.callsign; + const type = aircraft.typeCode; + const registration = aircraft.registration; + const operator = aircraft.operator; + const geoAltitudeM = aircraft.ellipsoidAltitudeM; + const baroAltitudeM = aircraft.baroAltitudeM; + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = flightState._geoidNCache.get(icao24); + if (geoidN === undefined && flightState._geoidReady) { + geoidN = geoidHeight(lat, lon); + flightState._geoidNCache.set(icao24, geoidN); + } + + // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a + // grounded plane's MODEL already rides groundSnap.js's one-shot tileset + // sample (_modelDisplayPosition), which is the visual on the ground, and + // its billboard is depth-test-free (_groundDepthDistance) so its exact + // height is cosmetic. Deliberately pass surfaceM=null so + // pickRenderAltitudeM's on-ground surface branch never fires here: + // 1. It would be the SECOND correction of the same grounded plane + // (model tileset-snap is the first) — the exact double-correct the + // brief forbids. + // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the + // grounded billboard sits at 0 m until a surface value warms; letting + // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls + // drags the model's ground-snap input past groundSnap's 50 m + // move-invalidation threshold and forces a re-sample every time the + // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant + // the track regression locks (qa: sampleHeight count must stay flat). + // Grounded planes therefore keep the pre-existing `altitudeM` default + // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what + // matters for AIRBORNE military planes — the actual "renders at MSL" bug. + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: baroAltitudeM, + onGround, + surfaceM: null, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // alt_geom nor alt_baro was ever reported for this aircraft (not even + // stickily) — fall back to the SAME existing default policy `altitudeM` + // already uses (which also carries the on-ground 0 m / last-known-baro + // case), so the two never disagree on the "no data yet" case. + let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; + // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only + // AIRBORNE contact near steep terrain can compute a render height + // BELOW the local surface (no alt_geom; baro+N carries QNH error + // larger than the height above ground). Floor it at the coarse-grid + // ellipsoidal ground (warm-cache read only — the batch warm below + // fills cells for later polls). Grounded contacts are deliberately + // NOT touched: their model rides groundSnap's tileset sample and + // their billboard is depth-test-free (see the surfaceM:null block + // above — same one-shot-invariant reasoning). + if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + _floorWarmPoints.push({ lat, lon }); + } else if (onGround) { + // Grounded contacts: warm the floor cell, and — round 4 — when NO + // 3D model owns this contact's visual, lift the billboard itself + // onto the floor (mesh-first): the R20053 heli sat "straight up in + // the ground" because grounded rows render at the legacy ~0 m. + // With a model present the billboard stays put (it hides behind + // the tileset-snapped model, and moving it would drag groundSnap's + // input past its move-invalidation threshold — the T7 one-shot + // invariant the track regression locks). + _floorWarmPoints.push({ lat, lon }); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsVisual) { + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); + } + } + } + + const position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + renderAltitudeM, + ); + // Landing/takeoff transition: the ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Either flip direction retires the model's ground snap: a departing plane + // flies free of it, a landing plane earns a fresh sample where it rolls out. + if (groundFlipped) flightState._groundSnap.forget(icao24); + + // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold + // last-known-good (bounded by the layer's eviction, which deletes the entry). + const stickyType = stickyText(type, prevMeta?.type); + const meta = { + sourceReference: aircraft.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + type: stickyType, + // Type outranks category automatically inside classifyAircraft. + klass: classifyAircraft({ + typeCode: stickyType, + category: aircraft?.category, + }), + registration: stickyText(registration, prevMeta?.registration), + operator: stickyText(operator, prevMeta?.operator), + altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitudeFt` — never rename/replace it (labels, + // the FL readout, and the landed-fast-cull heuristic all still read + // altitudeFt/baro). + geoAltitudeM, + renderAltitudeM, + speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), + track: stickyNumber(track, prevMeta?.track, null), + // Analyst seam (additive): sticky like the other kinematics. + verticalRateMps: stickyNumber( + verticalRateMps, + prevMeta?.verticalRateMps, + null, + ), + lastContactEpochMs: stickyNumber( + aircraft.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + turnRateDps: prevMeta?.turnRateDps || 0, + onGround, + // Round 7: sticky airborne history (see _likelyLanded). + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Round 6: lifted occlusion-test point for at/below-ellipsoid + // renders (mirror of flights.js). + cullPosition: + renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) + : null, + // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded + // re-floor sweep keys floors off these (mirror of flights.js). + rawLat: lat, + rawLon: lon, + }; + flightState._flightData.set(icao24, meta); + + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch. + // adsb.lol's seen_pos is the AGE in seconds of the last position + // report, so the fix epoch is receipt time minus that age. Only + // append when the fix actually advances, so stale repeats don't + // create zero-dt segments. + const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs; + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.speedMps, + track: meta.track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F2) + // so the visible trail keeps growing while tracked. Round 2 + // (owner): ground traffic appends too — taxi history stays live + // after touchdown; _appendTrailFix floors grounded positions at + // the surface so the ground leg drapes instead of diving. + if (isTracked) parts.tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = + parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + const kinematicsChanged = + newest.velocity !== meta.speedMps || newest.track !== meta.track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.speedMps, + track: meta.track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) + parts.tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + // Ground flips (landing/takeoff) restyle this SAME billboard in + // place — the transition is never a removal. + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Per-class GLBs: a class change can mean a different asset OR scale — + // resync the live model, any in-flight load, and the tracked + // standalone (mirror of flights.js). + if (prevMeta?.klass !== meta.klass) + parts.rendering._syncModelToClass(icao24); + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon( + parts.rendering._iconKind(icao24, meta.klass), + ), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta.klass] || 1) * + (meta.onGround ? GROUND_SCALE : 1), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + sizeInMeters: false, + scaleByDistance: + parts.rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + + // Takeoff while TRACKED: ground traffic drew no trail, so start one + // from the fresh airborne history (touchdown needs no action — the + // append gate above simply freezes the existing trail). + if (isTracked && groundFlipped && !meta.onGround) + parts.tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + const info = flightState._flightData.get(icao24); + parts.tracking._updateTrackedLabelModel(icao24); + } + } + + // Field-test fix (RS46): one batch warm of the low-airborne floor cells + // collected above — fire-and-forget, single-flight, results read + // synchronously by NEXT poll's clamp (same contract as flights.js's + // grounded-surface warm). + warmGroundFloor(_floorWarmPoints); + // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — + // a parked contact whose feed went quiet froze at its pre-warm height. + // The model-ownership gate matches the live grounded-clamp path (a + // tileset-snapped model owns its visual; moving its billboard would + // disturb groundSnap's input — the T7 one-shot invariant). + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (parts.rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + info.cullPosition = null; + const liftedPos = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const hist = flightState._positionHistory.get(icao24); + const newest = hist?.[hist.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone( + liftedPos, + newest.position, + ); + const bbStale = flightState._billboards.get(icao24); + if (bbStale) bbStale.position = liftedPos; + } + // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, + // budget-capped, viewer-proximate, google-3d regime only), excluding + // this layer's own billboards/models from the probe. + { + const viewerCarto = + flightState._viewer?.camera?.positionCartographic || null; + sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // adsb.lol routinely drops aircraft for a single poll; immediate removal + // made icons blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if ( + !snapshot.complete && + Date.now() - + (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < + 300000 + ) + continue; + const misses = (flightState._missingPolls.get(icao24) || 0) + 1; + const limit = parts.queries._likelyLanded(icao24) + ? LANDED_MISSING_POLL_LIMIT + : MISSING_POLL_LIMIT; + if (misses < limit) { + flightState._missingPolls.set(icao24, misses); + if ( + icao24 === flightState._trackedIcao && + flightState._trackedEntity + ) { + // Honest readout: the tracked plane has no faded billboard (its + // entity owns the visual), so refresh the label — with icao24 now + // in _missingPolls, _buildTrackedLabel appends the STALE cue so + // last-known altitude/speed aren't presented as live. + parts.tracking._updateTrackedLabelModel(icao24); + } + continue; + } + flightState._missingPolls.delete(icao24); + + // If the tracked flight is truly gone, clear tracking BEFORE deleting + // its state (M3 ordering, keep it): teardown reads the maps this loop + // is about to delete (billboard restore, DR cache reset), and we must + // never leave the camera mid-follow with stale tracking state. The + // camera is then RELEASED IN PLACE — it stays where the follow left + // it, fully free (owner decision 2026-07-02: no overview flyTo). + if (icao24 === flightState._trackedIcao) { + parts.tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._geoidNCache.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Feed the shared registry so the flights layer can classify/suppress + registerMilitaryIcaos(currentIcaos); + + flightState._count = flightState._billboards.size; + flightState._lastUpdate = receiptNowMs; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'accepted', + ids: currentIcaos, + source: flightState._lastSource, + }; + console.log(`[Data:Military] Updated: ${flightState._count} aircraft`); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Military update aborted', 'AbortError'); + } + console.warn('[Data:Military] Fetch error:', e); + flightState._backoff = true; + flightState._retryAt = + Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); + flightState._lastStatus = e?.status ?? null; + if (e?.source) { + flightState._lastSource = e.source; + this.source = flightState._lastSource; + } + flightState._lastError = + e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; + } finally { + flightState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js new file mode 100644 index 000000000..31d8b558e --- /dev/null +++ b/src/layers/military/lifecycle.js @@ -0,0 +1,301 @@ +import * as Cesium from 'cesium'; +import { JET_MODEL_URL } from './policy.js'; + +export function createLifecycle({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { clearFocusTarget } = services.focus; + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { setMilitaryLayerActive } = services.militaryRegistry; + + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (flightState._viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + flightState._source = source; + flightState._lastSource = source.label || flightState._lastSource; + this.source = flightState._lastSource; + }, + + /** + * Initialize the layer: create the billboard collection, reset all state, + * and install the click-to-track handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + init(viewer) { + if (flightState._viewer) + throw new Error('Flight layer is already initialized'); + if (typeof flightState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + if (flightState.lifetime.signal.aborted) + flightState.lifetime = new AbortController(); + clearFocusTarget('militaryFlights'); + flightState._viewer = viewer; + flightState._billboardCollection = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(flightState._billboardCollection); + registerSpriteCollection('military', flightState._billboardCollection); + flightState._modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(flightState._modelCollection); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(JET_MODEL_URL), + asynchronous: false, + }) + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + try { + m.destroy(); + } catch { + /* gone */ + } + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + flightState._billboards = new Map(); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map(); + flightState._positionHistory = new Map(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._backoff = false; + flightState._retryAt = 0; + flightState._lastError = null; + flightState._lastStatus = null; + flightState._trackedIcao = null; + parts.tracking._resetTrackedSelectionState(); + flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; + flightState._cockpitContactMode = + document.body.classList.contains('cockpit-mode'); + flightState._cockpitNearContacts = new Set(); + if (!flightState._cockpitModeListener) { + flightState._cockpitModeListener = (event) => + parts.tracking._applyCockpitState(event?.detail); + window.addEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + } + + parts.tracking._installClickHandler(viewer); + + restoreSpriteOrder(viewer); + + console.log('[Data:Military] Initialized with billboard icons'); + }, + + /** + * Show the layer and re-install the click handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + enable(viewer) { + if (flightState._billboardCollection) + flightState._billboardCollection.show = true; + holdContinuousRender('military'); // per-frame animator (perf wave 2) + if (flightState._modelCollection) + flightState._modelCollection.show = true; + parts.tracking._setCockpitContactMode( + document.body.classList.contains('cockpit-mode'), + ); + // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop + // only reads geoidHeight() synchronously after this resolves (guarded by + // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. + if (!flightState._geoidReady) { + const lifetime = flightState.lifetime; + ensureGeoidReady() + .then(() => { + if (!lifetime.signal.aborted) flightState._geoidReady = true; + }) + .catch(() => { + /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ + }); + } + parts.tracking._installClickHandler(viewer); + registerPickOwner('military', (pickedId) => + flightState._billboards.has(pickedId), + ); + // The flights layer suppresses its military duplicates while we render them + setMilitaryLayerActive(true); + // Force a fresh rotation pass on the first tick after re-enable + flightState._lastCamPoseSig = ''; + if (!flightState._preRenderRemove && viewer?.scene) { + flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering._fleetTick, + ); + } + if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { + flightState._trackedModelPreUpdateRemove = + viewer.scene.preUpdate.addEventListener( + parts.rendering._updateTrackedModel, + ); + } + if (!flightState._moveEndRemove && viewer?.camera) { + // Arrival polish (mirror of flights.js — see the comment there): a settled + // camera move forces a full rotation pass on the very next frame, because + // the pose-signature gate can eat the settle (final easing frames land + // inside one quantization bucket) and leave stale noses for up to + // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. + flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( + () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }, + ); + } + restoreSpriteOrder(viewer); + }, + + /** + * Hide the layer, clear any active tracking, and remove input handlers + * so clicks do not get intercepted while the layer is off. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + disable(viewer) { + parts.controller._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._billboardCollection) + flightState._billboardCollection.show = false; + releaseContinuousRender('military'); + parts.rendering._releaseModels(); + if (flightState._modelCollection) + flightState._modelCollection.show = false; + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + // Remove click handler + keydown listener while disabled to avoid + // intercepting input when the layer is off + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + unregisterPickOwner('military'); + // Flights layer takes over rendering known-military aircraft (amber) + setMilitaryLayerActive(false); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + }, + + /** + * Tear down the layer: clear tracking, remove handlers, remove the billboard + * collection from the scene, and release all state. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + destroy(viewer) { + flightState.lifetime.abort(); + parts.controller._abortActiveUpdates(); + releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + if (flightState._cockpitModeListener) { + window.removeEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + flightState._cockpitModeListener = null; + } + unregisterPickOwner('military'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + parts.rendering._releaseModels(); + if (flightState._billboardCollection) { + viewer.scene.primitives.remove(flightState._billboardCollection); + flightState._billboardCollection = null; + } + if (flightState._modelCollection) { + viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models + flightState._modelCollection = null; + } + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + try { + flightState._preloadModel.destroy(); + } catch { + /* gone */ + } + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._detectionObjects.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._missingPolls.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._cockpitContactMode = false; + flightState._cockpitNearContacts = new Set(); + flightState._cockpitSubjectId = null; + flightState._trackingRefreshEpoch += 1; + flightState._lastTrackingRefreshOutcome = { + epoch: flightState._trackingRefreshEpoch, + status: 'destroyed', + ids: new Set(), + source: flightState._lastSource, + }; + parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling + flightState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js new file mode 100644 index 000000000..004558e2e --- /dev/null +++ b/src/layers/military/motion.js @@ -0,0 +1,528 @@ +import * as Cesium from 'cesium'; +import { + lerpAngleDeg, + speedRamp, + courseBetweenCartesians, + COURSE_HOLD_SPEED_MPS, + staleCoastLimitSeconds, + arcOffsetEnu, + courseSlewCapDps, + limitCourseStep, +} from '../../data/motionModel.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + modelVisualAnchor, + modelAnchorWorld, +} from '../../data/modelVisualAnchor.js'; +import { + RENDER_DELAY_SEC, + TRACKED_MODEL_MAX_PX, + TRACKED_MODEL_MIN_PX, + BILLBOARD_SCALE, + TRACKED_BILLBOARD_SCALE_BY_DISTANCE, + DR_CORRECTION_MS, + COURSE_SLEW_DT_MAX_SEC, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createMotion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { trackedModelScaleForPixelCap } = services.camera; + const { + nearFarScalarValueAtDistance, + clearFocusTarget, + publishFocusTargetFromCachedPosition, + } = services.focus; + + /** + * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. + * + * Renders one poll interval behind real time so positions interpolate between + * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the + * newest fix is older than the delayed render time (stale position), projects + * forward using the aircraft's ground speed and track through the freshest + * source contact plus a bounded grace window. + * + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. + * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history + */ + + function _deadReckon(icao24, result) { + const history = flightState._positionHistory.get(icao24); + const info = flightState._flightData.get(icao24); + if (!history || history.length === 0) { + flightState._drCourseDeg = null; + flightState._drSpeedMps = null; + flightState._drCourseHold = false; + return null; + } + + const out = result || new Cesium.Cartesian3(); + // Render one poll interval behind real time so we interpolate between + // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); + + // Bracketing pair: interpolate — no extrapolation error, no snap-back. + for (let i = history.length - 1; i >= 1; i--) { + const a = history[i - 1]; + const b = history[i]; + if ( + Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && + Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) { + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + // Course of the DISPLAYED motion. The chord is only trustworthy when the + // segment covers real ground (at hover its direction is GPS jitter; on a + // slow tight turn it STEPS the whole per-segment turn at each boundary), + // so it is blended against the reported per-fix track by displayed + // ground speed — and the track is TIME-INTERPOLATED between the fixes so + // a slow turner's nose advances continuously through the segment instead + // of snapping once per poll. Helicopters always use the reported track + // (rotorcraft chords are noise-dominated at their typical speeds). + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const segSpeed = + span > 0 ? chordLenM / span : (info && info.speedMps) || 0; + const fallbackTrack = (info && info.track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); + const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); + const chordCourse = + w > 0 ? courseBetweenCartesians(a.position, b.position) : null; + flightState._drCourseDeg = + chordCourse != null + ? lerpAngleDeg(trackCourse, chordCourse, w) + : trackCourse; + flightState._drSpeedMps = segSpeed; + flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + } + + const newest = history[history.length - 1]; + const elapsedSec = Cesium.JulianDate.secondsDifference( + renderTime, + newest.time, + ); + if (elapsedSec <= 0) { + // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking + // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing + // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time + // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history + // fills, renderTime advances toward the first fix and the icon glides FORWARD into + // the bracketing interpolation above with NO freeze and NO backward snap. (Holding + // the oldest fix froze the icon until enough history accrued, then jumped.) + const oldest = history[0]; + const lookbackSec = Cesium.JulianDate.secondsDifference( + oldest.time, + renderTime, + ); // ≥ 0 + return _extrapolateFix( + oldest, + info, + -Math.min(lookbackSec, 60), + out, + (info && info.turnRateDps) || 0, + ); + } + + // Continue through fresh adsb.lol contacts even when the position epoch has + // not advanced, but keep an absolute stale-feed drift ceiling. + const coastLimitSec = staleCoastLimitSeconds({ + // Captured at poll normalization so the fleet tick does not allocate a + // Date for every aircraft twelve times per second. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, + }); + return _extrapolateFix( + newest, + info, + Math.min(elapsedSec, coastLimitSec), + out, + (info && info.turnRateDps) || 0, + ); + } + + /** + * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a + * constant-rate-turn arc when `turnRateDps` is significant (straight line + * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` + * projects BACKWARD (before the fix) — used for warm-up, estimating where the + * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, + * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets + * `_drCourseDeg` to the arc's instantaneous end course on every path. + * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). + */ + + function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { + const speed = Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.speedMps) || 0; + const heading = Number.isFinite(fix.track) + ? fix.track + : (info && info.track) || 0; + flightState._drSpeedMps = speed; + flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; + if (speed === 0 || dt === 0) { + flightState._drCourseDeg = heading; + return Cesium.Cartesian3.clone(fix.position, out); + } + // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a + // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. + arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); + flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; + Cesium.Cartesian3.fromElements( + flightState._scratchArc.east, + flightState._scratchArc.north, + 0, + flightState._scratchOffset, + ); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchEnu, + ); + return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); + } + + /** + * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) + * predates its oldest real fix — _deadReckon is extrapolating backward with no real + * history yet behind the displayed icon, so the trail must draw nothing. + * @returns {boolean} + */ + + function _isTrackWarmingUp() { + if (!flightState._trackedIcao) return false; + const history = flightState._positionHistory.get(flightState._trackedIcao); + if (!history || history.length === 0) return true; + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + return Cesium.JulianDate.lessThan(renderTime, history[0].time); + } + + /** Resolve the selected aircraft's actual rendered square extent this frame. */ + + function _trackedFocusSizePx(icao24, position) { + const camera = flightState._viewer?.camera; + const scene = flightState._viewer?.scene; + if (!camera?.positionWC || !position || !scene) return 28; + const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); + if (parts.rendering._modelOwnsVisual(icao24)) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(icao24)?.klass, + ); + const scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM, + viewportHeightPx: scene.canvas.clientHeight, + fovyRad: camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + const focalLengthPx = + scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + const projectedDiameterPx = + (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; + return Math.max( + TRACKED_MODEL_MIN_PX, + Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx), + ); + } + + const billboard = flightState._trackedEntity?.billboard; + const time = flightState._viewer.clock.currentTime; + const width = billboard?.width?.getValue(time) ?? 28; + const height = billboard?.height?.getValue(time) ?? 28; + const scale = + billboard?.scale?.getValue(time) ?? + BILLBOARD_SCALE * + (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + const scaleByDistance = + billboard?.scaleByDistance?.getValue(time) ?? + TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); + return Math.max(width, height) * scale * distanceScale; + } + + /** + * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium + * frame number so the position / rotation / trail-head callbacks share ONE computation + * (and one reconciliation-state update) per frame. Returns a stable module holder, or + * null when the aircraft has no fix. + * @param {string} icao24 + * @returns {Cesium.Cartesian3|null} + */ + + function _trackedDisplayPosition(icao24) { + const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frame === flightState._cachedDRFrame && + icao24 === flightState._drReconcileIcao + ) + return flightState._cachedDRPosition; + + // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting + // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) + const sameTrack = + flightState._drReconcileValid && flightState._drReconcileIcao === icao24; + const raw = _deadReckon(icao24, flightState._scratchDrRaw); + flightState._cachedDRCourse = flightState._drCourseDeg; + flightState._cachedDRSpeedMps = flightState._drSpeedMps; + flightState._cachedDRHold = flightState._drCourseHold; + flightState._cachedDRFrame = frame; + flightState._drReconcileIcao = icao24; + if (!raw) { + flightState._cachedDRPosition = null; + flightState._drReconcileValid = false; + clearFocusTarget('militaryFlights', icao24); + return null; + } + + const nowMs = Date.now(); + const info = flightState._flightData.get(icao24); + if (sameTrack) { + const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); + const speed = (info && info.speedMps) || 0; + // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. + const plausible = speed * dtSec * 4 + 25; + if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { + // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. + Cesium.Cartesian3.subtract( + flightState._drPrevDisplay, + raw, + flightState._drCorrection, + ); + flightState._drCorrectionStartMs = nowMs; + } + } else { + Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); + flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed + } + + const elapsed = nowMs - flightState._drCorrectionStartMs; + const factor = + elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; + const display = Cesium.Cartesian3.multiplyByScalar( + flightState._drCorrection, + factor, + flightState._trackedPosHolder, + ); + Cesium.Cartesian3.add(raw, display, display); + + Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); + Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); + flightState._drPrevMs = nowMs; + flightState._drReconcileValid = true; + flightState._cachedDRPosition = display; + const focusSizePx = _trackedFocusSizePx( + icao24, + flightState._cachedDRPosition, + ); + // Publish the exact frame cache shared by entity + follow camera. A second + // DR sample here would advance reconciliation in a different frame phase + // and visibly jitter the focus rectangle against the tracked aircraft. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'militaryFlights', + id: icao24, + scene: flightState._viewer?.scene, + camera: flightState._viewer?.camera, + displayPosition: flightState._cachedDRPosition, + widthPx: focusSizePx, + heightPx: focusSizePx, + }); + return display; + } + + /** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already + * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at + * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the + * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ + + function _trackedDisplayCached() { + return flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao + ? flightState._cachedDRPosition + : null; + } + + /** + * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D + * model is actually rendering with when the model owns the visual, otherwise the cached + * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. + * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, + * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its + * follow-camera anti-jitter contract untouched. + */ + + function _trackedVisualCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + return modelVisualAnchor( + flightState._trackedModel.modelMatrix, + spec.visualCenterNative, + Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale, + flightState._trackedVisualPos, + ); + } + return _trackedDisplayCached(); + } + + /** The tracked model's rendered bounding radius (m), or 0 when no model of this + * contact is drawing. Carries Cesium's effective `computedScale`, which may be + * above `scale` to satisfy minimumPixelSize — the trail head has to be judged + * against the size the operator SEES, not the nominal one. */ + /** World-space origin of the tracked model, or null when no model of this + * contact is drawing. This is the centre the rendered bounding sphere is + * measured from, so the trail clip and the envelope agree on one frame. */ + + function _trackedModelCenterWorld() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return null; + return Cesium.Matrix4.getTranslation( + flightState._trackedModel.modelMatrix, + flightState._scratchTrailClip, + ); + } + + function _trackedModelEnvelopeM() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return 0; + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const scale = Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale; + return spec.nativeRadiusM * scale; + } + + /** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on + * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ + + function _trackedTrailCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + // Through the model's OWN render chain (modelVisualAnchor's hand-rolled + // half-correction put this offset on the lateral axis — see + // modelAnchorWorld). + return modelAnchorWorld( + flightState._trackedModel, + spec.trailAnchorNative, + flightState._trackedTrailPos, + ); + } + return _trackedDisplayCached(); + } + + /** Smoothed world course for the tracked aircraft this frame. Reads the + * frame-cached course (set by _trackedDisplayPosition — the follow-camera's + * own computation), NEVER re-runs _deadReckon. Safe to call more than once + * per frame: the second call sees dt≈0 and the limiter is a no-op. + * + * The smoothed value lives in the SHARED per-icao _displayCourse entry (see + * its declaration): on click the limiter continues from whatever nose the + * fleet pass was displaying, and on untrack the fleet pass continues from + * whatever nose this path last wrote — tracked and fleet consumers of the + * same aircraft can never disagree across the handoff (mirror of flights.js). */ + + function _trackedDisplayCourse() { + const info = flightState._flightData.get(flightState._trackedIcao); + const fallback = (info && info.track) || 0; + const cacheValid = + flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao && + flightState._cachedDRCourse != null; + const raw = cacheValid ? flightState._cachedDRCourse : fallback; + const nowMs = Date.now(); + const dt = flightState._trackedCourseMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._trackedCourseMs) / 1000, + ) + : 0; + flightState._trackedCourseMs = nowMs; + const prev = flightState._displayCourse.get(flightState._trackedIcao); + // Hover hold: at near-zero displayed speed both the chord and the reported + // track are noise — keep the last stable nose direction instead of chasing. + if (cacheValid && flightState._cachedDRHold && prev != null) return prev; + const cap = courseSlewCapDps( + cacheValid + ? flightState._cachedDRSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ); + const course = limitCourseStep(prev, raw, cap, dt); + flightState._displayCourse.set(flightState._trackedIcao, course); + return course; + } + + /** Reset reconciliation + per-frame cache when tracking stops or switches + * target. Deliberately does NOT touch _displayCourse: the smoothed course + * entry belongs to the AIRCRAFT (not to the tracked session) and must + * survive the handoff back to the fleet pass. */ + + function _resetTrackedDisplay() { + flightState._drReconcileValid = false; + flightState._drReconcileIcao = null; + flightState._cachedDRFrame = -1; + flightState._cachedDRPosition = null; + flightState._cachedDRCourse = null; + flightState._cachedDRSpeedMps = null; + flightState._cachedDRHold = false; + flightState._trackedCourseMs = 0; + } + return { + _deadReckon, + _extrapolateFix, + _isTrackWarmingUp, + _trackedFocusSizePx, + _trackedDisplayPosition, + _trackedDisplayCached, + _trackedVisualCached, + _trackedModelCenterWorld, + _trackedModelEnvelopeM, + _trackedTrailCached, + _trackedDisplayCourse, + _resetTrackedDisplay, + }; +} diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs new file mode 100644 index 000000000..75a559f3f --- /dev/null +++ b/src/layers/military/ownership.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMilitaryFlightLayer } from './index.js'; +import { createFlightState } from './state.js'; + +function services() { + const names = [ + 'picking', + 'sprites', + 'trails', + 'aircraftPresentation', + 'camera', + 'militaryRegistry', + 'labels', + 'groundFloor', + 'meshFloor', + 'geoid', + 'focus', + 'readout', + 'context', + 'render', + 'recession', + ]; + return { + ...Object.fromEntries(names.map((name) => [name, {}])), + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + }; +} + +test('military layer instances isolate policy and restoration state without requesting a source', async () => { + let requests = 0; + const source = { + label: 'Fixture aircraft', + getSnapshot() { + requests++; + }, + }; + const first = createMilitaryFlightLayer({ source, services: services() }); + const second = createMilitaryFlightLayer({ source, services: services() }); + first.setParams({ models3dMode: 'all' }); + assert.equal(first.getParams().models3dMode, 'all'); + assert.equal(second.getParams().models3dMode, 'proximity'); + first.testing._setMilitaryTrackingRefreshOutcomeForTest({ ids: [] }); + assert.equal( + (await first.resolveTrackingRestoreTarget('abc123')).status, + 'missing', + ); + assert.equal( + (await second.resolveTrackingRestoreTarget('abc123')).status, + 'source-unavailable', + ); + assert.equal(requests, 0); +}); + +test('military source omission fails before viewer initialization', () => { + const layer = createMilitaryFlightLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); +}); + +test('military state owns separate contact maps, motion scratch and ground sampling', () => { + const first = createFlightState({ services: services() }); + const second = createFlightState({ services: services() }); + for (const key of [ + '_flightData', + '_billboards', + '_positionHistory', + '_groundSnap', + '_scratchCarto', + '_models', + '_activeUpdateControllers', + 'lifetime', + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test('a normalized source can retain its stale reason without changing standalone cache policy', async () => { + let reason = 'Source is refreshing'; + const supplied = services(); + supplied.groundFloor.warmGroundFloor = async () => {}; + supplied.meshFloor.sampleMeshFloorCells = () => {}; + supplied.militaryRegistry.registerMilitaryIcaos = () => {}; + const layer = createMilitaryFlightLayer({ + services: supplied, + source: { + label: 'Fixture aircraft', + async getSnapshot() { + return { + source: 'Fixture aircraft', + records: [], + complete: true, + observedAtMs: 123000, + stale: true, + freshness: 'stale', + reason, + }; + }, + }, + }); + await layer.update({}); + assert.equal(layer.getStats().error, reason); + assert.equal(layer.getStats().lastUpdate, 123000); + assert.equal(layer.getStats().stale, true); + reason = null; + await layer.update({}); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().stale, true); +}); diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js new file mode 100644 index 000000000..c72c06088 --- /dev/null +++ b/src/layers/military/policy.js @@ -0,0 +1,277 @@ +import * as Cesium from 'cesium'; + +/** + * @module militaryFlights + * @description Real-time military flight tracking layer powered by the adsb.lol API. + * + * Renders aircraft as amber chevron billboards in a single BillboardCollection + * for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft + * spawns an Entity the camera follows, whose position is driven by a + * dead-reckoning CallbackProperty. The whole fleet renders at + * now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes + * instead of extrapolating ahead and snapping back when the next poll lands. + * + * Billboard orientation uses alignedAxis set to the WGS84 surface normal at each + * position so that the rotation angle (heading/track) operates in the local tangent + * plane where 0 deg = north; near nadir the icons switch to screen-aligned + * rotation (camera.heading - track) with hysteresis. + */ + +/** @constant {number} Milliseconds to wait before retrying after a transient error */ + +export const ERROR_BACKOFF_INTERVAL = 20000; + +/** @constant {number} Max position samples retained per aircraft for dead reckoning */ + +export const POSITION_HISTORY_LIMIT = 5; + +/** @constant {number} Base billboard display scale */ + +export const BILLBOARD_SCALE = 0.7; + +/** @constant {Cesium.Color} Default amber tint for untracked military billboards */ + +export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); + +/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */ + +export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); + +// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- +// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate +// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude +// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable, +// sticky metadata updating normally. The on-ground flip restyles the existing +// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes +// draw no trails. In 3D mode they take model slots like airborne planes (owner +// decision 2026-07-03 — no air/ground distinction), placed by the one-shot +// ground snap (see _modelDisplayPosition). +// +// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field +// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can +// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail; +// "feed-dropped, coasting" stays the 45%-alpha stale fade. +/** Ground billboards render slightly smaller so airport clutter stays visually minor. */ + +export const GROUND_SCALE = 0.8; + +// --- 3D model rendering (mirrors flights.js) ----------------------------------------- +// When enabled, military aircraft render as 3D glTF jet models once the camera is below +// MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is +// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') +// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion +// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer +// apart from the commercial flights layer. + +export const JET_MODEL_URL = '/models/jet.glb'; + +export const MODEL_ALT_CEIL_M = 800000; +// m: below this camera altitude, draw 3D models (raised so it's easy to trigger) + +export const MODEL_MIN_PX = 24; +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) + +export const TRACKED_MODEL_MIN_PX = 40; +// keep the glTF silhouette comparable to the selected 2D glyph at handoff + +export const TRACKED_MODEL_MAX_PX = 200; +// owner-selected close-range tracked-target feel + +export const MODEL_NATIVE_RADIUS_M = 29.83; + +// jet.glb is transform-applied at real-world scale — native bounding radius +// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching +// flights.js world sizes. At the old copied ×24, models rendered +// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize +// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by +// modelScale.test.mjs. + +export const MODEL_SCALE = 1; + +// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity +// and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). + +export const MODEL_MAX = 150; +// 'proximity' cap + +export const MODEL_MAX_ALL = 350; +// 'all' cap + +export const MODEL_PROX_ADD_M = 150000; +// proximity: model NEW planes within 150 km + +export const MODEL_PROX_KEEP_M = 185000; +// proximity: KEEP modeled planes out to 185 km + +// Cockpit keeps the standard Display radii but lowers the GLB budget. Contacts +// inside the selected band remain AIR silhouettes when they cannot own a model; +// contacts outside the band use compact dots. + +export const COCKPIT_MODEL_MAX = 60; +// max concurrent GLBs in cockpit (never raises the map cap) + +export const MODEL_ALL_ADD_M = 400000; +// all: model NEW planes within 400 km (~to the horizon) + +export const MODEL_ALL_KEEP_M = 450000; +// all: KEEP modeled planes out to 450 km + +export const MODEL_HEADING_OFFSET_DEG = 180; +// every aircraft GLB is exported nose -X in the shared transform-applied convention +// Preserve the layer's amber identity while reducing approved texture/livery +// contribution to a weak diffuse hint, matching civilian launch presentation. + +export const MODEL_COLOR_BLEND_AMOUNT = 0.94; + +// airplane.glb (the shared 747) constants for this layer's heavy classes. The +// asset has its former 24× runtime calibration baked into transform-applied +// meter-scale geometry; these values mirror flights.js and are regression-pinned. + +export const PLANE_MODEL_URL = '/models/airplane.glb'; + +export const PLANE_MODEL_SCALE = 1; + +export const PLANE_NATIVE_RADIUS_M = 34.41; + +export const PLANE_BELLY_OFFSET_NATIVE = 6.719; + +// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this +// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node +// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). +// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model +// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to +// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. + +export const MODEL_BELLY_OFFSET_NATIVE = 5.631; + +/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ + +export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); + +export const COCKPIT_CONTACT_SIZE_PX = 6; + +export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 3.0, + 8000000, + 0.5, +); + +// --------------------------------------------------------------------------- +// Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the +// tracked aircraft. The accumulation array is intentionally SEPARATE from +// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning) +// so the visible trail can grow to TRAIL_MAX_POINTS fixes. +// --------------------------------------------------------------------------- + +/** @constant {string} Military trail hue (PRD F4, pinned). */ + +export const TRAIL_COLOR = '#FFB800'; + +/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +// --------------------------------------------------------------------------- +// Render-behind smoothing (mirrors flights.js): the fleet renders at +// now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes +// instead of extrapolating ahead and snapping back when the next poll lands. +// Removing the delay reintroduces the back/forward oscillation and is a +// regression. +// --------------------------------------------------------------------------- + +/** @constant {number} Display latency in seconds (= one poll interval). */ + +export const RENDER_DELAY_SEC = 15; + +/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */ + +export const MISSING_POLL_LIMIT = 3; + +// --- Landed-plane fast cull (mirror of flights.js; owner field report +// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown). +// The feed's ground flag lags the actual landing, so a landed plane's last +// airborne fixes show it low + slow on the runway; when it then drops out of +// the poll it has landed, not hit a transient gap — evict after ONE missed +// poll. Thresholds: below ~500 ft baro (≈150 m MSL — near-sea-level fields +// only; a high-elevation airport ghost falls back to the normal grace) AND +// below ~45 kts ground speed (≈23 m/s — rollout/taxi; nothing in normal +// FLIGHT is this slow, so cruise planes always keep the full grace). +/** @constant {number} Max baro altitude (ft, MSL) for the landed fast cull (~150 m). */ + +export const LANDED_ALT_MAX_FT = 500; + +/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */ + +export const LANDED_SPEED_MAX_MPS = 23; + +/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */ + +export const LANDED_MISSING_POLL_LIMIT = 1; + +// Field-test fix (RS46, 2026-07-06): only contacts rendering below this +// ellipsoidal height get the below-ground floor clamp + a coarse floor-cell +// warm. Terrain outside the extreme Himalaya tops out well under this, so +// cruise traffic (which can never be below ground) costs zero terrain +// lookups; low pattern/heli work — the class that actually clips hillsides — +// gets the floor. +/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ + +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +// --------------------------------------------------------------------------- +// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment +// degenerates when the camera looks straight down (the normal is parallel to +// the view direction and the shader's screen-projected angle is 0/0). Near +// nadir we switch to screen-aligned billboards with +// rotation = camera.heading - track (screen-up points at azimuth +// camera.heading; a CW track t appears t-h from screen-up; billboard rotation +// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations +// are continuous at the boundary with this formula. +// --------------------------------------------------------------------------- + +/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */ + +export const FLEET_DR_INTERVAL_MS = 80; + +/** @constant {number} Max ms between rotation passes while the camera is idle. */ + +export const ROTATION_REFRESH_MS = 1000; + +/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary + * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ + +export const COURSE_MAX_DPS = 60; + +/** Never spend a long render stall's full elapsed time in one visible course step. */ + +export const COURSE_SLEW_DT_MAX_SEC = 0.25; + +// -- Tracked-display reconciliation (see flights.js for the rationale): absorb a raw +// position step (warm-up→interpolation handoff, feed glitch, backfill splice) into a +// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked icon, +// camera, and trail head never visibly jump — with zero steady-state lag. -- + +export const DR_CORRECTION_MS = 900; + +/** Bounded on-demand loading for the tracked model (mirror of flights.js). The + * tracked regime is DEFAULT-ON and its driver runs every `scene.preUpdate`, so + * a missing or corrupt GLB — or a dead network — would otherwise spin + * load→reject at frame rate for as long as the contact stays selected. + * Failures are counted PER SELECTION: a short backoff absorbs a transient + * blip, then the layer stops asking until the operator selects something else. + * The billboard is the visual throughout (the handoff only fades it once a + * model actually renders), so a latched failure degrades to exactly the + * pre-3D presentation. */ + +export const TRACKED_MODEL_MAX_LOAD_FAILS = 3; + +export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; + +/** Boost flip = BATCHED release through the fleet tick (mirror of flights.js — + * destroying the whole fleet synchronously in the style handler stalls the + * render thread). Models tagged with their load-time boost state; stale queue + * entries skip; in-flight loads invalidated immediately; tracked reloads + * synchronously (single primitive). */ + +export const IR_RELOAD_BATCH = 40; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js new file mode 100644 index 000000000..a1e545d6d --- /dev/null +++ b/src/layers/military/queries.js @@ -0,0 +1,819 @@ +import { displayedKinematics } from '../../data/motionModel.js'; +import * as Cesium from 'cesium'; +import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; +import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; +import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; +import { + rankContactMatch, + contactMatchWins, + CONTACT_MATCH_TIER, +} from '../../data/contactMatch.js'; +import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; + +export function createQueries({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; + const { formatFlightLevel } = services.labels; + const { applyTrackedCameraFrame } = services.camera; + + /** + * Coerce a value to a finite number, returning null if not possible. + * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). + * @param {*} value - Raw value from the API response + * @returns {number|null} Finite number or null + */ + + function _toFiniteNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const n = Number.parseFloat(value.trim()); + return Number.isFinite(n) ? n : null; + } + return null; + } + + /** + * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. + * @param {*} value - Raw value to stringify + * @returns {string} Trimmed string + */ + + function _toCleanText(value) { + return String(value || '').trim(); + } + + /** + * Format an altitude value in feet for display, with fallback text. + * @param {number|null|undefined} altitudeFt - Barometric altitude in feet + * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") + */ + + function _formatAltitude(altitudeFt) { + if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; + return `${Math.round(altitudeFt)} ft`; + } + + /** + * True when the aircraft's latest metadata reads "on or about the runway" + * (low + slow) — see the landed fast-cull rationale above. Both gates must + * hold, so a plane missing either datum keeps the normal grace. + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @returns {boolean} + */ + + function _likelyLanded(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return false; + // Round 7 (mirror of flights.js): fast cull only for contacts seen + // AIRBORNE this session — parked contacts ride the normal grace so feed + // flaps don't churn their identity/floor state. + if (info.wasAirborne !== true) return false; + if (info.onGround) return true; + return ( + Number.isFinite(info.altitudeFt) && + info.altitudeFt < LANDED_ALT_MAX_FT && + Number.isFinite(info.speedMps) && + info.speedMps < LANDED_SPEED_MAX_MPS + ); + } + + /** + * Build a public descriptor for one aircraft using its best current position + * (dead-reckoned when history exists, billboard position otherwise). + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Descriptor with a cloned position, or null if the aircraft is unknown. + */ + + function _describeFlight(icao24) { + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + const basePos = + parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + if (!basePos) return null; + const displayed = displayedKinematics({ + derivedSpeedMps: flightState._drSpeedMps, + derivedTrackDeg: flightState._drCourseDeg, + reportedSpeedMps: info?.speedMps, + reportedTrackDeg: info?.track, + }); + const carto = Cesium.Cartographic.fromCartesian( + basePos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return null; + return { + icao24, + callsign: _toCleanText(info?.callsign) || null, + // Additive: the label chain's middle link, trimmed like `callsign`, so + // getTrackedSubject and the voice narration can read it straight off the + // descriptor instead of reaching back into `_flightData`. + registration: _toCleanText(info?.registration) || null, + position: Cesium.Cartesian3.clone(basePos), + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + // Keep the cockpit readout on the reported aviation altitude. Render + // terrain height is a separate visual datum and may be below zero. + altitudeM: Number.isFinite(info?.altitudeM) + ? info.altitudeM + : carto.height, + renderAltitudeM: Number.isFinite(info?.renderAltitudeM) + ? info.renderAltitudeM + : carto.height, + onGround: info?.onGround === true, + velocityMps: displayed.speedMps, + track: displayed.trackDeg, + stale: Boolean( + flightState._missingPolls.get(icao24) || flightState._backoff, + ), + }; + } + + function _normalizeTrackedIcao(candidate) { + const normalized = String(candidate ?? '') + .trim() + .toLowerCase(); + return normalized || null; + } + + /** + * Map one military aircraft's internal poll record to a plain JSON-safe + * analyst record (analyst query engine seam) — same shape as the flights + * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium + * types, no fetches. Missing/unknown fields are null, never NaN/undefined. + * adsb.lol carries no origin-country field and this layer has no route + * enrichment, so originCountry/routeOrigin/routeDestination are always null. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, + * lon: number|null, altitudeM: number|null, speedMps: number|null, + * heading: number|null, verticalRateMps: number|null, onGround: boolean, + * military: boolean, aircraftClass: string|null, originCountry: null, + * operator: string|null, routeOrigin: null, routeDestination: null}} + */ + + function mapAnalystRecord(icao24, info) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const callsign = text(info?.callsign); + return { + id: callsign || text(info?.registration) || icao24, + icao24, + callsign, + lat: num(info?.rawLat), + lon: num(info?.rawLon), + // altitudeFt is the sticky barometric/MSL aviation field — converted to + // meters here for shape parity with the flights layer. + altitudeM: Number.isFinite(info?.altitudeFt) + ? info.altitudeFt * 0.3048 + : null, + speedMps: num(info?.speedMps), + heading: num(info?.track), + verticalRateMps: num(info?.verticalRateMps), + onGround: info?.onGround === true, + military: true, + // A converted contact reports the class it RENDERS as (mirror of + // flights.js), so an analyst filter/superlative agrees with the triangle. + aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), + originCountry: null, + operator: text(info?.operator), + routeOrigin: null, + routeDestination: null, + }; + } + const methods = { + mapAnalystRecord, + + id: 'military', + + name: 'Military Flights', + + icon: '🎖️', + + source: flightState._lastSource, + + /** @type {number} Polling interval in ms between API fetches */ + updateInterval: 15000, + + /** @deprecated Compatibility alias for {@link enable}. */ + show(viewer) { + this.enable(viewer); + }, + + /** @deprecated Compatibility alias for {@link disable}. */ + hide(viewer) { + this.disable(viewer); + }, + + /** + * Live layer params. + * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, + * surrounding military aircraft become 3D models once the camera is zoomed in past + * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by + * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). + * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). + * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedMilitaryTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + if ( + typeof params.models3d === 'boolean' && + params.models3d !== flightState._models3dEnabled + ) { + flightState._models3dEnabled = params.models3d; + if (!flightState._models3dEnabled) { + parts.rendering._releaseModels(); + parts.tracking._syncTracked2dRotation(); + // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked + // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). + if (flightState._billboardCollection) + for (const [icao, bb] of flightState._billboards) { + if (icao !== flightState._trackedIcao) bb.show = true; + } + } + } + if ( + (params.models3dMode === 'proximity' || + params.models3dMode === 'all') && + params.models3dMode !== flightState._models3dMode + ) { + // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. + flightState._models3dMode = params.models3dMode; + if (flightState._cockpitContactMode) { + parts.tracking._refreshCockpitNearContacts(); + flightState._lastFleetTickMs = 0; + } + } + if ( + typeof params.irBoost === 'boolean' && + params.irBoost !== flightState._irBoost + ) { + flightState._irBoost = params.irBoost; + parts.rendering._reloadModelsForIrBoost(); + // Sprites don't reload with the models — swap the TR-3B glyph between its + // cold and thermal-reactive variants directly (mirror of flights.js). + parts.tracking._refreshTr3bForStyle(); + } + if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { + const requested = _normalizeTrackedIcao( + params.selectedMilitaryTrackingId, + ); + if (requested === flightState._trackedIcao) { + flightState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++flightState._trackingIntentGeneration; + flightState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + getParams() { + return { + models3d: flightState._models3dEnabled, + models3dMode: flightState._models3dMode, + irBoost: flightState._irBoost, + selectedMilitaryTrackingId: flightState._trackedIcao, + }; + }, + + /** + * Re-render a contact whose TR-3B conversion just flipped (Easter egg). + * Callers own the registry write; this only re-derives what renders. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when this layer owns the contact. + */ + refreshTr3b(icao24) { + return parts.tracking._refreshTr3bContact(icao24); + }, + + /** + * Find military aircraft near a given ECEF position, sorted by distance. + * Used by proximity-based features (e.g. detection overlay, spatial queries). + * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates + * @param {number} range - Maximum distance in meters (Infinity if not specified) + * @param {number} [maxCount=50] - Maximum number of results to return + * @param {object} [options] Query membership options. + * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. + * @returns {Array} Sorted array of nearby aircraft descriptors + */ + getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { + if ( + !center || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 50; + const maxRange = + Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; + + const now = Cesium.JulianDate.now(); + const nearby = []; + + for (const [icao24, bb] of flightState._billboards) { + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + if ( + !aircraftIncludedInNearby({ + isTracked, + billboardShown: bb.show, + modelRendering: parts.rendering._modelOwnsVisual(icao24), + includeHidden, + }) + ) + continue; + + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const pos = trackedPos || bb.position; + if (!pos) continue; + + const distance = Cesium.Cartesian3.distance(center, pos); + if (distance > maxRange) continue; + + const info = flightState._flightData.get(icao24); + nearby.push({ + id: info?.callsign?.trim() || info?.registration?.trim() || icao24, + icao24, + position: pos, + distance, + // Filter surface (mirror of flights.js): the cockpit next/previous path + // matches on this field, so it follows the conversion. + aircraftClass: tr3bAircraftClass( + icao24, + String(info?.klass || info?.type || '') + .trim() + .toLowerCase() || null, + ), + track: info?.track ?? null, + // Display type — converted too, so a Contacts row can't still name the + // airframe the triangle replaced (the filter matcher reads this as a + // fallback candidate as well). + type: tr3bTypeLabel(icao24, info?.type || null), + registration: info?.registration || null, + operator: info?.operator || null, + altitudeFt: info?.altitudeFt ?? null, + }); + } + + nearby.sort((a, b) => a.distance - b.distance); + return nearby.slice(0, limit); + }, + + /** + * Return a subset of aircraft suitable for the detection overlay system. + * Uses a deterministic stride-based sampling to keep the count manageable + * while distributing selections evenly across the collection. + * @param {Object} [options={}] - Options + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) + * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : flightState._billboards.size; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Deterministic stride: evenly space selections across the billboard map + const stride = Math.max( + 1, + Math.ceil(flightState._billboards.size / maxCount), + ); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [icao24, bb] of flightState._billboards) { + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if ( + flightState._cockpitContactMode && + icao24.toLowerCase() === flightState._cockpitSubjectId + ) + continue; + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so the detection box + // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + const model = flightState._models.get(icao24); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!isTracked && !bb.show && !modelOwnsVisual) continue; + const info = flightState._flightData.get(icao24); + const callsign = info?.callsign?.trim(); + const registration = info?.registration?.trim(); + let object = flightState._detectionObjects.get(icao24); + if (!object) { + object = { + sourceId: icao24, + type: 'AIR', + tier: 'military', + _weldPos: new Cesium.Cartesian3(), + }; + flightState._detectionObjects.set(icao24, object); + } + // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned + // contacts read the translation the fleet tick already wrote into their + // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket + // are co-located anyway. + const spec = modelOwnsVisual + ? parts.rendering._modelSpec(info?.klass) + : null; + const pos = isTracked + ? parts.motion._trackedVisualCached() || bb.position + : modelOwnsVisual + ? modelVisualAnchor( + model.modelMatrix, + spec.visualCenterNative, + Number.isFinite(model.computedScale) + ? model.computedScale + : spec.scale, + object._weldPos || (object._weldPos = new Cesium.Cartesian3()), + ) + : bb.position; + if (!pos) continue; + object.position = pos; + object.skipLabel = isTracked; + const id = callsign || registration || icao24; + if (object.id !== id) object.id = id; + // Card surface: detectionDraw composes its secondary line from + // `[src.klass, src.metric]`, so a converted contact's card must name the + // TR-3B, not the airframe underneath it. + const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); + if (object.klass !== klass) object.klass = klass; + const altitudeFt = info?.altitudeFt ?? 0; + if (object._altitudeFt !== altitudeFt) { + object._altitudeFt = altitudeFt; + // military metadata stores altitudeFt (feet); the helper takes metres + object.metric = formatFlightLevel(altitudeFt * 0.3048); + } + result.push(object); + if (result.length >= maxCount) break; + } + if ( + flightState._detectionObjects.size > + flightState._billboards.size + 512 + ) { + for (const icao24 of flightState._detectionObjects.keys()) { + if (!flightState._billboards.has(icao24)) + flightState._detectionObjects.delete(icao24); + } + } + return result; + }, + + /** + * Find a single aircraft by free-text query. + * Match priority: exact icao24 hex, exact callsign, callsign prefix, + * then callsign substring (all case-insensitive, trimmed). + * @param {string} query - ICAO hex or full/partial callsign. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Best match with a cloned, dead-reckoned position, or null if none. + */ + findByQuery(query) { + if (!flightState._flightData || flightState._flightData.size === 0) + return null; + const q = String(query || '') + .trim() + .toLowerCase(); + if (!q) return null; + + // Registration is searched alongside callsign because it is what the + // operator SEES: a callsign-less contact reads as its tail number on the + // card, in the analyst's answer, and in the Contacts list. Matching only + // callsigns meant "follow 6606" — and the analyst → track_entity handoff + // the tool instructions prescribe — answered "nothing matched" for the + // very identity the app had just shown (owner field session 2026-08-21, + // 23:48: three failed track_entity retries before a fallback stuck). + // Ranking is shared with the flights layer (contactMatch.js) so the two + // cannot disagree, and it is strictly tiered so a registration can never + // out-rank a real callsign on feed order alone. + let best = null; + for (const [icao24, info] of flightState._flightData) { + const candidate = { + tier: rankContactMatch({ + query: q, + hex: icao24, + callsign: info?.callsign, + registration: info?.registration, + }), + id: icao24, + }; + if (!contactMatchWins(candidate, best)) continue; + best = candidate; + if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; + } + return best ? _describeFlight(best.id) : null; + }, + + /** + * Return id/label/position for up to maxCount currently rendered aircraft. + * Cheap snapshot for voice-tool framing — billboard positions only, no + * dead reckoning and no cloning. + * @param {number} [maxCount=500] - Maximum number of entries to return. + * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} + */ + /** + * Whether this layer still carries a contact, in O(1). + * + * Mirror of `flights.hasContact`: presence consumers must not infer absence + * from the capped `getAllPositions` rows. Id matching follows trackById — + * exact key first, then lowercase. + * A disabled layer keeps its records but hides the collection, so it must + * decline rather than answer from data the user can no longer see. + * @param {string} icao24 Contact identifier. + * @returns {boolean|null} Presence, or null when the layer is disabled or + * holds no data and therefore cannot answer. + */ + hasContact(icao24) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._billboards.size === 0 + ) + return null; + if (!icao24) return false; + const id = String(icao24).trim(); + return ( + flightState._billboards.has(id) || + flightState._billboards.has(id.toLowerCase()) + ); + }, + + getAllPositions(maxCount = 500) { + if ( + !flightState._billboardCollection || + flightState._billboards.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + + const result = []; + for (const [icao24, bb] of flightState._billboards) { + const pos = bb.position; + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) continue; + const info = flightState._flightData.get(icao24); + result.push({ + id: icao24, // identity (trackById/Context resolve this) + // Last surface still reading as the raw hex for a callsign-less + // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. + label: + _toCleanText(info?.callsign) || + _toCleanText(info?.registration) || + icao24, + position: pos, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + }); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory 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 ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._flightData.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const [icao24, info] of flightState._flightData) { + result.push(mapAnalystRecord(icao24, info)); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Start camera-tracking an aircraft by ICAO hex identifier. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @returns {boolean} True if the aircraft exists and tracking started. + */ + trackById(icao24, { origin = 'programmatic' } = {}) { + if (!icao24) return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._billboards.has(id)) return false; + if (parts.tracking._isExplicitTrackingOrigin(origin)) + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao === id) + return parts.tracking._publishTrackedSelection(id, origin); + parts.tracking._trackFlight(id, { origin }); + return true; + }, + + /** Resolve a shared Follow target only against the latest accepted refresh. */ + async resolveTrackingRestoreTarget( + icao24, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = String(icao24 ?? '') + .trim() + .toLowerCase(); + if (!id) return { status: 'missing', reason: 'invalid-target' }; + const outcome = flightState._lastTrackingRefreshOutcome; + if (outcome.status !== 'accepted') { + return { + status: 'source-unavailable', + reason: `${flightState._lastSource} snapshot unavailable`, + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (!outcome.ids.has(id)) { + return { + status: 'missing', + reason: 'target-absent-from-snapshot', + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const followed = this.trackById(id, { origin }); + return followed + ? { + status: 'found', + refreshEpoch: outcome.epoch, + source: outcome.source, + } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }, + + /** Reapply the canonical follow frame without recreating the selected flight. */ + refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { + if ( + !icao24 || + flightState._cockpitContactMode || + !flightState._viewer || + !flightState._trackedEntity + ) + return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if ( + id !== flightState._trackedIcao || + !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) + return false; + flightState._trackedCameraFrameStop?.(); + flightState._viewer.camera.cancelFlight(); + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + parts.tracking._publishTrackedSelection(id, origin); + return true; + }, + + /** + * Stop tracking the currently followed aircraft (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Describe the currently tracked aircraft at its dead-reckoned position. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Tracked aircraft info, or null when nothing is tracked. + */ + getTrackedInfo() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described) return null; + const { position, ...rest } = described; + return rest; + }, + + /** + * Return the current aircraft as a Context subject without changing + * tracking or camera ownership. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached subject descriptor, or null when no aircraft is tracked. + */ + getTrackedSubject() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described?.position) return null; + return { + layerId: 'military', + id: described.icao24, + // Same label chain as getNearby/getDetectableObjects: a callsign-less + // contact reads as its registration, never as the raw ICAO hex. + label: described.callsign || described.registration || described.icao24, + position: Cesium.Cartesian3.clone(described.position), + }; + }, + + /** + * Return current layer health/status for the HUD status chip. + * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} + */ + getStats() { + const retryInSec = flightState._retryAt + ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) + : 0; + return { + count: flightState._count, + lastUpdate: flightState._lastUpdate, + stale: flightState._backoff, + error: flightState._lastError, + status: flightState._lastStatus, + retryInSec, + source: flightState._lastSource, + fallback: false, + }; + }, + }; + + return { + _toFiniteNumber, + _toCleanText, + _formatAltitude, + _likelyLanded, + _describeFlight, + _normalizeTrackedIcao, + mapAnalystRecord, + methods, + }; +} diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js new file mode 100644 index 000000000..1e5dbb573 --- /dev/null +++ b/src/layers/military/rendering.js @@ -0,0 +1,1118 @@ +import { + CLASS_MODEL_REAL, + CLASS_SCALE_3D, + CLASS_SCALE_2D, +} from '../../data/aircraftClass.js'; +import { + visualCenterForModel, + trailAnchorForModel, +} from '../../data/modelVisualAnchor.js'; +import * as Cesium from 'cesium'; +import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { + cameraPoseSignature, + horizonOccluder, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { + PLANE_MODEL_SCALE, + PLANE_MODEL_URL, + PLANE_NATIVE_RADIUS_M, + PLANE_BELLY_OFFSET_NATIVE, + MODEL_SCALE, + JET_MODEL_URL, + MODEL_NATIVE_RADIUS_M, + MODEL_BELLY_OFFSET_NATIVE, + MODEL_HEADING_OFFSET_DEG, + BILLBOARD_SCALE, + GROUND_SCALE, + COCKPIT_CONTACT_SIZE_PX, + MIL_ICON_COLOR, + TRACKED_ICON_COLOR, + MODEL_ALT_CEIL_M, + MODEL_MAX_ALL, + MODEL_MAX, + COCKPIT_MODEL_MAX, + MODEL_ALL_ADD_M, + MODEL_PROX_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_PROX_KEEP_M, + IR_RELOAD_BATCH, + MODEL_MIN_PX, + MODEL_COLOR_BLEND_AMOUNT, + TRACKED_MODEL_MIN_PX, + TRACKED_MODEL_MAX_PX, + FLEET_DR_INTERVAL_MS, + COURSE_SLEW_DT_MAX_SEC, + ROTATION_REFRESH_MS, + COURSE_MAX_DPS, +} from './policy.js'; + +export function createRendering({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bIconKind, isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { + focusNowMs, + getFocusTarget, + nearFarScalarValueAtDistance, + advanceProjectedSpriteFocus, + } = services.focus; + const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = + services.recession; + + /** Depth-test policy for aircraft billboards (mirror of flights.js — see the + * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact + * renders depth-test-free at every distance — a uniform always-visible rule; + * the fleet tick's horizon occluder still removes far-side contacts. */ + + function _groundDepthDistance() { + return Number.POSITIVE_INFINITY; + } + + function _modelSpec(klass) { + let spec = flightState._specCache.get(klass); + if (spec) return spec; + const real = CLASS_MODEL_REAL[klass]; + if (real) { + spec = { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } else if ( + klass === 'airliner' || + klass === 'quadjet' || + klass === 'glider' + ) { + const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: PLANE_MODEL_URL, + scale, + nativeRadiusM: PLANE_NATIVE_RADIUS_M, + bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), + trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), + }; + } else { + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: JET_MODEL_URL, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + visualCenterNative: visualCenterForModel(JET_MODEL_URL), + trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), + }; + } + flightState._specCache.set(klass, spec); + return spec; + } + + function _normalBillboardScaleByDistance() { + // Match commercial flights and preserve the established close-range 3× + // default. Smaller owner-visible scaling must be proposed separately. + return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); + } + + function _cockpitBillboardScaleByDistance() { + return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); + } + + function _militaryBillboardScale(icao24) { + const meta = flightState._flightData.get(icao24); + return ( + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1) + ); + } + + /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ + + function _applyFleetBillboardPresentation(icao24, bb) { + if (!bb) return; + const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; + const isCockpitContact = + flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; + const isCockpitNear = + isCockpitContact && flightState._cockpitNearContacts.has(icao24); + if (isCockpitContact && !isCockpitNear) { + const freshnessAlpha = bb.color?.alpha ?? 1; + bb.image = cockpitContactDotImage(); + bb.width = COCKPIT_CONTACT_SIZE_PX; + bb.height = COCKPIT_CONTACT_SIZE_PX; + bb.scale = limbScale; + bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.rotation = 0; + return; + } + + const meta = flightState._flightData.get(icao24); + bb.image = aircraftIcon( + _iconKind(icao24, meta?.klass), + bb._gevIconLarge ? TRACKED_ICON_PX : undefined, + ); + bb.width = icao24 === flightState._trackedIcao ? 24 : 20; + bb.height = icao24 === flightState._trackedIcao ? 24 : 20; + bb.scale = _militaryBillboardScale(icao24) * limbScale; + bb.scaleByDistance = _normalBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); + } + + /** Sprite kind for one contact's billboard. Identity for every aircraft except + * the ones the operator converted into a TR-3B (Easter egg), which draw the + * black-triangle glyph — its thermal-reactive variant while an IR style owns + * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a + * conversion survive the poll reconciler and the two-tier raster swap. */ + + const _iconKind = (icao24, klass) => + tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); + + /** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ + + function _modelColor(icao24) { + if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; + return MIL_ICON_COLOR; + } + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). Mirror of flights.js. */ + + function _modelRegimeActive() { + if (!flightState._models3dEnabled) return false; + const h = + flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the + * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. + return flightState._cockpitContactMode + ? Math.min(COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ + + function _modelAddDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_ADD_M + : MODEL_PROX_ADD_M; + } + + function _modelKeepDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_KEEP_M + : MODEL_PROX_KEEP_M; + } + + /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into + * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable + * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on + * the last-written transform, and the per-frame tracked write made it flicker). */ + + function _modelMatrix( + pos, + headingDeg, + result = flightState._scratchModelMtx, + offsetDeg = MODEL_HEADING_OFFSET_DEG, + ) { + flightState._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + offsetDeg, + ); + flightState._scratchModelHpr.pitch = 0; + flightState._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + flightState._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of + * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its + * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches + * picked-object IDs — every billboard AND model here carries its icao as `id` — plus + * the tracked entity object itself. Built lazily, only when a sample actually fires. */ + + function _groundSampleExclusions() { + const out = [...flightState._billboards.keys()]; + if (flightState._trackedEntity) out.push(flightState._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). + * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin + * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly + * offset so they sit on their + * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the + * depth-test-free billboard visible and the model hidden while this returns null; a + * contact that has already resolved once holds that measurement through a later + * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = flightState._flightData.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = flightState._groundSnap.heightFor( + flightState._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its billboard to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the billboard owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + if (!model) { + bb.show = true; + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + bb.show = true; + return false; + } + // The matrix is written BEFORE the readiness test on purpose: a model can flip + // `ready` during scene update after this tick, and a first rendered frame on a + // stale load-start matrix is the one-frame jump this ordering prevents. + _modelMatrix( + displayPos, + course, + model.modelMatrix, + _modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg, + ); + if (!model.ready) { + model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + bb.show = true; + return false; + } + beforeShow?.(); + if (!model.show) model.show = true; + if (bb.show) bb.show = false; // hand off ONLY once the model renders + return true; + } + + /** A model owns the visual only while it is ready, shown, and (for the tracked + * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ + + function _modelOwnsVisual(icao24) { + if (icao24 === flightState._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(flightState._trackedModel) + ); + } + return _modelIsRendering(flightState._models.get(icao24)); + } + + /** `show` is sufficient evidence of a safe placement because only the handoff and + * the tracked driver ever set it true, and both do so after committing a matrix; + * everything else — admission, unresolved ground, an unready glTF, the limb cull, + * a regime exit — only ever clears it. Mirror of flights.js. */ + + function _modelIsRendering(model) { + return !!model && model.ready === true && model.show === true; + } + + /** Spec identity for a LOADED model — mirror of flights.js: URL and scale + * together (same-URL classes differ by scale). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the class, re-showing the + * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const current = flightState._models.get(icao24); + if ( + (current && current._gevSpecKey !== key) || + (!current && flightState._modelPending.has(icao24)) + ) { + const bb = flightState._billboards.get(icao24); + if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao24); + } + if ( + icao24 === flightState._trackedIcao && + flightState._trackedModel && + flightState._trackedModel._gevSpecKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + flightState._irReloadQueue = [...flightState._models.keys()]; + for (const icao of flightState._modelPending) { + if (!flightState._models.has(icao)) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + if (!flightState._irReloadQueue) return; + const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); + for (const icao of batch) { + const model = flightState._models.get(icao); + if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state + const bb = flightState._billboards.get(icao); + if (bb && icao !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao); + } + if (flightState._irReloadQueue.length === 0) + flightState._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === flightState._trackedIcao) return; + if ( + flightState._models.has(icao24) || + flightState._modelPending.has(icao24) + ) + return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). + if ( + flightState._models.size + flightState._modelPending.size >= + _modelCap() + ) + return; + const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + flightState._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START (mirror of flights.js): a mid-load + // reclassification makes the post-await admission reject the stale asset. + // Boost state likewise — creation options bake it in. + const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const loadIrBoost = flightState._irBoost; + try { + const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === flightState._modelEpoch) { + flightState._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== flightState._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + flightState._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (flightState._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() || + !flightState._flightData.has(icao24) || + icao24 === flightState._trackedIcao || + flightState._models.has(icao24) || + flightState._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + flightState._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // Keep the pick identity explicit on the resolved primitive. This also + // protects injected/custom loaders that do not copy the creation option. + model.id = icao24; + model._gevSpecKey = specKey; // class-change sync compares against this + model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this + // Admitted, not yet the visual. Cesium's default is show=true, which would let + // an unplaced primitive claim ownership from the billboard for the frames + // between admission and the next fleet tick (and draw at the identity matrix, + // i.e. the Earth's centre). The handoff turns it on once it has a matrix. + model.show = false; + flightState._modelCollection.add(model); + flightState._models.set(icao24, model); + flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / track handoff). + * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ + + function _releaseModel(icao24) { + const m = flightState._models.get(icao24); + const pending = flightState._modelPending.has(icao24); + // Only bump the generation when there's something to invalidate (an in-flight load or a + // live model) — so removing never-modeled aircraft doesn't grow _modelGen. + if (m || pending) { + flightState._modelGen.set( + icao24, + (flightState._modelGen.get(icao24) || 0) + 1, + ); + } + if (m) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + flightState._models.delete(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if ( + !flightState._modelPending.has(icao24) && + !flightState._models.has(icao24) + ) + flightState._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + for (const icao of flightState._modelPending) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + for (const m of flightState._models.values()) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + } + flightState._models.clear(); + } + + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ + + function _releaseTrackedModel() { + flightState._trackedModelGen++; + flightState._trackedModelLoading = false; + if (flightState._trackedModel) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(flightState._trackedModel); + } catch { + /* gone */ + } + } + flightState._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The + * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ + + function _updateTrackedModel() { + const active = + flightState._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + flightState._modelCollection && + !flightState._modelCollection.isDestroyed(); + if (!active) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(flightState._trackedIcao) || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + if ( + !flightState._trackedModel && + !flightState._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + flightState._trackedModelLoading = true; + const gen = flightState._trackedModelGen; + const trackedSpec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedKey = _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedIrBoost = flightState._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) + .then((m) => { + if ( + gen !== flightState._trackedModelGen || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ) !== trackedKey || + flightState._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + flightState._trackedModelLoading = false; + return; + } + // Assign after resolution as well as in the creation options so the + // standalone primitive always exposes the tracked aircraft pick id. + m.id = flightState._trackedIcao; + m._gevSpecKey = trackedKey; // class-change sync compares against this + m.show = false; // admitted, not yet the visual — the driver shows it once placed + // Seed the world transform before the primitive enters the scene. A model + // can become ready+shown between render phases; leaving Cesium's identity + // default here produces a one-frame jump to the Earth's center. + const currentPos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (currentPos) { + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + currentPos, + flightState._scratchGroundPos, + ); + if (displayPos) { + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + m.modelMatrix, + trackedSpec.headingOffsetDeg, + ); + } + } + flightState._trackedModel = m; + flightState._trackedModelLoading = false; + // A good load retires this selection's failure budget — a contact that + // recovers after a transient blip is not one attempt from giving up. + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + flightState._modelCollection.add(m); + flightState._planeModelLoaded = true; + }) + .catch((err) => { + if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure + flightState._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; + } + if (flightState._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + return; + } + const spec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + flightState._trackedModel.modelMatrix, + spec.headingOffsetDeg, + ); + if (!flightState._trackedModel.ready) return; + flightState._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, + fovyRad: flightState._viewer.camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + flightState._trackedModel.show = true; + } + } + + /** + * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, + * horizon-culls billboards beyond the limb (no far-side depth with the globe + * hidden), and refreshes screen-projected icon rotations whenever the camera + * pose changed (plus a 1s drift catch-up while idle). Driven by + * scene.preRender — NOT camera.changed, whose granularity is globally + * degraded by other layers mutating camera.percentageChanged. + * @returns {void} + */ + + function _fleetTick() { + if ( + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + const scene = flightState._viewer.scene; + const camera = flightState._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz + // primitive rebuild here. The body rebuilds only when a real fix arrives.) + + if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; + const tickDtSec = flightState._lastFleetTickMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._lastFleetTickMs) / 1000, + ) + : 0.08; + flightState._lastFleetTickMs = nowMs; + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (flightState._cockpitContactMode) + parts.tracking._refreshCockpitNearContacts(); + const poseSig = cameraPoseSignature(camera); + // Only nearby Cockpit silhouettes need projected course; far dots remain + // rotation-free through the per-contact gate below. + const doRotations = + poseSig !== flightState._lastCamPoseSig || + nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; + if (doRotations) { + flightState._lastCamPoseSig = poseSig; + flightState._lastRotPassMs = nowMs; + } + + const occluder = horizonOccluder(camera); + const focusTarget = getFocusTarget(); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if ( + !useModels && + (flightState._models.size || flightState._modelPending.size) + ) + _releaseModels(); + + // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — + // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen + // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP + // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). + let modelEligible = null; + if (useModels) { + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + const cand = []; + for (const [icao, bb] of flightState._billboards) { + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as a billboard and can never take a model, so + // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded + // at selection time, not just at the handoff below. + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded + // placement is handled by the one-shot ground snap in _modelDisplayPosition). + const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + if (d2 > keepDistSq) continue; // beyond keep radius → never eligible + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`, + ); + flightState._lastModelCapWarnMs = nowMs; + } + modelEligible = new Set(); + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 1. KEEP on-screen + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 2. ADD on-screen + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 3. KEEP off-screen (can't starve visible) + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 4. ADD off-screen leftover + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + } + + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + + const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + // Gate the write — assigning Billboard.position dirties the whole + // collection's vertex buffer, so skip sub-meter moves. + if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { + bb.position = dr; + } + + // Round 6: occlusion-test a LIFTED point for contacts at/below the + // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb + // read "beyond the horizon" and would hide low contacts awaiting floors). + const beyondHorizon = !occluder.isPointVisible( + flightState._flightData.get(icao24)?.cullPosition || bb.position, + ); + // A billboard flipping INTO view (horizon reveal while the camera idles) + // gets its rotation refreshed THIS tick even without a pose change — + // otherwise it reappears wearing its stale (often creation-north) nose for + // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false + // here; harmless — the model branch below `continue`s past the rotation.) + const revealed = !beyondHorizon && !bb.show; + if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + if (beyondHorizon) { + // Also hide any 3D model — otherwise a model that crossed the limb would keep + // rendering through the hidden globe at its last matrix. + const m = flightState._models.get(icao24); + if (m && m.show) m.show = false; + continue; + } + + const info = flightState._flightData.get(icao24); + + // One sprite-owned write site composes freshness × focus × limb haze and + // base class/ground scale × limb taper. The locked NearFarScalar remains + // untouched and multiplicative; there is no cull or zero-alpha path. + const cameraDistanceM = Cesium.Cartesian3.distance( + camera.positionWC, + bb.position, + ); + const distanceScale = nearFarScalarValueAtDistance( + bb.scaleByDistance, + cameraDistanceM, + ); + const focus = advanceProjectedSpriteFocus( + bb, + bb.position, + scene, + camera, + nowMs, + focusTarget, + undefined, + (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, + (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + ); + const isCockpitNear = + flightState._cockpitContactMode && + flightState._cockpitNearContacts.has(icao24); + const treatment = applyAircraftBillboardTreatment({ + billboard: bb, + baseScale: + flightState._cockpitContactMode && !isCockpitNear + ? 1 + : _militaryBillboardScale(icao24), + baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseColor: MIL_ICON_COLOR, + focusFactor: focus.factor, + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }); + flightState._billboardLimbScale.set(bb, treatment.factors.scale); + // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on + // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so + // focus/limb recession counts) with hysteresis (atlas has no mips). + if (!flightState._cockpitContactMode || isCockpitNear) { + const glyphDevPx = + (bb.width || 20) * + (bb.scale || 1) * + distanceScale * + (globalThis.devicePixelRatio || 1); + const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; + if (wantLarge !== !!bb._gevIconLarge) { + bb._gevIconLarge = wantLarge; + bb.image = aircraftIcon( + _iconKind(icao24, flightState._flightData.get(icao24)?.klass), + wantLarge ? TRACKED_ICON_PX : undefined, + ); + } + } + + // Smoothed display course: the path direction _deadReckon just reported + // for THIS aircraft (nothing else calls _deadReckon in between), rate- + // limited so segment-boundary course steps glide instead of snapping. + // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering + // aircraft (hold flag) keeps its previous nose direction outright. + const rawCourse = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : (info && info.track) || 0; + const prevCourse = flightState._displayCourse.get(icao24); + const course = + flightState._drCourseHold && prevCourse != null + ? prevCourse + : limitCourseStep( + prevCourse, + rawCourse, + courseSlewCapDps( + flightState._drSpeedMps != null + ? flightState._drSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ), + tickDtSec, + ); + flightState._displayCourse.set(icao24, course); + + // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the + // billboard stays shown until the model is actually READY to render, so a plane is never both + // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every + // tick regardless so it's framed the instant it becomes ready. + // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter + // egg IS the triangle and there is no GLB for it, so the model handoff is + // suppressed. The billboard keeps rendering, so the contact still satisfies + // the getNearby/getDetectableObjects visibility guards. + if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const ownsVisual = _driveFleetModelHandoff( + icao24, + model, + bb, + dr, + course, + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write; boosted + // models also skip the recession fade (mirror of flights.js — + // billboards keep their normal fade, hot MODELS stay full-strength). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost ? 1 : treatment.alpha, + }); + }, + ); + if (ownsVisual) continue; // skip billboard rotation + } + + if ( + (!flightState._cockpitContactMode || isCockpitNear) && + (doRotations || revealed) + ) { + const rot = screenProjectedRotation( + scene, + bb.position, + course, + bb.rotation, + ); + if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { + bb.rotation = rot; + } + } + } + } + return { + _groundDepthDistance, + _modelSpec, + _normalBillboardScaleByDistance, + _cockpitBillboardScaleByDistance, + _militaryBillboardScale, + _applyFleetBillboardPresentation, + _iconKind, + _modelColor, + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; +} diff --git a/src/layers/military/state.js b/src/layers/military/state.js new file mode 100644 index 000000000..8d5cc27cd --- /dev/null +++ b/src/layers/military/state.js @@ -0,0 +1,400 @@ +import * as Cesium from 'cesium'; + +export function createFlightState({ source, services }) { + const { createGroundSnap } = services.groundSnap; + const flightState = { lifetime: new AbortController() }; + + flightState._source = source; + + /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: + * military contacts should read as their WEIGHT CLASS, always in this layer's + * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → + * 180° offset); airliner/quadjet/glider get the shared 747 silhouette + * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); + * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays + * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, + * and the tint must dominate any livery (owner: "stays yellow"). Specs are + * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + + flightState._specCache = new Map(); + + /** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ + + flightState._groundSnap = createGroundSnap(); + + flightState._scratchGroundCarto = new Cesium.Cartographic(); + + flightState._scratchGroundPos = new Cesium.Cartesian3(); + + /** @type {Cesium.PrimitiveCollection|null} */ + + flightState._modelCollection = null; + + /** @type {Map} icao24 → model */ + + flightState._models = new Map(); + + /** @type {Set} icao24 currently loading (async) */ + + flightState._modelPending = new Set(); + + /** @type {Map} icao24 → load generation; bumped on release to invalidate + * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ + + flightState._modelGen = new Map(); + + /** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle + * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ + + flightState._modelEpoch = 0; + + /** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs + * layer-state restoration, so this initializer — not the codec — is what the app + * actually starts with; it must stay in lockstep with the `models3d` default in + * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would + * light a button the layer has not armed. */ + + flightState._models3dEnabled = true; + + flightState._models3dMode = 'proximity'; + // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) + + flightState._lastModelCapWarnMs = 0; + // throttle the "more planes in view than the cap" console notice + // The tracked entity's billboard goes transparent (rather than hidden) once the model takes + // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its + // alpha after the GLB is preloaded so the model is ready to render the instant the billboard + // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not + // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + + flightState._planeModelLoaded = false; + + /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + + flightState._preloadModel = null; + + flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + + flightState._scratchModelMtx = new Cesium.Matrix4(); + + flightState._scratchModelBS = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1.0, + ); + // frustum-visibility test + /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ + + flightState._billboardLimbScale = new WeakMap(); + + // --------------------------------------------------------------------------- + // Module-level state + // --------------------------------------------------------------------------- + + /** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ + + flightState._billboardCollection = null; + + /** @type {Map} ICAO hex -> billboard primitive */ + + flightState._billboards = new Map(); + + /** Stable lightweight records reused by the detection overlay between polls. */ + + flightState._detectionObjects = new Map(); + + /** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ + + flightState._flightData = new Map(); + + /** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ + + flightState._positionHistory = new Map(); + + /** @type {number} Current number of visible aircraft */ + + flightState._count = 0; + + /** @type {number|null} Epoch ms of last successful API update */ + + flightState._lastUpdate = null; + + /** @type {boolean} True when in error-backoff mode */ + + flightState._backoff = false; + + /** @type {number} Epoch ms after which the next retry is allowed */ + + flightState._retryAt = 0; + + /** @type {string|null} Human-readable description of the last error */ + + flightState._lastError = null; + + flightState._activeUpdateControllers = new Set(); + + /** @type {number|null} HTTP status code from the last API response */ + + flightState._lastStatus = null; + + flightState._lastSource = source?.label || 'Aircraft'; + + /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ + + flightState._geoidReady = false; + + /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ + + flightState._geoidNCache = new Map(); + + // -- Click-to-track state -- + /** @type {string|null} ICAO hex of the currently tracked aircraft */ + + flightState._trackedIcao = null; + + flightState._pendingTrackingRestore = null; + + flightState._trackingIntentGeneration = 0; + + flightState._trackingRefreshEpoch = 0; + + flightState._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: flightState._lastSource, + }; + + /** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ + + flightState._trackedEntity = null; + + /** Disposes the single active tracked-camera framing owner. */ + + flightState._trackedCameraFrameStop = null; + + /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on + * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere + * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ + + flightState._trackedModel = null; + + flightState._trackedModelGen = 0; + + flightState._trackedModelLoading = false; + + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ + + flightState._clickHandler = null; + + /** @type {Cesium.Viewer|null} Cached viewer reference */ + + flightState._viewer = null; + + /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ + + flightState._cockpitContactMode = false; + + /** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ + + flightState._cockpitNearContacts = new Set(); + + /** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ + + flightState._cockpitSubjectId = null; + + /** @type {((event: CustomEvent) => void)|null} */ + + flightState._cockpitModeListener = null; + + /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ + + flightState._trail = null; + + /** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ + + flightState._trailHeadEntity = null; + + /** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ + + flightState._trailHeadSeq = 0; + + /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + + flightState._trailPositions = []; + + /** @type {number} Monotonic token — invalidates in-flight backfill responses */ + + flightState._trailBackfillToken = 0; + + /** @type {Map} icao24 -> consecutive missed polls */ + + flightState._missingPolls = new Map(); + + /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ + + flightState._lastFleetTickMs = 0; + + /** @type {string} Camera pose signature at the last rotation pass */ + + flightState._lastCamPoseSig = ''; + + /** @type {number} Epoch ms of the last full rotation pass */ + + flightState._lastRotPassMs = 0; + + /** @type {number} Last computed rotation for the tracked entity (radians) */ + + flightState._lastTrackedRotation = 0; + + /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + + flightState._preRenderRemove = null; + + flightState._trackedModelPreUpdateRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + + flightState._moveEndRemove = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + flightState._trackedEntityChangedRemove = null; + + // -- Scratch variables (reused each frame to avoid GC pressure) -- + + flightState._scratchOffset = new Cesium.Cartesian3(); + + flightState._scratchCarto = new Cesium.Cartographic(); + + flightState._scratchEnu = new Cesium.Matrix4(); + + flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; + + flightState._scratchRenderTime = new Cesium.JulianDate(); + + flightState._scratchFleetPos = new Cesium.Cartesian3(); + + flightState._scratchDrRaw = new Cesium.Cartesian3(); + + flightState._scratchWarmupTime = new Cesium.JulianDate(); + + flightState._trackedPosHolder = new Cesium.Cartesian3(); + + // -- Per-frame dead-reckoning cache for the tracked entity -- + /** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ + + flightState._cachedDRPosition = null; + + /** @type {number} Frame number the cache was last populated for */ + + flightState._cachedDRFrame = -1; + + /** Course (deg) of the position `_deadReckon` most recently returned — set on + * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same + * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ + + flightState._drCourseDeg = null; + + /** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion + * `_deadReckon` just returned, and whether that motion is too slow for ANY + * course source to be trusted (hover/GPS drift — consumers HOLD their + * previous display course instead of chasing noise). */ + + flightState._drSpeedMps = null; + + flightState._drCourseHold = false; + + /** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ + + flightState._cachedDRCourse = null; + + /** Frame-cached siblings of _cachedDRCourse (same discipline). */ + + flightState._cachedDRSpeedMps = null; + + flightState._cachedDRHold = false; + + /** Wall-clock of the tracked course limiter's last advance (dt source only — + * the course VALUE lives in the shared per-icao _displayCourse map below). */ + + flightState._trackedCourseMs = 0; + + /** Per-aircraft smoothed display course — the SINGLE source of truth for the + * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 + * field fix). The fleet pass reads/writes it at tick cadence for untracked + * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for + * the tracked plane (the fleet pass skips the tracked icao, so exactly one + * writer owns an entry at a time). Sharing the entry — including its slew + * state — keeps the tracked↔fleet handoff seamless (separate states froze + * the fleet entry while tracked → nose flip on click / click-away). */ + + flightState._displayCourse = new Map(); + + flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); + + flightState._drCorrectionStartMs = 0; + + flightState._drPrevRaw = new Cesium.Cartesian3(); + + flightState._drPrevDisplay = new Cesium.Cartesian3(); + + flightState._drPrevMs = 0; + + flightState._drReconcileValid = false; + + /** @type {string|null} icao the reconciliation state currently belongs to */ + + flightState._drReconcileIcao = null; + + /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ + + flightState._trackedVisualPos = new Cesium.Cartesian3(); + + flightState._trackedTrailPos = new Cesium.Cartesian3(); + + /** Scratch for the tracked model's world-space origin (the envelope centre). */ + + flightState._scratchTrailClip = new Cesium.Cartesian3(); + + /** Scratch for the shortened trail-head start. Owned by the head-segment + * callback alone, so nothing else can overwrite it mid-frame. */ + + flightState._scratchTrailHead = new Cesium.Cartesian3(); + + /** Hysteresis latch for the tracked contact's zoom regime, plus the selection it + * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER + * ceiling instead of inheriting the previous target's looser EXIT band. */ + + flightState._trackedZoomLatched = false; + + flightState._trackedZoomLatchIcao = null; + + flightState._trackedModelFailIcao = null; + + flightState._trackedModelFailCount = 0; + + flightState._trackedModelRetryAtMs = 0; + + /** IR hot-target mode — mirror of flights.js: under the luminance-mapped + * NVG/FLIR post-styles every model renders flat white (hottest); the dominant + * amber/tracked tint restores on style exit. */ + + flightState._irBoost = false; + + /** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR + * field rounds): material-stage tint still sun-shades, so side-on planes + * read near-black under a high sun; UNLIT emits flat white regardless. + * customShader on a READY model is a silent no-op (field-verified), so the + * boost flips by release-and-reload — creation-time options carry it. */ + + flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }); + + flightState._irReloadQueue = null; + + /** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ + + flightState._scratchTrailCarto = new Cesium.Cartographic(); + return flightState; +} diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js new file mode 100644 index 000000000..670676f4f --- /dev/null +++ b/src/layers/military/testing.js @@ -0,0 +1,165 @@ +import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; + +export function createTesting({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + /** + * Seed only the mutable state needed to exercise tracked-card refreshes through + * the production military poll reconciler. Tests still call + * `militaryFlightsLayer.update()` rather than invoking the writer directly. + * @param {object} state + * @param {string} state.icao24 + * @param {object} state.entity + * @param {object} state.meta + * @param {object} state.billboard + * @param {object} state.billboardCollection + * @param {object} state.viewer + * @param {Array} [state.history=[]] + * @param {boolean} [state.tracked=true] + * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed + * by icao24, for the billboard-hidden/model-shown handoff state. + */ + + function _setTrackedMilitaryRefreshStateForTest({ + icao24, + entity, + meta, + billboard, + billboardCollection, + viewer, + history = [], + tracked = true, + models = [], + modelCollection = null, + }) { + flightState._viewer = viewer; + flightState._modelCollection = modelCollection; + flightState._billboardCollection = billboardCollection; + flightState._billboards = new Map([[icao24, billboard]]); + flightState._models.clear(); + for (const [key, model] of models) flightState._models.set(key, model); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map([[icao24, meta]]); + flightState._positionHistory = new Map([[icao24, history]]); + flightState._missingPolls = new Map(); + flightState._displayCourse.clear(); + flightState._geoidNCache.clear(); + flightState._trackedIcao = tracked ? icao24 : null; + flightState._trackedEntity = tracked ? entity : null; + flightState._trackedModel = null; + parts.tracking._cancelPendingTrackingRestore(); + flightState._trackedModelLoading = false; + // NOTE: deliberately does NOT reset the per-selection latches. They are + // production state owned by the tracking lifecycle (_resetTrackedSelectionState), + // and clearing them here would mask exactly the deselect→re-track hole this + // seam is used to test. + flightState._backoff = false; + flightState._retryAt = 0; + } + + /** Seed the authoritative snapshot outcome used by share-Follow tests. */ + + function _setMilitaryTrackingRefreshOutcomeForTest({ + status = 'accepted', + ids = [], + source = flightState._lastSource, + } = {}) { + const epoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch, + status, + ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), + source, + }; + } + + /** Add a cached contact so tests can model a target arriving on a later feed. */ + + function _addMilitaryTrackingCandidateForTest({ + icao24, + meta, + billboard, + history = [], + }) { + flightState._billboards.set(icao24, billboard); + flightState._flightData.set(icao24, meta); + flightState._positionHistory.set(icao24, history); + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingMilitaryTrackingRestoreForTest() { + return flightState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated feed refresh. */ + + function _applyPendingMilitaryTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Set the exact Cockpit subject through the production state transition for focused tests. */ + + function _setCockpitDetectionSubjectForTest(active, subjectId = null) { + parts.tracking._applyCockpitState({ active, subjectId }); + } + + /** Evaluate the TRACKED contact's zoom regime through the production predicate. + * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) + * and otherwise only observable through a live scene, so tests drive it here. */ + + function _trackedModelRegimeActiveForTest() { + return parts.tracking._trackedModelRegimeActive(); + } + + /** Run one frame of the production tracked-model driver (normally a + * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ + + function _updateTrackedModelForTest() { + return parts.rendering._updateTrackedModel(); + } + + /** Evaluate the production tracked-billboard handoff colour for focused tests. */ + + function _trackedBillboardColorForTest() { + return parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR; + } + + /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + + function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { + return parts.rendering._driveFleetModelHandoff( + icao24, + flightState._models.get(icao24), + flightState._billboards.get(icao24), + position, + course, + ); + } + + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + + async function _ensureFleetModelForTest(icao24) { + await parts.rendering._ensureModel(icao24); + return flightState._models.get(icao24) || null; + } + return { + _setTrackedMilitaryRefreshStateForTest, + _setMilitaryTrackingRefreshOutcomeForTest, + _addMilitaryTrackingCandidateForTest, + _pendingMilitaryTrackingRestoreForTest, + _applyPendingMilitaryTrackingRestoreForTest, + _setCockpitDetectionSubjectForTest, + _trackedModelRegimeActiveForTest, + _updateTrackedModelForTest, + _trackedBillboardColorForTest, + _driveFleetModelHandoffForTest, + _ensureFleetModelForTest, + }; +} diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js new file mode 100644 index 000000000..f4c6e2fc6 --- /dev/null +++ b/src/layers/military/tracking.js @@ -0,0 +1,1172 @@ +import * as Cesium from 'cesium'; +import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { trailHeadStart } from '../../data/modelVisualAnchor.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + bindTrackingClickGesture, + isTrackingSelectionGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import { + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + TRAIL_MAX_POINTS, + RENDER_DELAY_SEC, + TRAIL_COLOR, + BILLBOARD_SCALE, + GROUND_SCALE, + MIL_ICON_COLOR, + AMBER_TRANSPARENT, + TRACKED_ICON_COLOR, +} from './policy.js'; + +export function createTracking({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + selectTrackedSubjectContext, + refreshTrackedSubjectContext, + clearTrackedSubjectContext, + } = services.context; + const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = + services.aircraftPresentation; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = + services.groundFloor; + const { createTrail } = services.trails; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { clearFocusTarget } = services.focus; + const { applyTrackedCameraFrame } = services.camera; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'military', + id: icao24, + label: + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24, + position: Cesium.Cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; + } + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const info = flightState._flightData.get(icao24); + const label = described.callsign || described.registration || icao24; + return { + id: icao24, + layerId: 'military', + layerName: 'Military Flights', + source: flightState._lastSource, + label, + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: label, + operator: parts.queries._toCleanText(info?.operator) || '', + callsign: described.callsign || '', + registration: described.registration || '', + // Converted contacts report their class as TR-3B — the same override + // the readout and Contacts card show. + type: tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || '', + ), + altitude: described.onGround + ? 'on ground' + : parts.queries._formatAltitude(info?.altitudeFt), + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${Math.round(described.track)}°` + : '', + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; + } + + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } + + /** Refresh the AIR-only Cockpit near band independently from model admission. */ + + function _refreshCockpitNearContacts() { + if ( + !flightState._cockpitContactMode || + !flightState._viewer?.camera?.positionWC + ) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; + } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + Cesium.Cartesian3.distanceSquared( + flightState._viewer.camera.positionWC, + bb.position, + ), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; + } + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. + if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + } + + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); + } + + /** + * Build the multi-line presentation text for the protected tracked host card. + * Lines: callsign, type/registration, operator/altitude. + * While the plane is in its missed-poll grace (coasting on dead reckoning + * with sticky metadata) the first line carries a "· STALE" cue — the fleet's + * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity + * owns the visual), so without this the readout would present last-known + * altitude/speed as live. + * @param {Object|null} info - Flight metadata from _flightData + * @param {string} icao24 - ICAO hex identifier (fallback display name) + * @returns {string} Newline-separated label text + */ + + function _buildTrackedLabel(info, icao24) { + const stale = + flightState._missingPolls.get(icao24) || flightState._backoff + ? ' · STALE' + : ''; + const callsign = + (parts.queries._toCleanText(info?.callsign) || + parts.queries._toCleanText(info?.registration) || + icao24) + stale; + // Converted contacts report their class as TR-3B — that override is exactly + // what the Easter egg replaces the real type with. + const type = tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || 'Type unknown', + ); + const registration = + parts.queries._toCleanText(info?.registration) || 'Reg unknown'; + const operator = + parts.queries._toCleanText(info?.operator) || 'Operator unknown'; + const altitude = parts.queries._formatAltitude(info?.altitudeFt); + const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; + const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; + return [ + callsign, + `${type} · ${registration}`, + `${operator} · ${tail}`, + ].join('\n'); + } + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(flightState._flightData.get(icao24), icao24), + '#ffd166', + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) + return; + flightState._trackedEntity.billboard.image = aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ); + } + + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Mirror of flights.js: converting drops any 3D model so the triangle + * owns the visual, then the billboard, tracked entity, and card re-derive. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ + + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const modelled = flightState._billboards.get(id); + if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._viewer?.scene?.requestRender?.(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; + } + + /** Re-image every converted contact this layer owns (IR style flip). */ + + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); + } + } + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to this layer) never + * makes `_trackedIcao` *observably* change, so the guard never fires. Without + * the reset, a contact dropped inside the hysteresis band comes back as a + * MODEL above the ENTER ceiling, and a contact whose GLB had already failed + * out would never get its retries back. Mirror of flights.js. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the + * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET + * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live + * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the + * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km + * ceiling this used to inherit), hand back only above + * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. + * First-person means your own airframe is not drawn in cockpit — the eye sits + * metres from its origin. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. Mirror of flights.js. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._viewer?.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._viewer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm + * coarse ground cell — a pure LIFT (above-floor positions pass through + * untouched, unknown floors change nothing). Grounded military billboards + * deliberately render at the pre-datum default height (T7 groundSnap + * invariant), so every position entering the TRAIL subsystem (seed, append, + * per-frame head) goes through this instead — the visible taxi history sits + * on the surface without touching the billboard/model machinery. + * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). + * @returns {Cesium.Cartesian3} The same or a lifted position. + */ + + function _trailFloorPosition(position) { + const carto = Cesium.Cartographic.fromCartesian( + position, + Cesium.Ellipsoid.WGS84, + flightState._scratchTrailCarto, + ); + if (!carto) return position; + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const floored = floorAltitudeM( + carto.height, + cachedGroundFloor(latDeg, lonDeg), + ); + if (floored == null || floored === carto.height) return position; + return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push(_trailFloorPosition(position)); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That + * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. + * The cheap per-frame _trailHeadEntity segment bridges the last body point to the + * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll + * cadence), never at motion cadence. + */ + + function _refreshTrailDisplay() { + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget an adsb.lol trace backfill (~24 h of real history). + * @param {string} icao24 - ICAO hex identifier being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join via _appendTrailFix as they age. + const seedRenderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + for (const fix of history) { + if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + // Floor grounded-history seeds (round 2): a grounded contact's stored + // fixes carry the pre-datum default height — the trail copy sits on + // the surface instead (pure lift; airborne fixes pass through). + flightState._trailPositions.push( + _trailFloorPosition(Cesium.Cartesian3.clone(fix.position)), + ); + } + } + if (!flightState._trail && flightState._viewer) { + flightState._trail = createTrail(flightState._viewer, { + color: TRAIL_COLOR, + width: 2.5, + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated + // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, + // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued + // to the 12 Hz icon instead of lagging ~1 s behind it. + if (!flightState._trailHeadEntity && flightState._viewer) { + flightState._trailHeadEntity = flightState._viewer.entities.add({ + // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + polyline: { + positions: new Cesium.CallbackProperty(() => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the + // segment would draw IN FRONT of the plane. Likewise during warm-up the icon + // predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[ + flightState._trailPositions.length - 2 + ]; + // Floor the head too (round 2): a grounded tracked contact's DR + // display height is the pre-datum default — without this the last + // segment dives underground while taxiing. + const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. Measured against + // the FLOORED endpoint, which is the one actually drawn. + // See trailHeadStart. + const from = trailHeadStart( + start, + end, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, end]; + }, false), + width: 2.5, + material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads). + depthFailMaterial: + Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }, + }); + } + _refreshTrailDisplay(); + + const oldestFixEpochSec = history.length + ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + /** + * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * stride-thinned to the remaining vertex budget, then spliced strictly older + * than the oldest seeded fix AHEAD of the locally accumulated fine segment, + * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ + * malformed) silently keeps the local-only trail. + * @param {string} icao24 - ICAO hex identifier being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let trace = null; + try { + const track = await flightState._source.getTrack?.( + flightState._flightData.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + trace = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if (!trace) return; + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a + // readsb trace point's altitude is barometric feet (same datum as the live + // alt_baro, NOT geometric), so a trail waypoint's render height is the same + // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) + // — geometrically approximate, not exact. + await ensureGeoidReady(); + + // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] + const parsed = []; + for (const point of trace) { + const t = point.observedAtMs / 1000; + const lat = point.latitude, + lon = point.longitude; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse + // ellipsoidal ground along the trace and clamp every waypoint at it. A + // 'ground'/null point sits ON the local surface — the old fixed 50 m + // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m + // ellipsoidal) and dragged the whole pattern-work loop with it. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + let older = []; + let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet + for (const { lat, lon, baroAltitudeM } of parsed) { + const baroM = + baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon); + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // Ground/no-alt point with an unresolved floor: hold the previous + // waypoint's altitude (continuity — never a dive to a made-up depth). + // Leading points with nothing to carry keep the old low breadcrumb + // sentinel (an arbitrary placeholder, never a reported altitude). + if (altM == null) altM = lastAltM != null ? lastAltM : 50; + lastAltM = altM; + older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + } + + // Stride-thin the backfill so it plus the live fine segment fit the cap. + const budget = Math.max( + 1, + TRAIL_MAX_POINTS - flightState._trailPositions.length, + ); + if (older.length > budget) { + const stride = Math.ceil(older.length / budget); + const thinned = []; + for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); + older = thinned; + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if ( + flightState._trailHeadEntity && + flightState._viewer && + !flightState._viewer.isDestroyed() + ) { + try { + flightState._viewer.entities.remove(flightState._trailHeadEntity); + } catch { + /* already gone */ + } + } + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the current aircraft and clean up all tracking state. + * Restores the original billboard, removes the tracked Entity, invalidates + * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo + * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back + * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms + * way up and loses my context"). The camera stays at its current + * position/orientation, immediately free to orbit/zoom. Applies to every + * deselect path: click-empty-space, Escape, aged-out plane, layer disable, + * and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget('militaryFlights'); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget('militaryFlights', clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + const meta = flightState._flightData.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground-aware restore (a plane untracked while taxiing comes back at + // ground scale; tint is full amber on the ground and in the air). + bb.scale = + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1); + bb.color = MIL_ICON_COLOR; + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the + // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). + // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in + // place, so the view stays where the follow left it and the user can immediately orbit/zoom. + if (flightState._viewer && !skipViewerUntrack) { + flightState._viewer.trackedEntity = undefined; + } + if (flightState._trackedEntity) { + flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext('military'); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'military', + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache so a same-frame re-track cannot read + // the previous aircraft's cached position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._billboardCollection?.show || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** + * Begin tracking a specific aircraft. Clears any previous tracking, hides the + * billboard, creates a new Entity with dead-reckoning CallbackProperties for + * smooth continuous motion, and sets the viewer's trackedEntity so the camera + * follows. + * @param {string} icao24 - ICAO hex identifier of the aircraft to track + */ + + function _trackFlight(icao24, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard -- the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/rotation/trail-head callbacks, with + // discontinuity reconciliation). Falls back to the last billboard position when the + // aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Dead-reckoning position property evaluated every render frame. + // isConstant = false so CesiumJS re-evaluates each frame. + const positionProperty = new Cesium.CallbackProperty(() => { + return getTrackedPosition(); + }, false); + + // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D + // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is + // what makes the follow-camera's bounding sphere always ready (see _trackedModel). + flightState._trackedEntity = flightState._viewer.entities.add({ + position: positionProperty, + // Keep Cesium's EntityView in the same ENU frame as the close-range + // camera guard; AUTO may otherwise alternate with a velocity frame. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[ + flightState._flightData.get(flightState._trackedIcao)?.klass + ] || 1), + // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: new Cesium.CallbackProperty( + () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR, + false, + ), + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the amber icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: new Cesium.CallbackProperty(() => { + const tracked = flightState._flightData.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._viewer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, false), + }, + }); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `military:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(info, icao24), + '#ffd166', + ); + + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; + const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); + flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + 0, + -followRange * 0.8, + followRange * 0.55, + ); + + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked + // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires + // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. + // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a + // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the + // follow-camera anti-jitter contract; presentation that must weld to the aircraft you + // can see reads `gevVisualPosition`. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + flightState._viewer.camera.cancelFlight(); + // Camera follows the tracked entity + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + + // Track-history trail (PRD F2): seed from local history + async backfill. + // Ground traffic draws NO trail — if the plane takes off while tracked, the + // ground→air transition in update() starts one. + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select. Trail positions are floored + // at the surface (_trailFloorPosition), so ground legs drape, never dive. + _startTrail(icao24); + + const callsign = + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24; + _publishTrackedSelection(icao24, origin); + console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); + } + + /** + * Global keydown handler: pressing Escape clears the active aircraft tracking. + * @param {KeyboardEvent} e - The keyboard event + */ + + function _onKeyDown(e) { + if (e.key === 'Escape' && flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Install a left-click handler on the scene canvas for aircraft selection. + * Clicking a military billboard starts tracking that aircraft; clicking empty + * space or a non-military object deselects. Also attaches the Escape key + * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + + function _installClickHandler(viewer) { + if (flightState._clickHandler) return; // already installed + + // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our + // tracking without touching viewer.trackedEntity (the new owner controls it). + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if ( + flightState._trackedIcao && + flightState._viewer && + flightState._viewer.trackedEntity && + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: + flightState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + // Camera drags never select or deselect, even if they finish over a plane. + // Duration alone is allowed through so a stationary long press can still + // select/switch contacts; the destructive empty-space branch below applies + // the full travel + duration click classifier. + if (!isTrackingSelectionGesture(gesture)) return; + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference. Empty globe clicks are inert until the user + // exits with C, Escape, or the dedicated button. + if (document.body.classList.contains('cockpit-mode')) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking the tracked entity itself -- ignore (don't deselect) + if (picked.id === flightState._trackedEntity) return; + + // Clicking the plane we're ALREADY tracking (its standalone 3D model or + // any pick carrying its icao) — same no-op as the 2D tracked-entity click + // above. H1: the model used to have no pick id, so this fell through to + // "empty space" and deselected the very plane being tracked. + if (flightState._trackedIcao) { + const rawPick = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if ( + picked.primitive === flightState._trackedModel || + rawPick === flightState._trackedIcao + ) + return; + } + + // BillboardCollection picks: CesiumJS may expose the billboard as + // picked.primitive (with .id = icao24) or directly as picked.id + const billboard = picked.primitive; + if ( + billboard && + billboard.id && + flightState._billboards.has(billboard.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(billboard.id, { origin: 'user' }); + return; + } + // Fallback: some CesiumJS versions surface the id string at picked.id + if ( + picked.id && + typeof picked.id === 'string' && + flightState._billboards.has(picked.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(picked.id, { origin: 'user' }); + return; + } + } + + // A pick that belongs to a sibling layer (commercial flight, satellite, + // vessel, station, CCTV camera…) is not "empty space" — leave tracking + // alone and let that layer handle it. resolvePickId String()-coerces the + // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the + // registry predicates can recognize them (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; + } + + // Clicked empty space -- deselect only for a clean, short click. A slow + // stationary press may select above, but cannot release existing tracking. + if (!isTrackingClickGesture(gesture)) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + }); + + document.addEventListener('keydown', _onKeyDown); + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _buildTrackedLabel, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + _trailFloorPosition, + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackFlight, + _onKeyDown, + _installClickHandler, + }; +} diff --git a/src/layers/radio/catalogModel.js b/src/layers/radio/catalogModel.js new file mode 100644 index 000000000..ef06b08c3 --- /dev/null +++ b/src/layers/radio/catalogModel.js @@ -0,0 +1,147 @@ +import { normalizeRadioCountryInput } from '../../data/radioCountry.js'; +import { RADIO_UUID_RE } from './policy.js'; + +export function createCatalogModel({ + state: layerState, + services, + parts, + source, +}) { + function isNonGlobalRadioIpv4(hostname) { + const pieces = hostname.split('.'); + if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) + return false; + const values = pieces.map(Number); + if (values.some((value) => value > 255)) return true; + const [a, b, c] = values; + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ); + } + + function isSafeRadioHttpsUrl(value) { + if (typeof value !== 'string' || !value) return false; + try { + const url = new URL(value); + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + !hostname + ) + return false; + return !( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + isNonGlobalRadioIpv4(hostname) || + hostname.includes(':') + ); + } catch { + return false; + } + } + + function isValidRadioDirectoryStation(station) { + const cleanText = (value, maxLength, { allowEmpty = true } = {}) => + typeof value === 'string' && + value.length <= maxLength && + (allowEmpty || value.trim().length > 0) && + !/[\u0000-\u001f\u007f]/.test(value) && + value === value.trim() && + !/\s{2,}/.test(value); + const textArray = (value, limit, itemMaxLength) => + Array.isArray(value) && + value.length <= limit && + value.every((item) => + cleanText(item, itemMaxLength, { allowEmpty: false }), + ); + return Boolean( + station && + RADIO_UUID_RE.test(station.id) && + cleanText(station.name, 140, { allowEmpty: false }) && + Number.isFinite(station.lat) && + station.lat >= -90 && + station.lat <= 90 && + Number.isFinite(station.lon) && + station.lon >= -180 && + station.lon <= 180 && + isSafeRadioHttpsUrl(station.streamUrl) && + (station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) && + textArray(station.tags, 24, 80) && + textArray(station.languages, 8, 40) && + cleanText(station.state, 80) && + cleanText(station.country, 80) && + cleanText(station.countryCode, 2) && + (station.countryCode === '' || + normalizeRadioCountryInput(station.countryCode).valid) && + station.metadataTrust === 'untrusted-community' && + cleanText(station.codec, 16, { allowEmpty: false }) && + /^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) && + (station.bitrate === null || + (Number.isInteger(station.bitrate) && + station.bitrate >= 8 && + station.bitrate <= 1024)), + ); + } + + function freezeRadioStation(station) { + return Object.freeze({ + id: station.id, + name: station.name, + lat: station.lat, + lon: station.lon, + streamUrl: station.streamUrl, + homepage: station.homepage, + tags: Object.freeze([...station.tags]), + languages: Object.freeze([...station.languages]), + state: station.state, + country: station.country, + countryCode: station.countryCode, + metadataTrust: station.metadataTrust, + codec: station.codec, + bitrate: station.bitrate, + }); + } + + function createAcceptedCatalogSnapshot( + instance, + generation, + updatedAt, + stations, + ) { + if (!Number.isSafeInteger(generation) || generation < 1) return null; + if (typeof instance !== 'string' || !instance) return null; + const frozenStations = Object.freeze(stations.map(freezeRadioStation)); + return Object.freeze({ + instance, + generation, + updatedAt, + stations: frozenStations, + stationIds: Object.freeze(frozenStations.map((station) => station.id)), + }); + } + return { + isNonGlobalRadioIpv4, + isSafeRadioHttpsUrl, + isValidRadioDirectoryStation, + freezeRadioStation, + createAcceptedCatalogSnapshot, + }; +} diff --git a/src/layers/radio/categories.js b/src/layers/radio/categories.js new file mode 100644 index 000000000..a7bfc182e --- /dev/null +++ b/src/layers/radio/categories.js @@ -0,0 +1,131 @@ +import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js'; + +export function createCategories({ + state: layerState, + services, + parts, + source, +}) { + /** Normalize one directory tag to a stable, lower-case display token. */ + + function normalizeRadioTag(value) { + return String(value ?? '') + .trim() + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .slice(0, 80); + } + + function stationTags(station) { + if (Array.isArray(station?.tags)) + return station.tags.map(normalizeRadioTag).filter(Boolean); + return String(station?.tags ?? '') + .split(',') + .map(normalizeRadioTag) + .filter(Boolean); + } + + function hasTag(station, needles) { + const tags = stationTags(station); + return needles.some((needle) => + tags.some((tag) => tag === needle || tag.includes(needle)), + ); + } + + function detectedGenres(station) { + return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map( + ([genre]) => genre, + ); + } + + /** Return whether a station belongs in a station-tag category. */ + + function stationMatchesRadioCategory(station, categoryId) { + if (categoryId === 'all') return true; + if (categoryId.startsWith('genre:')) { + return detectedGenres(station).includes( + categoryId.slice('genre:'.length), + ); + } + if (categoryId === 'music') { + return ( + detectedGenres(station).length > 0 || + hasTag(station, ['music', 'hits', 'songs']) + ); + } + if (categoryId === 'other') { + return ( + !Object.entries(CATEGORY_MATCHERS).some(([id]) => + stationMatchesRadioCategory(station, id), + ) && !stationMatchesRadioCategory(station, 'music') + ); + } + return hasTag(station, CATEGORY_MATCHERS[categoryId] || []); + } + + /** Build canonical and detected-genre categories from station-level tags. */ + + function buildRadioCategories(stations) { + const rows = Array.isArray(stations) ? stations : []; + const categories = [ + { id: 'all', label: 'All' }, + { id: 'news', label: 'News' }, + { id: 'talk', label: 'Talk' }, + { id: 'weather', label: 'Weather / Emergency' }, + { id: 'public-safety', label: 'Public Safety' }, + { id: 'aviation-marine', label: 'Aviation / Marine' }, + { id: 'traffic-transit', label: 'Traffic / Transit' }, + { id: 'music', label: 'Music' }, + ]; + + for (const [genre, label] of MUSIC_GENRES) { + const id = `genre:${genre}`; + if (rows.some((station) => stationMatchesRadioCategory(station, id))) { + categories.push({ id, label }); + } + } + categories.push({ id: 'other', label: 'Other' }); + return categories.map((category) => ({ + ...category, + color: parts.model.radioCategoryColor(category.id), + count: rows.filter((station) => + stationMatchesRadioCategory(station, category.id), + ).length, + })); + } + + /** Filter stations without changing the active stream or selection. */ + + function filterRadioStations(stations, categoryId = 'all') { + return (Array.isArray(stations) ? stations : []).filter((station) => + stationMatchesRadioCategory(station, categoryId), + ); + } + + /** Return whether Radio Browser metadata identifies a station as English-language. */ + + function isEnglishRadioStation(station) { + const languages = Array.isArray(station?.languages) + ? station.languages + : []; + return languages.some((language) => { + const normalized = normalizeRadioTag(language); + return ( + normalized === 'en' || + normalized === 'eng' || + normalized.startsWith('english') + ); + }); + } + return { + normalizeRadioTag, + stationTags, + hasTag, + detectedGenres, + stationMatchesRadioCategory, + buildRadioCategories, + filterRadioStations, + isEnglishRadioStation, + }; +} diff --git a/src/layers/radio/clustering.js b/src/layers/radio/clustering.js new file mode 100644 index 000000000..416276317 --- /dev/null +++ b/src/layers/radio/clustering.js @@ -0,0 +1,266 @@ +import { + GLOBAL_RADIO_ALTITUDE_M, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_OVERLAY_COHORT_LIMIT, +} from './policy.js'; + +export function createClustering({ + state: layerState, + services, + parts, + source, +}) { + /** Return the bounded singleton-label allowance for the current camera scale. */ + + function radioSingletonLabelLimit(cameraHeightM) { + const height = Math.max(0, Number(cameraHeightM) || 0); + if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT; + if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT; + return RADIO_SINGLETON_NEAR_LIMIT; + } + + /** Rank visible singleton stations by camera distance and stable station id. */ + + function selectRadioSingletonCandidates( + candidates, + limit = RADIO_SINGLETON_NEAR_LIMIT, + ) { + const distance = (candidate) => { + const value = Number(candidate?.distanceM); + return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY; + }; + return [...(candidates || [])] + .sort( + (a, b) => + distance(a) - distance(b) || + String(a?.station?.id || a?.id || '').localeCompare( + String(b?.station?.id || b?.id || ''), + ), + ) + .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); + } + + /** Rank and cap ambient Radio clusters before shared-host entry allocation. */ + + function selectRadioClusterCandidates( + candidates, + limit = RADIO_OVERLAY_COHORT_LIMIT, + ) { + return [...(candidates || [])] + .sort( + (a, b) => + (Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) || + String(a?.id || '').localeCompare(String(b?.id || '')), + ) + .slice(0, Math.max(0, Math.floor(Number(limit) || 0))); + } + + /** + * Preserve one-to-one overlay identity for overlapping clusters. + * Cesium clustering is screen-space, so a small camera move may alter exact + * membership even when the same geographic cluster remains visible. Every + * positive overlap participates in greatest-contributor discovery, while + * inheritance remains mutual-best: a current cluster accepts only one of its + * greatest contributors and a prior identity transfers only to one of its + * strongest split children. + */ + + function reconcileRadioClusterCandidates( + candidates, + previous = [], + createId = null, + ) { + const current = (Array.isArray(candidates) ? candidates : []).map( + (candidate, index) => { + const stationIds = [ + ...new Set((candidate?.stationIds || []).map((id) => String(id))), + ].sort(); + const membershipId = String( + candidate?.id || `membership:${stationIds.join('|')}`, + ); + return { + ...candidate, + _reconcileIndex: index, + _reconcileMembershipId: membershipId, + _reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`, + stationIds, + }; + }, + ); + const priorMembershipsByIdentity = new Map(); + for (const candidate of Array.isArray(previous) ? previous : []) { + const identityId = String(candidate?.identityId || candidate?.id || ''); + if (!identityId) continue; + if (!priorMembershipsByIdentity.has(identityId)) { + priorMembershipsByIdentity.set(identityId, new Set()); + } + const membership = priorMembershipsByIdentity.get(identityId); + for (const stationId of candidate?.stationIds || []) + membership.add(String(stationId)); + } + const prior = [...priorMembershipsByIdentity] + .map(([identityId, stationIds]) => ({ + identityId, + stationIds: [...stationIds].sort(), + })) + .filter((candidate) => candidate.stationIds.length) + .sort((a, b) => a.identityId.localeCompare(b.identityId)); + const priorByStation = new Map(); + for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) { + for (const stationId of prior[priorIndex].stationIds) { + if (!priorByStation.has(stationId)) priorByStation.set(stationId, []); + priorByStation.get(stationId).push(priorIndex); + } + } + + const edges = []; + const greatestOverlapByCurrent = new Map(); + const greatestOverlapByPrior = new Map(); + for ( + let currentIndex = 0; + currentIndex < current.length; + currentIndex += 1 + ) { + const overlapByPrior = new Map(); + for (const stationId of current[currentIndex].stationIds) { + for (const priorIndex of priorByStation.get(stationId) || []) { + overlapByPrior.set( + priorIndex, + (overlapByPrior.get(priorIndex) || 0) + 1, + ); + } + } + for (const [priorIndex, overlap] of overlapByPrior) { + const union = + current[currentIndex].stationIds.length + + prior[priorIndex].stationIds.length - + overlap; + const smaller = Math.min( + current[currentIndex].stationIds.length, + prior[priorIndex].stationIds.length, + ); + const score = smaller > 0 ? overlap / smaller : 0; + const similarity = union > 0 ? overlap / union : 0; + edges.push({ currentIndex, priorIndex, overlap, score, similarity }); + greatestOverlapByCurrent.set( + currentIndex, + Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap), + ); + greatestOverlapByPrior.set( + priorIndex, + Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap), + ); + } + } + const eligibleEdges = edges.filter( + (edge) => + edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) && + edge.overlap === greatestOverlapByPrior.get(edge.priorIndex), + ); + eligibleEdges.sort( + (a, b) => + // Identity follows the contributor representing the largest share of the + // new cluster. Overlap-coefficient-first incorrectly lets a fully retained + // two-station minority beat a larger partial contributor during a merge. + b.overlap - a.overlap || + b.score - a.score || + b.similarity - a.similarity || + current[a.currentIndex]._reconcileCanonicalKey.localeCompare( + current[b.currentIndex]._reconcileCanonicalKey, + ) || + prior[a.priorIndex].identityId.localeCompare( + prior[b.priorIndex].identityId, + ), + ); + + const inheritedByCurrent = new Map(); + const usedPriorIdentities = new Set(); + for (const edge of eligibleEdges) { + const identityId = prior[edge.priorIndex].identityId; + if ( + inheritedByCurrent.has(edge.currentIndex) || + usedPriorIdentities.has(identityId) + ) + continue; + inheritedByCurrent.set( + edge.currentIndex, + prior[edge.priorIndex].identityId, + ); + usedPriorIdentities.add(identityId); + } + + const generatedByCurrent = new Map(); + if (typeof createId === 'function') { + const freshIndices = current + .map((candidate, index) => ({ candidate, index })) + .filter(({ index }) => !inheritedByCurrent.has(index)) + .sort((a, b) => + a.candidate._reconcileCanonicalKey.localeCompare( + b.candidate._reconcileCanonicalKey, + ), + ); + for (const { candidate, index } of freshIndices) { + generatedByCurrent.set(index, String(createId(candidate, index) || '')); + } + } + + return current.map((candidate, index) => { + const membershipId = candidate._reconcileMembershipId; + const inherited = inheritedByCurrent.get(index); + const generated = generatedByCurrent.get(index) || ''; + const identityId = inherited || generated || membershipId; + const { + _reconcileIndex, + _reconcileMembershipId, + _reconcileCanonicalKey, + ...rest + } = candidate; + return { ...rest, membershipId, identityId, id: identityId }; + }); + } + + function resetRadioClusterOverlayIdentities() { + layerState._clusterOverlayIdentities = []; + } + + function emptyRadioOverlayDiagnostics() { + return { + entryCount: 0, + selectedCount: 0, + singletonTexts: [], + singletonIds: [], + clusterTexts: [], + clusterIds: [], + clusterMemberships: [], + }; + } + + /** Retain refresh identities only while every represented station still exists. */ + + function retainRadioClusterIdentitiesForStations(previous, stations) { + const stationIds = new Set( + (stations || []) + .map((station) => String(station?.id || '')) + .filter(Boolean), + ); + return (previous || []).filter( + (candidate) => + Array.isArray(candidate?.stationIds) && + candidate.stationIds.length > 0 && + candidate.stationIds.every((stationId) => + stationIds.has(String(stationId)), + ), + ); + } + return { + radioSingletonLabelLimit, + selectRadioSingletonCandidates, + selectRadioClusterCandidates, + reconcileRadioClusterCandidates, + resetRadioClusterOverlayIdentities, + emptyRadioOverlayDiagnostics, + retainRadioClusterIdentitiesForStations, + }; +} diff --git a/src/layers/radio/controls.js b/src/layers/radio/controls.js new file mode 100644 index 000000000..6313ccf4d --- /dev/null +++ b/src/layers/radio/controls.js @@ -0,0 +1,119 @@ +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'radio', + + name: 'Radio', + + icon: '◉', + + source: 'Radio Browser', + + updateInterval: 45 * 60 * 1000, + + /** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */ + setLifecyclePresentation({ + lifecycleState = null, + enabled = false, + uncertain = false, + } = {}) { + const settledState = enabled ? 'enabled' : 'disabled'; + const normalizedState = [ + 'enabling', + 'enabled', + 'disabling', + 'disabled', + ].includes(lifecycleState) + ? lifecycleState + : settledState; + layerState._managerLifecyclePresentation = { + lifecycleState: normalizedState, + enabled: Boolean(enabled), + uncertain: Boolean(uncertain), + }; + parts.interaction.syncRadioLifecyclePresentation(); + parts.presentation.emitState(); + }, + + /** Layer statistics for HUD/debug surfaces. */ + getStats() { + return { + count: layerState._stations.length, + filtered: parts.queries.visibleStations().length, + selected: layerState._selectedId, + playing: layerState._audioStationId, + stale: layerState._stale, + degraded: layerState._degraded, + loading: layerState._loading, + error: layerState._error, + lastUpdate: layerState._updatedAt + ? Date.parse(layerState._updatedAt) + : null, + horizonScans: layerState._horizonScanCount, + overlayEntries: layerState._overlayDiagnostics.entryCount, + }; + }, + + subscribe: parts.presentation.subscribeToRadio, + + subscribePlaybackControls: + parts.presentation.subscribeToRadioPlaybackControls, + + getAcceptedCatalogSnapshot: + parts.presentation.getRadioAcceptedCatalogSnapshot, + + getUIState: parts.presentation.getRadioUIState, + + getParams: parts.selection.getRadioParams, + + setParams: parts.selection.setRadioParams, + + getOverlayDiagnostics: () => ({ + ...layerState._overlayDiagnostics, + singletonTexts: [...layerState._overlayDiagnostics.singletonTexts], + singletonIds: [...layerState._overlayDiagnostics.singletonIds], + clusterTexts: [...layerState._overlayDiagnostics.clusterTexts], + clusterIds: [...layerState._overlayDiagnostics.clusterIds], + clusterMemberships: layerState._overlayDiagnostics.clusterMemberships.map( + (entry) => ({ ...entry }), + ), + }), + + getTunerStations: parts.tuning.getRadioTunerStations, + + beginTuning: parts.tuning.beginRadioTuning, + + setTuningStatic: parts.tuning.setRadioTuningStatic, + + previewTuningStation: parts.tuning.previewRadioTuningStation, + + commitTuningStation: parts.tuning.commitRadioTuningStation, + + cancelTuning: parts.tuning.cancelRadioTuning, + + endTuning: parts.tuning.endRadioTuning, + + setFilter: parts.selection.setRadioFilter, + + selectStation: parts.selection.selectRadioStation, + + selectRequestedStation: parts.selection.selectRequestedRadioStation, + + cycleStation: parts.selection.cycleRadioStation, + + togglePlayback: parts.playback.toggleRadioPlayback, + + play: parts.playback.playSelectedRadio, + + playForVoice: parts.playback.playPreparedRadioForVoice, + + pause: parts.playback.pauseRadioPlayback, + + stopPlayback: parts.playback.stopRadioPlayback, + + setVolume: parts.volume.setRadioVolume, + + setVoiceDucked: parts.volume.setRadioVoiceDucking, + }; + + return { methods }; +} diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js new file mode 100644 index 000000000..fa84e1054 --- /dev/null +++ b/src/layers/radio/index.js @@ -0,0 +1,142 @@ +import { createCatalogModel } from './catalogModel.js'; +import { createLabels } from './labels.js'; +import { createClustering } from './clustering.js'; +import { createVolume } from './volume.js'; +import { createNavigation } from './navigation.js'; +import { createCategories } from './categories.js'; +import { createModel } from './model.js'; +import { createQueries } from './queries.js'; +import { createInteraction } from './interaction.js'; +import { createTuning } from './tuning.js'; +import { createTuningNoise } from './tuningNoise.js'; +import { createRendering } from './rendering.js'; +import { createPresentation } from './presentation.js'; +import { createPlayback } from './playback.js'; +import { createSelection } from './selection.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createRadioLayer({ services, source }) { + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.catalogModel = createCatalogModel(context); + parts.labels = createLabels(context); + parts.clustering = createClustering(context); + parts.volume = createVolume(context); + parts.navigation = createNavigation(context); + parts.categories = createCategories(context); + parts.model = createModel(context); + parts.queries = createQueries(context); + parts.interaction = createInteraction(context); + parts.tuning = createTuning(context); + parts.tuningNoise = createTuningNoise(context); + parts.rendering = createRendering(context); + parts.presentation = createPresentation(context); + parts.playback = createPlayback(context); + parts.selection = createSelection(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + radioGlobeLabel: parts.labels.radioGlobeLabel, + createRadioSelectedOverlayEntry: + parts.labels.createRadioSelectedOverlayEntry, + createRadioClusterOverlayEntry: + parts.labels.createRadioClusterOverlayEntry, + createRadioSingletonOverlayEntry: + parts.labels.createRadioSingletonOverlayEntry, + radioSingletonLabelLimit: parts.clustering.radioSingletonLabelLimit, + selectRadioSingletonCandidates: + parts.clustering.selectRadioSingletonCandidates, + selectRadioClusterCandidates: + parts.clustering.selectRadioClusterCandidates, + reconcileRadioClusterCandidates: + parts.clustering.reconcileRadioClusterCandidates, + radioCameraPositionChanged: parts.navigation.radioCameraPositionChanged, + normalizeRadioTag: parts.categories.normalizeRadioTag, + stationMatchesRadioCategory: parts.categories.stationMatchesRadioCategory, + radioCategoryColor: parts.model.radioCategoryColor, + radioClusterBadgeText: parts.model.radioClusterBadgeText, + radioClusterCategoryId: parts.model.radioClusterCategoryId, + radioSelectionBracketSvg: parts.labels.radioSelectionBracketSvg, + radioStationCategoryId: parts.model.radioStationCategoryId, + buildRadioCategories: parts.categories.buildRadioCategories, + filterRadioStations: parts.categories.filterRadioStations, + isEnglishRadioStation: parts.categories.isEnglishRadioStation, + rankRadioStationsForViewport: parts.queries.rankRadioStationsForViewport, + rankRadioStationsForRequest: parts.queries.rankRadioStationsForRequest, + radioViewIsGlobal: parts.navigation.radioViewIsGlobal, + radioRequestIsCurrent: parts.model.radioRequestIsCurrent, + radioStationIdFromPick: parts.interaction.radioStationIdFromPick, + radioStationCameraPlan: parts.navigation.radioStationCameraPlan, + radioGlobeNeedsRecentering: parts.navigation.radioGlobeNeedsRecentering, + radioGlobeRecenterHeight: parts.navigation.radioGlobeRecenterHeight, + buildRadioTunerBand: parts.tuning.buildRadioTunerBand, + radioTuningStaticShouldPlay: + parts.tuningNoise.radioTuningStaticShouldPlay, + getRadioAcceptedCatalogSnapshot: + parts.presentation.getRadioAcceptedCatalogSnapshot, + radioStationResolutionMatches: parts.model.radioStationResolutionMatches, + getRadioUIState: parts.presentation.getRadioUIState, + subscribeToRadio: parts.presentation.subscribeToRadio, + subscribeToRadioPlaybackControls: + parts.presentation.subscribeToRadioPlaybackControls, + getRadioTunerStations: parts.tuning.getRadioTunerStations, + beginRadioTuning: parts.tuning.beginRadioTuning, + setRadioTuningStatic: parts.tuning.setRadioTuningStatic, + radioCameraNavigationAllowed: + parts.navigation.radioCameraNavigationAllowed, + previewRadioTuningStation: parts.tuning.previewRadioTuningStation, + endRadioTuning: parts.tuning.endRadioTuning, + cancelRadioTuning: parts.tuning.cancelRadioTuning, + commitRadioTuningStation: parts.tuning.commitRadioTuningStation, + playSelectedRadio: parts.playback.playSelectedRadio, + confirmRadioPlayback: parts.playback.confirmRadioPlayback, + playPreparedRadioForVoice: parts.playback.playPreparedRadioForVoice, + stopRadioPlayback: parts.playback.stopRadioPlayback, + toggleRadioPlayback: parts.playback.toggleRadioPlayback, + pauseRadioPlayback: parts.playback.pauseRadioPlayback, + setRadioVolume: parts.volume.setRadioVolume, + setRadioParams: parts.selection.setRadioParams, + getRadioParams: parts.selection.getRadioParams, + setRadioVoiceDucking: parts.volume.setRadioVoiceDucking, + selectRadioStation: parts.selection.selectRadioStation, + cycleRadioStation: parts.selection.cycleRadioStation, + selectRequestedRadioStation: parts.selection.selectRequestedRadioStation, + setRadioFilter: parts.selection.setRadioFilter, + retainRadioClusterIdentitiesForStations: + parts.clustering.retainRadioClusterIdentitiesForStations, + }, + ); +} +export { + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_SINGLETON_GLOBAL_LIMIT, + RADIO_SINGLETON_MID_LIMIT, + RADIO_SINGLETON_NEAR_LIMIT, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, + GLOBAL_RADIO_ALTITUDE_M, +} from './policy.js'; + +export { createRadioSource } from './source.js'; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from '../../ui/radioTunerModel.js'; + +export { normalizeRadioCountryInput } from '../../data/radioCountry.js'; diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js new file mode 100644 index 000000000..8a89b444c --- /dev/null +++ b/src/layers/radio/ingestion.js @@ -0,0 +1,174 @@ +import { + RADIO_DIRECTORY_STALE_MS, + RADIO_DIRECTORY_FUTURE_SKEW_MS, +} from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + /** Refresh directory metadata through the hardened same-origin broker. */ + async update() { + if (!layerState._enabled) return; + const generation = ++layerState._requestGeneration; + const sessionGeneration = layerState._sessionGeneration; + layerState._abortController?.abort(); + layerState._abortController = new AbortController(); + layerState._loading = true; + layerState._error = null; + parts.presentation.emitState(); + try { + const body = await source.getDirectory({ + signal: layerState._abortController.signal, + }); + if ( + !parts.model.radioRequestIsCurrent( + generation, + layerState._requestGeneration, + layerState._enabled, + sessionGeneration, + layerState._sessionGeneration, + ) + ) + return; + if (!Array.isArray(body?.stations)) + throw new Error('Radio directory response was malformed'); + const updatedAt = + typeof body.updatedAt === 'string' && + Number.isFinite(Date.parse(body.updatedAt)) + ? body.updatedAt + : null; + const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN; + if ( + !updatedAt || + updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS || + updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS || + typeof body.stale !== 'boolean' || + typeof body.degraded !== 'boolean' + ) + throw new Error('Radio directory freshness metadata was malformed'); + const rows = body.stations; + const acceptedRows = rows.filter( + parts.catalogModel.isValidRadioDirectoryStation, + ); + if (!acceptedRows.length) + throw new Error('Radio directory returned no usable stations'); + if (acceptedRows.length !== rows.length) { + throw new Error( + 'Radio directory response contained malformed stations', + ); + } + const acceptedGeneration = body.acceptedGeneration; + if ( + acceptedGeneration !== null && + (!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1) + ) + throw new Error('Radio directory generation metadata was malformed'); + if (!body.stale && !body.degraded && acceptedGeneration === null) { + throw new Error('Radio directory omitted its accepted generation'); + } + const catalogInstance = body.catalogInstance; + if ( + !body.stale && + !body.degraded && + (typeof catalogInstance !== 'string' || !catalogInstance) + ) { + throw new Error('Radio directory omitted its catalog instance'); + } + const preservingWarmCatalog = + layerState._stations.length > 0 && (body.stale || body.degraded); + // Generations are only comparable within one producer instance. A new + // instance token (server restart, different proxy process) starts a fresh + // sequence: never a repeat and never a regression. + const sameCatalogInstance = + layerState._acceptedCatalogSnapshot?.instance === catalogInstance; + const currentAcceptedGeneration = sameCatalogInstance + ? layerState._acceptedCatalogSnapshot?.generation + : null; + if ( + !body.stale && + !body.degraded && + Number.isSafeInteger(currentAcceptedGeneration) && + acceptedGeneration < currentAcceptedGeneration + ) + throw new Error('Radio directory generation regressed'); + const repeatingAcceptedGeneration = + !body.stale && + !body.degraded && + Number.isSafeInteger(currentAcceptedGeneration) && + acceptedGeneration === currentAcceptedGeneration; + if (!preservingWarmCatalog) { + const immutableRows = acceptedRows.map( + parts.catalogModel.freezeRadioStation, + ); + if (repeatingAcceptedGeneration) { + layerState._updatedAt = + layerState._acceptedCatalogSnapshot.updatedAt; + if ( + !layerState._tuningActive && + layerState._cancelledTuningPresentationStation + ) { + layerState._cancelledTuningPresentationStation = null; + parts.rendering.updateSelectionEntity(); + } + } else if (!body.stale && !body.degraded) { + const acceptedSnapshot = + parts.catalogModel.createAcceptedCatalogSnapshot( + catalogInstance, + acceptedGeneration, + updatedAt, + immutableRows, + ); + if (!acceptedSnapshot) + throw new Error( + 'Radio directory generation metadata was malformed', + ); + layerState._acceptedCatalogSnapshot = acceptedSnapshot; + parts.rendering.reconcileStations(acceptedSnapshot.stations); + } else { + parts.rendering.reconcileStations(immutableRows); + } + if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; + } + layerState._degraded = body.degraded; + layerState._stale = body.stale; + layerState._error = preservingWarmCatalog + ? 'Directory refresh degraded; showing the previous station catalog.' + : layerState._degraded + ? 'Radio directory coverage is degraded.' + : null; + } catch (error) { + if ( + error?.name === 'AbortError' || + !parts.model.radioRequestIsCurrent( + generation, + layerState._requestGeneration, + layerState._enabled, + sessionGeneration, + layerState._sessionGeneration, + ) + ) + return; + layerState._error = layerState._stations.length + ? 'Directory refresh failed; showing the previous station catalog.' + : 'Radio directory is temporarily unavailable.'; + layerState._stale = layerState._stations.length > 0; + layerState._degraded = layerState._stations.length > 0; + } finally { + if ( + generation === layerState._requestGeneration && + sessionGeneration === layerState._sessionGeneration + ) { + layerState._loading = false; + layerState._abortController = null; + parts.presentation.emitState(); + } + } + }, + }; + + return { methods }; +} diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js new file mode 100644 index 000000000..da8ff558a --- /dev/null +++ b/src/layers/radio/interaction.js @@ -0,0 +1,159 @@ +import * as Cesium from 'cesium'; +import { + RADIO_PREFIX, + RADIO_PICK_OFFSETS, + RADIO_OVERLAY_SOURCE_ID, + HORIZON_TICK_MS, +} from './policy.js'; + +export function createInteraction({ + state: layerState, + services, + parts, + source, +}) { + const { + resolvePickId, + isOwnedByOtherLayer, + registerPickOwner, + unregisterPickOwner, + } = services.picking; + const { setOverlaySourceVisible } = services.overlays; + + /** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ + + function radioStationIdFromPick(picked) { + const pending = [picked?.id, picked?.primitive?.id]; + const seen = new Set(); + while (pending.length) { + const value = pending.shift(); + if (typeof value === 'string' || typeof value === 'number') { + const id = String(value); + if (id.startsWith(`${RADIO_PREFIX}selected:`)) + return id.slice(`${RADIO_PREFIX}selected:`.length); + if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length); + continue; + } + if (!value || typeof value !== 'object' || seen.has(value)) continue; + seen.add(value); + if (Array.isArray(value)) pending.push(...value); + else { + pending.push(value.id); + if (value.primitive) pending.push(value.primitive.id); + } + } + return null; + } + + function pickedRadioStationAt(position) { + const scene = layerState._viewer?.scene; + if (!scene || !position) return null; + + const stationFromPick = (picked) => { + const stationId = radioStationIdFromPick(picked); + return stationId && layerState._stationById.has(stationId) + ? stationId + : null; + }; + const primaryPick = scene.pick(position); + const primaryStationId = stationFromPick(primaryPick); + if (primaryStationId) return primaryStationId; + const primaryId = resolvePickId(primaryPick); + if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; + + if (typeof scene.drillPick === 'function') { + const drilled = scene.drillPick(position, 16) || []; + for (const picked of drilled) { + const stationId = stationFromPick(picked); + if (stationId) return stationId; + } + } + + for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { + const offsetPosition = new Cesium.Cartesian2( + position.x + offsetX, + position.y + offsetY, + ); + const picked = scene.pick(offsetPosition); + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; + const stationId = stationFromPick(picked); + if (stationId) return stationId; + } + return null; + } + + function radioPresentationAllowed() { + if (!layerState._managerLifecyclePresentation) return layerState._enabled; + return ( + layerState._enabled && + layerState._managerLifecyclePresentation.lifecycleState === 'enabled' && + layerState._managerLifecyclePresentation.enabled && + !layerState._managerLifecyclePresentation.uncertain + ); + } + + function syncRadioLifecyclePresentation() { + const visible = radioPresentationAllowed(); + if (layerState._dataSource) layerState._dataSource.show = visible; + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); + if (visible) { + installInteraction(); + parts.rendering.updateSelectionEntity(); + parts.rendering.updateRenderVisibility(); + parts.rendering.scheduleRadioOverlayPublish(); + } else { + removeInteraction(); + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + } + } + + function installInteraction() { + if (!layerState._viewer || layerState._clickHandler) return; + registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + layerState._viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + if (!radioPresentationAllowed()) return; + const stationId = pickedRadioStationAt(click.position); + if (!stationId) return; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + parts.selection.selectRadioStation(stationId, { + autoplay: true, + origin: 'user', + }); + if (typeof document !== 'undefined') { + document.dispatchEvent( + new CustomEvent('gev:radio-selected', { detail: { stationId } }), + ); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + // Polling remains bounded during flights, but a stationary camera no longer + // rewrites every station's visibility four times per second while voice and + // audio processing share the main thread. + layerState._horizonTimer = setInterval( + () => parts.rendering.updateRenderVisibility({ force: false }), + HORIZON_TICK_MS, + ); + } + + function removeInteraction() { + unregisterPickOwner('radio'); + layerState._clickHandler?.destroy(); + layerState._clickHandler = null; + if (layerState._horizonTimer) clearInterval(layerState._horizonTimer); + layerState._horizonTimer = null; + } + return { + radioStationIdFromPick, + pickedRadioStationAt, + radioPresentationAllowed, + syncRadioLifecyclePresentation, + installInteraction, + removeInteraction, + }; +} diff --git a/src/layers/radio/labels.js b/src/layers/radio/labels.js new file mode 100644 index 000000000..f801384ce --- /dev/null +++ b/src/layers/radio/labels.js @@ -0,0 +1,215 @@ +import { + RADIO_LABEL_SEGMENTER, + RADIO_GLOBE_LABEL_MAX_CHARS, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_CATEGORY_COLORS, +} from './policy.js'; + +export function createLabels({ state: layerState, services, parts, source }) { + function compactRadioLabelText(value, maxChars) { + const text = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const graphemes = RADIO_LABEL_SEGMENTER + ? [...RADIO_LABEL_SEGMENTER.segment(text)].map( + (segment) => segment.segment, + ) + : Array.from(text); + if (graphemes.length <= maxChars) return text; + return `${graphemes + .slice(0, Math.max(1, maxChars - 1)) + .join('') + .trimEnd()}…`; + } + + function cleanRadioLabelName(value) { + return String(value || '') + .replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '') + .replace(/\s+/g, ' ') + .replace(/\s+\([^()]{1,40}\)$/, '') + .replace(/\s+FM$/i, '') + .trim(); + } + + /** Return compact frequency-first text for a Radio globe label. */ + + function radioGlobeLabel(station) { + const fullName = String(station?.name || '') + .replace(/\s+/g, ' ') + .trim(); + if (!fullName) return ''; + + let frequency = null; + let labelName = ''; + const explicitMatches = [ + ...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi), + ]; + const explicit = explicitMatches.find((match) => { + const value = Number(match[1]); + return value >= 64 && value <= 108; + }); + if (explicit) { + frequency = explicit[1]; + const before = cleanRadioLabelName(fullName.slice(0, explicit.index)); + const after = cleanRadioLabelName( + fullName.slice(explicit.index + explicit[0].length), + ); + labelName = before || after; + } else { + // Infer only a leading decimal in the conventional FM band. Integers such + // as "80's" or "100 GREATEST" and domains such as "1.fm" stay names. + const leading = fullName.match( + /^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/, + ); + const value = Number(leading?.[1]); + if (leading && value >= 87.5 && value <= 108) { + frequency = leading[1]; + labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]); + } + } + + if (!frequency) + return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS); + const prefix = `${frequency} FM`; + if (!labelName) return prefix; + const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3; + return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`; + } + + /** Build the protected selected-station text published through WorldOverlay. */ + + function createRadioSelectedOverlayEntry(station, position) { + if (!station?.id || !station?.name || !position) return null; + return { + id: `selected:${station.id}`, + position, + variant: 'selected', + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-label', + priority: Number.MAX_SAFE_INTEGER, + title: radioGlobeLabel(station), + accent: parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ), + interactive: false, + anchorRadiusPx: 20, + minAnchorGapPx: 8, + verticalOnly: true, + placement: 'above', + gapPx: 8, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + }; + } + + /** Build one bounded ambient cluster badge for the shared overlay host. */ + + function createRadioClusterOverlayEntry({ + id, + position, + text, + accent, + stationCount, + }) { + if (!id || !position || !text) return null; + return { + id: `cluster:${id}`, + position, + variant: 'label', + paintLane: 'ambient-label', + collisionGroup: 'ambient-label', + priority: Math.max(1, Number(stationCount) || 1), + title: text, + accent: accent || RADIO_CATEGORY_COLORS.other, + interactive: false, + anchorRadiusPx: Math.min( + 13, + 6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8, + ), + minAnchorGapPx: 4, + verticalOnly: true, + placement: 'above', + gapPx: 6, + // Cluster membership/counts update continuously while the camera moves. + // Paint them as hard-opacity incumbents so a replacement count never + // cross-fades with its predecessor or dims merely for leaving the keyhole. + // Collision, viewport rejection, and horizon culling remain host-owned. + stateless: true, + edgeFade: 'none', + horizonCull: true, + terrainOcclusion: false, + // The camera can sit above 24,000 km in the supported full-globe view, and + // horizon clusters are farther from it than the camera's surface altitude. + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + distanceScale: { + near: 100_000, + nearValue: 1.08, + far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + farValue: 0.92, + }, + }; + } + + /** Build one ambient station label while Cesium retains its point and picking. */ + + function createRadioSingletonOverlayEntry({ + station, + position, + priority = 1, + }) { + if (!station?.id || !station?.name || !position) return null; + return { + id: `station:${station.id}`, + position, + variant: 'label', + paintLane: 'ambient-label', + collisionGroup: 'ambient-label', + priority: Math.max(1, Number(priority) || 1), + title: radioGlobeLabel(station), + accent: parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ), + interactive: false, + anchorRadiusPx: 9, + minAnchorGapPx: 4, + verticalOnly: true, + placement: 'above', + gapPx: 5, + // Camera/filter changes replace this bounded cohort. Prevent the prior + // cohort from fading over the current visible points. + stateless: true, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + distanceScale: { + near: 100_000, + nearValue: 1, + far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + farValue: 0.86, + }, + }; + } + + /** Build the code-native four-corner bracket used by the selected station. */ + + function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) { + const stroke = /^#[0-9a-f]{6}$/i.test(String(color)) + ? String(color) + : RADIO_CATEGORY_COLORS.other; + return ``; + } + return { + compactRadioLabelText, + cleanRadioLabelName, + radioGlobeLabel, + createRadioSelectedOverlayEntry, + createRadioClusterOverlayEntry, + createRadioSingletonOverlayEntry, + radioSelectionBracketSvg, + }; +} diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js new file mode 100644 index 000000000..414cfa5c0 --- /dev/null +++ b/src/layers/radio/lifecycle.js @@ -0,0 +1,122 @@ +import * as Cesium from 'cesium'; +import { + RADIO_OVERLAY_SOURCE_ID, + DEFAULT_RADIO_VOLUME, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, + DEFAULT_RADIO_FILTER, +} from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { clearOverlaySource, setOverlaySourceVisible } = services.overlays; + + const methods = { + /** Initialize the Cesium data source and the single audio element. */ + init(viewer) { + layerState._sessionGeneration += 1; + layerState._viewer = viewer; + parts.clustering.resetRadioClusterOverlayIdentities(); + parts.playback.installAudio(); + if (!layerState._dataSource) { + layerState._dataSource = new Cesium.CustomDataSource('Radio stations'); + viewer.dataSources.add(layerState._dataSource); + parts.rendering.installClusterStyling(); + } + layerState._dataSource.show = false; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + }, + + /** Show stations. Enabling or preset restoration never starts audio. */ + enable() { + layerState._enabled = true; + parts.interaction.syncRadioLifecyclePresentation(); + parts.presentation.emitState(); + }, + + /** Hide the layer and stop playback without forgetting the selected station. */ + disable() { + layerState._sessionGeneration += 1; + layerState._enabled = false; + parts.navigation.invalidateRadioCameraNavigation(); + layerState._requestGeneration += 1; + layerState._abortController?.abort(); + layerState._abortController = null; + layerState._loading = false; + layerState._selectionGeneration += 1; + if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer); + layerState._selectionTimer = null; + parts.interaction.removeInteraction(); + parts.tuning.endRadioTuning(); + layerState._cancelledTuningPresentationStation = null; + layerState._tuningUnavailableStationId = null; + parts.playback.stopRadioPlayback({ origin: 'layer-disable' }); + if (layerState._dataSource) layerState._dataSource.show = false; + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + if (layerState._overlayPublishTimer) + clearTimeout(layerState._overlayPublishTimer); + layerState._overlayPublishTimer = null; + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._overlayDiagnostics = + parts.clustering.emptyRadioOverlayDiagnostics(); + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + parts.presentation.emitState(); + }, + + /** Release rendering, event, request, and playback resources. */ + destroy() { + this.disable(); + parts.volume.cancelRadioVolumeTransition(); + layerState._voiceDucked = false; + layerState._voiceRestoring = false; + parts.tuning.endRadioTuning(); + const closedTuningNoise = layerState._tuningNoiseContext?.close?.(); + if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {}); + layerState._tuningNoiseContext = null; + if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME; + layerState._audio = null; + layerState._userVolume = DEFAULT_RADIO_VOLUME; + layerState._removeClusterListener?.(); + layerState._removeClusterListener = null; + if (layerState._dataSource && layerState._viewer) + layerState._viewer.dataSources.remove(layerState._dataSource, true); + layerState._dataSource = null; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + layerState._stations = []; + layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._stationById.clear(); + layerState._categories = Object.freeze([]); + layerState._filter = DEFAULT_RADIO_FILTER; + layerState._renderById.clear(); + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._lastHorizonCameraPosition = null; + layerState._horizonScanCount = 0; + layerState._selectedId = null; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + layerState._tuningUnavailableStationId = null; + layerState._cancelledTuningPresentationStation = null; + layerState._loading = false; + layerState._stale = false; + layerState._degraded = false; + layerState._error = null; + layerState._updatedAt = null; + layerState._managerLifecyclePresentation = null; + layerState._viewer = null; + parts.presentation.emitState(); + layerState._listeners.clear(); + layerState._playbackControlListeners.clear(); + }, + }; + + return { methods }; +} diff --git a/src/layers/radio/model.js b/src/layers/radio/model.js new file mode 100644 index 000000000..f969a63fe --- /dev/null +++ b/src/layers/radio/model.js @@ -0,0 +1,131 @@ +import { + RADIO_CATEGORY_COLORS, + RADIO_CLUSTER_LABELS, + RADIO_MARKER_CATEGORY_ORDER, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + /** Return the shared CSS color for a canonical or detected-genre category. */ + + function radioCategoryColor(categoryId = 'other') { + const normalized = String(categoryId || 'other'); + const canonical = normalized.startsWith('genre:') ? 'music' : normalized; + return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other; + } + + /** Format the concise count/category badge shown above a Radio cluster. */ + + function radioClusterBadgeText(categoryId = 'other', count = 0) { + const normalized = String(categoryId || 'other'); + const canonical = normalized.startsWith('genre:') ? 'music' : normalized; + const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other; + const stationCount = Math.max(0, Math.floor(Number(count) || 0)); + return `${stationCount} ${label}`; + } + + /** Choose the category advertised by a cluster in the active station-tag view. */ + + function radioClusterCategoryId(stations, activeFilter = 'all') { + const filter = String(activeFilter || 'all'); + if (filter !== 'all') { + if ( + filter.startsWith('genre:') || + RADIO_MARKER_CATEGORY_ORDER.includes(filter) || + filter === 'other' + ) { + return filter; + } + return 'other'; + } + const categoryCounts = new Map(); + for (const station of Array.isArray(stations) ? stations : []) { + const categoryId = radioStationCategoryId(station); + categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1); + } + let clusterCategory = 'other'; + let clusterCategoryCount = 0; + for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) { + const count = categoryCounts.get(categoryId) || 0; + if (count > clusterCategoryCount) { + clusterCategory = categoryId; + clusterCategoryCount = count; + } + } + if ((categoryCounts.get('other') || 0) > clusterCategoryCount) + return 'other'; + return clusterCategory; + } + + /** Choose one stable display category for a station that may match several filters. */ + + function radioStationCategoryId(station) { + return ( + RADIO_MARKER_CATEGORY_ORDER.find((categoryId) => + parts.categories.stationMatchesRadioCategory(station, categoryId), + ) || 'other' + ); + } + + /** Pure stale-response guard shared by the async directory update path. */ + + function radioRequestIsCurrent( + generation, + currentGeneration, + enabled, + sessionGeneration = null, + currentSessionGeneration = sessionGeneration, + ) { + return ( + generation === currentGeneration && + Boolean(enabled) && + sessionGeneration === currentSessionGeneration + ); + } + + /** Compare every station field that determines tuner presentation and playback. */ + + function radioStationResolutionMatches(frozenStation, currentStation) { + if (!frozenStation || !currentStation) return false; + for (const key of [ + 'id', + 'name', + 'lat', + 'lon', + 'streamUrl', + 'homepage', + 'state', + 'country', + 'countryCode', + 'metadataTrust', + 'codec', + 'bitrate', + ]) { + if (frozenStation[key] !== currentStation[key]) return false; + } + return ['tags', 'languages'].every((key) => { + const frozenValues = Array.isArray(frozenStation[key]) + ? frozenStation[key] + : []; + const currentValues = Array.isArray(currentStation[key]) + ? currentStation[key] + : []; + return ( + frozenValues.length === currentValues.length && + frozenValues.every((value, index) => value === currentValues[index]) + ); + }); + } + + function tuningResolutionStation(id) { + return id ? layerState._tuningStationById.get(String(id)) || null : null; + } + return { + radioCategoryColor, + radioClusterBadgeText, + radioClusterCategoryId, + radioStationCategoryId, + radioRequestIsCurrent, + radioStationResolutionMatches, + tuningResolutionStation, + }; +} diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js new file mode 100644 index 000000000..243a1941b --- /dev/null +++ b/src/layers/radio/navigation.js @@ -0,0 +1,395 @@ +import * as Cesium from 'cesium'; +import { + HORIZON_CAMERA_MOVE_EPSILON_M, + GLOBAL_RADIO_ALTITUDE_M, + RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, +} from './policy.js'; + +export function createNavigation({ + state: layerState, + services, + parts, + source, +}) { + const { + isFullGlobeInsideKeyhole, + GLOBE_ENTER_CLEARANCE_PX, + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, + } = services.globe; + + /** Return whether camera translation materially changes horizon visibility. */ + + function radioCameraPositionChanged( + previous, + current, + epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M, + ) { + if (!previous || !current) return true; + const dx = Number(current.x) - Number(previous.x); + const dy = Number(current.y) - Number(previous.y); + const dz = Number(current.z) - Number(previous.z); + if (![dx, dy, dz].every(Number.isFinite)) return true; + const threshold = Math.max(0, Number(epsilonM) || 0); + return dx * dx + dy * dy + dz * dz > threshold * threshold; + } + + /** Classify a globe-scale Radio view without flapping on Cesium height round-off. */ + + function radioViewIsGlobal(altitudeM) { + return ( + Number.isFinite(altitudeM) && + Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M + ); + } + + /** Plan a station-centered camera move that preserves altitude and view angle. */ + + function radioStationCameraPlan(station, cameraState = {}) { + const targetLat = Number(station?.lat); + const targetLon = Number(station?.lon); + const height = Math.max(1, Number(cameraState.height) || 1); + const heading = Number.isFinite(cameraState.heading) + ? cameraState.heading + : 0; + const pitch = Number.isFinite(cameraState.pitch) + ? cameraState.pitch + : -Math.PI / 2; + const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0; + if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null; + + const downAngle = Math.min( + Math.PI / 2, + Math.max(0.08, Math.abs(Math.min(-0.001, pitch))), + ); + const groundOffsetM = + downAngle > Math.PI / 2 - 1e-6 + ? 0 + : Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle))); + const angularDistance = groundOffsetM / 6_378_137; + const targetLatRad = (targetLat * Math.PI) / 180; + const cameraBearing = heading + Math.PI; + const cameraLatRad = Math.asin( + Math.sin(targetLatRad) * Math.cos(angularDistance) + + Math.cos(targetLatRad) * + Math.sin(angularDistance) * + Math.cos(cameraBearing), + ); + const cameraLonRad = + (targetLon * Math.PI) / 180 + + Math.atan2( + Math.sin(cameraBearing) * + Math.sin(angularDistance) * + Math.cos(targetLatRad), + Math.cos(angularDistance) - + Math.sin(targetLatRad) * Math.sin(cameraLatRad), + ); + const cameraLon = (((cameraLonRad * 180) / Math.PI + 540) % 360) - 180; + return { + lat: (cameraLatRad * 180) / Math.PI, + lon: cameraLon, + height, + heading, + pitch, + roll, + }; + } + + /** + * Decide whether Radio navigation should first restore a centered Earth view. + * Fit-capable clipped discs and closer views whose optical center has reached + * the Earth limb use the staged path; ordinary centered local views stay direct. + */ + + function radioGlobeNeedsRecentering(geometry) { + if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false; + const centeredGeometry = { + ...geometry, + earthCenterX: geometry.keyholeCenterX, + earthCenterY: geometry.keyholeCenterY, + }; + if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true; + const values = [ + geometry.earthCenterX, + geometry.earthCenterY, + geometry.earthRadius, + geometry.keyholeCenterX, + geometry.keyholeCenterY, + ]; + if (!values.every(Number.isFinite) || geometry.earthRadius <= 0) + return false; + const centerOffset = Math.hypot( + geometry.earthCenterX - geometry.keyholeCenterX, + geometry.earthCenterY - geometry.keyholeCenterY, + ); + return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX; + } + + /** Preserve closer zoom while capping an extreme full-globe recovery. */ + + function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) { + if (!Number.isFinite(currentHeight) || currentHeight < 0) return null; + return fullGlobeCapable + ? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M) + : currentHeight; + } + + function radioCameraState(camera = layerState._viewer?.camera) { + if (!camera) return null; + return { + height: camera.positionCartographic?.height, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }; + } + + /** Radio may move the globe only while no tracked entity owns the follow camera. */ + + function radioCameraNavigationAllowed(viewer = layerState._viewer) { + return Boolean(viewer?.camera) && !viewer.trackedEntity; + } + + function radioGlobeRecenterPlan(viewer = layerState._viewer) { + const camera = viewer?.camera; + const canvas = viewer?.scene?.canvas; + const width = canvas?.clientWidth || canvas?.width; + const height = canvas?.clientHeight || canvas?.height; + const cartographic = camera?.positionCartographic; + if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null; + const geometry = projectEarthDiscToViewport( + viewer, + width, + height, + layerState._radioEarthScreenCenter, + layerState._radioEarthToCenter, + ); + let fullGlobeCapable = false; + if (geometry) { + if (!radioGlobeNeedsRecentering(geometry)) return null; + fullGlobeCapable = isFullGlobeInsideKeyhole( + { + ...geometry, + earthCenterX: geometry.keyholeCenterX, + earthCenterY: geometry.keyholeCenterY, + }, + false, + ); + } else { + const earthRadius = earthDiscScreenRadius( + Cesium.Cartesian3.magnitude(camera.positionWC), + height, + camera.frustum?.fovy, + ); + const facingEarthCenter = + Cesium.Cartesian3.dot( + camera.directionWC, + layerState._radioEarthToCenter, + ) > 0; + if (!earthRadius || facingEarthCenter) return null; + const keyhole = getKeyholeGeometry(width, height); + fullGlobeCapable = + earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius; + } + const recenterHeight = radioGlobeRecenterHeight( + cartographic.height, + fullGlobeCapable, + ); + if (recenterHeight == null) return null; + return { + destination: Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + recenterHeight, + ), + cameraState: { + ...radioCameraState(camera), + height: recenterHeight, + heading: 0, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }; + } + + function radioCameraNavigationIsCurrent(navigation) { + return Boolean( + navigation && + navigation.generation === layerState._radioCameraNavigationGeneration && + layerState._enabled && + radioCameraNavigationAllowed(layerState._viewer), + ); + } + + function cancelActiveRadioCameraFlight() { + if ( + !layerState._activeRadioCameraFlight || + !radioCameraNavigationAllowed(layerState._viewer) + ) + return; + layerState._activeRadioCameraFlight = null; + layerState._viewer.camera.cancelFlight(); + } + + function invalidateRadioCameraNavigation() { + layerState._radioCameraNavigationGeneration += 1; + cancelActiveRadioCameraFlight(); + layerState._activeRadioCameraFlight = null; + } + + function radioCameraNavigationOwnsSelection(navigation, station) { + return Boolean( + navigation && + station && + navigation.generation === layerState._radioCameraNavigationGeneration && + navigation.target?.id === station.id, + ); + } + + function startRadioCameraFlight(navigation, options, onComplete) { + if (!radioCameraNavigationIsCurrent(navigation)) return false; + const token = ++layerState._radioCameraFlightSequence; + layerState._activeRadioCameraFlight = { + generation: navigation.generation, + token, + }; + const finish = (completed) => { + if (layerState._activeRadioCameraFlight?.token === token) + layerState._activeRadioCameraFlight = null; + if (completed && radioCameraNavigationIsCurrent(navigation)) + onComplete?.(); + }; + layerState._viewer.camera.flyTo({ + ...options, + complete: () => finish(true), + cancel: () => finish(false), + }); + return true; + } + + function focusRadioNavigationTarget(navigation) { + if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target) + return false; + const plan = radioStationCameraPlan( + navigation.target, + navigation.cameraState, + ); + if (!plan) return false; + navigation.phase = 'focusing'; + return startRadioCameraFlight( + navigation, + { + destination: Cesium.Cartesian3.fromDegrees( + plan.lon, + plan.lat, + plan.height, + ), + orientation: { + heading: plan.heading, + pitch: plan.pitch, + roll: plan.roll, + }, + duration: navigation.duration, + }, + () => { + navigation.phase = 'settled'; + }, + ); + } + + function beginRadioCameraNavigation(cameraState = null) { + const generation = ++layerState._radioCameraNavigationGeneration; + if ( + !radioCameraNavigationAllowed(layerState._viewer) || + !layerState._enabled + ) + return null; + cancelActiveRadioCameraFlight(); + const recenterPlan = radioGlobeRecenterPlan(layerState._viewer); + return { + generation, + phase: 'idle', + recentered: false, + recenterPlan, + cameraState: + recenterPlan?.cameraState || cameraState || radioCameraState(), + target: null, + duration: 0.35, + }; + } + + function rotateRadioStationIntoView( + station, + duration = 0.35, + cameraState = null, + navigation = null, + ) { + const activeNavigation = + navigation || beginRadioCameraNavigation(cameraState); + if (!station || !radioCameraNavigationIsCurrent(activeNavigation)) + return false; + activeNavigation.target = station; + activeNavigation.duration = duration; + if (activeNavigation.phase === 'recentering') return true; + if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight(); + if (activeNavigation.recenterPlan && !activeNavigation.recentered) { + activeNavigation.phase = 'recentering'; + const recenterDuration = Math.min(0.9, Math.max(0.65, duration)); + return startRadioCameraFlight( + activeNavigation, + { + destination: activeNavigation.recenterPlan.destination, + orientation: { + heading: activeNavigation.cameraState.heading, + pitch: activeNavigation.cameraState.pitch, + roll: activeNavigation.cameraState.roll, + }, + duration: recenterDuration, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + () => { + activeNavigation.recentered = true; + activeNavigation.phase = 'idle'; + focusRadioNavigationTarget(activeNavigation); + }, + ); + } + return focusRadioNavigationTarget(activeNavigation); + } + + function focusStation(station) { + layerState._radioCameraNavigationGeneration += 1; + if (!station || !radioCameraNavigationAllowed(layerState._viewer)) + return false; + cancelActiveRadioCameraFlight(); + layerState._viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + station.lon, + station.lat, + 85_000, + ), + duration: 1.1, + }); + return true; + } + return { + radioCameraPositionChanged, + radioViewIsGlobal, + radioStationCameraPlan, + radioGlobeNeedsRecentering, + radioGlobeRecenterHeight, + radioCameraState, + radioCameraNavigationAllowed, + radioGlobeRecenterPlan, + radioCameraNavigationIsCurrent, + cancelActiveRadioCameraFlight, + invalidateRadioCameraNavigation, + radioCameraNavigationOwnsSelection, + startRadioCameraFlight, + focusRadioNavigationTarget, + beginRadioCameraNavigation, + rotateRadioStationIntoView, + focusStation, + }; +} diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js new file mode 100644 index 000000000..cbb4b8be5 --- /dev/null +++ b/src/layers/radio/playback.js @@ -0,0 +1,382 @@ +import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js'; + +export function createPlayback({ state: layerState, services, parts, source }) { + function audioEventBelongsToActiveAttempt(audio) { + const attempt = layerState._activePlaybackAttempt; + return ( + layerState._audio === audio && + Boolean(attempt) && + attempt.generation === layerState._playGeneration && + attempt.stationId === layerState._audioStationId + ); + } + + function installAudio({ replace = false } = {}) { + if (layerState._audio && !replace) return; + if (typeof Audio === 'undefined') return; + const previousAudio = layerState._audio; + layerState._audio = null; + if (previousAudio) { + try { + previousAudio.pause(); + } catch { + /* already stopped */ + } + try { + previousAudio.removeAttribute('src'); + } catch { + /* no source */ + } + try { + previousAudio.load(); + } catch { + /* detached media */ + } + } + const audio = new Audio(); + layerState._audio = audio; + audio.preload = 'none'; + audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume; + audio.addEventListener('playing', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + layerState._audioState = 'playing'; + layerState._audioError = null; + if (layerState._tuningAwaitingStationId === layerState._audioStationId) + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + parts.presentation.emitState(); + }); + audio.addEventListener('pause', () => { + if (layerState._audio !== audio) return; + if ( + layerState._audioState === 'stopped' || + layerState._audioState === 'loading' + ) + return; + layerState._audioState = 'paused'; + parts.presentation.emitState(); + }); + audio.addEventListener('waiting', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + layerState._audioState = 'buffering'; + parts.presentation.emitState(); + }); + audio.addEventListener('error', () => { + if (layerState._audio !== audio) return; + if (!audioEventBelongsToActiveAttempt(audio)) return; + if (!['loading', 'buffering', 'playing'].includes(layerState._audioState)) + return; + const failedId = layerState._audioStationId; + const attempt = layerState._activePlaybackAttempt; + if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return; + layerState._audioState = 'error'; + layerState._audioError = + 'Broadcaster stream is unavailable or blocked by the browser.'; + parts.presentation.emitState(); + }); + } + + function tryRadioFallback( + failedId, + origin = layerState._playFallbackOrigin || 'programmatic', + attemptId = layerState._playFallbackAttemptId, + ) { + const fallbackId = layerState._playFallbackId; + const fallbackStation = fallbackId + ? layerState._stationById.get(fallbackId) + : null; + const fallbackFocusPolicy = layerState._playFallbackFocus; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + if ( + !fallbackStation || + fallbackId === failedId || + layerState._selectedId !== failedId + ) { + return false; + } + const fallbackFocusResult = + typeof fallbackFocusPolicy === 'function' + ? fallbackFocusPolicy(fallbackStation) + : fallbackFocusPolicy; + const fallbackCameraNavigation = + fallbackFocusResult && typeof fallbackFocusResult === 'object' + ? fallbackFocusResult + : null; + const fallbackFocus = fallbackCameraNavigation + ? false + : Boolean(fallbackFocusResult); + parts.selection.selectRadioStation(fallbackId, { + autoplay: true, + focus: fallbackFocus, + origin: origin || 'programmatic', + attemptId, + cameraNavigation: fallbackCameraNavigation, + }); + return true; + } + + function recordDirectoryClick(id) { + Promise.resolve() + .then(() => source.recordClick(id)) + .catch(() => {}); + } + + /** Play the selected broadcaster stream after an explicit user action. */ + + async function playSelectedRadio({ + origin = 'programmatic', + attemptId = null, + } = {}) { + const station = parts.queries.selectedStation(); + if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl) + return false; + if (layerState._tuningActive) parts.tuning.endRadioTuning(); + // A media event carries no reliable attempt identity. Give every explicit + // play/resume/replacement its own element so queued events from the retired + // attempt remain bound to the discarded element and cannot mutate this one. + installAudio({ replace: true }); + if (!layerState._audio) return false; + + const generation = ++layerState._playGeneration; + const ownedAttemptId = + attemptId || `radio-play-${++layerState._playAttemptSequence}`; + layerState._activePlaybackAttempt = { + id: ownedAttemptId, + origin, + stationId: station.id, + streamUrl: station.streamUrl, + generation, + }; + if (layerState._playFallbackId) { + layerState._playFallbackOrigin = origin; + layerState._playFallbackAttemptId = ownedAttemptId; + } + layerState._audioError = null; + layerState._audioState = 'loading'; + if ( + layerState._audioStationId !== station.id || + layerState._audio.src !== station.streamUrl + ) { + layerState._audio.pause(); + layerState._audio.src = station.streamUrl; + layerState._audioStationId = station.id; + } + parts.presentation.emitState(); + + try { + const playback = layerState._audio.play(); + if (playback?.then) await playback; + if ( + generation !== layerState._playGeneration || + layerState._audioStationId !== station.id || + layerState._activePlaybackAttempt?.id !== ownedAttemptId + ) + return false; + layerState._audioState = 'playing'; + if (layerState._tuningAwaitingStationId === station.id) + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + recordDirectoryClick(station.id); + parts.presentation.emitState(); + if (origin === 'user') + parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId); + return true; + } catch (error) { + if ( + generation !== layerState._playGeneration || + layerState._activePlaybackAttempt?.id !== ownedAttemptId + ) + return false; + if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false; + layerState._audioState = 'error'; + layerState._audioError = + error?.name === 'NotAllowedError' + ? 'Playback requires a direct click or tap.' + : 'Broadcaster stream could not be started.'; + parts.presentation.emitState(); + return false; + } + } + + /** + * Wait for confirmed Radio playback while voice owns a hard mute. + * A fallback station may replace the first stream during this wait, so the + * state subscription—not the first play() promise—is authoritative. + */ + + function confirmRadioPlayback({ + startPlayback, + subscribe, + getState, + timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS, + } = {}) { + if ( + typeof startPlayback !== 'function' || + typeof subscribe !== 'function' || + typeof getState !== 'function' + ) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + let started = false; + let unsubscribe = () => {}; + let timer = null; + const finish = (ok) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + unsubscribe(); + resolve(Boolean(ok)); + }; + const inspect = (state) => { + if (!started || !state) return; + if ( + !state.voiceDucked && + ['loading', 'buffering', 'playing'].includes(state.audioState) + ) { + finish(false); + return; + } + if ( + state.audioState === 'playing' && + state.playingStationId && + state.voiceDucked + ) { + finish(true); + } else if (state.audioState === 'error') { + finish(false); + } + }; + unsubscribe = subscribe(inspect); + started = true; + timer = setTimeout( + () => finish(false), + Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS), + ); + Promise.resolve() + .then(startPlayback) + .then((playStarted) => { + const state = getState(); + inspect(state); + if ( + !playStarted && + !['loading', 'buffering', 'playing'].includes(state?.audioState) + ) + finish(false); + }) + .catch(() => finish(false)); + }); + } + + /** Start and verify a prepared station without ever making it audible under voice. */ + + function playPreparedRadioForVoice(options = {}) { + if (!layerState._voiceDucked) return Promise.resolve(false); + return confirmRadioPlayback({ + startPlayback: () => + playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }), + subscribe: parts.presentation.subscribeToRadio, + getState: parts.presentation.getRadioUIState, + timeoutMs: options.timeoutMs, + }); + } + + /** Stop the shared stream and release its network resource. */ + + function stopRadioPlayback({ + origin = 'programmatic', + attemptId = null, + } = {}) { + if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId) + return false; + const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null; + parts.tuning.endRadioTuning(); + layerState._playGeneration += 1; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + layerState._activePlaybackAttempt = null; + if (layerState._audio) { + layerState._audio.pause(); + layerState._audio.removeAttribute('src'); + layerState._audio.load(); + } + layerState._audioStationId = null; + layerState._audioState = 'stopped'; + layerState._audioError = null; + parts.presentation.emitState(); + if (origin === 'user' || origin === 'voice') { + parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId); + } + return true; + } + + /** Pause or resume the selected stream. Resuming is still click initiated. */ + + function toggleRadioPlayback({ origin = 'programmatic' } = {}) { + if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) { + return Promise.resolve(pauseRadioPlayback({ origin })); + } + if (!parts.interaction.radioPresentationAllowed()) + return Promise.resolve(false); + if (!layerState._selectedId) { + const ranked = parts.queries.rankedVisibleStations(); + if (!ranked.length) return Promise.resolve(false); + layerState._playFallbackId = ranked[1]?.id || null; + layerState._playFallbackFocus = false; + parts.selection.selectRadioStation(ranked[0].id, { + autoplay: false, + focus: false, + }); + } + return playSelectedRadio({ origin }); + } + + /** Pause Radio without toggling a stopped or already-paused stream back on. */ + + function pauseRadioPlayback({ origin = 'programmatic' } = {}) { + if (!['loading', 'playing', 'buffering'].includes(layerState._audioState)) + return false; + const pausedAttemptId = layerState._activePlaybackAttempt?.id || null; + if (layerState._tuningAwaitingStationId && !layerState._tuningActive) + parts.tuning.endRadioTuning(); + layerState._playGeneration += 1; + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + layerState._activePlaybackAttempt = null; + layerState._audio?.pause(); + layerState._audioState = 'paused'; + parts.presentation.emitState(); + if (origin === 'user' || origin === 'voice') { + parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId); + } + return true; + } + return { + audioEventBelongsToActiveAttempt, + installAudio, + tryRadioFallback, + recordDirectoryClick, + playSelectedRadio, + confirmRadioPlayback, + playPreparedRadioForVoice, + stopRadioPlayback, + toggleRadioPlayback, + pauseRadioPlayback, + }; +} diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js new file mode 100644 index 000000000..21d987473 --- /dev/null +++ b/src/layers/radio/policy.js @@ -0,0 +1,170 @@ +export const RADIO_PREFIX = 'radio:'; + +export const DIRECTORY_ENDPOINT = '/api/radio/stations'; + +export const HORIZON_TICK_MS = 250; + +export const HORIZON_CAMERA_MOVE_EPSILON_M = 1; + +export const MARKER_LIFT_M = 2.5; + +export const SELECTED_LIFT_M = 5; + +export const RADIO_PICK_TOLERANCE_PX = 8; + +export const RADIO_TUNER_DIRECTORY_LIMIT = 750; + +export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT; + +export const RADIO_TUNER_STATIC_MAX_GAIN = 0.018; + +export const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000; + +export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; + +export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; + +export const RADIO_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const RADIO_OVERLAY_SOURCE_ID = 'radio'; + +export const RADIO_OVERLAY_COHORT_LIMIT = 64; + +export const RADIO_SINGLETON_GLOBAL_LIMIT = 16; + +export const RADIO_SINGLETON_MID_LIMIT = 32; + +export const RADIO_SINGLETON_NEAR_LIMIT = 48; + +export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000; + +export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000; + +export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: RADIO_OVERLAY_COHORT_LIMIT, + collisionCapacity: 96, + moving: false, +}); + +export const RADIO_PICK_OFFSETS = Object.freeze([ + [0, -RADIO_PICK_TOLERANCE_PX], + [RADIO_PICK_TOLERANCE_PX, 0], + [0, RADIO_PICK_TOLERANCE_PX], + [-RADIO_PICK_TOLERANCE_PX, 0], + [6, -6], + [6, 6], + [-6, 6], + [-6, -6], +]); + +export const DEFAULT_RADIO_FILTER = 'all'; + +export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; + +export const MUSIC_GENRES = Object.freeze([ + ['alternative', 'Alternative'], + ['ambient', 'Ambient'], + ['blues', 'Blues'], + ['classical', 'Classical'], + ['country', 'Country'], + ['dance', 'Dance'], + ['electronic', 'Electronic'], + ['folk', 'Folk'], + ['funk', 'Funk'], + ['hip hop', 'Hip-Hop'], + ['house', 'House'], + ['indie', 'Indie'], + ['jazz', 'Jazz'], + ['latin', 'Latin'], + ['metal', 'Metal'], + ['oldies', 'Oldies'], + ['pop', 'Pop'], + ['punk', 'Punk'], + ['r&b', 'R&B'], + ['reggae', 'Reggae'], + ['rock', 'Rock'], + ['soul', 'Soul'], + ['techno', 'Techno'], + ['trance', 'Trance'], + ['world', 'World'], +]); + +export const CATEGORY_MATCHERS = Object.freeze({ + news: ['news', 'current affairs', 'journalism'], + talk: ['talk', 'spoken word', 'interview', 'podcast'], + weather: ['weather', 'emergency', 'noaa'], + 'public-safety': [ + 'public safety', + 'scanner', + 'police', + 'fire', + 'ems', + 'dispatch', + 'emergency', + ], + 'aviation-marine': [ + 'aviation', + 'air traffic', + 'atc', + 'airport', + 'marine', + 'maritime', + 'coast guard', + ], + 'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'], +}); + +export const RADIO_CATEGORY_COLORS = Object.freeze({ + all: '#b9fbff', + news: '#44adff', + talk: '#f2b84b', + weather: '#ff5c78', + 'public-safety': '#ff8b4a', + 'aviation-marine': '#a87cff', + 'traffic-transit': '#ffd166', + music: '#54d17a', + other: '#9aa7b3', +}); + +export const RADIO_CLUSTER_LABELS = Object.freeze({ + news: 'NEWS', + talk: 'TALK', + weather: 'WEATHER', + 'public-safety': 'SAFETY', + 'aviation-marine': 'AIR / SEA', + 'traffic-transit': 'TRANSIT', + music: 'MUSIC', + other: 'OTHER', +}); + +export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([ + 'news', + 'public-safety', + 'weather', + 'aviation-marine', + 'traffic-transit', + 'talk', + 'music', +]); + +export const DEFAULT_RADIO_VOLUME = 0.8; + +export const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({ + instance: null, + generation: null, + updatedAt: null, + stations: Object.freeze([]), + stationIds: Object.freeze([]), +}); + +export const VOICE_RESTORE_DELAY_MS = 650; + +export const VOICE_RESTORE_DURATION_MS = 1800; + +export const RADIO_GLOBE_LABEL_MAX_CHARS = 30; + +export const RADIO_LABEL_SEGMENTER = + typeof Intl?.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null; diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js new file mode 100644 index 000000000..5d6763ca4 --- /dev/null +++ b/src/layers/radio/presentation.js @@ -0,0 +1,105 @@ +export function createPresentation({ + state: layerState, + services, + parts, + source, +}) { + /** Return the deeply immutable healthy catalog generation shared with tuner consumers. */ + + function getRadioAcceptedCatalogSnapshot() { + return layerState._acceptedCatalogSnapshot; + } + + /** Return an immutable snapshot consumed by the right-rail UI. */ + + function getRadioUIState() { + const visible = parts.queries.visibleStations(); + const selected = parts.queries.selectedStation(); + return Object.freeze({ + enabled: layerState._enabled, + loading: layerState._loading, + stale: layerState._stale, + degraded: layerState._degraded, + error: layerState._error, + updatedAt: layerState._updatedAt, + filter: layerState._filter, + categories: layerState._categories, + acceptedCatalogGeneration: layerState._acceptedCatalogSnapshot.generation, + presentationActive: parts.interaction.radioPresentationAllowed(), + stationCount: layerState._stations.length, + filteredCount: visible.length, + selected, + selectedIndex: selected + ? visible.findIndex((station) => station.id === selected.id) + : -1, + audioState: layerState._audioState, + audioError: layerState._audioError, + playingStationId: layerState._audioStationId, + volume: layerState._userVolume, + effectiveVolume: + layerState._audio?.volume ?? + (layerState._voiceDucked ? 0 : layerState._userVolume), + voiceDucked: layerState._voiceDucked, + voiceRestoring: layerState._voiceRestoring, + tuningActive: layerState._tuningActive, + tuningStatic: layerState._tuningStatic, + tuningAwaitingStationId: layerState._tuningAwaitingStationId, + tuningPreviewStationId: layerState._tuningPreviewId, + tuningRestoredStationId: + layerState._cancelledTuningPresentationStation?.id || null, + tuningCatalogGeneration: layerState._tuningResolutionSnapshot.generation, + tuningUnavailableStationId: layerState._tuningUnavailableStationId, + }); + } + + function emitState() { + const snapshot = getRadioUIState(); + for (const listener of layerState._listeners) { + try { + listener(snapshot); + } catch { + // A broken consumer must not break playback or rendering. + } + } + } + + /** Subscribe to radio state; the current state is delivered immediately. */ + + function subscribeToRadio(listener) { + if (typeof listener !== 'function') return () => {}; + layerState._listeners.add(listener); + listener(getRadioUIState()); + return () => layerState._listeners.delete(listener); + } + + /** Subscribe to explicit playback controls so voice handoffs cannot undo them. */ + + function subscribeToRadioPlaybackControls(listener) { + if (typeof listener !== 'function') return () => {}; + layerState._playbackControlListeners.add(listener); + return () => layerState._playbackControlListeners.delete(listener); + } + + function emitPlaybackControl( + action, + origin, + attemptId = layerState._activePlaybackAttempt?.id || null, + ) { + const event = { action, origin, attemptId }; + for (const listener of layerState._playbackControlListeners) { + try { + listener(event); + } catch { + // Playback controls must remain usable if an observer fails. + } + } + } + return { + getRadioAcceptedCatalogSnapshot, + getRadioUIState, + emitState, + subscribeToRadio, + subscribeToRadioPlaybackControls, + emitPlaybackControl, + }; +} diff --git a/src/layers/radio/queries.js b/src/layers/radio/queries.js new file mode 100644 index 000000000..5bc4cc573 --- /dev/null +++ b/src/layers/radio/queries.js @@ -0,0 +1,152 @@ +import * as Cesium from 'cesium'; +import { normalizeRadioCountryInput } from '../../data/radioCountry.js'; + +export function createQueries({ state: layerState, services, parts, source }) { + function radioAngularDistance(station, anchor) { + const lat1 = Cesium.Math.toRadians(Number(anchor?.lat)); + const lon1 = Cesium.Math.toRadians(Number(anchor?.lon)); + const lat2 = Cesium.Math.toRadians(Number(station?.lat)); + const lon2 = Cesium.Math.toRadians(Number(station?.lon)); + if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) + return Number.POSITIVE_INFINITY; + const deltaLat = lat2 - lat1; + const deltaLon = lon2 - lon1; + const haversine = + Math.sin(deltaLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2; + return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine)))); + } + + /** Rank a copied station list by viewport distance, with an optional English-first tier. */ + + function rankRadioStationsForViewport( + stations, + anchor, + { preferEnglish = false } = {}, + ) { + return (Array.isArray(stations) ? stations : []) + .map((station, index) => ({ + station, + index, + distance: radioAngularDistance(station, anchor), + languageTier: + preferEnglish && !parts.categories.isEnglishRadioStation(station) + ? 1 + : 0, + })) + .sort( + (a, b) => + a.languageTier - b.languageTier || + a.distance - b.distance || + a.index - b.index, + ) + .map(({ station }) => station); + } + + /** Rank stations for an explicit voice/player request without moving the camera. */ + + function rankRadioStationsForRequest( + stations, + { categoryId = 'all', anchor = null, country = '', stationQuery = '' } = {}, + ) { + const countryFilter = normalizeRadioCountryInput(country); + if (!countryFilter.valid) return []; + const query = parts.categories.normalizeRadioTag(stationQuery); + let matches = parts.categories.filterRadioStations(stations, categoryId); + if (countryFilter.code || countryFilter.name) { + matches = matches.filter((station) => { + const stationCode = String(station?.countryCode || '') + .trim() + .toUpperCase(); + const stationCountry = normalizeRadioCountryInput(station?.country); + return ( + (countryFilter.code && stationCode === countryFilter.code) || + (countryFilter.code && + stationCountry.valid && + stationCountry.code === countryFilter.code) + ); + }); + } + if (query) { + matches = matches.filter((station) => + [ + station?.id, + station?.name, + station?.state, + station?.country, + station?.countryCode, + ...(Array.isArray(station?.tags) ? station.tags : []), + ].some((value) => + parts.categories.normalizeRadioTag(value).includes(query), + ), + ); + } + return anchor + ? rankRadioStationsForViewport(matches, anchor) + : matches.slice(); + } + + function selectedStation() { + return layerState._selectedId + ? layerState._stationById.get(layerState._selectedId) || null + : null; + } + + function selectedPresentationStation() { + if (layerState._tuningActive) + return parts.model.tuningResolutionStation(layerState._tuningPreviewId); + return layerState._cancelledTuningPresentationStation || selectedStation(); + } + + function visibleStations() { + return parts.categories.filterRadioStations( + layerState._stations, + layerState._filter, + ); + } + + function viewportRadioAnchor() { + const camera = layerState._viewer?.camera; + const scene = layerState._viewer?.scene; + if (!camera) return null; + const altitudeM = Number(camera.positionCartographic?.height); + let cartographic = null; + const canvas = scene?.canvas; + if (canvas && typeof camera.pickEllipsoid === 'function') { + const center = new Cesium.Cartesian2( + canvas.clientWidth / 2, + canvas.clientHeight / 2, + ); + const position = camera.pickEllipsoid( + center, + scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + ); + if (position) cartographic = Cesium.Cartographic.fromCartesian(position); + } + cartographic ||= camera.positionCartographic || null; + if (!cartographic) return null; + return { + lat: Cesium.Math.toDegrees(cartographic.latitude), + lon: Cesium.Math.toDegrees(cartographic.longitude), + altitudeM, + globalView: parts.navigation.radioViewIsGlobal(altitudeM), + }; + } + + function rankedVisibleStations() { + const anchor = viewportRadioAnchor(); + return rankRadioStationsForViewport(visibleStations(), anchor, { + preferEnglish: Boolean(anchor?.globalView), + }); + } + return { + radioAngularDistance, + rankRadioStationsForViewport, + rankRadioStationsForRequest, + selectedStation, + selectedPresentationStation, + visibleStations, + viewportRadioAnchor, + rankedVisibleStations, + }; +} diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js new file mode 100644 index 000000000..36cbc5050 --- /dev/null +++ b/src/layers/radio/rendering.js @@ -0,0 +1,411 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { + MARKER_LIFT_M, + RADIO_PREFIX, + SELECTED_LIFT_M, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + RADIO_OVERLAY_SOURCE_ID, + RADIO_OVERLAY_COHORT_LIMIT, + RADIO_OVERLAY_SOURCE_OPTIONS, + DEFAULT_RADIO_FILTER, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { cachedGroundFloor } = services.ground; + const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } = + services.overlays; + const { governorRequestRender } = services.render; + + function markerPosition(station, liftM = MARKER_LIFT_M) { + const floor = cachedGroundFloor(station.lat, station.lon); + return Cesium.Cartesian3.fromDegrees( + station.lon, + station.lat, + (Number.isFinite(floor) ? floor : 0) + liftM, + ); + } + + function updateSelectionEntity() { + if (layerState._selectedEntity && layerState._viewer) + layerState._viewer.entities.remove(layerState._selectedEntity); + layerState._selectedEntity = null; + const station = parts.queries.selectedPresentationStation(); + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._viewer || + !station + ) { + scheduleRadioOverlayPublish(); + return; + } + const selectionColor = parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ); + const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`; + + layerState._selectedEntity = layerState._viewer.entities.add({ + id: `${RADIO_PREFIX}selected:${station.id}`, + position: markerPosition(station, SELECTED_LIFT_M), + point: { + pixelSize: 14, + color: Cesium.Color.fromCssColorString(selectionColor), + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + }, + billboard: { + image: bracketImage, + width: 40, + height: 40, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72), + }, + }); + scheduleRadioOverlayPublish(); + } + + function clusterPointCollection() { + return layerState._dataSource?.clustering?._clusterPointCollection || null; + } + + function publishRadioOverlayEntries() { + layerState._overlayPublishTimer = null; + if (!parts.interaction.radioPresentationAllowed()) { + parts.clustering.resetRadioClusterOverlayIdentities(); + layerState._overlayDiagnostics = + parts.clustering.emptyRadioOverlayDiagnostics(); + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + return; + } + + const entries = []; + const station = parts.queries.selectedPresentationStation(); + if (station) { + const selectedEntry = parts.labels.createRadioSelectedOverlayEntry( + station, + markerPosition(station, SELECTED_LIFT_M), + ); + if (selectedEntry) entries.push(selectedEntry); + } + + const clusterCandidates = []; + const clusteredStationIds = new Set(); + const points = clusterPointCollection(); + for (let index = 0; index < (points?.length || 0); index += 1) { + const point = points.get(index); + if ( + !point?.show || + !point.position || + !Array.isArray(point.id) || + point.id.length < 3 + ) + continue; + const stationIds = point.id + .map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length)) + .filter((id) => { + const station = layerState._stationById.get(id); + return ( + station && + parts.categories.stationMatchesRadioCategory( + station, + layerState._filter, + ) + ); + }) + .sort(); + if (stationIds.length < 3) continue; + for (const stationId of stationIds) clusteredStationIds.add(stationId); + const clusteredStations = stationIds.map((id) => + layerState._stationById.get(id), + ); + const category = parts.model.radioClusterCategoryId( + clusteredStations, + layerState._filter, + ); + clusterCandidates.push({ + id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`, + stationIds, + point, + text: parts.model.radioClusterBadgeText(category, stationIds.length), + accent: parts.model.radioCategoryColor(category), + stationCount: stationIds.length, + }); + } + const selectedClusterCandidates = + parts.clustering.selectRadioClusterCandidates(clusterCandidates); + layerState._clusterOverlayIdentities = + parts.clustering.reconcileRadioClusterCandidates( + selectedClusterCandidates, + layerState._clusterOverlayIdentities, + () => `stable:${++layerState._clusterOverlayIdentitySequence}`, + ); + for (const candidate of layerState._clusterOverlayIdentities) { + const clusterEntry = parts.labels.createRadioClusterOverlayEntry({ + ...candidate, + position: () => candidate.point.position, + }); + if (clusterEntry) entries.push(clusterEntry); + } + + const cameraPosition = layerState._viewer?.camera?.positionWC; + const singletonAllowance = Math.min( + parts.clustering.radioSingletonLabelLimit( + layerState._viewer?.camera?.positionCartographic?.height, + ), + Math.max( + 0, + RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length, + ), + ); + const singletonCandidates = parts.clustering.selectRadioSingletonCandidates( + [...layerState._renderById.values()] + .filter( + (record) => + record.entity?.show && + record.station?.id !== station?.id && + !clusteredStationIds.has(record.station?.id), + ) + .map((record) => ({ + ...record, + distanceM: cameraPosition + ? Cesium.Cartesian3.distance(cameraPosition, record.position) + : Number.POSITIVE_INFINITY, + })), + singletonAllowance, + ); + for (let index = 0; index < singletonCandidates.length; index += 1) { + const candidate = singletonCandidates[index]; + const singletonEntry = parts.labels.createRadioSingletonOverlayEntry({ + station: candidate.station, + position: candidate.position, + // Cluster priorities begin at three. Keep clusters authoritative while + // retaining nearest-first singleton ordering inside the ambient lane. + priority: 2 - index / Math.max(1, singletonCandidates.length + 1), + }); + if (singletonEntry) entries.push(singletonEntry); + } + + setOverlayEntries( + RADIO_OVERLAY_SOURCE_ID, + entries, + RADIO_OVERLAY_SOURCE_OPTIONS, + ); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true); + layerState._overlayDiagnostics = { + entryCount: entries.length, + selectedCount: entries.filter((entry) => entry.selected).length, + singletonTexts: entries + .filter((entry) => entry.id.startsWith('station:')) + .map((entry) => entry.title), + singletonIds: entries + .filter((entry) => entry.id.startsWith('station:')) + .map((entry) => entry.id), + clusterTexts: entries + .filter((entry) => entry.id.startsWith('cluster:')) + .map((entry) => entry.title), + clusterIds: entries + .filter((entry) => entry.id.startsWith('cluster:')) + .map((entry) => entry.id), + clusterMemberships: layerState._clusterOverlayIdentities.map( + (candidate) => ({ + membershipId: candidate.membershipId, + entryId: `cluster:${candidate.id}`, + }), + ), + }; + } + + function scheduleRadioOverlayPublish() { + if (layerState._overlayPublishTimer) + clearTimeout(layerState._overlayPublishTimer); + const sessionGeneration = layerState._sessionGeneration; + layerState._overlayPublishTimer = setTimeout(() => { + if (sessionGeneration === layerState._sessionGeneration) + publishRadioOverlayEntries(); + }, 0); + } + + function updateRenderVisibility({ force = true } = {}) { + if (!layerState._viewer || !layerState._dataSource) return; + const cameraPosition = layerState._viewer.camera?.positionWC; + if ( + !force && + !parts.navigation.radioCameraPositionChanged( + layerState._lastHorizonCameraPosition, + cameraPosition, + ) + ) + return; + layerState._lastHorizonCameraPosition = cameraPosition + ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z } + : null; + layerState._horizonScanCount += 1; + const occluder = horizonOccluder(layerState._viewer.camera); + let visibilityChanged = false; + for (const [id, record] of layerState._renderById) { + const matches = parts.categories.stationMatchesRadioCategory( + record.station, + layerState._filter, + ); + const visible = matches && occluder.isPointVisible(record.position); + if (record.entity.show !== visible) visibilityChanged = true; + record.entity.show = visible; + } + if (layerState._selectedEntity) { + const position = layerState._selectedEntity.position?.getValue?.( + Cesium.JulianDate.now(), + ); + const selectedVisible = !position || occluder.isPointVisible(position); + if (layerState._selectedEntity.show !== selectedVisible) + visibilityChanged = true; + layerState._selectedEntity.show = selectedVisible; + } + scheduleRadioOverlayPublish(); + // The horizon timer can commit AFTER the camera settles and the governor + // parks the scene — a changed show flag needs one frame. (perf wave 2 fix) + if (visibilityChanged) governorRequestRender('radio-horizon'); + } + + function reconcileStations(stations) { + if (!layerState._tuningActive) + layerState._cancelledTuningPresentationStation = null; + layerState._clusterOverlayIdentities = + parts.clustering.retainRadioClusterIdentitiesForStations( + layerState._clusterOverlayIdentities, + stations, + ); + layerState._stations = Object.freeze([...stations]); + layerState._stationById = new Map( + stations.map((station) => [station.id, station]), + ); + layerState._categories = Object.freeze( + parts.categories + .buildRadioCategories(stations) + .map((category) => Object.freeze(category)), + ); + if ( + layerState._filter === DEFAULT_RADIO_FILTER && + !parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER) + .length + ) { + layerState._filter = 'all'; + } + if ( + layerState._selectedId && + !layerState._stationById.has(layerState._selectedId) + ) { + if (layerState._audioStationId === layerState._selectedId) + parts.playback.stopRadioPlayback(); + layerState._selectedId = null; + } + + if (layerState._dataSource) layerState._dataSource.entities.removeAll(); + layerState._renderById.clear(); + for (const station of stations) { + const position = markerPosition(station); + const markerColor = parts.model.radioCategoryColor( + parts.model.radioStationCategoryId(station), + ); + const entity = layerState._dataSource.entities.add({ + id: `${RADIO_PREFIX}${station.id}`, + position, + point: { + pixelSize: 13, + color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86), + outlineColor: Cesium.Color.fromCssColorString('#071b25'), + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar( + 100_000, + 1.15, + 12_000_000, + 1, + ), + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ), + }, + }); + layerState._renderById.set(station.id, { station, entity, position }); + } + updateSelectionEntity(); + updateRenderVisibility(); + } + + function installClusterStyling() { + if (!layerState._dataSource || layerState._removeClusterListener) return; + const clustering = layerState._dataSource.clustering; + clustering.enabled = true; + clustering.pixelRange = 42; + clustering.minimumClusterSize = 3; + clustering.clusterPoints = true; + clustering.clusterLabels = false; + clustering.clusterBillboards = false; + layerState._removeClusterListener = + clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { + const clusteredStations = []; + for (const entity of clusteredEntities) { + const stationId = String(entity.id || '').slice(RADIO_PREFIX.length); + const station = layerState._renderById.get(stationId)?.station; + if (station) clusteredStations.push(station); + } + const clusterCategory = parts.model.radioClusterCategoryId( + clusteredStations, + layerState._filter, + ); + const clusterColor = Cesium.Color.fromCssColorString( + parts.model.radioCategoryColor(clusterCategory), + ); + // Cesium assigns the entity-id array only to the generated cluster label. + // Mirror it to the visible point so clicking either part of the callout + // resolves the first station and remains a direct playback gesture. + cluster.point.id = clusteredEntities; + cluster.billboard.id = clusteredEntities; + cluster.label.show = false; + cluster.label.text = ''; + cluster.point.show = true; + cluster.point.pixelSize = Math.min( + 26, + 12 + Math.log2(clusteredEntities.length) * 1.6, + ); + cluster.point.color = clusterColor.withAlpha(0.9); + cluster.point.outlineColor = Cesium.Color.BLACK; + cluster.point.outlineWidth = 2; + cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY; + cluster.point.distanceDisplayCondition = + new Cesium.DistanceDisplayCondition( + 0, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ); + scheduleRadioOverlayPublish(); + }); + } + return { + markerPosition, + updateSelectionEntity, + clusterPointCollection, + publishRadioOverlayEntries, + scheduleRadioOverlayPublish, + updateRenderVisibility, + reconcileStations, + installClusterStyling, + }; +} diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js new file mode 100644 index 000000000..bffc7de04 --- /dev/null +++ b/src/layers/radio/selection.js @@ -0,0 +1,269 @@ +import { normalizeRadioFilter } from '../../data/layerState.js'; +import { RADIO_TUNER_DIRECTORY_LIMIT } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { warmGroundFloor } = services.ground; + + /** Durable Radio preferences; this surface never creates or plays audio. */ + + function setRadioParams(params = {}) { + const nextFilter = Object.hasOwn(params, 'filter') + ? normalizeRadioFilter(params.filter) + : null; + if (Object.hasOwn(params, 'filter') && !nextFilter) return false; + const numericVolume = Object.hasOwn(params, 'volume') + ? Number(params.volume) + : null; + if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume)) + return false; + const nextVolume = + numericVolume === null + ? null + : parts.volume.clampRadioVolume(numericVolume); + + let changed = false; + let filterChanged = false; + let clearedCancelledPresentation = false; + if (nextFilter !== null) { + filterChanged = nextFilter !== layerState._filter; + if ( + filterChanged && + (layerState._tuningActive || layerState._tuningAwaitingStationId) + ) + parts.tuning.endRadioTuning(); + clearedCancelledPresentation = Boolean( + layerState._cancelledTuningPresentationStation, + ); + layerState._cancelledTuningPresentationStation = null; + layerState._filter = nextFilter; + if (clearedCancelledPresentation) parts.rendering.updateSelectionEntity(); + if (filterChanged) parts.clustering.resetRadioClusterOverlayIdentities(); + changed ||= filterChanged || clearedCancelledPresentation; + } + if (nextVolume !== null && nextVolume !== layerState._userVolume) { + layerState._userVolume = nextVolume; + if (layerState._audio && !layerState._voiceDucked) { + parts.volume.cancelRadioVolumeTransition(); + layerState._voiceRestoring = false; + layerState._audio.volume = nextVolume; + } + parts.tuningNoise.syncTuningNoiseGain(); + changed = true; + } + if (changed && parts.interaction.radioPresentationAllowed()) { + parts.rendering.updateRenderVisibility(); + if (filterChanged && layerState._dataSource?.clustering?.enabled) { + const clusterPoints = layerState._dataSource.clustering.clusterPoints; + layerState._dataSource.clustering.clusterPoints = !clusterPoints; + layerState._dataSource.clustering.clusterPoints = clusterPoints; + layerState._viewer?.scene?.requestRender(); + } + parts.rendering.scheduleRadioOverlayPublish(); + } + parts.presentation.emitState(); + return true; + } + + function getRadioParams() { + return { filter: layerState._filter, volume: layerState._userVolume }; + } + + /** Select a station. Playback occurs only when autoplay is explicitly true. */ + + function selectRadioStation( + id, + { + autoplay = false, + focus = false, + origin = 'programmatic', + attemptId = null, + cameraNavigation = null, + } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const station = layerState._stationById.get(String(id)); + if (!station) return false; + layerState._tuningUnavailableStationId = null; + if ( + !parts.navigation.radioCameraNavigationOwnsSelection( + cameraNavigation, + station, + ) + ) { + parts.navigation.invalidateRadioCameraNavigation(); + } + if ( + layerState._tuningActive || + (layerState._tuningAwaitingStationId && + layerState._tuningAwaitingStationId !== station.id) + ) + parts.tuning.endRadioTuning(); + layerState._cancelledTuningPresentationStation = null; + layerState._selectedId = station.id; + const generation = ++layerState._selectionGeneration; + const sessionGeneration = layerState._sessionGeneration; + parts.rendering.updateSelectionEntity(); + warmGroundFloor([{ lat: station.lat, lon: station.lon }]); + if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer); + layerState._selectionTimer = setTimeout(() => { + layerState._selectionTimer = null; + if ( + sessionGeneration === layerState._sessionGeneration && + generation === layerState._selectionGeneration && + layerState._selectedId === station.id + ) + parts.rendering.updateSelectionEntity(); + }, 1300); + if (focus) parts.navigation.focusStation(station); + parts.presentation.emitState(); + if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId }); + return true; + } + + /** Select the previous or next station and optionally retain a UI-owned band order. */ + + function cycleRadioStation( + direction = 1, + { + rotate = false, + stationIds = null, + autoplay = true, + origin = 'programmatic', + } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const ranked = + Array.isArray(stationIds) && stationIds.length + ? stationIds + .slice(0, RADIO_TUNER_DIRECTORY_LIMIT) + .map((id) => layerState._stationById.get(String(id))) + .filter( + (station) => + station && + parts.categories.stationMatchesRadioCategory( + station, + layerState._filter, + ), + ) + : parts.queries.rankedVisibleStations(); + if (!ranked.length) return false; + const current = ranked.findIndex( + (station) => station.id === layerState._selectedId, + ); + const nextIndex = + current < 0 + ? 0 + : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; + layerState._playFallbackId = + ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; + const rotationCameraState = rotate + ? parts.navigation.radioCameraState() + : null; + const rotationNavigation = rotate + ? parts.navigation.beginRadioCameraNavigation(rotationCameraState) + : null; + layerState._playFallbackFocus = rotate + ? (fallbackStation) => { + parts.navigation.rotateRadioStationIntoView( + fallbackStation, + 0.65, + rotationCameraState, + rotationNavigation, + ); + return rotationNavigation; + } + : false; + const station = ranked[nextIndex]; + if (rotate) + parts.navigation.rotateRadioStationIntoView( + station, + 0.65, + rotationCameraState, + rotationNavigation, + ); + return selectRadioStation(station.id, { + autoplay, + focus: false, + origin, + cameraNavigation: rotationNavigation, + }); + } + + /** Select and optionally play the best station for a location/category request. */ + + function selectRequestedRadioStation( + criteria = {}, + { autoplay = true, origin = 'programmatic' } = {}, + ) { + if (!parts.interaction.radioPresentationAllowed()) return null; + const requestedCategory = String(criteria.categoryId || 'all'); + const categoryId = layerState._categories.some( + (category) => category.id === requestedCategory, + ) + ? requestedCategory + : 'all'; + setRadioFilter(categoryId); + const ranked = parts.queries.rankRadioStationsForRequest( + layerState._stations, + { + ...criteria, + categoryId, + anchor: criteria.anchor || parts.queries.viewportRadioAnchor(), + }, + ); + if (!ranked.length) return null; + layerState._playFallbackId = ranked[1]?.id || null; + layerState._playFallbackFocus = false; + selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); + return ranked[0]; + } + + /** Change marker/list category without interrupting an active station. */ + + function setRadioFilter(categoryId) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const valid = layerState._categories.some( + (category) => category.id === categoryId, + ); + const nextFilter = valid ? categoryId : 'all'; + const changed = nextFilter !== layerState._filter; + if ( + changed && + (layerState._tuningActive || layerState._tuningAwaitingStationId) + ) + parts.tuning.endRadioTuning(); + const clearsCancelledPresentation = Boolean( + layerState._cancelledTuningPresentationStation, + ); + layerState._cancelledTuningPresentationStation = null; + layerState._filter = nextFilter; + if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity(); + if (changed) parts.clustering.resetRadioClusterOverlayIdentities(); + parts.rendering.updateRenderVisibility(); + if (changed && layerState._dataSource?.clustering?.enabled) { + // Entity visibility changes do not invalidate Cesium's existing cluster + // primitives. Toggle a public clustering input twice to mark the current + // cluster set dirty without changing its effective configuration. + const clusterPoints = layerState._dataSource.clustering.clusterPoints; + layerState._dataSource.clustering.clusterPoints = !clusterPoints; + layerState._dataSource.clustering.clusterPoints = clusterPoints; + layerState._viewer?.scene?.requestRender(); + } + parts.rendering.scheduleRadioOverlayPublish(); + parts.presentation.emitState(); + return true; + } + return { + setRadioParams, + getRadioParams, + selectRadioStation, + cycleRadioStation, + selectRequestedRadioStation, + setRadioFilter, + }; +} diff --git a/src/layers/radio/source.js b/src/layers/radio/source.js new file mode 100644 index 000000000..818e410f5 --- /dev/null +++ b/src/layers/radio/source.js @@ -0,0 +1,33 @@ +import { DIRECTORY_ENDPOINT, RADIO_UUID_RE } from './policy.js'; + +/** Supply directory metadata and click reporting; audio stays with the broadcaster. */ +export function createRadioSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getDirectory({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(DIRECTORY_ENDPOINT, { signal }); + if (!response.ok) + throw new Error(`Radio directory returned ${response.status}`); + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + async recordClick(id, { signal } = {}) { + if (typeof id !== 'string' || !RADIO_UUID_RE.test(id)) + throw new Error('Invalid radio station id'); + signal?.throwIfAborted(); + const response = await fetchImpl( + `/api/radio/click/${encodeURIComponent(id)}`, + { + method: 'POST', + signal, + }, + ); + signal?.throwIfAborted(); + if (!response.ok) + throw new Error(`Radio click returned ${response.status}`); + }, + }; +} diff --git a/src/layers/radio/source.test.mjs b/src/layers/radio/source.test.mjs new file mode 100644 index 000000000..8d28e1485 --- /dev/null +++ b/src/layers/radio/source.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRadioSource, createRadioLayer } from './index.js'; + +const id = '00000000-0000-4000-8000-000000000001'; + +test('radio source confines directory and click requests to their existing routes', async () => { + const calls = []; + const body = { stations: [], stale: false }; + const source = createRadioSource({ + fetchImpl: async (path, options) => { + calls.push({ path, options }); + return new Response(JSON.stringify(body)); + }, + }); + const controller = new AbortController(); + assert.deepEqual( + await source.getDirectory({ signal: controller.signal }), + body, + ); + await source.recordClick(id, { signal: controller.signal }); + assert.deepEqual( + calls.map((call) => call.path), + ['/api/radio/stations', `/api/radio/click/${id}`], + ); + assert.equal(calls[1].options.method, 'POST'); + assert.ok(calls.every((call) => call.options.signal === controller.signal)); + for (const invalid of [ + '../stations', + 'https://example.com', + '', + null, + `${id}?x=1`, + ]) + await assert.rejects( + source.recordClick(invalid), + /Invalid radio station id/, + ); + assert.equal(calls.length, 2); +}); + +test('radio source propagates denial and cancels completed body parsing', async () => { + const denied = createRadioSource({ + fetchImpl: async () => new Response('', { status: 403 }), + }); + await assert.rejects(denied.getDirectory(), /403/); + await assert.rejects(denied.recordClick(id), /403/); + const controller = new AbortController(); + const source = createRadioSource({ + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { stations: [] }; + }, + }), + }); + await assert.rejects(source.getDirectory({ signal: controller.signal }), { + name: 'AbortError', + }); + let called = false; + const idle = createRadioSource({ + fetchImpl: async () => { + called = true; + }, + }); + await assert.rejects(idle.recordClick(id, { signal: controller.signal }), { + name: 'AbortError', + }); + assert.equal(called, false); +}); + +test('radio factories keep settings and subscriptions independent without fetching or audio startup', () => { + let requested = false; + const source = { + getDirectory() { + requested = true; + }, + recordClick() { + requested = true; + }, + }; + const services = { + ground: {}, + picking: { unregisterPickOwner() {} }, + globe: {}, + render: { governorRequestRender() {} }, + overlays: { + clearOverlaySource() {}, + setOverlaySourceVisible() {}, + setOverlayEntries() {}, + }, + }; + const first = createRadioLayer({ source, services }); + const second = createRadioLayer({ source, services }); + let changes = 0; + const off = second.subscribeToRadio(() => changes++); + const initialChanges = changes; + first.setRadioParams({ volume: 0.3 }); + assert.equal(first.getRadioParams().volume, 0.3); + assert.equal(second.getRadioParams().volume, 0.8); + assert.equal(changes, initialChanges); + assert.equal(requested, false); + off(); + first.destroy(); + assert.equal(second.getRadioParams().volume, 0.8); + second.destroy(); +}); diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js new file mode 100644 index 000000000..dca461e03 --- /dev/null +++ b/src/layers/radio/state.js @@ -0,0 +1,157 @@ +import * as Cesium from 'cesium'; +import { + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, + DEFAULT_RADIO_FILTER, + DEFAULT_RADIO_VOLUME, +} from './policy.js'; + +export function createState({ services }) { + const layerState = {}; + + layerState._radioEarthScreenCenter = new Cesium.Cartesian2(); + + layerState._radioEarthToCenter = new Cesium.Cartesian3(); + + layerState._viewer = null; + + layerState._dataSource = null; + + layerState._enabled = false; + + layerState._managerLifecyclePresentation = null; + + layerState._loading = false; + + layerState._stale = false; + + layerState._degraded = false; + + layerState._error = null; + + layerState._updatedAt = null; + + layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + + layerState._stations = []; + + layerState._stationById = new Map(); + + layerState._categories = []; + + layerState._renderById = new Map(); + + layerState._filter = DEFAULT_RADIO_FILTER; + + layerState._selectedId = null; + + layerState._selectedEntity = null; + + layerState._selectionGeneration = 0; + + layerState._selectionTimer = null; + + layerState._radioCameraNavigationGeneration = 0; + + layerState._radioCameraFlightSequence = 0; + + layerState._activeRadioCameraFlight = null; + + layerState._audio = null; + + layerState._audioStationId = null; + + layerState._audioState = 'stopped'; + + layerState._audioError = null; + + layerState._userVolume = DEFAULT_RADIO_VOLUME; + + layerState._tuningActive = false; + + layerState._tuningStatic = false; + + layerState._tuningAwaitingStationId = null; + + layerState._tuningPreviewId = null; + + layerState._tuningStartStationId = null; + + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + + layerState._tuningStationById = new Map(); + + layerState._tuningUnavailableStationId = null; + + layerState._cancelledTuningPresentationStation = null; + + layerState._tuningCameraNavigation = null; + + layerState._tuningNoiseContext = null; + + layerState._tuningNoiseSource = null; + + layerState._tuningNoiseFilter = null; + + layerState._tuningNoiseGain = null; + + layerState._voiceDucked = false; + + layerState._voiceRestoring = false; + + layerState._voiceRestoreTimer = null; + + layerState._volumeFadeFrame = null; + + layerState._volumeTransitionGeneration = 0; + + layerState._playGeneration = 0; + + layerState._playAttemptSequence = 0; + + layerState._activePlaybackAttempt = null; + + layerState._playFallbackId = null; + + layerState._playFallbackFocus = null; + + layerState._playFallbackOrigin = 'programmatic'; + + layerState._playFallbackAttemptId = null; + + layerState._clickHandler = null; + + layerState._horizonTimer = null; + + layerState._lastHorizonCameraPosition = null; + + layerState._horizonScanCount = 0; + + layerState._abortController = null; + + layerState._requestGeneration = 0; + + layerState._sessionGeneration = 0; + + layerState._removeClusterListener = null; + + layerState._overlayPublishTimer = null; + + layerState._clusterOverlayIdentitySequence = 0; + + layerState._clusterOverlayIdentities = []; + + layerState._overlayDiagnostics = { + entryCount: 0, + selectedCount: 0, + singletonTexts: [], + singletonIds: [], + clusterTexts: [], + clusterIds: [], + clusterMemberships: [], + }; + + layerState._listeners = new Set(); + + layerState._playbackControlListeners = new Set(); + return layerState; +} diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js new file mode 100644 index 000000000..ab5c56bb5 --- /dev/null +++ b/src/layers/radio/tuning.js @@ -0,0 +1,233 @@ +import { + RADIO_TUNER_STATION_LIMIT, + RADIO_TUNER_DIRECTORY_LIMIT, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, +} from './policy.js'; + +export function createTuning({ state: layerState, services, parts, source }) { + /** Bound one already ordered filtered directory without injecting outside selections. */ + + function buildRadioTunerBand( + rankedStations, + selected, + limit = RADIO_TUNER_STATION_LIMIT, + ) { + const boundedLimit = Math.min( + RADIO_TUNER_DIRECTORY_LIMIT, + Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)), + ); + return (Array.isArray(rankedStations) ? rankedStations : []).slice( + 0, + boundedLimit, + ); + } + + function captureRadioTuningResolutionSnapshot() { + return layerState._acceptedCatalogSnapshot; + } + + /** Return the complete accepted filtered directory in stable catalog order. */ + + function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._acceptedCatalogSnapshot.stations.length + ) + return []; + return buildRadioTunerBand( + parts.queries.visibleStations(), + parts.queries.selectedStation(), + limit, + ); + } + + /** Begin a direct-manipulation tuning gesture and pause the current stream. */ + + function beginRadioTuning() { + if ( + !parts.interaction.radioPresentationAllowed() || + !parts.queries.visibleStations().length + ) + return false; + const snapshot = captureRadioTuningResolutionSnapshot(); + if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length) + return false; + layerState._tuningResolutionSnapshot = snapshot; + layerState._tuningStationById = new Map( + layerState._tuningResolutionSnapshot.stations.map((station) => [ + station.id, + station, + ]), + ); + layerState._tuningUnavailableStationId = null; + layerState._tuningActive = true; + layerState._tuningStatic = false; + layerState._tuningAwaitingStationId = null; + layerState._cancelledTuningPresentationStation = null; + layerState._tuningStartStationId = layerState._selectedId; + layerState._tuningPreviewId = layerState._selectedId; + parts.tuningNoise.installTuningNoise(); + parts.playback.pauseRadioPlayback({ origin: 'user' }); + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** Toggle low-volume synthesized static for tuner preview and stream handoff. */ + + function setRadioTuningStatic(active) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive + ) + return false; + const next = Boolean(active); + if (next === layerState._tuningStatic) return true; + layerState._tuningStatic = next; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** Preview a tuner station bracket and camera orientation without starting audio. */ + + function previewRadioTuningStation(id, { rotate = true } = {}) { + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive + ) + return false; + const station = parts.model.tuningResolutionStation(id); + const nextId = station?.id || null; + const changed = nextId !== layerState._tuningPreviewId; + layerState._tuningPreviewId = nextId; + layerState._tuningStatic = !station; + if (changed) { + parts.rendering.updateSelectionEntity(); + if (station && rotate) { + const cameraState = parts.navigation.radioCameraState(); + layerState._tuningCameraNavigation = + parts.navigation.beginRadioCameraNavigation(cameraState); + parts.navigation.rotateRadioStationIntoView( + station, + 0.35, + cameraState, + layerState._tuningCameraNavigation, + ); + } else if (rotate) { + layerState._tuningCameraNavigation = null; + parts.navigation.invalidateRadioCameraNavigation(); + } + } + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return Boolean(station); + } + + /** Finish a tuning gesture and release its synthesized noise source. */ + + function endRadioTuning() { + if ( + !layerState._tuningActive && + !layerState._tuningStatic && + !layerState._tuningAwaitingStationId && + !layerState._tuningNoiseSource && + !layerState._cancelledTuningPresentationStation + ) + return; + parts.tuningNoise.clearRadioTuningNoise(); + } + + /** Cancel a tuning gesture and restore its frozen start marker for presentation only. */ + + function cancelRadioTuning() { + if (!layerState._tuningActive) return false; + const restoredStation = parts.model.tuningResolutionStation( + layerState._tuningStartStationId, + ); + parts.tuningNoise.clearRadioTuningNoise({ restoredStation }); + return true; + } + + /** Commit the exact frozen drag resolution or report that it is unavailable. */ + + function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) { + const frozenStation = parts.model.tuningResolutionStation(id); + const currentStation = layerState._stationById.get(String(id)) || null; + const generation = layerState._tuningResolutionSnapshot.generation; + if ( + !parts.interaction.radioPresentationAllowed() || + !layerState._tuningActive || + !frozenStation + ) { + endRadioTuning(); + return Object.freeze({ + ok: false, + reason: 'not-tuning', + stationId: String(id || ''), + generation, + }); + } + if ( + !parts.model.radioStationResolutionMatches(frozenStation, currentStation) + ) { + const stationId = frozenStation.id; + // A failed exact release owns the visible outcome. Do not let the previous + // selection—or changed metadata under the same ID—reappear between the + // frozen preview and the unavailable state. + layerState._selectedId = null; + layerState._selectionGeneration += 1; + parts.tuningNoise.clearRadioTuningNoise({ emit: false }); + layerState._tuningUnavailableStationId = stationId; + parts.presentation.emitState(); + return Object.freeze({ + ok: false, + reason: 'station-unavailable', + stationId, + generation, + }); + } + layerState._tuningActive = false; + layerState._tuningStatic = true; + layerState._tuningAwaitingStationId = frozenStation.id; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningUnavailableStationId = null; + const cameraNavigation = layerState._tuningCameraNavigation; + layerState._tuningCameraNavigation = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + // An exact tuner release owns its frozen target. A fallback armed by an + // earlier non-playing selection must never retarget this gesture if the + // broadcaster fails after release. + layerState._playFallbackId = null; + layerState._playFallbackFocus = null; + layerState._playFallbackOrigin = 'programmatic'; + layerState._playFallbackAttemptId = null; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + const selected = parts.selection.selectRadioStation(frozenStation.id, { + autoplay: true, + focus: false, + origin, + cameraNavigation, + }); + return Object.freeze({ + ok: selected, + reason: selected ? null : 'station-unavailable', + stationId: frozenStation.id, + generation, + }); + } + return { + buildRadioTunerBand, + captureRadioTuningResolutionSnapshot, + getRadioTunerStations, + beginRadioTuning, + setRadioTuningStatic, + previewRadioTuningStation, + endRadioTuning, + cancelRadioTuning, + commitRadioTuningStation, + }; +} diff --git a/src/layers/radio/tuningNoise.js b/src/layers/radio/tuningNoise.js new file mode 100644 index 000000000..dfbc3fe3b --- /dev/null +++ b/src/layers/radio/tuningNoise.js @@ -0,0 +1,145 @@ +import { + RADIO_TUNER_STATIC_MAX_GAIN, + EMPTY_ACCEPTED_CATALOG_SNAPSHOT, +} from './policy.js'; + +export function createTuningNoise({ + state: layerState, + services, + parts, + source, +}) { + /** Decide whether tuner static should be audible for the current handoff state. */ + + function radioTuningStaticShouldPlay({ + tuningActive = false, + tuningStatic = false, + awaitingStationId = null, + voiceDucked = false, + } = {}) { + return Boolean( + tuningStatic && !voiceDucked && (tuningActive || awaitingStationId), + ); + } + + function stopTuningNoiseSource() { + if (layerState._tuningNoiseSource) { + try { + layerState._tuningNoiseSource.stop(); + } catch { + /* already stopped */ + } + try { + layerState._tuningNoiseSource.disconnect(); + } catch { + /* already disconnected */ + } + } + try { + layerState._tuningNoiseFilter?.disconnect(); + } catch { + /* already disconnected */ + } + try { + layerState._tuningNoiseGain?.disconnect(); + } catch { + /* already disconnected */ + } + layerState._tuningNoiseSource = null; + layerState._tuningNoiseFilter = null; + layerState._tuningNoiseGain = null; + } + + function syncTuningNoiseGain() { + if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; + const audible = radioTuningStaticShouldPlay({ + tuningActive: layerState._tuningActive, + tuningStatic: layerState._tuningStatic, + awaitingStationId: layerState._tuningAwaitingStationId, + voiceDucked: layerState._voiceDucked, + }); + const target = audible + ? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, layerState._userVolume * 0.03) + : 0; + const now = layerState._tuningNoiseContext.currentTime; + layerState._tuningNoiseGain.gain.cancelScheduledValues(now); + layerState._tuningNoiseGain.gain.setValueAtTime( + layerState._tuningNoiseGain.gain.value, + now, + ); + layerState._tuningNoiseGain.gain.linearRampToValueAtTime( + target, + now + 0.025, + ); + } + + function installTuningNoise() { + const AudioContextClass = + globalThis.AudioContext || globalThis.webkitAudioContext; + if (!AudioContextClass) return false; + if (!layerState._tuningNoiseContext) + layerState._tuningNoiseContext = new AudioContextClass(); + const resumed = layerState._tuningNoiseContext.resume?.(); + if (resumed?.catch) void resumed.catch(() => {}); + if (layerState._tuningNoiseSource) return true; + + const frameCount = Math.max( + 1, + Math.floor(layerState._tuningNoiseContext.sampleRate * 0.75), + ); + const buffer = layerState._tuningNoiseContext.createBuffer( + 1, + frameCount, + layerState._tuningNoiseContext.sampleRate, + ); + const channel = buffer.getChannelData(0); + for (let index = 0; index < channel.length; index += 1) + channel[index] = Math.random() * 2 - 1; + + layerState._tuningNoiseSource = + layerState._tuningNoiseContext.createBufferSource(); + layerState._tuningNoiseFilter = + layerState._tuningNoiseContext.createBiquadFilter(); + layerState._tuningNoiseGain = layerState._tuningNoiseContext.createGain(); + layerState._tuningNoiseSource.buffer = buffer; + layerState._tuningNoiseSource.loop = true; + layerState._tuningNoiseFilter.type = 'bandpass'; + layerState._tuningNoiseFilter.frequency.value = 1_650; + layerState._tuningNoiseFilter.Q.value = 0.55; + layerState._tuningNoiseGain.gain.value = 0; + layerState._tuningNoiseSource.connect(layerState._tuningNoiseFilter); + layerState._tuningNoiseFilter.connect(layerState._tuningNoiseGain); + layerState._tuningNoiseGain.connect( + layerState._tuningNoiseContext.destination, + ); + layerState._tuningNoiseSource.start(); + return true; + } + + function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) { + if (layerState._tuningCameraNavigation) + parts.navigation.invalidateRadioCameraNavigation(); + layerState._tuningActive = false; + layerState._tuningStatic = false; + layerState._tuningAwaitingStationId = null; + layerState._tuningPreviewId = null; + layerState._tuningStartStationId = null; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; + layerState._tuningStationById = new Map(); + layerState._tuningCameraNavigation = null; + layerState._cancelledTuningPresentationStation = restoredStation; + parts.rendering.updateSelectionEntity(); + syncTuningNoiseGain(); + stopTuningNoiseSource(); + const suspended = layerState._tuningNoiseContext?.suspend?.(); + if (suspended?.catch) void suspended.catch(() => {}); + if (emit) parts.presentation.emitState(); + } + return { + radioTuningStaticShouldPlay, + stopTuningNoiseSource, + syncTuningNoiseGain, + installTuningNoise, + clearRadioTuningNoise, + }; +} diff --git a/src/layers/radio/volume.js b/src/layers/radio/volume.js new file mode 100644 index 000000000..1be63cc53 --- /dev/null +++ b/src/layers/radio/volume.js @@ -0,0 +1,148 @@ +import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js'; + +export function createVolume({ state: layerState, services, parts, source }) { + function clampRadioVolume(value) { + return Math.min(1, Math.max(0, Number(value) || 0)); + } + + function cancelRadioVolumeTransition() { + layerState._volumeTransitionGeneration += 1; + if (layerState._voiceRestoreTimer) + clearTimeout(layerState._voiceRestoreTimer); + layerState._voiceRestoreTimer = null; + if (layerState._volumeFadeFrame) { + if (typeof cancelAnimationFrame === 'function') + cancelAnimationFrame(layerState._volumeFadeFrame); + else clearTimeout(layerState._volumeFadeFrame); + } + layerState._volumeFadeFrame = null; + } + + function scheduleVolumeFrame(callback) { + if (typeof requestAnimationFrame === 'function') + return requestAnimationFrame(callback); + return setTimeout(() => callback(Date.now()), 16); + } + + function volumeClock() { + return typeof performance !== 'undefined' && + typeof performance.now === 'function' + ? performance.now() + : Date.now(); + } + + /** Set shared audio volume, clamped to [0, 1]. */ + + function setRadioVolume(value) { + if (!parts.interaction.radioPresentationAllowed()) return false; + const volume = clampRadioVolume(value); + layerState._userVolume = volume; + parts.playback.installAudio(); + if (!layerState._voiceDucked) { + cancelRadioVolumeTransition(); + layerState._voiceRestoring = false; + if (layerState._audio) layerState._audio.volume = volume; + } + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return true; + } + + /** + * Mute Radio during a live voice turn, then gently restore the user-owned + * volume after voice returns to standby. Repeated state sync is idempotent. + */ + + function setRadioVoiceDucking( + ducked, + { + restoreDelayMs = VOICE_RESTORE_DELAY_MS, + restoreDurationMs = VOICE_RESTORE_DURATION_MS, + } = {}, + ) { + const shouldDuck = Boolean(ducked); + if ( + shouldDuck === layerState._voiceDucked && + (shouldDuck || layerState._voiceRestoring) + ) + return; + if ( + !shouldDuck && + !layerState._voiceDucked && + !layerState._voiceRestoring && + (!layerState._audio || + Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001) + ) + return; + + cancelRadioVolumeTransition(); + layerState._voiceDucked = shouldDuck; + layerState._voiceRestoring = false; + + if (shouldDuck) { + if (layerState._audio) layerState._audio.volume = 0; + parts.tuningNoise.syncTuningNoiseGain(); + parts.presentation.emitState(); + return; + } + if (!layerState._audio) { + parts.tuningNoise.syncTuningNoiseGain(); + layerState._voiceRestoring = false; + parts.presentation.emitState(); + return; + } + + const generation = layerState._volumeTransitionGeneration; + parts.tuningNoise.syncTuningNoiseGain(); + layerState._voiceRestoring = true; + parts.presentation.emitState(); + const beginRestore = () => { + layerState._voiceRestoreTimer = null; + if ( + generation !== layerState._volumeTransitionGeneration || + layerState._voiceDucked || + !layerState._audio + ) + return; + const startedAt = volumeClock(); + const initialVolume = layerState._audio.volume; + const duration = Math.max(0, Number(restoreDurationMs) || 0); + const step = (now) => { + if ( + generation !== layerState._volumeTransitionGeneration || + layerState._voiceDucked || + !layerState._audio + ) + return; + const progress = + duration === 0 + ? 1 + : Math.min(1, Math.max(0, (now - startedAt) / duration)); + const eased = progress * progress * (3 - 2 * progress); + layerState._audio.volume = clampRadioVolume( + initialVolume + (layerState._userVolume - initialVolume) * eased, + ); + if (progress < 1) { + layerState._volumeFadeFrame = scheduleVolumeFrame(step); + return; + } + layerState._volumeFadeFrame = null; + layerState._voiceRestoring = false; + parts.presentation.emitState(); + }; + layerState._volumeFadeFrame = scheduleVolumeFrame(step); + }; + const delay = Math.max(0, Number(restoreDelayMs) || 0); + if (delay > 0) + layerState._voiceRestoreTimer = setTimeout(beginRestore, delay); + else beginRestore(); + } + return { + clampRadioVolume, + cancelRadioVolumeTransition, + scheduleVolumeFrame, + volumeClock, + setRadioVolume, + setRadioVoiceDucking, + }; +} diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js new file mode 100644 index 000000000..25eaf9b2c --- /dev/null +++ b/src/layers/satellites/catalog.js @@ -0,0 +1,239 @@ +import * as Cesium from 'cesium'; +import { twoline2satrec } from 'satellite.js'; +import { + DENSE_REFRESH_FRAMES, + DENSE_GROUP_PATH, + POINT_STYLES, + DENSE_CREATE_CHUNK, +} from './policy.js'; + +export function createCatalog({ state: layerState, services, parts, source }) { + function _abortActiveUpdates() { + for (const controller of layerState._activeUpdateControllers) + controller.abort(); + layerState._activeUpdateControllers.clear(); + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + } + + /** + * Re-propagate a small per-frame slice of the dense extras (round-robin). + * Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES + * frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays + * ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the + * work per frame avoids the once-per-second spike a tick-sized chunk + * (~2K props ≈ 4ms) would cause. + */ + + function _propagateDenseChunk() { + if (layerState._denseIds.length === 0) return; + const perFrame = Math.max( + 1, + Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), + ); + const now = new Date(); + for (let i = 0; i < perFrame; i++) { + if (layerState._denseCursor >= layerState._denseIds.length) + layerState._denseCursor = 0; + const noradId = layerState._denseIds[layerState._denseCursor++]; + if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it + const sat = layerState._catalog.get(noradId); + const point = layerState._points.get(noradId); + if (!sat || !point) continue; + const pos = parts.orbits.propagatePosition(sat.satrec, now); + if (pos) { + point.position = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + } + } + } + + /** + * Load the dense catalog extras (Starlink) as points-only satellites. + * Chunked so ~10K twoline2satrec builds + initial propagations never block a + * frame; a token guards against mode flips / catalog rebuilds mid-load. + */ + + async function _loadDenseCatalog({ signal = null } = {}) { + if (!layerState._viewer || !layerState._pointCollection) + return { status: 'source-unavailable', reason: 'layer-unavailable' }; + layerState._denseLoadController?.abort(); + const resourceController = new AbortController(); + layerState._denseLoadController = resourceController; + const loadSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + const token = ++layerState._denseLoadToken; + layerState._denseStatus = 'loading'; + layerState._denseError = null; + parts.controls._notifyRowControls(); + try { + loadSignal.throwIfAborted(); + const res = await source.readGroup(DENSE_GROUP_PATH, { + signal: loadSignal, + }); + if (!res.ok) { + console.warn( + `[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' fetch failed (${res.status})`, + ); + _denseLoadFailed(token, `feed unavailable (${res.status})`); + return { + status: 'source-unavailable', + reason: `feed unavailable (${res.status})`, + }; + } + const text = res.text; + loadSignal.throwIfAborted(); + if ( + token !== layerState._denseLoadToken || + layerState._params.catalog !== 'dense' + ) { + return { status: 'superseded', reason: 'dense-load-superseded' }; + } + + const entries = parts.orbits.parseTLE(text); + const style = POINT_STYLES.dense; + const now = new Date(); + let added = 0; + + for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { + loadSignal.throwIfAborted(); + if ( + token !== layerState._denseLoadToken || + layerState._params.catalog !== 'dense' || + !layerState._pointCollection + ) { + return { status: 'superseded', reason: 'dense-load-superseded' }; + } + const end = Math.min(start + DENSE_CREATE_CHUNK, entries.length); + for (let i = start; i < end; i++) { + const entry = entries[i]; + const satrec = twoline2satrec(entry.line1, entry.line2); + if (!satrec || satrec.error !== 0) continue; + const noradId = Number(satrec.satnum); + if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority + const pos = parts.orbits.propagatePosition(satrec, now); + if (!pos) continue; + + layerState._catalog.set(noradId, { + name: entry.name, + satrec, + group: 'dense', + }); + const point = layerState._pointCollection.add({ + position: Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ), + pixelSize: style.pixelSize, + color: style.color, + outlineColor: style.outlineColor, + outlineWidth: style.outlineWidth, + scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + id: noradId, + }); + layerState._points.set(noradId, point); + layerState._denseIds.push(noradId); + added++; + } + // Yield to the event loop between chunks. + await new Promise((resolve) => setTimeout(resolve, 0)); + loadSignal.throwIfAborted(); + } + + // A 200 that yields nothing usable is still a failed load — an empty body, + // an HTML error page the proxy passed through, or a feed of TLEs the core + // catalog already owns. Treating it as success is the same lie as treating + // a 502 as success, just through a different door. + if (added === 0) { + console.warn( + `[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' returned no usable satellites`, + ); + _denseLoadFailed(token, 'feed returned no satellites'); + return { + status: 'source-unavailable', + reason: 'feed returned no satellites', + }; + } + + layerState._count = layerState._points.size; + layerState._catalogRevision++; + layerState._denseStatus = 'ready'; + console.log( + `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, + ); + // The panel would otherwise keep the pre-load count and legend until the + // next natural refresh — up to the 5-minute catalog interval. + parts.controls._notifyRowControls(); + parts.tracking._applyPendingTrackingRestore(); + return { status: 'ready', added }; + } catch (e) { + if (loadSignal.aborted || e?.name === 'AbortError') { + return { + status: 'cancelled', + reason: String(loadSignal.reason || 'aborted'), + }; + } + console.warn('[Data:Satellites] Dense catalog load failed:', e); + _denseLoadFailed(token, 'feed unreachable'); + return { status: 'source-unavailable', reason: 'feed unreachable' }; + } finally { + if (layerState._denseLoadController === resourceController) + layerState._denseLoadController = null; + } + } + + /** + * Settle a failed dense load: drop any partial chunk, return the layer to the + * core catalog, and leave the reason on the chip. Reverting the param is the + * point — a chip that reads ACTIVE over an empty sky is a lie. + * @param {number} token The load token that failed. + * @param {string} reason Short operator-facing cause. + */ + + function _denseLoadFailed(token, reason) { + // A newer load (or a mode flip) already owns the state — say nothing. + if (token !== layerState._denseLoadToken) return; + layerState._params.catalog = 'core'; + _removeDenseCatalog(); + layerState._denseStatus = 'failed'; + layerState._denseError = reason; + parts.controls._notifyRowControls(); + } + + /** Remove all dense extras (catalog entries, points, tracking if needed). */ + + function _removeDenseCatalog() { + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + layerState._denseLoadToken++; // cancel any in-flight dense load + if ( + layerState._trackedNorad !== null && + layerState._catalog.get(layerState._trackedNorad)?.group === 'dense' + ) { + parts.tracking._clearTracking(); + } + for (const noradId of layerState._denseIds) { + const point = layerState._points.get(noradId); + if (point && layerState._pointCollection) + layerState._pointCollection.remove(point); + layerState._points.delete(noradId); + layerState._catalog.delete(noradId); + } + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._count = layerState._points.size; + layerState._catalogRevision++; + } + return { + _abortActiveUpdates, + _propagateDenseChunk, + _loadDenseCatalog, + _denseLoadFailed, + _removeDenseCatalog, + }; +} diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js new file mode 100644 index 000000000..f4910f754 --- /dev/null +++ b/src/layers/satellites/controls.js @@ -0,0 +1,562 @@ +import { + tallySatelliteClasses, + satelliteClassLabel, + satelliteClassLegend, +} from '../../data/satelliteClass.js'; +import * as Cesium from 'cesium'; +import { ISS_NORAD, POINT_STYLES } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { isExplicitLayerStateOrigin } = services.layerState; + + /** + * Resolve the canonical point style for a satellite. + * @param {number} noradId NORAD catalog number. + * @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense'). + * @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }} + */ + + function _pointStyleFor(noradId, group) { + if (noradId === ISS_NORAD) return POINT_STYLES.iss; + return POINT_STYLES[group] || POINT_STYLES.visual; + } + + /** Tell the manager to re-render this layer's row (chip state / legend counts). */ + + function _notifyRowControls() { + try { + layerState._rowControlsListener?.(); + } catch (error) { + console.warn('[Data:Satellites] row-controls listener failed:', error); + } + } + + /** + * Per-class tally for the row legend, cached against the catalog revision. + * Without the cache this scans ~10.7k entries in dense mode on every panel + * refresh, and any layer polling on the 1s stats timer refreshes the panel. + * @returns {Record} Class key → count. + */ + + function _classTally() { + if ( + layerState._classTallyCache.counts && + layerState._classTallyCache.revision === layerState._catalogRevision + ) { + return layerState._classTallyCache.counts; + } + const entries = []; + // Pass the ISS flag, not just the group: during a stations-feed outage the + // ISS is ingested as `visual`, and the legend must file it exactly where its + // card does (STATION) rather than letting STATION vanish from the legend. + for (const [noradId, sat] of layerState._catalog) { + entries.push({ group: sat.group, isIss: noradId === ISS_NORAD }); + } + const counts = tallySatelliteClasses(entries); + layerState._classTallyCache = { + revision: layerState._catalogRevision, + counts, + }; + return counts; + } + + /** + * Satellite preferences may be restored while the layer is disabled (for + * example, when Space Missions releases its dependency). Preferences must not + * revive render primitives until the layer is explicitly enabled again. + * @param {boolean} layerEnabled Whether the Satellite layer is enabled. + * @param {boolean} requestedVisible Whether the current presentation requests visibility. + * @returns {boolean} Whether a primitive should be visible now. + */ + + function satelliteVisualsVisible(layerEnabled, requestedVisible) { + return Boolean(layerEnabled) && Boolean(requestedVisible); + } + + /** + * Decide whether a valid catalog request represents a real mode transition. + * Reapplying an already-active mode must not restart the dense TLE load. + * @param {'core'|'dense'} currentCatalog Current catalog mode. + * @param {string|undefined} requestedCatalog Requested catalog mode. + * @returns {boolean} Whether the catalog mode should change. + */ + + function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) { + return ( + (requestedCatalog === 'core' || requestedCatalog === 'dense') && + requestedCatalog !== currentCatalog + ); + } + const methods = { + id: 'satellites', + + name: 'Satellites', + + icon: '🛰️', + + source: 'CelesTrak', + + updateInterval: 0, + // We use preRender for real-time updates, not interval polling + refreshInterval: 5 * 60 * 1000, + + getDetectableObjects(options = {}) { + if (!layerState._pointCollection || !layerState._pointCollection.show) + return []; + // Dense extras are points-only: excluded from the detection overlay. + const eligibleCount = Math.max( + 1, + layerState._points.size - layerState._denseIds.length, + ); + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : eligibleCount; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + const stride = Math.max(1, Math.ceil(eligibleCount / maxCount)); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [noradId, point] of layerState._points) { + if ( + layerState._denseIds.length > 0 && + layerState._catalog.get(noradId)?.group === 'dense' + ) + continue; + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if (!point.position) continue; + const isTracked = noradId === layerState._trackedNorad; + // A docked companion sits at the tracked subject's own position, so its + // mark and label would stack underneath the tracked card. It is listed on + // that card instead. Only members of the tracked cluster are affected — + // unrelated nearby satellites are never suppressed. + if (!isTracked && layerState._dockedCompanions.has(noradId)) continue; + const cat = layerState._catalog.get(noradId); + let object = layerState._detectionObjects.get(noradId); + if (!object) { + object = { + sourceId: noradId, + id: cat?.name || `SAT-${noradId}`, + type: 'SAT', + // Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS"). + // The detection canvas composites ABOVE the post-FX chain, so this + // is how class survives NVG/FLIR once the dot colors are collapsed. + klass: satelliteClassLabel(cat?.group, { + isIss: noradId === ISS_NORAD, + }), + }; + layerState._detectionObjects.set(noradId, object); + } + object.position = point.position; + object.skipLabel = isTracked; + result.push(object); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Find a satellite by exact NORAD id (numeric string) or case-insensitive + * name substring. Position is freshly propagated via SGP4. + * @param {string|number} query NORAD id or partial name. + * @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null} + */ + findByQuery(query) { + if ( + query === null || + query === undefined || + !layerState._catalog || + layerState._catalog.size === 0 + ) + return null; + const q = String(query).trim(); + if (!q) return null; + + let noradId = null; + if (/^\d+$/.test(q) && layerState._catalog.has(Number(q))) { + noradId = Number(q); + } else { + const lower = q.toLowerCase(); + for (const [id, sat] of layerState._catalog) { + if (sat.name.toLowerCase().includes(lower)) { + noradId = id; + break; + } + } + } + if (noradId === null) return null; + + const sat = layerState._catalog.get(noradId); + const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + if (!pos) return null; + + return { + noradId, + name: sat.name.trim(), + position: Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ), + latitude: pos.latitude, + longitude: pos.longitude, + altitudeM: pos.altitude, + }; + }, + + /** + * Get positions of currently rendered satellites from per-point state + * (no SGP4 re-propagation). + * @param {number} [maxCount=300] Maximum entries to return. + * @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>} + */ + getAllPositions(maxCount = 300) { + const result = []; + if (!layerState._points || layerState._points.size === 0) return result; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300; + + for (const [noradId, point] of layerState._points) { + if (result.length >= cap) break; + if (!point.position) continue; + // Dense extras are points-only — keep voice/framing lists to the core catalog. + if ( + layerState._denseIds.length > 0 && + layerState._catalog.get(noradId)?.group === 'dense' + ) + continue; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) continue; + const sat = layerState._catalog.get(noradId); + result.push({ + id: noradId, + label: sat ? sat.name.trim() : String(noradId), + position: point.position, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + }); + } + return result; + }, + + /** + * Track a satellite by NORAD id (camera follow + orbit path + highlight), + * same path as clicking its point. + * @param {string|number} noradId NORAD catalog number. + * @returns {boolean} True if tracking started. + */ + trackById(noradId, { origin = 'programmatic' } = {}) { + const id = Number(noradId); + if ( + !Number.isFinite(id) || + !layerState._viewer || + !layerState._catalog.has(id) || + !layerState._points.has(id) + ) + return false; + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(id, { origin }); + return layerState._trackedNorad === id; + }, + + /** + * Resolve a shared Follow target only after the applicable CelesTrak + * catalog has settled. A partial catalog can prove presence, never absence. + */ + async resolveTrackingRestoreTarget( + noradId, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = parts.tracking._normalizeTrackedNorad(noradId); + if (id === null) return { status: 'missing', reason: 'invalid-target' }; + const outcome = layerState._lastTrackingRefreshOutcome; + const found = () => + layerState._catalog.has(id) && layerState._points.has(id); + const follow = () => { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + return this.trackById(id, { origin }) + ? { status: 'found', refreshEpoch: outcome.epoch } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }; + + if (found()) return follow(); + if (outcome.status !== 'accepted' && outcome.status !== 'partial') { + return { + status: 'source-unavailable', + reason: 'CelesTrak catalog unavailable', + refreshEpoch: outcome.epoch, + }; + } + + if (layerState._params.catalog === 'dense') { + const dense = await (layerState._denseLoadPromise || + Promise.resolve({ status: 'source-unavailable' })); + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + if (layerState._lastTrackingRefreshOutcome.epoch !== outcome.epoch) { + return { status: 'superseded', reason: 'newer-catalog-refresh' }; + } + if (found()) return follow(); + if (dense?.status !== 'ready') { + return { + status: 'source-unavailable', + reason: dense?.reason || 'dense catalog unavailable', + refreshEpoch: outcome.epoch, + }; + } + } + + if (outcome.status === 'partial') { + return { + status: 'source-unavailable', + reason: 'partial CelesTrak catalog cannot prove absence', + refreshEpoch: outcome.epoch, + failedGroups: [...outcome.failedGroups], + }; + } + return { + status: 'missing', + reason: 'target-absent-from-catalog', + refreshEpoch: outcome.epoch, + }; + }, + + /** + * Stop tracking the currently tracked satellite (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Get info about the currently tracked satellite. + * @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null} + */ + getTrackedInfo() { + if ( + layerState._trackedNorad === null || + !layerState._catalog.has(layerState._trackedNorad) + ) + return null; + const sat = layerState._catalog.get(layerState._trackedNorad); + // Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame. + const pos = parts.tracking._getTrackedFramePosition(); + if (!pos) return null; + return { + noradId: layerState._trackedNorad, + name: sat.name.trim(), + latitude: pos.latitude, + longitude: pos.longitude, + altitudeM: pos.altitude, + }; + }, + + /** + * Runtime params (DataLayerManager.setLayerParams path). + * catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell + * as points-only extras on a relaxed propagation budget). + * @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params] + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedSatTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + const catalog = params.catalog; + if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense') + return false; + const catalogChanged = satelliteCatalogModeChanged( + layerState._params.catalog, + catalog, + ); + if (catalogChanged) { + layerState._params.catalog = catalog; + } + if (params.showPoints !== undefined) { + layerState._params.showPoints = params.showPoints !== false; + if (layerState._pointCollection) + layerState._pointCollection.show = satelliteVisualsVisible( + layerState._enabled, + layerState._params.showPoints, + ); + } + if (params.showOrbits !== undefined) { + layerState._params.showOrbits = params.showOrbits !== false; + for (const path of layerState._orbitPaths.values()) + path.primitive.show = satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); + parts.labels._syncIssOverlay(); + } + if (catalogChanged && catalog === 'dense') { + layerState._denseLoadPromise = parts.catalog._loadDenseCatalog(); + } else if (catalog === 'core') { + if (catalogChanged) parts.catalog._removeDenseCatalog(); + // Any explicit request for core clears the error, even when the mode did + // NOT change: a failed dense load already reverted the param to core, so + // a Space Missions restore of an already-core snapshot would otherwise + // leave the user staring at a DENSE ✕ they never caused. + layerState._denseStatus = 'idle'; + layerState._denseError = null; + } + if (catalogChanged) + console.log(`[Data:Satellites] Catalog mode: ${catalog}`); + if (Object.hasOwn(params, 'selectedSatTrackingId')) { + const requested = parts.tracking._normalizeTrackedNorad( + params.selectedSatTrackingId, + ); + if (requested === layerState._trackedNorad) { + layerState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (layerState._trackedNorad !== null) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++layerState._trackingIntentGeneration; + layerState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (layerState._trackedNorad !== null) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + /** @returns {{ catalog: string }} Current runtime params. */ + getParams() { + return { + catalog: layerState._params.catalog, + showPoints: layerState._params.showPoints, + showOrbits: layerState._params.showOrbits, + selectedSatTrackingId: layerState._trackedNorad, + }; + }, + + /** + * Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE + * catalog chip plus a class legend so the point colors are learnable without + * a new panel. + * + * The chip is stateless — it declares the params to apply and the manager + * owns the write, so the Space Missions snapshot/restore path (which drives + * the same `catalog` param) stays the single source of truth and the chip + * always renders whatever the layer actually has. + * + * The chip reports the dense LOAD state, not the catalog param: the param + * flips synchronously while the Starlink shell takes seconds to arrive and + * frequently 502s, so ACTIVE means "dense points are on screen" and nothing + * less. The legend tally is cached against the catalog revision. + * @returns {{ chips: Array, legend: Array }} Row controls. + */ + getRowControls() { + // A dependency owner (Space Missions) borrows this layer for TLE lookup + // with showPoints:false. Nothing is rendered, so a legend would describe an + // empty sky and a chip write would be silently reverted by that owner's + // restore. Surrender the row rather than lie about it. + if (!layerState._params.showPoints) return { chips: [], legend: [] }; + + const loading = layerState._denseStatus === 'loading'; + const failed = layerState._denseStatus === 'failed'; + const active = + layerState._params.catalog === 'dense' && + layerState._denseStatus === 'ready'; + let title = + 'Add the full Starlink broadband shell (thousands of extra points)'; + if (loading) title = 'Loading the Starlink shell…'; + else if (failed) + title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`; + else if (active) + title = + 'Showing the full Starlink shell — click for the core catalog only'; + return { + chips: [ + { + id: 'catalog', + label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE', + active, + busy: loading, + disabled: loading, + state: loading + ? 'loading' + : failed + ? 'error' + : active + ? 'active' + : 'idle', + title, + params: { catalog: active ? 'core' : 'dense' }, + }, + ], + legend: satelliteClassLegend(_classTally()), + }; + }, + + /** + * Install the manager's "row controls changed" callback. The dense load is + * asynchronous, so completion and failure have to push a re-render — nothing + * else would repaint this row before the 5-minute catalog refresh. + * @param {(() => void)|null} listener Callback, or null to detach. + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + + getStats() { + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + stale: false, + status: + layerState._lastError === 'CelesTrak unreachable' + ? 'unavailable' + : layerState._lastError + ? 'degraded' + : 'nominal', + error: layerState._lastError, + }; + }, + }; + + return { + _pointStyleFor, + _notifyRowControls, + _classTally, + satelliteVisualsVisible, + satelliteCatalogModeChanged, + methods, + }; +} diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js new file mode 100644 index 000000000..7bec672c0 --- /dev/null +++ b/src/layers/satellites/index.js @@ -0,0 +1,73 @@ +import { createControls } from './controls.js'; +import { createCatalog } from './catalog.js'; +import { createLabels } from './labels.js'; +import { createOrbits } from './orbits.js'; +import { createRendering } from './rendering.js'; +import { createTracking } from './tracking.js'; +import { createTesting } from './testing.js'; +import { createInteraction } from './interaction.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createSatellitesLayer({ services, source }) { + if (typeof source?.readGroup !== 'function') + throw new TypeError('A satellites source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.controls = createControls(context); + parts.catalog = createCatalog(context); + parts.labels = createLabels(context); + parts.orbits = createOrbits(context); + parts.rendering = createRendering(context); + parts.tracking = createTracking(context); + parts.testing = createTesting(context); + parts.interaction = createInteraction(context); + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + { + satelliteVisualsVisible: parts.controls.satelliteVisualsVisible, + satelliteCatalogModeChanged: parts.controls.satelliteCatalogModeChanged, + createIssOverlayEntry: parts.labels.createIssOverlayEntry, + orbitFrameModelMatrix: parts.orbits.orbitFrameModelMatrix, + _setTrackedSatelliteRefreshStateForTest: + parts.testing._setTrackedSatelliteRefreshStateForTest, + _setSatelliteTrackingRefreshOutcomeForTest: + parts.testing._setSatelliteTrackingRefreshOutcomeForTest, + _trackedFrameCartesianForTest: + parts.testing._trackedFrameCartesianForTest, + _runSatellitePreRenderForTest: + parts.testing._runSatellitePreRenderForTest, + _setDenseCatalogStateForTest: parts.testing._setDenseCatalogStateForTest, + _clearDenseCatalogStateForTest: + parts.testing._clearDenseCatalogStateForTest, + _catalogGroupForTest: parts.testing._catalogGroupForTest, + _setSatelliteLabelLifecycleStateForTest: + parts.testing._setSatelliteLabelLifecycleStateForTest, + _trackIssForTest: parts.testing._trackIssForTest, + _pendingSatelliteTrackingRestoreForTest: + parts.testing._pendingSatelliteTrackingRestoreForTest, + _applyPendingSatelliteTrackingRestoreForTest: + parts.testing._applyPendingSatelliteTrackingRestoreForTest, + _removeSatelliteTrackingCandidateForTest: + parts.testing._removeSatelliteTrackingCandidateForTest, + _clearSatelliteLabelLifecycleForTest: + parts.testing._clearSatelliteLabelLifecycleForTest, + applySatellitePointFocusDeemphasis: + parts.rendering.applySatellitePointFocusDeemphasis, + getNextIssPass: parts.orbits.getNextIssPass, + scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch, + findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle, + getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack, + }, + ); +} +export { ISS_OVERLAY_SOURCE_ID, ISS_OVERLAY_SOURCE_OPTIONS } from './policy.js'; +export { createSatelliteSource } from './source.js'; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js new file mode 100644 index 000000000..7fb36bb00 --- /dev/null +++ b/src/layers/satellites/ingestion.js @@ -0,0 +1,193 @@ +import { twoline2satrec } from 'satellite.js'; +import * as Cesium from 'cesium'; +import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const methods = { + async update(viewer, { signal = null } = {}) { + const trackingRefreshEpoch = ++layerState._trackingRefreshEpoch; + layerState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'source-unavailable', + failedGroups: [], + }; + const resourceController = new AbortController(); + layerState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + // Load all core groups in parallel; a failed/empty group degrades + // gracefully (parseTLE of an upstream error body yields []). + const results = await Promise.all( + CATALOG_GROUPS.map(async (groupDef) => { + try { + const res = await source.readGroup(groupDef.path, { + signal: updateSignal, + }); + if (!res.ok) return { ...groupDef, entries: [], ok: false }; + const entries = parts.orbits.parseTLE(res.text); + updateSignal.throwIfAborted(); + return { ...groupDef, entries, ok: entries.length > 0 }; + } catch (error) { + if (updateSignal.aborted || error?.name === 'AbortError') + throw error; + return { ...groupDef, entries: [], ok: false }; + } + }), + ); + updateSignal.throwIfAborted(); + + const failed = results.filter((r) => !r.ok).map((r) => r.path); + if (failed.length > 0) { + console.warn( + `[Data:Satellites] Groups failed or empty: ${failed.join(', ')}`, + ); + } + console.log( + `[Data:Satellites] Loaded ${results.map((r) => `${r.tag}:${r.entries.length}`).join(' ')}`, + ); + + // CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing. + // Wiping the collection + catalog here would blank all 838 satellites while + // the chip still read "just now". Keep the existing (stale) catalog on + // screen and surface the outage instead — do NOT stamp _lastUpdate. + if (results.every((r) => !r.ok)) { + layerState._lastError = 'CelesTrak unreachable'; + console.warn( + '[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage', + ); + // Re-apply dense mode is skipped (no fresh core catalog); tracking untouched. + return; + } + + // At least one group loaded — proceed with a fresh rebuild, but keep the + // partial outage visible at the layer control instead of presenting the + // reduced catalog as a fully healthy refresh. + layerState._lastError = failed.length + ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` + : null; + + // Clear existing + layerState._pointCollection.removeAll(); + layerState._points.clear(); + for (const path of layerState._orbitPaths.values()) + viewer.scene.primitives.remove(path.primitive); + layerState._orbitPaths.clear(); + layerState._catalog.clear(); + // The detection overlay caches one record per satellite and stamps its + // id/class at creation only. A rebuild can re-tag a satellite (a failed + // group shifts which one wins dedupe), so the cache must go with the + // catalog or those labels stay stale for the life of the session. + layerState._detectionObjects.clear(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadController?.abort(); + layerState._denseLoadController = null; + layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog + parts.labels._syncIssOverlay(); + + const now = new Date(); + + // Process all TLE entries in CATALOG_GROUPS order + const allEntries = []; + for (const r of results) { + for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); + } + + // Deduplicate by NORAD ID — first (most specific) group tag wins + const seen = new Set(); + + for (const entry of allEntries) { + const satrec = twoline2satrec(entry.line1, entry.line2); + if (!satrec || satrec.error !== 0) continue; + + const noradId = Number(satrec.satnum); + if (seen.has(noradId)) continue; + seen.add(noradId); + + // Store in catalog + layerState._catalog.set(noradId, { + name: entry.name, + satrec, + group: entry.group, + }); + + // Propagate initial position + const pos = parts.orbits.propagatePosition(satrec, now); + if (!pos) continue; + + const cartesian = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + + // Add point primitive (styling from the shared table, WS-D3) + const style = parts.controls._pointStyleFor(noradId, entry.group); + const point = layerState._pointCollection.add({ + position: cartesian, + pixelSize: style.pixelSize, + color: style.color, + outlineColor: style.outlineColor, + outlineWidth: style.outlineWidth, + scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + id: noradId, + }); + + layerState._points.set(noradId, point); + } + + // Show ISS orbital path by default + if (layerState._catalog.has(ISS_NORAD)) { + parts.rendering._showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color); + const issPath = layerState._orbitPaths.get(ISS_NORAD); + if (issPath) issPath.primitive.show = layerState._params.showOrbits; + + parts.labels._syncIssOverlay(); + } + + layerState._count = layerState._points.size; + layerState._catalogRevision++; + layerState._lastUpdate = Date.now(); + layerState._lastPropagation = Date.now(); + layerState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: failed.length ? 'partial' : 'accepted', + failedGroups: [...failed], + }; + console.log( + `[Data:Satellites] ${layerState._count} satellites active, ISS path shown`, + ); + + // Re-apply dense mode after a full catalog rebuild (fire-and-forget — + // _loadDenseCatalog handles its own errors and token invalidation). + layerState._denseLoadPromise = + layerState._params.catalog === 'dense' + ? parts.catalog._loadDenseCatalog({ signal: updateSignal }) + : Promise.resolve({ status: 'not-requested' }); + // The catalog the published voice subject was resolved against is gone. + // Re-resolve it, or release the slot if the subject provably did not + // survive. Deliberately not awaited: it waits on dense settlement, and + // the rebuild must not block on that. + void parts.tracking._reconcileTrackedSubjectContext(); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Satellite update aborted', 'AbortError'); + } + console.warn('[Data:Satellites] Fetch error:', e); + } finally { + layerState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; +} diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js new file mode 100644 index 000000000..5944beaa5 --- /dev/null +++ b/src/layers/satellites/interaction.js @@ -0,0 +1,86 @@ +import * as Cesium from 'cesium'; + +export function createInteraction({ + state: layerState, + services, + parts, + source, +}) { + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + + function _onKeyDown(e) { + if (layerState._enabled && e.key === 'Escape' && layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + } + + function _installClickHandler(viewer) { + if (layerState._clickHandler) return; // already installed + + // Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights, + // military, …) grabs the follow-camera, drop our tracking so the orbit ring / + // tracked entity don't orphan — without touching viewer.trackedEntity (the + // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity + // briefly undefined mid-_trackSatellite) doesn't self-clear. + if (!layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if (!layerState._enabled) return; + if ( + layerState._trackedNorad && + layerState._viewer && + layerState._viewer.trackedEntity && + layerState._viewer.trackedEntity !== layerState._trackedEntity + ) { + parts.tracking._clearTracking(true, { + origin: + layerState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((click) => { + if (!layerState._enabled) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking tracked entity itself — ignore + if (picked.id === layerState._trackedEntity) return; + + // Check if it's a satellite point (id is NORAD catalog number) + const prim = picked.primitive; + if (prim && prim.id != null) { + const noradId = Number(prim.id); + if (!isNaN(noradId) && layerState._catalog.has(noradId)) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(noradId, { origin: 'user' }); + return; + } + } + } + + // A pick that belongs to a sibling layer (plane, vessel, station, CCTV + // camera…) is not "empty space" — leave OUR tracking (and crucially + // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; + } + + // Clicked empty space — deselect + if (layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + document.addEventListener('keydown', _onKeyDown); + } + return { _onKeyDown, _installClickHandler }; +} diff --git a/src/layers/satellites/labels.js b/src/layers/satellites/labels.js new file mode 100644 index 000000000..95169be35 --- /dev/null +++ b/src/layers/satellites/labels.js @@ -0,0 +1,125 @@ +import { + ISS_NORAD, + DOCKED_SCAN_INTERVAL_MS, + DOCKED_COMPANION_RADIUS_M, + ISS_OVERLAY_SOURCE_ID, + ISS_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createLabels({ state: layerState, services, parts, source }) { + /** Build the persistent ISS ambient label from the cached point position. */ + + function createIssOverlayEntry(position) { + return { + id: String(ISS_NORAD), + position, + variant: 'label', + title: 'ISS', + accent: '#ff4444', + priority: 1000, + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + distanceScale: { + near: 1_000_000, + nearValue: 1, + far: 30_000_000, + farValue: 0.4, + }, + gapPx: 14, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** Cached ISS point only; never performs a fresh SGP4 propagation. */ + + function _issDisplayCached() { + return layerState._points.get(ISS_NORAD)?.position || null; + } + + /** + * Rebuild the docked-companion set for the tracked satellite. + * @returns {boolean} true when membership changed (callers resync presentation). + */ + + function _refreshDockedCompanions(nowMs) { + const trackedPosition = + layerState._trackedNorad === null + ? null + : parts.tracking._trackedDisplayCached(); + if (!trackedPosition) { + if (layerState._dockedCompanions.size === 0) return false; + layerState._dockedCompanions = new Set(); + return true; + } + if (nowMs - layerState._lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS) + return false; + layerState._lastDockedScanMs = nowMs; + const radiusSq = DOCKED_COMPANION_RADIUS_M * DOCKED_COMPANION_RADIUS_M; + let changed = false; + let found = 0; + const next = new Set(); + for (const [noradId, point] of layerState._points) { + if (noradId === layerState._trackedNorad || !point?.position) continue; + const dx = point.position.x - trackedPosition.x; + const dy = point.position.y - trackedPosition.y; + const dz = point.position.z - trackedPosition.z; + if (dx * dx + dy * dy + dz * dz > radiusSq) continue; + next.add(noradId); + found++; + if (!layerState._dockedCompanions.has(noradId)) changed = true; + } + if (found !== layerState._dockedCompanions.size) changed = true; + if (changed) layerState._dockedCompanions = next; + return changed; + } + + /** Names of the docked companions, stable-sorted so the card text never churns. */ + + function _dockedCompanionNames() { + const names = []; + for (const noradId of layerState._dockedCompanions) { + names.push( + layerState._catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`, + ); + } + return names.sort(); + } + + /** Keep persistent ISS text mutually exclusive with the tracked host card. */ + + function _syncIssOverlay() { + // Hidden when ISS is the tracked subject, and equally when ISS is DOCKED to + // whatever is tracked: its ambient label would otherwise sit underneath the + // tracked card at the same position. + const visible = + layerState._enabled && + layerState._params.showOrbits && + layerState._trackedNorad !== ISS_NORAD && + !layerState._dockedCompanions.has(ISS_NORAD) && + layerState._catalog.has(ISS_NORAD) && + _issDisplayCached(); + if (!visible) { + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + return; + } + layerState._overlayHost.setEntries( + ISS_OVERLAY_SOURCE_ID, + [createIssOverlayEntry(_issDisplayCached)], + ISS_OVERLAY_SOURCE_OPTIONS, + ); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true); + } + return { + createIssOverlayEntry, + _issDisplayCached, + _refreshDockedCompanions, + _dockedCompanionNames, + _syncIssOverlay, + }; +} diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js new file mode 100644 index 000000000..7c0d36670 --- /dev/null +++ b/src/layers/satellites/lifecycle.js @@ -0,0 +1,179 @@ +import * as Cesium from 'cesium'; +import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { clearFocusTarget } = services.focus; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const methods = { + // Catalog data refresh; propagation remains preRender-owned. + + async init(viewer) { + parts.catalog._abortActiveUpdates(); + clearFocusTarget('satellites'); + layerState._viewer = viewer; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._detectionObjects = new Map(); + layerState._orbitPaths = new Map(); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._trackedNorad = null; + parts.tracking._cancelPendingTrackingRestore(); + layerState._trackedEntity = null; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._trackedFrameNowForTest = null; + layerState._lastFocusUpdate = 0; + layerState._activeFocusCount = 0; + layerState._enabled = false; + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + // Dense extras rebuild via update() when _params.catalog === 'dense' + // (the catalog-mode preference itself is sticky across init/destroy). + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadPromise = null; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + + // Point primitives for satellite dots + layerState._pointCollection = new Cesium.PointPrimitiveCollection(); + viewer.scene.primitives.add(layerState._pointCollection); + + parts.interaction._installClickHandler(viewer); + + // Pre-render listener for real-time position updates + // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) + layerState._preRenderListener = viewer.scene.preRender.addEventListener( + parts.rendering._preRenderTick, + ); + + console.log('[Data:Satellites] Initialized'); + }, + + enable(viewer) { + layerState._enabled = true; + holdContinuousRender('satellites'); // per-frame animator (perf wave 2) + if (layerState._pointCollection) + layerState._pointCollection.show = + parts.controls.satelliteVisualsVisible( + layerState._enabled, + layerState._params.showPoints, + ); + // Orbit ring primitives + persistent ISS host label — show them + for (const path of layerState._orbitPaths.values()) + path.primitive.show = parts.controls.satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); + parts.labels._syncIssOverlay(); + // Re-attach input handlers and preRender propagation + parts.interaction._installClickHandler(viewer); + // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; + // the registry hands predicates String()-coerced ids, so match via Number(). + registerPickOwner('satellites', (pickedId) => { + const norad = Number(pickedId); + return Number.isFinite(norad) && layerState._points.has(norad); + }); + if (!layerState._preRenderListener && viewer) { + layerState._preRenderListener = viewer.scene.preRender.addEventListener( + parts.rendering._preRenderTick, + ); + } + parts.tracking._applyPendingTrackingRestore(); + }, + + disable(viewer) { + parts.catalog._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + layerState._enabled = false; + releaseContinuousRender('satellites'); + if (layerState._pointCollection) layerState._pointCollection.show = false; + for (const path of layerState._orbitPaths.values()) + path.primitive.show = false; + parts.tracking._clearTracking(); + parts.labels._syncIssOverlay(); + // Remove click handler + keydown listener + preRender propagation while disabled + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove(); + layerState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.interaction._onKeyDown); + unregisterPickOwner('satellites'); + if (layerState._preRenderListener) { + layerState._preRenderListener(); + layerState._preRenderListener = null; + } + }, + + destroy(viewer) { + parts.catalog._abortActiveUpdates(); + releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) + layerState._enabled = false; + parts.tracking._clearTracking(); + parts.tracking._cancelPendingTrackingRestore(); + if (layerState._clickHandler) { + layerState._clickHandler.destroy(); + layerState._clickHandler = null; + } + if (layerState._trackedEntityChangedRemove) { + layerState._trackedEntityChangedRemove(); + layerState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.interaction._onKeyDown); + unregisterPickOwner('satellites'); + if (layerState._preRenderListener) { + layerState._preRenderListener(); + layerState._preRenderListener = null; + } + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + // Orbit ring primitives are removed (and destroyed) here + for (const path of layerState._orbitPaths.values()) { + viewer.scene.primitives.remove(path.primitive); + } + layerState._points.clear(); + layerState._detectionObjects.clear(); + layerState._orbitPaths.clear(); + layerState._catalog.clear(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + layerState._rowControlsListener = null; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastError = null; + layerState._lastFocusUpdate = 0; + layerState._activeFocusCount = 0; + layerState._trackingRefreshEpoch += 1; + layerState._lastTrackingRefreshOutcome = { + epoch: layerState._trackingRefreshEpoch, + status: 'destroyed', + failedGroups: [], + }; + layerState._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js new file mode 100644 index 000000000..943e5af14 --- /dev/null +++ b/src/layers/satellites/orbits.js @@ -0,0 +1,327 @@ +import * as Cesium from 'cesium'; +import { + gstime, + propagate, + eciToGeodetic, + degreesLong, + degreesLat, + twoline2satrec, +} from 'satellite.js'; +import { findNextIssPass } from '../../data/issPass.js'; +import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; + +export function createOrbits({ state: layerState, services, parts, source }) { + /** + * Build the rigid ECEF transform that keeps an orbit path baked at one GMST + * aligned with live SGP4 positions propagated at another epoch. + * @param {number} gmstAtBake GMST used when the path positions were baked. + * @param {Date} nowDate Epoch whose rotating-Earth frame should be displayed. + * @param {Cesium.Matrix4} [result] Optional matrix to update in place. + * @returns {Cesium.Matrix4} Z-rotation from bake-time ECEF to current ECEF. + */ + + function orbitFrameModelMatrix( + gmstAtBake, + nowDate, + result = new Cesium.Matrix4(), + ) { + const deltaGmst = gstime(nowDate) - gmstAtBake; + const rotation = Cesium.Matrix3.fromRotationZ( + -deltaGmst, + layerState._scratchRingRotation, + ); + return Cesium.Matrix4.fromRotationTranslation( + rotation, + Cesium.Cartesian3.ZERO, + result, + ); + } + + /** + * Parse TLE text into array of { name, line1, line2 } objects. + */ + + function parseTLE(text) { + const lines = text + .trim() + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const result = []; + for (let i = 0; i < lines.length - 2; i += 3) { + const name = lines[i]; + const line1 = lines[i + 1]; + const line2 = lines[i + 2]; + if (line1.startsWith('1 ') && line2.startsWith('2 ')) { + result.push({ name, line1, line2 }); + } + } + return result; + } + + /** + * Propagate satellite position at a given JS Date. + * Returns geodetic position plus inertial speed from the same SGP4 propagation + * epoch, or null on error. + */ + + function propagatePosition(satrec, date) { + try { + const posVel = propagate(satrec, date); + if (!posVel.position || typeof posVel.position === 'boolean') return null; + + const gmst = gstime(date); + const geo = eciToGeodetic(posVel.position, gmst); + const velocity = + posVel.velocity && typeof posVel.velocity !== 'boolean' + ? posVel.velocity + : null; + const speedMps = velocity + ? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000 + : null; + + return { + longitude: degreesLong(geo.longitude), + latitude: degreesLat(geo.latitude), + altitude: geo.height * 1000, // km → meters + speedMps: Number.isFinite(speedMps) ? speedMps : null, + }; + } catch { + return null; + } + } + + function orbitalPeriodSeconds(satrec) { + const meanMotion = satrec.no * (1440 / (2 * Math.PI)); + return 86400 / Math.max(meanMotion, 0.1); + } + + /** + * Compute full orbital path as array of Cartesian3 positions. + * Steps around one full orbit based on the satellite's mean motion. + */ + + function computeOrbitPath(satrec, referenceDate) { + const periodSec = orbitalPeriodSeconds(satrec); + const stepSec = periodSec / ORBIT_PATH_STEPS; + + const positions = []; + const baseTime = referenceDate.getTime(); + // Fix GMST to reference time so the orbital ring closes. + // Without this, Earth rotation during the orbit period (~24° for LEO) + // shifts the end point west of the start, leaving a visible gap. + const fixedGmst = gstime(referenceDate); + + for (let i = 0; i <= ORBIT_PATH_STEPS; i++) { + const t = new Date(baseTime + i * stepSec * 1000); + try { + const posVel = propagate(satrec, t); + if (!posVel.position || typeof posVel.position === 'boolean') continue; + const geo = eciToGeodetic(posVel.position, fixedGmst); + positions.push( + Cesium.Cartesian3.fromDegrees( + degreesLong(geo.longitude), + degreesLat(geo.latitude), + geo.height * 1000, + ), + ); + } catch { + continue; + } + } + + return positions; + } + + /** + * Next ISS pass for an observer. Requires the catalog to have loaded (the + * satellites layer enabled at least once this session). + * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} + */ + + function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { + const sat = layerState._catalog.get(ISS_NORAD); + if (!sat || !sat.satrec) return { status: 'no-tle' }; + const pass = findNextIssPass({ + satrec: sat.satrec, + latDeg, + lonDeg, + fromMs: Date.now(), + minElevDeg, + }); + return pass ? { status: 'ok', pass } : { status: 'none' }; + } + + /** + * Score a mission-to-catalog name match. Compact identifier containment handles + * names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as + * "Starlink Group 17-40" → an arbitrary "STARLINK-1008" remain below the + * acceptance threshold. + * @param {string} query Mission or payload name. + * @param {string} catalogName Satellite catalog name. + * @returns {number} Match score; 0 means no useful relationship. + */ + + function scoreSatelliteNameMatch(query, catalogName) { + const q = String(query || '') + .trim() + .toLowerCase(); + const name = String(catalogName || '') + .trim() + .toLowerCase(); + if (!q || !name) return 0; + const qCompact = q.replace(/[^a-z0-9]/g, ''); + const nameCompact = name.replace(/[^a-z0-9]/g, ''); + if (qCompact === nameCompact) return 1000; + let score = 0; + if ( + Math.min(qCompact.length, nameCompact.length) >= 5 && + (qCompact.includes(nameCompact) || nameCompact.includes(qCompact)) + ) { + score += 200 + Math.min(qCompact.length, nameCompact.length); + } + const ignored = new Set([ + 'group', + 'block', + 'mission', + 'launch', + 'falcon', + 'rocket', + ]); + const tokens = q + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 4 && !ignored.has(token)); + score += tokens.reduce( + (total, token) => total + (name.includes(token) ? token.length : 0), + 0, + ); + return score; + } + + function internationalDesignatorYear(satrec) { + const match = String(satrec?.intldesg || '').match(/^(\d{2})/); + if (!match) return null; + const shortYear = Number(match[1]); + return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; + } + + function lookupTleEntries(tleText) { + if (tleText !== layerState._lookupTleText) { + layerState._lookupTleText = tleText; + layerState._lookupTleEntries = parseTLE(tleText); + } + return layerState._lookupTleEntries; + } + + function tleLineLaunchYear(line1) { + const shortYear = Number(String(line1 || '').slice(9, 11)); + if (!Number.isFinite(shortYear)) return null; + return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; + } + + function orbitTrackFromRecord(name, satrec) { + const referenceDate = new Date(); + const current = propagatePosition(satrec, referenceDate); + if (!current) return null; + return { + noradId: Number(satrec.satnum), + name: String(name || '').trim(), + current, + periodSec: orbitalPeriodSeconds(satrec), + orbitPath: computeOrbitPath(satrec, referenceDate), + gmstAtBake: gstime(referenceDate), + positionAt: (date) => propagatePosition(satrec, date), + }; + } + + /** + * Find and propagate a mission payload directly from a TLE catalog. + * This supports newly launched payloads that are present in CelesTrak's active + * feed but have not yet moved into a narrower operational group. + * @param {string} tleText Three-line-element catalog text. + * @param {string} query Mission or payload name. + * @param {{launchTime?: string|null}} [options] Optional launch epoch for namesake rejection. + * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} + */ + + function findSatelliteOrbitTrackInTle(tleText, query, options = {}) { + const launchYear = Number.isFinite(Date.parse(options.launchTime)) + ? new Date(options.launchTime).getUTCFullYear() + : null; + let bestEntry = null; + let bestScore = 0; + const catalogText = String(tleText || ''); + for (const entry of lookupTleEntries(catalogText)) { + const designatorYear = tleLineLaunchYear(entry.line1); + if ( + launchYear !== null && + designatorYear !== null && + designatorYear !== launchYear + ) + continue; + const score = scoreSatelliteNameMatch(query, entry.name); + if (score > bestScore) { + bestEntry = entry; + bestScore = score; + } + } + if (!bestEntry || bestScore < 12) return null; + const satrec = twoline2satrec(bestEntry.line1, bestEntry.line2); + if (!satrec || satrec.error !== 0) return null; + return orbitTrackFromRecord(bestEntry.name, satrec); + } + + /** + * Return the current propagated position and one-orbit path for a catalog satellite. + * @param {string|number} query NORAD id or mission/payload name. + * @param {{launchTime?: string|null}} [options] Optional launch epoch used to reject namesakes from another launch year. + * @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null} + */ + + function getSatelliteOrbitTrack(query, options = {}) { + if (query === null || query === undefined || !layerState._catalog?.size) + return null; + const q = String(query).trim().toLowerCase(); + let noradId = /^\d+$/.test(q) ? Number(q) : null; + if (noradId === null || !layerState._catalog.has(noradId)) { + noradId = null; + const launchYear = Number.isFinite(Date.parse(options.launchTime)) + ? new Date(options.launchTime).getUTCFullYear() + : null; + let bestScore = 0; + for (const [id, sat] of layerState._catalog) { + const designatorYear = internationalDesignatorYear(sat.satrec); + if ( + launchYear !== null && + designatorYear !== null && + designatorYear !== launchYear + ) + continue; + const score = scoreSatelliteNameMatch(q, sat.name); + if (score > bestScore) { + bestScore = score; + noradId = id; + } + } + if (bestScore < 12) noradId = null; + } + if (noradId === null) return null; + const sat = layerState._catalog.get(noradId); + return orbitTrackFromRecord(sat.name, sat.satrec); + } + return { + orbitFrameModelMatrix, + parseTLE, + propagatePosition, + orbitalPeriodSeconds, + computeOrbitPath, + getNextIssPass, + scoreSatelliteNameMatch, + internationalDesignatorYear, + lookupTleEntries, + tleLineLaunchYear, + orbitTrackFromRecord, + findSatelliteOrbitTrackInTle, + getSatelliteOrbitTrack, + }; +} diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js new file mode 100644 index 000000000..656374fc6 --- /dev/null +++ b/src/layers/satellites/policy.js @@ -0,0 +1,182 @@ +import * as Cesium from 'cesium'; +import { satelliteClassColor } from '../../data/satelliteClass.js'; + +/** + * Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation. + * + * Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS, + * Galileo, and the geosynchronous belt. Optional dense mode (setParams + * catalog:'dense') adds the Starlink shell as points-only extras. + * Renders positions via PointPrimitiveCollection, orbital paths as polylines. + * Click any satellite to track it with camera follow + orbital path. + * + * ISS gets special treatment: larger point, persistent host label, path shown by default. + */ + +export const ISS_NORAD = 25544; + +export const ISS_OVERLAY_SOURCE_ID = 'satellites-iss'; + +export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: true, + solveIntervalMs: 125, +}); + +export const ORBIT_PATH_STEPS = 180; +// points per orbital path + +export const POSITION_UPDATE_MS = 1000; +// re-propagate every 1s (SGP4 is smooth at this rate) + +export const RING_ROTATION_MS = 1000; +// re-align baked orbit rings to current GMST every 1s + +/** + * CelesTrak groups loaded as the core catalog, in dedupe-priority order: + * a satellite that appears in multiple groups keeps the FIRST (most specific) + * tag. `path` is the upstream GROUP name forwarded by the /api/celestrak + * proxy; `tag` is the internal group key used for POINT_STYLES lookup. + * Note: CelesTrak's GLONASS group is named 'glo-ops' (not + * 'glonass-operational' — that name 404s upstream). + */ + +export const CATALOG_GROUPS = [ + { tag: 'stations', path: 'stations' }, + { tag: 'visual', path: 'visual' }, + { tag: 'gps-ops', path: 'gps-ops' }, + { tag: 'glonass', path: 'glo-ops' }, + { tag: 'galileo', path: 'galileo' }, + { tag: 'geo', path: 'geo' }, +]; + +// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as +// points-only extras — no labels, no detection-overlay participation, and a +// relaxed propagation budget (round-robin, ~1/5 of core cadence per sat). + +export const DENSE_GROUP_PATH = 'starlink'; + +export const DENSE_REFRESH_FRAMES = 300; +// full dense pass spread over ~300 frames (~5s @ 60fps) + +export const DENSE_CREATE_CHUNK = 1500; +// satrec builds per macro-task while loading + +/** + * Tracked-entity camera offset, east-north-up meters (Entity.viewFrom). + * Magnitude ≈ 726 km — the user-validated "slightly zoomed out" framing for + * LEO: far enough that per-frame satellite motion doesn't stutter the camera + * or smear the label, with +Z biasing the view down onto the satellite. + * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. + */ + +export const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3( + -450000, + -450000, + 350000, +); + +export const HIGH_ORBIT_ALTITUDE_M = 2000000; + +export const TRACK_VIEW_FROM_HIGH_SCALE = 4; +// ≈ 2900 km back for MEO/GEO + +/** + * Shared per-group point styling — single source of truth used by BOTH the + * creation site (update) and tracking restore (_clearTracking) so deselecting + * a satellite never loses the original palette (WS-D3). + * + * Colors come from `satelliteClass.js` so the dot, the class label on the + * card, and the legend swatch on the layer row can never disagree. Only + * pixelSize/outline live here — those encode per-group prominence, not class. + * Converted once at module load; per-point color is a plain primitive + * attribute, so classification costs nothing per frame. + */ + +export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); + +export const _classColor = (group) => + Cesium.Color.fromCssColorString(satelliteClassColor(group)); + +export const POINT_STYLES = { + // The ISS keeps its own long-standing red hero styling rather than the + // STATION class color: it is the object most users open this layer for, it + // carries a permanent name label, and its size/outline already set it apart. + // Its card still reads "STATION · ISS", so the class stays legible. + iss: { + pixelSize: 12, + color: Cesium.Color.fromCssColorString('#ff4444'), + outlineColor: POINT_OUTLINE, + outlineWidth: 2, + }, + stations: { + pixelSize: 8, + color: _classColor('stations'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + visual: { + pixelSize: 6, + color: _classColor('visual'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + // Nav constellations (GPS / GLONASS / Galileo) resolve to one shared NAV + // color; 6px so the MEO shells read as clearly as the old visual group did. + 'gps-ops': { + pixelSize: 6, + color: _classColor('gps-ops'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + glonass: { + pixelSize: 6, + color: _classColor('glonass'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + galileo: { + pixelSize: 6, + color: _classColor('galileo'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + geo: { + pixelSize: 5, + color: _classColor('geo'), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, + // Dense-mode extras (Starlink): dim, small, points-only. + dense: { + pixelSize: 3, + color: _classColor('dense').withAlpha(0.9), + outlineColor: POINT_OUTLINE, + outlineWidth: 0, + }, +}; + +/** + * Physically DOCKED vehicles are separate real tracks sharing one position: the + * station and everything berthed to it sit within metres of each other. Their + * ambient labels therefore stack underneath the tracked card, which is what the + * owner saw. This radius is deliberately tight — it must catch a docked stack + * and nothing else, so an unrelated satellite in a similar orbit is never + * suppressed. Formation-flying pairs are km apart; a docked stack is ~100 m. + */ + +export const DOCKED_COMPANION_RADIUS_M = 2000; + +/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */ + +export const DOCKED_SCAN_INTERVAL_MS = 1000; + +/** + * Shared-context refresh interval. The tracked satellite is re-propagated + * every rendered frame; the voice context only needs to be current to about + * the propagation cadence, so this refreshes on the same 1 s beat instead of + * allocating a record 60 times a second. + */ + +export const CONTEXT_REFRESH_INTERVAL_MS = 1000; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js new file mode 100644 index 000000000..08e4396bc --- /dev/null +++ b/src/layers/satellites/rendering.js @@ -0,0 +1,320 @@ +import * as Cesium from 'cesium'; +import { gstime } from 'satellite.js'; +import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + nearFarScalarValueAtDistance, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + /** + * Show orbital path for a satellite. + * + * RING FLICKER FIX: this is a one-instance Cesium.Primitive built + * synchronously ONCE (asynchronous: false), then re-aligned to current GMST + * each tick via its modelMatrix. The previous Entity-polyline approach + * rebuilt geometry ASYNCHRONOUSLY on every positions assignment, which made + * both the selected ring and the ISS ring blink once per second while the + * rebuild was in flight. A rigid Z-rotation needs no rebuild at all. + * + * Why not a CallbackProperty entity (dynamic mode)? Verified in Cesium + * 1.138 source: the dynamic polyline updater renders through a shared + * PolylineCollection and applies only the fill material — + * `depthFailMaterial` is silently dropped, losing the dimmed behind-Earth + * segment. The Primitive keeps it via depthFailAppearance + the per-instance + * depthFailColor attribute (same mechanism the entity STATIC batch uses): + * bright where above the horizon, dimmed where behind the globe. + */ + + function _showOrbitPath(noradId, color) { + if (layerState._orbitPaths.has(noradId)) return; // already showing + + const sat = layerState._catalog.get(noradId); + if (!sat || !layerState._viewer) return; + + const bakeDate = new Date(); + const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); + if (basePositions.length < 2) return; + + const pathColor = color || Cesium.Color.CYAN; + + const primitive = new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions: basePositions, + width: noradId === ISS_NORAD ? 2.5 : 2.0, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + pathColor.withAlpha(0.6), + ), + depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( + pathColor.withAlpha(0.35), + ), + }, + }), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + depthFailAppearance: new Cesium.PolylineColorAppearance({ + translucent: true, + }), + asynchronous: false, // build this frame — no async-rebuild blink window + allowPicking: false, // ring clicks fall through to satellites/deselect + }); + layerState._viewer.scene.primitives.add(primitive); + + layerState._orbitPaths.set(noradId, { + primitive, + // Same Date object as computeOrbitPath's internal fixedGmst → identical + // GMST value (gstime is pure), so delta starts at exactly 0 and the + // initial identity modelMatrix is correct until the first ring tick. + gmstAtBake: gstime(bakeDate), + }); + } + + /** + * Rotate every baked orbit ring from its bake-time ECEF snapshot to current + * GMST (WS-D1). The baked ring is an inertial-frame snapshot frozen at + * gmstAtBake; the live dot lives in true rotating-frame ECEF, so without this + * the dot slides west off the ring at Earth-rotation rate (~0.25°/min). + * + * Sign derivation: a point fixed in inertial space keeps its right ascension + * α, so its ECEF longitude λ = α − gmst DECREASES by ΔGMST as time advances + * (it drifts WEST). Cesium.Matrix3.fromRotationZ(θ) rotates +X toward +Y, + * i.e. INCREASES longitude by θ — so we rotate the baked points by −ΔGMST. + * Mental check: ISS baked over Austin at t0; 10 min later Austin has rotated + * east under the (inertial) ring, so the ring must sit further WEST in ECEF. + * + * Wraparound: gstime returns radians in [0, 2π), so the raw difference can be + * off from the continuous ΔGMST — but only by exact multiples of 2π, which a + * rotation cannot distinguish. Tiny negative deltas right after bake are + * likewise harmless. No unwrapping needed. + * + * No SGP4 here — the rotation is applied as the ring primitive's modelMatrix + * (exact compensation; WGS84 is rotationally symmetric about Z, so rotating + * the baked curve rigidly equals rebaking from rotated points). modelMatrix + * updates are synchronous uniforms — no geometry rebuild, no flicker. Post- + * creation modelMatrix changes are supported for one-instance primitives in + * 3D mode, which these rings are. In-place mutation is safe: Primitive.update + * diffs modelMatrix against an internal clone. + * @param {Date} nowDate Epoch used for current GMST. + */ + + function _updateOrbitPathRotations(nowDate) { + if (layerState._orbitPaths.size === 0) return; + + for (const path of layerState._orbitPaths.values()) { + if (!path.primitive) continue; + parts.orbits.orbitFrameModelMatrix( + path.gmstAtBake, + nowDate, + path.primitive.modelMatrix, + ); + } + } + + /** + * Remove orbital path for a satellite. + */ + + function _hideOrbitPath(noradId) { + const path = layerState._orbitPaths.get(noradId); + if (path) { + if (layerState._viewer) + layerState._viewer.scene.primitives.remove(path.primitive); // remove() destroys + layerState._orbitPaths.delete(noradId); + } + } + + /** + * Propagate all CORE satellite positions and update point primitives. + * (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are + * excluded: they refresh on the round-robin budget in _propagateDenseChunk. + */ + + function _propagateAll() { + const now = new Date(); + let updated = 0; + + for (const [noradId, sat] of layerState._catalog) { + if (sat.group === 'dense') continue; + const pos = parts.orbits.propagatePosition(sat.satrec, now); + if (!pos) continue; + + const cartesian = Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + ); + const point = layerState._points.get(noradId); + if (point) { + point.position = cartesian; + updated++; + } + } + + return updated; + } + + /** + * Shared scene.preRender tick (single definition for init + enable): + * - core fleet propagation at 200ms-tracked / 1s-idle cadence, + * - dense extras on a per-frame round-robin budget, + * - tracked satellite's point primitive per frame (WS-D2), + * - orbit ring GMST re-alignment every ~1s (WS-D1). + */ + + function _preRenderTick() { + if (!layerState._enabled) return; + const now = focusNowMs(Date.now()); + + const interval = layerState._trackedNorad ? 200 : POSITION_UPDATE_MS; + // Space Missions keeps this layer enabled for TLE lookup while deliberately + // hiding its standalone fleet. Do not rebuild hidden point buffers on the + // one-second propagation cadence: that GPU upload presented as a periodic + // whole-globe pulse even though the camera remained stationary. + if ( + layerState._params.showPoints && + now - layerState._lastPropagation >= interval + ) { + _propagateAll(); + layerState._lastPropagation = now; + } + + if (layerState._params.showPoints) parts.catalog._propagateDenseChunk(); + + // Keep the tracked dot on the per-frame epoch shared with label + camera — + // runs after _propagateAll so the per-frame sample wins over the 200ms one. + if (layerState._trackedNorad !== null) { + const pos = parts.tracking._getTrackedFramePosition(); + const point = layerState._points.get(layerState._trackedNorad); + if (pos && point) { + point.position = layerState._trackedFrameCartesian; // primitive setter clones + } + } + + _updatePointFocus(now); + + // Hidden standalone orbit primitives do not need GMST matrix writes while + // Space Missions draws the selected mission orbit itself. + if ( + layerState._params.showOrbits && + now - layerState._lastRingRotation >= RING_ROTATION_MS + ) { + _updateOrbitPathRotations(new Date(now)); + layerState._lastRingRotation = now; + } + } + + /** Focus alpha for satellite points, inside the existing shared preRender tick. */ + + function _updatePointFocus(nowMs) { + const target = getFocusTarget(); + if ( + !layerState._params.showPoints || + !focusPassIsNeeded(target, layerState._activeFocusCount) + ) + return; + if (nowMs - layerState._lastFocusUpdate < 80) return; + layerState._lastFocusUpdate = nowMs; + const scene = layerState._viewer.scene; + const camera = layerState._viewer.camera; + const result = applySatellitePointFocusDeemphasis({ + points: layerState._points, + trackedId: layerState._trackedNorad, + target, + previousActiveCount: layerState._activeFocusCount, + nowMs, + screenPositionFor: (position) => + Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + position, + layerState._scratchFocusScreen, + ), + cameraDistanceFor: (position) => + Cesium.Cartesian3.distance(camera.positionWC, position), + baseColorFor: (noradId) => + parts.controls._pointStyleFor( + noradId, + layerState._catalog.get(noradId)?.group, + ).color, + }); + layerState._activeFocusCount = result.activeCount; + } + + /** + * Apply the gated satellite-point focus pass through the production color path. + * @param {object} input + * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} + */ + + function applySatellitePointFocusDeemphasis({ + points, + trackedId, + target, + previousActiveCount = 0, + nowMs, + screenPositionFor, + cameraDistanceFor, + baseColorFor, + 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 [noradId, point] of points || []) { + if (noradId === trackedId || !point?.position) continue; + const cameraDistance = cameraDistanceFor(point.position); + const distanceScale = nearFarScalarValueAtDistance( + point.scaleByDistance, + cameraDistance, + ); + const halfExtentPx = (point.pixelSize || 5) * distanceScale * 0.5; + const focus = advanceSpriteFocus(point, { + // Hidden points still release toward identity, preventing stale dim + // alpha if a catalog/presentation toggle later makes them visible. + screenPosition: + point.show === false ? null : screenPositionFor(point.position), + cameraDistance, + nowMs, + target, + params, + spriteHalfWidthPx: halfExtentPx, + spriteHalfHeightPx: halfExtentPx, + }); + transitioning ||= focus.transitioning; + if (focus.active) activeCount += 1; + const base = baseColorFor(noradId); + const alpha = base.alpha * focus.factor; + if (focusAlphaNeedsWrite(point.color?.alpha, alpha, params)) { + // Point stays continuously present at the non-zero emphasis floor; its + // own alpha yields around the tracked target, independent of draw order. + point.color = base.withAlpha(alpha); + writes += 1; + } + } + return { writes, transitioning, activeCount, ran: true }; + } + return { + _showOrbitPath, + _updateOrbitPathRotations, + _hideOrbitPath, + _propagateAll, + _preRenderTick, + _updatePointFocus, + applySatellitePointFocusDeemphasis, + }; +} diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js new file mode 100644 index 000000000..8bcab7b02 --- /dev/null +++ b/src/layers/satellites/source.js @@ -0,0 +1,25 @@ +const GROUPS = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', +]); + +/** Read catalog text from the existing group endpoint using a supplied transport. */ +export function createSatelliteSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async readGroup(group, { signal } = {}) { + if (!GROUPS.has(group)) throw new TypeError('Unknown satellite group'); + signal?.throwIfAborted(); + const response = await fetchImpl(`/api/celestrak/${group}`, { signal }); + const text = response.ok ? await response.text() : ''; + signal?.throwIfAborted(); + return { ok: response.ok, status: response.status, text }; + }, + }; +} diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs new file mode 100644 index 000000000..a44ad565b --- /dev/null +++ b/src/layers/satellites/source.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSatelliteSource } from './source.js'; +import { createSatellitesLayer } from './index.js'; + +test('satellite sources confine catalog groups and reject a cancelled body', async () => { + const controller = new AbortController(); + let requests = 0; + const source = createSatelliteSource({ + fetchImpl: async (url) => { + requests++; + assert.equal(url, '/api/celestrak/stations'); + return { + ok: true, + status: 200, + text: async () => { + controller.abort(); + return 'late catalog'; + }, + }; + }, + }); + await assert.rejects( + source.readGroup('../active'), + /Unknown satellite group/, + ); + assert.equal(requests, 0); + await assert.rejects( + source.readGroup('stations', { signal: controller.signal }), + { name: 'AbortError' }, + ); +}); + +test('satellite factories keep control state separate and construct without requests', () => { + const source = { + readGroup() { + assert.fail('construction fetched a catalog'); + }, + }; + const services = Object.fromEntries( + [ + 'picking', + 'focus', + 'readout', + 'overlays', + 'context', + 'render', + 'layerState', + ].map((key) => [key, {}]), + ); + services.layerState.isExplicitLayerStateOrigin = () => false; + const first = createSatellitesLayer({ source, services }); + const second = createSatellitesLayer({ source, services }); + first.setParams({ showPoints: false }); + assert.equal(first.getParams().showPoints, false); + assert.equal(second.getParams().showPoints, true); + assert.notEqual(first.getStats(), second.getStats()); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js new file mode 100644 index 000000000..106802ba6 --- /dev/null +++ b/src/layers/satellites/state.js @@ -0,0 +1,165 @@ +import * as Cesium from 'cesium'; + +export function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + const state = {}; + + state.DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }); + + state._overlayHost = state.DEFAULT_OVERLAY_HOST; + + // Satellite catalog: { noradId → { name, satrec, group } } + + state._catalog = new Map(); + + state._pointCollection = null; + + state._points = new Map(); + // noradId → point primitive + /** Stable lightweight records reused by the detection overlay between updates. */ + + state._detectionObjects = new Map(); + + // noradId → { primitive, gmstAtBake } + // primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF + // at gmstAtBake; the preRender tick re-aligns it to current GMST by updating + // its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1). + + state._orbitPaths = new Map(); + + state._count = 0; + + state._lastUpdate = null; + + /** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */ + + state._lastError = null; + + state._activeUpdateControllers = new Set(); + + state._denseLoadController = null; + + state._viewer = null; + + state._preRenderListener = null; + + state._lastPropagation = 0; + + state._lastRingRotation = 0; + + state._lastFocusUpdate = 0; + + /** Points whose animated emphasis remains outside the 1.0 deadband. */ + + state._activeFocusCount = 0; + + state._scratchFocusScreen = new Cesium.Cartesian2(); + + state._enabled = false; + + // Click-to-track state + + state._trackedNorad = null; + + state._pendingTrackingRestore = null; + + state._trackingIntentGeneration = 0; + + state._trackingRefreshEpoch = 0; + + state._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + failedGroups: [], + }; + + state._trackedEntity = null; + + state._clickHandler = null; + + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + + state._trackedEntityChangedRemove = null; + + // Runtime params (DataLayerManager.setLayerParams path) + + state._params = { catalog: 'core', showPoints: true, showOrbits: true }; + // 'core' | 'dense' + + state._denseIds = []; + // norad ids of dense extras, round-robin order + + state._denseCursor = 0; + // next dense id to re-propagate + + state._denseLoadToken = 0; + // invalidates in-flight dense loads on mode flip/reload + + state._denseLoadPromise = null; + + /** + * Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'. + * The catalog param flips synchronously but the Starlink shell arrives over + * seconds and can fail outright (CelesTrak 502s this feed regularly), so the + * row chip reports THIS, not the param. An active chip must mean dense points + * are actually on screen. + */ + + state._denseStatus = 'idle'; + + /** @type {string|null} Why the last dense load failed, for the chip tooltip. */ + + state._denseError = null; + + /** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */ + + state._catalogRevision = 0; + + state._classTallyCache = { revision: -1, counts: null }; + + /** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */ + + state._rowControlsListener = null; + + /** @type {Set} NORAD ids co-located with the tracked satellite. */ + + state._dockedCompanions = new Set(); + + state._lastDockedScanMs = Number.NEGATIVE_INFINITY; + + // Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and + // getTrackedInfo all read one SGP4 sample per rendered frame, keyed on + // scene.frameState.frameNumber, so they never diverge in epoch. + + state._trackedFrameNumber = -1; + + state._trackedFrameGeo = null; + // { longitude, latitude, altitude } or null + + state._trackedFrameCartesian = new Cesium.Cartesian3(); + + /** Optional deterministic clock used only by the production-frame test seam. */ + + state._trackedFrameNowForTest = null; + + // Scratch variables + + state._scratchCartesian = new Cesium.Cartesian3(); + + state._scratchRingRotation = new Cesium.Matrix3(); + + /** Update the explicit model only when the rounded altitude line changes. */ + /** Epoch of the last shared-context refresh for the tracked satellite. */ + + state._contextRefreshedAtMs = 0; + + state._lookupTleText = null; + + state._lookupTleEntries = []; + return state; +} diff --git a/src/layers/satellites/testing.js b/src/layers/satellites/testing.js new file mode 100644 index 000000000..e0f394a50 --- /dev/null +++ b/src/layers/satellites/testing.js @@ -0,0 +1,251 @@ +import * as Cesium from 'cesium'; +import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; + +export function createTesting({ state: layerState, services, parts, source }) { + /** + * Seed a single tracked satellite so tests can invoke the exact production + * pre-render callback without constructing a WebGL viewer. + * @param {object} state + * @param {number} state.noradId + * @param {string} state.name + * @param {object} state.satrec + * @param {object} state.entity + * @param {object} state.point + * @param {object} state.viewer + * @param {() => (number|Date)} state.now + */ + + function _setTrackedSatelliteRefreshStateForTest({ + noradId, + name, + satrec, + entity, + point, + viewer, + now, + // Extra catalog/point rows so a test can exercise a docked cluster (and a + // control satellite that must NOT be treated as part of it). + neighbours = [], + }) { + layerState._viewer = viewer; + layerState._catalog = new Map([ + [noradId, { name, satrec, group: 'stations' }], + ]); + layerState._points = new Map([[noradId, point]]); + for (const neighbour of neighbours) { + layerState._catalog.set(neighbour.noradId, { + name: neighbour.name, + satrec: neighbour.satrec || satrec, + group: neighbour.group || 'stations', + }); + layerState._points.set(neighbour.noradId, neighbour.point); + } + layerState._dockedCompanions = new Set(); + layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY; + layerState._trackedNorad = noradId; + layerState._trackedEntity = entity; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._trackedFrameNowForTest = now; + layerState._params = { + catalog: 'core', + showPoints: false, + showOrbits: false, + }; + layerState._enabled = true; + } + + /** Seed catalog authority and optional dense settlement for share-Follow tests. */ + + function _setSatelliteTrackingRefreshOutcomeForTest({ + status = 'accepted', + failedGroups = [], + catalog = 'core', + densePromise = null, + } = {}) { + const epoch = ++layerState._trackingRefreshEpoch; + layerState._lastTrackingRefreshOutcome = { + epoch, + status, + failedGroups: [...failedGroups], + }; + layerState._params.catalog = catalog; + layerState._denseLoadPromise = + densePromise || Promise.resolve({ status: 'not-requested' }); + } + + /** The tracked satellite's current ECEF sample — the value the docked-cluster + * scan measures against. Exposed so a test can place neighbours around it. */ + + function _trackedFrameCartesianForTest() { + return layerState._trackedFrameCartesian; + } + + /** Invoke the same callback registered on `scene.preRender` in production. */ + + function _runSatellitePreRenderForTest() { + parts.rendering._preRenderTick(); + } + + /** + * Seed the minimum render state a dense-catalog load needs, so a test can + * exercise the real async load/settle/fail path (and the row-control states it + * drives) without constructing a WebGL viewer. + * @param {{ catalog?: 'core'|'dense', showPoints?: boolean }} [options] + */ + + function _setDenseCatalogStateForTest({ + catalog = 'core', + showPoints = true, + } = {}) { + layerState._viewer = { + scene: { primitives: { add: (p) => p, remove() {} } }, + }; + // Neutralize the shared world-overlay host: these tests exercise catalog and + // row-control logic, not the ISS callout. + layerState._overlayHost = { + setEntries() {}, + setVisible() {}, + clearSource() {}, + }; + layerState._pointCollection = { + show: true, + add: (opts) => ({ ...opts }), + remove() {}, + removeAll() {}, + }; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._detectionObjects = new Map(); + layerState._orbitPaths = new Map(); + layerState._denseIds = []; + layerState._denseCursor = 0; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._catalogRevision++; + layerState._trackedNorad = null; + parts.tracking._cancelPendingTrackingRestore(); + layerState._params = { catalog, showPoints, showOrbits: false }; + layerState._enabled = true; + } + + /** Tear the dense seam back down so ordering cannot leak into other tests. */ + + function _clearDenseCatalogStateForTest() { + layerState._rowControlsListener = null; + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + layerState._orbitPaths = new Map(); + layerState._viewer = null; + layerState._pointCollection = null; + layerState._catalog = new Map(); + layerState._points = new Map(); + layerState._denseIds = []; + layerState._denseLoadToken++; + layerState._denseStatus = 'idle'; + layerState._denseError = null; + layerState._params = { + catalog: 'core', + showPoints: true, + showOrbits: true, + }; + parts.tracking._cancelPendingTrackingRestore(); + layerState._enabled = false; + } + + /** Catalog group tag recorded for a satellite, for ingestion-path assertions. */ + + function _catalogGroupForTest(noradId) { + return layerState._catalog.get(Number(noradId))?.group; + } + + /** Seed ISS/tracking state while retaining the production track and host paths. */ + + function _setSatelliteLabelLifecycleStateForTest({ + viewer, + satrec, + point, + overlayHost, + preservePending = false, + }) { + layerState._viewer = viewer; + layerState._catalog = new Map([ + [ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }], + ]); + layerState._points = new Map([[ISS_NORAD, point]]); + layerState._orbitPaths = new Map([ + [ + ISS_NORAD, + { + primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, + gmstAtBake: 0, + }, + ], + ]); + layerState._trackedNorad = null; + layerState._trackedEntity = null; + if (!preservePending) parts.tracking._cancelPendingTrackingRestore(); + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + layerState._enabled = true; + layerState._params = { + catalog: 'core', + showPoints: true, + showOrbits: true, + }; + layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST; + parts.labels._syncIssOverlay(); + } + + /** Exercise production ISS tracking from the cached catalog and point. */ + + function _trackIssForTest() { + parts.tracking._trackSatellite(ISS_NORAD); + return layerState._trackedEntity; + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingSatelliteTrackingRestoreForTest() { + return layerState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated catalog refresh. */ + + function _applyPendingSatelliteTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Remove a cached catalog row so tests can model a target arriving later. */ + + function _removeSatelliteTrackingCandidateForTest(noradId) { + const id = Number(noradId); + layerState._catalog.delete(id); + layerState._points.delete(id); + } + + /** Exercise production untrack and restore the default host seam. */ + + function _clearSatelliteLabelLifecycleForTest() { + parts.tracking._clearTracking(); + layerState._enabled = false; + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); + layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; + } + return { + _setTrackedSatelliteRefreshStateForTest, + _setSatelliteTrackingRefreshOutcomeForTest, + _trackedFrameCartesianForTest, + _runSatellitePreRenderForTest, + _setDenseCatalogStateForTest, + _clearDenseCatalogStateForTest, + _catalogGroupForTest, + _setSatelliteLabelLifecycleStateForTest, + _trackIssForTest, + _pendingSatelliteTrackingRestoreForTest, + _applyPendingSatelliteTrackingRestoreForTest, + _removeSatelliteTrackingCandidateForTest, + _clearSatelliteLabelLifecycleForTest, + }; +} diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js new file mode 100644 index 000000000..7bdb1d29f --- /dev/null +++ b/src/layers/satellites/tracking.js @@ -0,0 +1,479 @@ +import * as Cesium from 'cesium'; +import { satelliteClassLabel } from '../../data/satelliteClass.js'; +import { + ISS_NORAD, + CONTEXT_REFRESH_INTERVAL_MS, + HIGH_ORBIT_ALTITUDE_M, + TRACK_VIEW_FROM_HIGH_SCALE, + TRACK_VIEW_FROM_LEO, +} from './policy.js'; + +export function createTracking({ state: layerState, services, parts, source }) { + const { clearFocusTarget, publishFocusTargetFromCachedPosition } = + services.focus; + const { + clearTrackedSubjectContext, + getContextStore, + refreshTrackedSubjectContext, + selectTrackedSubjectContext, + } = services.context; + const { refreshTrackedReadout } = services.readout; + + function _normalizeTrackedNorad(candidate) { + const numeric = Number(candidate); + if (!Number.isFinite(numeric)) return null; + const rounded = Math.trunc(numeric); + return rounded > 0 ? rounded : null; + } + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _applyPendingTrackingRestore() { + const pending = layerState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== layerState._trackingIntentGeneration || + !layerState._enabled + ) + return false; + if ( + !layerState._viewer || + !layerState._catalog.has(pending.id) || + !layerState._points.has(pending.id) + ) + return false; + layerState._pendingTrackingRestore = null; + _trackSatellite(pending.id, { origin: pending.origin }); + return layerState._trackedNorad === pending.id; + } + + function _cancelPendingTrackingRestore() { + layerState._trackingIntentGeneration += 1; + layerState._pendingTrackingRestore = null; + } + + /** + * Stop tracking the currently followed satellite. + * @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed + * the follow-camera (viewer.trackedEntityChanged), tear down our own state + * but do NOT clear viewer.trackedEntity — the new owner controls it now, + * and clearing it would yank the camera off their target (mirror of flights). + */ + + function _clearTracking( + skipViewerUntrack = false, + { origin = 'programmatic' } = {}, + ) { + // Untracking dissolves the cluster: every companion returns to its own + // ambient label on the next collection. + layerState._dockedCompanions = new Set(); + layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY; + if (!layerState._trackedNorad) { + clearFocusTarget('satellites'); + parts.labels._syncIssOverlay(); + return; + } + const clearedNorad = layerState._trackedNorad; + clearFocusTarget('satellites', layerState._trackedNorad); + + const lastPos = layerState._points.get(layerState._trackedNorad); + + // Re-show the primitive (hidden while the tracked entity rendered the dot) + // and restore the original group palette from the shared style table (WS-D3) + if (lastPos) { + const style = parts.controls._pointStyleFor( + layerState._trackedNorad, + layerState._catalog.get(layerState._trackedNorad)?.group, + ); + lastPos.show = true; + lastPos.pixelSize = style.pixelSize; + lastPos.color = style.color; + lastPos.outlineColor = style.outlineColor; + lastPos.outlineWidth = style.outlineWidth; + lastPos.disableDepthTestDistance = 0; + } + + // Invalidate the per-frame tracked-position cache (WS-D2) + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + + // Remove tracked entity and orbit path (unless ISS — keep its path) + if (layerState._trackedNorad !== ISS_NORAD) { + parts.rendering._hideOrbitPath(layerState._trackedNorad); + } + if (layerState._viewer && !skipViewerUntrack) + layerState._viewer.trackedEntity = undefined; + if (layerState._trackedEntity) { + layerState._viewer.entities.remove(layerState._trackedEntity); + layerState._trackedEntity = null; + } + layerState._trackedNorad = null; + parts.labels._syncIssOverlay(); + clearTrackedSubjectContext('satellites'); + layerState._contextRefreshedAtMs = 0; + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'satellites', + id: clearedNorad, + origin, + }); + } + + /** + * Get the tracked satellite's geodetic position, propagated at most once per + * rendered frame (WS-D2). All tracked-satellite consumers (entity position + * callback → camera, host model, point primitive, getTrackedInfo) share this + * single `new Date()` epoch per frame, so they can never diverge by the old + * 200ms throttle. The matching ECEF position is left in + * `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap. + * @returns {{ longitude: number, latitude: number, altitude: number }|null} + */ + + function _getTrackedFramePosition() { + if (layerState._trackedNorad === null) return null; + const sat = layerState._catalog.get(layerState._trackedNorad); + if (!sat) return null; + + const frameNumber = + layerState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frameNumber === -1 || + frameNumber !== layerState._trackedFrameNumber || + layerState._trackedFrameGeo === null + ) { + const pos = parts.orbits.propagatePosition( + sat.satrec, + layerState._trackedFrameNowForTest + ? new Date(layerState._trackedFrameNowForTest()) + : new Date(), + ); + if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample + layerState._trackedFrameGeo = pos; + Cesium.Cartesian3.fromDegrees( + pos.longitude, + pos.latitude, + pos.altitude, + undefined, + layerState._trackedFrameCartesian, + ); + layerState._trackedFrameNumber = frameNumber; + // Throttled inside; membership changes are rare, so resync the ISS ambient + // gate only when the cluster actually changed. + const clusterChanged = parts.labels._refreshDockedCompanions( + layerState._trackedFrameNowForTest + ? layerState._trackedFrameNowForTest() + : Date.now(), + ); + _updateTrackedSatelliteLabelModel(); + // The card and the context slot describe the same satellite — keep them + // together so voice never narrates a fix the card has already replaced. + _refreshTrackedSubjectContext(); + if (clusterChanged) parts.labels._syncIssOverlay(); + const name = sat.name?.trim() || `SAT-${layerState._trackedNorad}`; + const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${layerState._trackedNorad}`; + const labelWidthPx = + Math.max(name.length, altitudeText.length) * 7.8 + 20; + const labelHeightPx = 2 * 13 + 12; + const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides + // Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all + // share one epoch, avoiding a second propagation phase and its old jitter. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'satellites', + id: layerState._trackedNorad, + scene: layerState._viewer?.scene, + camera: layerState._viewer?.camera, + displayPosition: layerState._trackedFrameCartesian, + widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)), + // Union of the 14 px point and the two-line label shifted 18 px upward. + heightPx: trackedPointDiameterPx + 18 + labelHeightPx, + }); + } + return layerState._trackedFrameGeo; + } + + /** Cached ECEF display point only; never propagates a fresh SGP4 sample. */ + + function _trackedDisplayCached() { + return layerState._trackedFrameGeo + ? layerState._trackedFrameCartesian + : null; + } + + /** + * Describe the tracked satellite for the shared context slot the voice tools + * read. Values come from the live per-frame propagation, not a selection-time + * snapshot, so a long follow never narrates a position the satellite has left. + * @param {number} noradId Catalog identity. + * @param {{latitude: number, longitude: number, altitude: number}|null} [position] + * Explicit position; defaults to the current per-frame propagation. + * @returns {object|null} Context metadata, or null when the satellite is gone. + */ + + function _contextSubjectMetadata(noradId, position = null) { + const sat = layerState._catalog.get(noradId); + if (!sat) return null; + const pos = position || _getTrackedFramePosition(); + if (!pos) return null; + const name = sat.name?.trim() || `SAT-${noradId}`; + const altitudeKm = Number.isFinite(pos.altitude) + ? Math.round(pos.altitude / 1000) + : null; + return { + id: String(noradId), + layerId: 'satellites', + layerName: 'Satellites', + source: 'CelesTrak', + label: name, + latitude: pos.latitude, + longitude: pos.longitude, + // Flat text only: the voice payload compacts properties through a string + // cleaner that drops nested objects. + properties: { + name, + operator: '', + noradId: String(noradId), + class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }), + altitude: + altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`, + }, + }; + } + + /** + * Reconcile the published subject with a freshly rebuilt catalog. + * + * A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the + * catalog, so the tracked satellite's entry is a NEW object with a new satrec. + * A surviving subject is simply re-resolved against it. A subject that is GONE + * must release the slot: the per-frame refresh cannot do this itself, because + * `_getTrackedFramePosition` returns early once the satellite has no catalog + * entry — so without this the record would linger and voice would narrate a + * satellite the catalog no longer carries, frozen at its last position. + * + * Releasing is gated on PROOF. The subject is preserved unless it is absent + * from a catalog that is both complete (`accepted`, no failed CelesTrak group) + * and applicable (dense settled, when dense is the requested catalog). A + * partial refresh, a failed dense load, or an empty catalog is unproven + * absence, and unproven absence is not absence — the same honesty rule the + * tracking-restore path already applies. + * @returns {Promise} Resolves once the applicable catalog has settled. + */ + + async function _reconcileTrackedSubjectContext() { + const subjectAtStart = layerState._trackedNorad; + if (subjectAtStart === null) return; + // Dense extras land AFTER the core rebuild resolves. Deciding before they + // settle called a dense subject missing and deleted its record; the record + // then stayed gone, because a refresh can update an existing record but + // cannot recreate one. + const denseSettlement = + layerState._params.catalog === 'dense' + ? layerState._denseLoadPromise + : null; + if (denseSettlement) { + try { + await denseSettlement; + } catch { + // A failed dense load proves nothing about the subject; fall through and + // let the outcome check below preserve it. + } + } + // The operator may have moved on while we waited. + if (layerState._trackedNorad !== subjectAtStart) return; + + const metadata = _contextSubjectMetadata(subjectAtStart); + if (metadata) { + const store = getContextStore(); + const key = String(subjectAtStart); + if (store.entities.has(key)) { + refreshTrackedSubjectContext(metadata); + } else if (!store.selectedEntityId) { + // The record was dropped while the subject was briefly unresolvable. + // Restore it — but only into an EMPTY slot: a satellite reappearing must + // never yank the subject away from something the operator selected since. + selectTrackedSubjectContext(metadata); + } + layerState._contextRefreshedAtMs = Date.now(); + return; + } + + // Absence only counts when EVERY catalog that could carry the subject + // actually loaded. Unproven absence is not absence — the same honesty rule + // the tracking-restore path applies. + // + // Deliberately NOT scoped to the group the subject was last seen in: + // CelesTrak reclassifies satellites between groups, so a subject missing + // from its old group may simply have moved to one that failed this refresh. + // Believing the old group alone would drop it. Any failed or empty group is + // therefore a reason to wait — and `accepted` already means every group + // returned entries. + if (layerState._catalog.size === 0) return; + if (layerState._lastTrackingRefreshOutcome?.status !== 'accepted') return; + // Dense is a potential carrier too whenever it was REQUESTED — and the + // request is read from `denseSettlement`, captured before the await, not + // from `_params.catalog` now. A failed dense load reverts the mode to 'core' + // as part of settling (an ACTIVE chip over an empty sky would be a lie), so + // by the time we get here the intent that made dense a carrier has been + // erased. Re-reading it would skip this guard on exactly the runs that need + // it and delete a subject the dense catalog might have carried. + if (denseSettlement && layerState._denseStatus !== 'ready') return; + clearTrackedSubjectContext('satellites'); + layerState._contextRefreshedAtMs = 0; + } + + /** + * Keep the shared context slot current with the tracked satellite, on the + * propagation beat rather than the frame clock. + * @returns {void} + */ + + function _refreshTrackedSubjectContext() { + if (layerState._trackedNorad === null) return; + const now = Date.now(); + if (now - layerState._contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS) + return; + layerState._contextRefreshedAtMs = now; + refreshTrackedSubjectContext( + _contextSubjectMetadata(layerState._trackedNorad), + ); + } + + function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) { + if (!layerState._trackedEntity || layerState._trackedNorad === null) return; + const sat = layerState._catalog.get(layerState._trackedNorad); + const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`; + const altitudeM = + layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM; + const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`; + // Class leads the detail block: it is what tells the operator WHAT they are + // looking at, and it stays readable under the IR styles that flatten the + // dot colors to a single channel (the card is painted above post-FX). + const details = [ + satelliteClassLabel(sat?.group, { + isIss: layerState._trackedNorad === ISS_NORAD, + }), + detail, + ]; + // Docked companions are consolidated onto the tracked card as SECONDARY info + // instead of competing with it as separate ambient labels. Identities are + // preserved: the catalog is untouched and every companion returns to its own + // label the moment the cluster is no longer tracked. + const companions = parts.labels._dockedCompanionNames(); + if (companions.length > 0) { + const extra = companions.length - 1; + details.push( + `DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`, + ); + } + const current = layerState._trackedEntity.gevLabelModel; + // Compare the WHOLE detail array: comparing only `details[0]` swallowed any + // change confined to the companions line, so the card would never republish. + const unchanged = + current?.title === title && + current?.details?.length === details.length && + details.every((line, index) => current.details[index] === line); + if (unchanged) return; + layerState._trackedEntity.gevLabelModel = { + title, + details, + accent: '#ffd84d', + }; + refreshTrackedReadout(layerState._trackedEntity); + } + + function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); + + const point = layerState._points.get(noradId); + const sat = layerState._catalog.get(noradId); + if (!point || !sat) return; + + layerState._trackedNorad = noradId; + layerState._trackedFrameNumber = -1; + layerState._trackedFrameGeo = null; + parts.labels._syncIssOverlay(); + + // Hide the primitive — the tracked ENTITY renders the dot below. The + // entity must own a point graphic so the Viewer's tracking camera can + // resolve a bounding sphere and engage viewFrom (a label-only entity + // left the camera stranded; this mirrors the proven flights pattern). + point.show = false; + + // Show orbital path + parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW); + + // Tracked entity position propagates per evaluation through the per-frame + // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. + // Falls back to the point primitive's 1s-throttled position if SGP4 fails. + const positionProperty = new Cesium.CallbackProperty(() => { + const pos = _getTrackedFramePosition(); + return pos ? layerState._trackedFrameCartesian : point.position; + }, false); + + const name = sat.name.trim(); + + // Comfortable tracking landing: ~726 km back for LEO (user-validated + // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled + // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. + const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const viewScale = + initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M + ? TRACK_VIEW_FROM_HIGH_SCALE + : 1; + const viewFrom = Cesium.Cartesian3.multiplyByScalar( + TRACK_VIEW_FROM_LEO, + viewScale, + new Cesium.Cartesian3(), + ); + + layerState._trackedEntity = layerState._viewer.entities.add({ + position: positionProperty, + viewFrom, + point: { + pixelSize: 14, + color: Cesium.Color.YELLOW, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + layerState._trackedEntity.gevSelectionOrigin = origin; + layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`; + layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached; + _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); + + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'satellites', + id: noradId, + label: name, + position: Cesium.Cartesian3.clone(point.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); + layerState._contextRefreshedAtMs = Date.now(); + + layerState._viewer.trackedEntity = layerState._trackedEntity; + console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); + } + return { + _normalizeTrackedNorad, + _emitAwarenessEvent, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _clearTracking, + _getTrackedFramePosition, + _trackedDisplayCached, + _contextSubjectMetadata, + _reconcileTrackedSubjectContext, + _refreshTrackedSubjectContext, + _updateTrackedSatelliteLabelModel, + _trackSatellite, + }; +} diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js new file mode 100644 index 000000000..bda32e78a --- /dev/null +++ b/src/layers/traffic/animation.js @@ -0,0 +1,314 @@ +import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js'; +import { presetDotOutline } from '../../data/trafficPresetStyle.js'; +import * as Cesium from 'cesium'; +import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js'; +import { + SPEED_MPS, + MAX_DOTS, + JAM_DOT_FAR_SCALE, + JAM_DOT_DEPTH_PUNCH, + CREEP_MOVE_MS, + CREEP_STOP_MS, + CREEP_BURST, + HEAT_JAM_BASE_ALPHA, + HEAT_JAM_PULSE_ALPHA, +} from './policy.js'; + +export function createAnimation({ + state: layerState, + services, + parts, + source, +}) { + /** + * Spawn animated dot primitives along a single road. + * + * Each dot is placed at a random position along the road, assigned a + * randomized speed (base +/-30%), and given a direction (alternating + * forward/backward to simulate two-way traffic). + * + * @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road + * Parsed road object with pre-computed waypoints. + * @param {number} altitude - Camera altitude (used if budgetCount is null). + * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back + * to `computeDotCount` when null. + */ + + function spawnDotsForRoad(road, altitude, budgetCount = null) { + // Live flow styling (`road.flow` only exists in live mode; keyless path is + // byte-identical): closures spawn nothing, congestion colors/slows dots. + const flow = layerState._liveMode ? road.flow : null; + if (flow?.closure) return; + if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide') + return; + + const count = Number.isFinite(budgetCount) + ? Math.max(0, Math.floor(budgetCount)) + : parts.model.computeDotCount(road, altitude); + const numSegments = road.waypoints.length - 1; + if (numSegments < 1 || count <= 0) return; + + const baseMps = SPEED_MPS[road.type] || 5; + const bucket = flow ? flowBucket(flow.level) : null; + // Jam dots get +1px: a red queue should read as a queue at a glance. + // Preset-aware styling adds its own size delta and floors the base (0 / + // no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT. + const pixelSize = + parts.style.baseDotSize(road.type, bucket) + + (bucket === 'jam' ? 1 : 0) + + parts.style.activeSizeDelta(bucket); + const flowColor = bucket ? layerState._activeBucketColors[bucket] : null; + // Dark-halo outline under styled presets (null = shipped no-outline). + const outlineSpec = + bucket && layerState._presetDots === 'on' + ? presetDotOutline(layerState._stylePreset, bucket) + : null; + const outlineColor = outlineSpec + ? new Cesium.Color( + outlineSpec.rgba[0] / 255, + outlineSpec.rgba[1] / 255, + outlineSpec.rgba[2] / 255, + outlineSpec.rgba[3], + ) + : null; + const flowSpeed = flow ? flowSpeedScale(flow.level) : 1; + const now = Date.now(); + + // Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper + // platoons (one shared direction per queue) instead of uniform scatter. + // Unreachable in sim mode — `bucket` requires flow. + let placements = null; + if (bucket === 'jam' && parts.style.jamDensityOn()) { + let totalLen = 0; + for (const d of road.segmentDist) totalLen += d; + const platoons = queuePlatoons(totalLen, count); + if (platoons.length) { + placements = []; + for (let p = 0; p < platoons.length; p++) { + const dir = road.oneway ? road.oneway : p % 2 === 0 ? 1 : -1; + for (const s of platoons[p]) { + const { segIdx, t } = locateAlongRoad(road.segmentDist, s); + placements.push({ segIdx, t, direction: dir }); + } + } + } + } + + for (let i = 0; i < count; i++) { + if (layerState._dots.length >= MAX_DOTS) return; + + // Random start position: pick a random segment and offset within it — + // unless this is a queued jam dot with a platoon placement. + const segIdx = placements + ? placements[i].segIdx + : Math.floor(Math.random() * numSegments); + const t = placements ? placements[i].t : Math.random(); + + // Speed noise: base speed +/-30% for organic variation. baseMps (noise + // included, flow excluded) is kept on the dot so a late-arriving flow + // match can rescale speed in place (recolorDotsInPlace). + const noisedMps = + baseMps * layerState._speedScale * (0.7 + Math.random() * 0.6); + const mps = noisedMps * flowSpeed; + + // One-way roads flow only their legal direction; two-way alternates + // (per platoon in queue mode — a queue moves as one). + const direction = placements + ? placements[i].direction + : road.oneway + ? road.oneway + : i % 2 === 0 + ? 1 + : -1; + + // Compute initial Cartesian3 position via linear interpolation + Cesium.Cartesian3.lerp( + road.waypoints[segIdx], + road.waypoints[segIdx + 1], + t, + layerState._scratchLerp, + ); + + // Jam-viz density prototype: jam dots stay visible at city scale — a + // longer depth-test punch-through (single-sample road heights sit under + // the mesh at oblique views) and a higher far-scale floor. Sim dots and + // other buckets keep the shipped values. + const jamProminent = bucket === 'jam' && parts.style.jamDensityOn(); + const point = layerState._pointCollection.add({ + position: Cesium.Cartesian3.clone(layerState._scratchLerp), + pixelSize, + // No flow data → today's exact simulated white. + color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), + scaleByDistance: new Cesium.NearFarScalar( + 100, + 1.5, + layerState._fadeScaleFar, + jamProminent ? JAM_DOT_FAR_SCALE : 0.3, + ), + translucencyByDistance: new Cesium.NearFarScalar( + 100, + 1.0, + layerState._fadeTransFar, + 0.0, + ), + // visible through tiles only when very close (jam: city-scale punch) + disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000, + // Preset dark halo (spread only when present — the keyless/normal + // path passes the exact shipped option set). + ...(outlineSpec + ? { outlineColor, outlineWidth: outlineSpec.width } + : {}), + }); + layerState._bucketCounts[bucket || 'sim'] += 1; + + layerState._dots.push({ + point, + road, + bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse + waypoints: road.waypoints, + segmentDist: road.segmentDist, + numSegments, + segIdx, + t, + mps, // meters per second (flow-scaled) + baseMps: noisedMps, // pre-flow speed, for in-place flow rescale + direction, + stoppedUntil: 0, + // Stop-and-go creep state (jam-viz density prototype): jam dots + // alternate move-bursts and stops. Null in sim mode and for non-jam. + creep: + bucket === 'jam' && parts.style.jamDensityOn() + ? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 } + : null, + }); + } + } + + /** + * Per-frame animation callback registered on `scene.preRender`. + * + * For every active dot: + * 1. Skip if currently paused by a simulated stop-light. + * 2. Convert speed (m/s) to a parametric t-delta relative to the current + * segment's Cartesian distance. + * 3. Advance t in the dot's travel direction, handling segment boundary + * crossings and end-of-road reversals. + * 4. Linearly interpolate between the two bounding waypoints and update the + * point primitive's position. + * + * Delta time is capped at 100 ms to prevent large jumps after background tabs. + */ + + function animate() { + const now = Date.now(); + // Delta time in seconds, capped to avoid jumps when returning from background tab + const dt = layerState._lastAnimTime + ? Math.min((now - layerState._lastAnimTime) / 1000, 0.1) + : 0.016; + layerState._lastAnimTime = now; + + for (let i = 0; i < layerState._dots.length; i++) { + const dot = layerState._dots[i]; + + // Simulated stop-light pause — skip movement while timer is active + if (now < dot.stoppedUntil) continue; + + // Stop-and-go creep (jam-viz density prototype, live jam dots only): + // alternate short forward bursts with stops. The burst multiplier keeps + // the long-run average near the honest TomTom crawl speed. + let burst = 1; + if (dot.creep) { + if (now >= dot.creep.until) { + dot.creep.moving = !dot.creep.moving; + const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS; + dot.creep.until = now + lo + Math.random() * (hi - lo); + } + if (!dot.creep.moving) continue; + burst = CREEP_BURST; + } + + // Convert m/s speed to parametric t-delta for the current segment length + const segLen = dot.segmentDist[dot.segIdx] || 1; + const tDelta = (dot.mps * burst * dt) / segLen; + + // Advance parametric position along the road in the current direction + dot.t += tDelta * dot.direction; + + // Handle forward segment boundary crossing (t >= 1.0) + if (dot.t >= 1.0) { + dot.t -= 1.0; + dot.segIdx++; + if (dot.segIdx >= dot.numSegments) { + // End of road: recycle to the road's entry with a small stagger — + // cars don't reverse at the end of a street (field-test round 1). + // Direction is preserved, so one-way flow stays legal. + dot.segIdx = 0; + dot.t = Math.random() * 0.3; + } + maybeStopLight(dot, now); + } else if (dot.t <= 0.0) { + // Handle backward segment boundary crossing (t <= 0.0) + dot.t += 1.0; + dot.segIdx--; + if (dot.segIdx < 0) { + // Start of road (traveling backward): recycle to the far end. + dot.segIdx = dot.numSegments - 1; + dot.t = 1.0 - Math.random() * 0.3; + } + maybeStopLight(dot, now); + } + + // Lerp between pre-computed Cartesian3 waypoints (no trig needed). + // Pass the scratch directly: PointPrimitive's position setter clones the + // value into its own storage (and skips the VBO dirty flag when equal), so + // the extra defensive clone here allocated 360–720k Cartesian3/s of pure + // garbage across 6000 dots. (perf item 5) + const a = dot.waypoints[dot.segIdx]; + const b = dot.waypoints[dot.segIdx + 1]; + Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp); + dot.point.position = layerState._scratchLerp; + } + + // Jam heat-lines throb (~1.6 s period) — one shared material uniform for + // the whole jam batch, no geometry rebuild. Null outside live heatline mode. + if (layerState._heatJamPrim?.appearance) { + layerState._heatJamPrim.appearance.material.uniforms.color.alpha = + HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); + } + + layerState._animFrame++; + } + + /** + * Randomly pause a dot near road endpoints to simulate stop-light behaviour. + * + * Only triggers within the first 2 or last 2 segments of the road, and only + * with a very low per-frame probability (0.8%) to keep traffic flowing. + * + * @param {Object} dot - The dot state object. + * @param {number} now - Current timestamp in milliseconds. + */ + + function maybeStopLight(dot, now) { + const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2; + if (nearEnd && Math.random() < 0.008) { + // Pause for 2–6 seconds + dot.stoppedUntil = now + 2000 + Math.random() * 4000; + } + } + + // ─── Cleanup ─────────────────────────────────────────────── + + /** Remove all point primitives and reset dot/road arrays and counters. */ + + function clearDots() { + if (layerState._pointCollection) layerState._pointCollection.removeAll(); + parts.rendering.removeHeatLines(); + layerState._dots = []; + layerState._roads = []; + layerState._count = 0; + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + layerState._closedRoads = 0; + } + return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; +} diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js new file mode 100644 index 000000000..95b3665a3 --- /dev/null +++ b/src/layers/traffic/controls.js @@ -0,0 +1,189 @@ +import { + trafficBucketTier, + trafficStyleProfile, +} from '../../data/trafficPresetStyle.js'; +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createControls({ state: layerState, services, parts, source }) { + const { getFlowSessionStats } = source; + + const methods = { + id: 'traffic', + + name: 'Street Traffic', + + icon: '🚗', + + source: 'OpenStreetMap', + + /** @type {number} Zero — layer is self-managed via camera listener + preRender */ + updateInterval: 0, + + /** + * Update user-adjustable parameters (density and speed scaling). + * + * @param {Object} [params] + * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). + * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). + */ + setParams(params = {}) { + if (typeof params.densityScale === 'number') { + layerState._densityScale = Math.max( + 0.2, + Math.min(2.5, params.densityScale), + ); + } + if (typeof params.speedScale === 'number') { + layerState._speedScale = Math.max( + 0.3, + Math.min(3.0, params.speedScale), + ); + } + // Live-mode treatment of roads TomTom has no flow data for: + // 'sim' (default) keeps them as today's white ambient dots — colored = + // real data, white = simulation; 'hide' spawns nothing on them (strict + // data-integrity view). Owner-explorable; re-render applies on the next + // camera-driven load. + if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') { + layerState._uncoveredMode = params.uncoveredRoads; + } + // Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior; + // live mode only, applies on the next camera-driven load like the + // uncoveredRoads param above. + if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) { + layerState._jamViz = params.jamViz; + } + // Preset-aware dot styling kill switch (owner A/B): 'off' forces the + // shipped palette under every post-FX preset. Applies immediately via + // in-place restyle — no refetch — so A/B legs share identical dots. + if (params.presetDots === 'on' || params.presetDots === 'off') { + if (params.presetDots !== layerState._presetDots) { + layerState._presetDots = params.presetDots; + parts.style.restyleDotsInPlace(); + } + } + }, + + /** + * Return the current user-adjustable parameters. + * @returns {{densityScale:number, speedScale:number}} + */ + getParams() { + return { + densityScale: layerState._densityScale, + speedScale: layerState._speedScale, + uncoveredRoads: layerState._uncoveredMode, + jamViz: layerState._jamViz, + presetDots: layerState._presetDots, + }; + }, + + /** + * Return a sub-sampled list of active dot positions for detection overlays + * (e.g. CCTV bounding-box rendering). + * + * Uses a deterministic stride-based sampling so different seeds yield + * non-overlapping subsets without sorting or shuffling. + * + * @param {Object} [options] + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). + * @param {number} [options.seed] - Integer seed to offset the sampling start. + * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} + */ + getDetectableObjects(options = {}) { + if (!layerState._enabled || layerState._dots.length === 0) return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : layerState._dots.length; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Stride-based sampling: step through dots evenly to get ~maxCount samples + const stride = Math.max(1, Math.ceil(layerState._dots.length / maxCount)); + const start = seed % stride; + + const result = []; + for (let i = start; i < layerState._dots.length; i += stride) { + const pos = layerState._dots[i].point.position; + if (!pos) continue; + const entry = { + position: pos, + id: `VEH-${String(i).padStart(4, '0')}`, + type: 'VEH', + }; + // Live mode: the detection bracket carries the congestion signal — + // its canvas sits ABOVE the post-FX chain, so tier colors survive + // every preset (owner round 2: "bounding boxes do the heavy + // lifting"). Keyless mode sets no tier: contacts keep the stock + // 'vehicle' bracket and the keyless experience stays untouched. + if (layerState._liveMode) { + const tier = trafficBucketTier(layerState._dots[i].bucket || 'sim'); + if (tier) entry.tier = tier; + } + result.push(entry); + if (result.length >= maxCount) break; + } + return result; + }, + + /** + * Return current layer statistics for UI status chips. + * `mode` is the CONFIGURED source — 'live' (a TomTom key is present) or + * 'sim' (keyless simulation, which the manager renders as a FALLBACK chip); + * `error` carries this instant's health, so a live-configured layer whose + * flow feed went down reads DEGRADED with the reason instead of a stale + * LIVE coverage number. `flowCoveragePct` is matched roads / roads with any + * flow candidates (0–100 int); `tilesFetched` counts flow-tile requests + * issued to the proxy this session (decode-cache hits excluded). + * @returns {{count:number, lastUpdate:number|null, loading:boolean, + * mode:'live'|'sim', error:string|null, flowCoveragePct:number, + * tilesFetched:number}} + */ + getStats() { + // Outstanding flow work counts as loading: the paint race can leave a + // TomTom request in flight after the roads have settled, and the shared + // loading batch has to stay open long enough to announce its failure. + const loading = layerState._fetching || layerState._flowPending > 0; + const feed = parts.model.trafficFeedPresentation({ + liveMode: layerState._liveMode, + fetching: loading, + flowError: layerState._flowError, + coveragePct: layerState._flowCoveragePct, + statusUnavailable: layerState._flowStatusUnavailable, + }); + return { + count: layerState._count, + lastUpdate: layerState._lastUpdate, + loading, + mode: feed.mode, + error: layerState._roadError || feed.error, + flowCoveragePct: layerState._flowCoveragePct, + tilesFetched: getFlowSessionStats().tilesFetched, + ...(TRAFFIC_TIMING_ENABLED + ? { trafficTiming: parts.timing.getTrafficTimingDiagnostics() } + : {}), + // Per-bucket rendered-dot counts (sim = white ambient). Drives the + // qa-traffic color assertions and the sync-chip mode label below. + flowBuckets: { ...layerState._bucketCounts }, + closedRoads: layerState._closedRoads, + // Jam-viz prototype diagnostics (additive — harness contract untouched). + heatLines: layerState._heatLineCount, + jamViz: layerState._jamViz, + // Preset-styling diagnostics (additive): active style + profile. + stylePreset: layerState._stylePreset, + styleProfile: + layerState._presetDots === 'on' + ? trafficStyleProfile(layerState._stylePreset) + : 'normal', + // Sync-chip text: shown while busy, and flashed on its own for 1.5 s + // after each completed load (ui.js _updateTrafficSyncChip semantics). + // The settled flash carries NO progress number beside it — this label's + // coverage figure is the chip's only percentage — so a label that ends + // in one had better be the honest one. This is also where LIVE vs + // SIMULATED mode is surfaced, and it must never imply a live feed the + // layer does not have. + loadingLabel: feed.loadingLabel, + }; + }, + }; + + return { methods }; +} diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js new file mode 100644 index 000000000..c12f05f53 --- /dev/null +++ b/src/layers/traffic/flow.js @@ -0,0 +1,215 @@ +import { matchFlowToRoads } from '../../data/flowMatch.js'; +import { TRAFFIC_TIMING_ENABLED, FLOW_RENDER_RACE_MS } from './policy.js'; + +export function createFlow({ state: layerState, services, parts, source }) { + const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits; + const { fetchFlowForBounds } = source; + + // ─── Live Flow (TomTom) ──────────────────────────────────── + + /** + * Map a failed flow fetch onto one short, honest user-facing reason. + * + * `fetchFlowForBounds` only rejects when EVERY covering tile failed, so a + * non-null result here always means "there is no live flow to show right + * now" — the dots fall back to simulated white. Mirrors the + * `deriveAisFeedError` honesty helper. + * + * @param {Error|{name?:string, message?:string}|null|undefined} error - Rejection from the flow fetch. + * @returns {string|null} Short reason, or null for an aborted (superseded) fetch. + */ + + function deriveTrafficFlowError(error) { + if (!error || error.name === 'AbortError') return null; + const message = String(error.message || error); + const status = Number(message.match(/HTTP (\d{3})/)?.[1]); + if (status === 503) return 'TomTom key unavailable'; + if (status === 429) return 'TomTom daily budget reached'; + if (status === 502 || status === 504) return 'TomTom upstream unreachable'; + if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`; + return 'TomTom flow unavailable'; + } + + /** + * Check `/api/tomtom/status` once per session and cache the result. + * Live mode iff the server holds a TomTom key; the TomTom attribution credit + * registers the first time live mode activates. Keyless or unreachable → + * simulation mode, exactly today's behavior. + * + * @returns {Promise} Resolves when `_liveMode` is settled. + */ + + function ensureFlowStatus() { + if (!layerState._flowStatusPromise) { + layerState._flowStatusPromise = source + .getStatus() + .then((status) => { + layerState._liveMode = Boolean(status?.hasKey); + layerState._flowStatusUnavailable = false; + if (layerState._liveMode) { + console.log('[Data:Traffic] TomTom key present — live flow mode'); + registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT); + } + }) + .catch((e) => { + // Simulating because we could not ask, which is NOT the same as + // "server says no key" — getStats() distinguishes the two. + layerState._liveMode = false; + layerState._flowStatusUnavailable = true; + console.warn( + '[Data:Traffic] TomTom status unreachable — simulated traffic:', + e?.message || e, + ); + }); + } + return layerState._flowStatusPromise; + } + + /** + * Live mode only: fetch TomTom flow for the clamped bounds, match it onto the + * parsed roads, and attach `road.flow` (`{level, closure}` or null). + * + * Reuses the load-generation guard: stale flow responses are discarded, and + * the shared AbortController lets `cancelActiveFetch()` (next load / disable) + * cancel an in-flight flow fetch. Any failure leaves roads unmatched — the + * dots then render in today's simulated white, never a phantom color — and is + * recorded in `_flowError` so `getStats()` degrades honestly instead of + * reporting a stale "LIVE · N% cov" over simulated dots. + * + * @param {Array} roads - Parsed road objects (mutated: `road.flow`). + * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. + * @param {number} generation - `_loadGeneration` at call time. + * @returns {Promise} + */ + + async function applyFlowToRoads(roads, clamped, generation) { + // Claim the work synchronously, before the first await, so `stats.loading` + // covers this request from the same tick the caller started it — the + // loading batch must not be able to close underneath an in-flight fetch. + layerState._flowPending += 1; + try { + if (!layerState._flowStatusPromise) return; // status check not started — sim mode + await layerState._flowStatusPromise; + if (!layerState._liveMode || !layerState._enabled) return; + if (generation !== layerState._loadGeneration) return; + if (!Array.isArray(roads) || roads.length === 0) return; + try { + // Cached paths reach here without a live controller; the fetch paths + // reuse theirs so one cancel covers both roads and flow. + if (!layerState._activeFetchAbort) + layerState._activeFetchAbort = new AbortController(); + const segments = await fetchFlowForBounds(clamped, { + signal: layerState._activeFetchAbort.signal, + }); + if (generation !== layerState._loadGeneration) return; + const { matches, matchedCount, candidateCount } = matchFlowToRoads( + roads, + segments, + ); + for (let i = 0; i < roads.length; i++) { + roads[i].flow = matches[i]; + } + layerState._flowCoveragePct = + candidateCount > 0 + ? Math.round((matchedCount / candidateCount) * 100) + : 0; + layerState._flowError = null; + } catch (e) { + if (e?.name === 'AbortError') return; + // Same guard the success path gets: a superseded request rejecting late + // (or after disable() cleared the state) must not restore a stale + // outage over newer good data. + if (generation !== layerState._loadGeneration || !layerState._enabled) + return; + // Every covering tile failed: there is no live flow on screen. Drop the + // now-false coverage number and surface the reason through getStats(). + layerState._flowError = deriveTrafficFlowError(e); + layerState._flowCoveragePct = 0; + console.warn( + '[Data:Traffic] Flow fetch failed (sim colors remain):', + e?.message || e, + ); + } + } finally { + if (generation === layerState._loadGeneration) + layerState._flowPending -= 1; + } + } + + /** + * Race flow application against the paint deadline, render, and schedule an + * in-place recolor if flow lost the race. + * @param {Array} roads - Parsed road objects. + * @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds. + * @param {number} generation - `_loadGeneration` at call time. + * @param {number} altitude - Camera altitude in meters. + * @param {string} label - Render log label. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} True if this generation rendered. + */ + + async function applyFlowThenRender( + roads, + clamped, + generation, + altitude, + label, + trace = null, + ) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingRenderState(trace, label) + : null; + const flowRaceStart = state + ? parts.timing.trafficTimingMark(state, 'flow-render-race-start', { + deadlineMs: FLOW_RENDER_RACE_MS, + }) + : null; + const flowJob = applyFlowToRoads(roads, clamped, generation); + const outcome = await Promise.race([ + flowJob.then(() => 'flow'), + new Promise((resolve) => + setTimeout(() => resolve('timeout'), FLOW_RENDER_RACE_MS), + ), + ]); + if (state) { + const flowRaceEnd = parts.timing.trafficTimingMark( + state, + 'flow-render-race-end', + { + deadlineMs: FLOW_RENDER_RACE_MS, + outcome, + }, + ); + parts.timing.trafficTimingMeasure( + 'flow-render-race', + state, + flowRaceStart, + flowRaceEnd, + { + deadlineMs: FLOW_RENDER_RACE_MS, + outcome, + }, + ); + } + if (generation !== layerState._loadGeneration) return false; + parts.rendering.renderRoadsForAltitude(roads, altitude, label, trace); + if (outcome === 'timeout') { + flowJob + .then(() => { + if (generation !== layerState._loadGeneration) return; + parts.model.recolorDotsInPlace(label); + }) + .catch(() => { + /* applyFlowToRoads settles its own failures */ + }); + } + return true; + } + return { + deriveTrafficFlowError, + ensureFlowStatus, + applyFlowToRoads, + applyFlowThenRender, + }; +} diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js new file mode 100644 index 000000000..016ce576b --- /dev/null +++ b/src/layers/traffic/flowDecode.js @@ -0,0 +1,70 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; + +/** + * @file TomTom traffic-flow vector-tile client: fetch + MVT decode. + * + * Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy + * (the TomTom key never reaches the browser) and decodes the Mapbox Vector + * Tile layer "Traffic flow" into plain lon/lat polylines with congestion + * attributes. Consumed by the traffic layer's live mode + * (`src/data/traffic.js` → `src/data/flowMatch.js`). + * + * Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string, + * closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed + * ratio (1 = free flow). Features with a missing/non-finite `traffic_level` + * are skipped unless `road_closure` is true (closures decode with level 0). + * + * Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and + * tree-shakeable; decoding happens client-side so the proxy stays a dumb + * binary cache. + * + * @module data/flowTiles + */ + +const FLOW_LAYER_NAME = 'Traffic flow'; +export function decodeFlowTile(data, z, x, y) { + let layer; + try { + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const tile = new VectorTile(new PbfReader(bytes)); + layer = tile.layers[FLOW_LAYER_NAME]; + } catch { + return []; + } + if (!layer) return []; + + const segments = []; + for (let i = 0; i < layer.length; i++) { + let feature; + let geometry; + try { + feature = layer.feature(i); + geometry = feature.toGeoJSON(x, y, z).geometry; + } catch { + continue; // one malformed feature must not drop the tile + } + const props = feature.properties || {}; + const closure = + props.road_closure === true || props.road_closure === 'true'; + const rawLevel = props.traffic_level; + const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel); + // Skip features we can't color — unless closed (closures render dot-free + // regardless of level, so they stay useful without one). + if (!hasLevel && !closure) continue; + const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0; + const roadType = typeof props.road_type === 'string' ? props.road_type : ''; + + const lines = + geometry.type === 'LineString' + ? [geometry.coordinates] + : geometry.type === 'MultiLineString' + ? geometry.coordinates + : []; + for (const coords of lines) { + if (!Array.isArray(coords) || coords.length < 2) continue; + segments.push({ coords, trafficLevel, roadType, closure }); + } + } + return segments; +} diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js new file mode 100644 index 000000000..dc1ed8b90 --- /dev/null +++ b/src/layers/traffic/flowSource.js @@ -0,0 +1,97 @@ +import { tilesForBounds } from '../../data/tomtomTiles.js'; +import { decodeFlowTile } from './flowDecode.js'; +/** Own one decoded flow cache and its session counters. */ +export function createFlowTileSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const DECODE_CACHE_TTL_MS = 120_000; + /** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */ + const DECODE_CACHE_MAX_ENTRIES = 64; + + /** + * Decoded-tile cache keyed by "z/x/y". + * @type {Map} + */ + const _decodeCache = new Map(); + /** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */ + let _tilesFetched = 0; + + /** Insert into the decode cache with oldest-entry eviction. */ + function cacheSet(key, entry) { + if ( + !_decodeCache.has(key) && + _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES + ) { + const oldest = _decodeCache.keys().next().value; + _decodeCache.delete(oldest); + } + _decodeCache.set(key, entry); + } + + /** + * Fetch + decode all flow tiles covering the given bounds. + * + * Tiles are fetched from the local proxy in parallel; each decoded tile is + * cached in memory for 120 s (keyed z/x/y), so repeat calls for the same + * viewport are free. Partial tile failures return the segments that DID + * decode (last-good philosophy); the promise rejects only when every tile + * failed (e.g. keyless 503, aborted signal, proxy down). + * + * @param {{south:number, west:number, north:number, east:number}} bounds - Degrees. + * @param {Object} [opts] + * @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled). + * @param {number} [opts.zoom=12] - Flow tile zoom level. + * @returns {Promise>} + * Flat array of flow segments across all covering tiles. + */ + async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) { + signal?.throwIfAborted(); + const tiles = tilesForBounds(bounds, zoom); + if (tiles.length === 0) return []; + const now = Date.now(); + + const results = await Promise.allSettled( + tiles.map(async ({ z, x, y }) => { + const key = `${z}/${x}/${y}`; + const cached = _decodeCache.get(key); + if (cached && now - cached.at < DECODE_CACHE_TTL_MS) + return cached.segments; + + _tilesFetched += 1; + const res = await fetchImpl(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { + signal, + }); + if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`); + const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y); + signal?.throwIfAborted(); + cacheSet(key, { at: Date.now(), segments }); + return segments; + }), + ); + + signal?.throwIfAborted(); + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + if (fulfilled.length === 0) { + throw results[0].reason instanceof Error + ? results[0].reason + : new Error('flow fetch failed'); + } + return fulfilled.flatMap((r) => r.value); + } + + /** + * Session diagnostics for `getStats()` surfaces. + * @returns {{tilesFetched:number}} Count of tile requests issued to the proxy + * this session (decode-cache hits excluded). + */ + function getFlowSessionStats() { + return { tilesFetched: _tilesFetched }; + } + + /** Clear the decode cache (tests + layer teardown). Session stats persist. */ + function resetFlowTileCache() { + _decodeCache.clear(); + } + + return { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache }; +} diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js new file mode 100644 index 000000000..4039ada17 --- /dev/null +++ b/src/layers/traffic/index.js @@ -0,0 +1,63 @@ +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { createStyle } from './style.js'; +import { createTiming } from './timing.js'; +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createAnimation } from './animation.js'; +import { createViewport } from './viewport.js'; +import { createFlow } from './flow.js'; +import { createRendering } from './rendering.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createTrafficLayer({ services, source }) { + if ( + ![ + 'requestRoads', + 'getStatus', + 'fetchFlowForBounds', + 'getFlowSessionStats', + 'resetFlowTileCache', + ].every((key) => typeof source?.[key] === 'function') + ) + throw new TypeError('A traffic source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.style = createStyle(context); + parts.timing = createTiming(context); + parts.model = createModel(context); + parts.ingestion = createIngestion(context); + parts.animation = createAnimation(context); + parts.viewport = createViewport(context); + parts.flow = createFlow(context); + parts.rendering = createRendering(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + state._parseRoads = TRAFFIC_TIMING_ENABLED + ? (data, trace) => + trace + ? parts.timing.parseRoadsTimed(data, trace) + : parts.model.parseRoads(data) + : parts.model.parseRoads; + + state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED + ? parts.timing.loadRoadsForBoundsTimed + : parts.ingestion.loadRoadsForBounds; + + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion?.methods, + { + getTrafficTimingDiagnostics: parts.timing.getTrafficTimingDiagnostics, + deriveTrafficFlowError: parts.flow.deriveTrafficFlowError, + trafficFeedPresentation: parts.model.trafficFeedPresentation, + }, + ); +} + +export { createTrafficSource } from './source.js'; diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js new file mode 100644 index 000000000..b7d48597f --- /dev/null +++ b/src/layers/traffic/ingestion.js @@ -0,0 +1,354 @@ +import { + TRAFFIC_TIMING_ENABLED, + OVERPASS_URL, + TILE_CACHE_MAX_ENTRIES, + FAST_FETCH_ALTITUDE, +} from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + const { fetchFlowForBounds } = source; + + /** + * Fetch road geometries from the Overpass API via the local proxy. + * + * Sends a POST with the query as form-encoded `data`. Supports + * AbortController signals so in-flight requests can be cancelled + * when the camera moves before the response arrives. + * + * @param {number} south - Southern latitude bound (degrees). + * @param {number} west - Western longitude bound (degrees). + * @param {number} north - Northern latitude bound (degrees). + * @param {number} east - Eastern longitude bound (degrees). + * @param {Object} [opts] + * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes. + * @param {number} [opts.timeoutSec=25] - Server-side Overpass timeout. + * @param {AbortSignal} [opts.signal] - Abort signal for cancellation. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} Parsed JSON response from Overpass. + * @throws {Error} If the HTTP response status is not OK. + */ + + async function fetchRoads( + south, + west, + north, + east, + { majorOnly = false, timeoutSec = 25, signal } = {}, + trace = null, + ) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingPass( + trace, + majorOnly ? 'major' : 'full', + 'proxy', + ) + : null; + if (state) trace.currentPass = state.pass; + const fetchStart = state + ? parts.timing.trafficTimingMark(state, 'fetch-start') + : null; + if (state) { + parts.timing.trafficTimingMeasure( + 'last-camera-change-to-fetch-start', + state, + trace.cameraChangeMark, + fetchStart, + ); + } + const response = await source.requestRoads( + { south, west, north, east }, + { majorOnly, timeoutSec, signal }, + ); + + if (!response.ok) { + throw new Error(`Overpass API returned ${response.status}`); + } + + if (!state) { + const data = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(data?.elements)) + throw new Error('Malformed road snapshot'); + return data; + } + + if (state) { + state.proxyCache = response.headers.get('x-overpass-cache'); + state.proxyUpstream = response.headers.get('x-overpass-upstream'); + } + const responseStart = state + ? parts.timing.trafficTimingMark(state, 'response-json-start', { + responseStatus: response.status, + }) + : null; + if (state) { + parts.timing.trafficTimingMeasure( + 'fetch-to-response', + state, + fetchStart, + responseStart, + { + responseStatus: response.status, + }, + ); + } + const data = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(data?.elements)) + throw new Error('Malformed road snapshot'); + if (state) { + const responseEnd = parts.timing.trafficTimingMark( + state, + 'response-json-end', + { + responseStatus: response.status, + }, + ); + parts.timing.trafficTimingMeasure( + 'response-json', + state, + responseStart, + responseEnd, + { + responseStatus: response.status, + }, + ); + } + return data; + } + + /** Abort any in-flight Overpass fetch and clear the controller reference. */ + + function cancelActiveFetch() { + if (layerState._activeFetchAbort) { + layerState._activeFetchAbort.abort(); + layerState._activeFetchAbort = null; + } + } + + /** + * Load road data for the given viewport bounds and render traffic dots. + * + * Implements a two-pass fetch strategy with tile caching: + * + * 1. Check the tile cache (keyed by clamped bounding-box coordinates). + * - If a full road set is cached, render immediately and return. + * - If only major roads are cached, render those first. + * 2. Fetch major roads from Overpass (fast, small payload). Render. + * 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full + * road graph (includes tertiary/residential). Render again to upgrade. + * + * Each fetch is guarded by a monotonic `_loadGeneration` counter so that + * stale responses from superseded requests are silently discarded. + * + * @param {{south:number, west:number, north:number, east:number}} bounds + * Viewport bounds (will be clamped internally). + * @param {number} altitude - Camera altitude in meters. + * @param {Object|null} [trace=null] - Development-only correlated load trace. + * @returns {Promise} + */ + + async function loadRoadsForBounds(bounds, altitude, trace = null) { + // Increment generation to invalidate any in-flight responses from prior calls + const generation = ++layerState._loadGeneration; + cancelActiveFetch(); + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + layerState._flowPending = 0; + layerState._activeFetchAbort = new AbortController(); + const requestSignal = layerState._activeFetchAbort.signal; + const clamped = parts.viewport.clampBounds(bounds); + + // Cache key: fixed-precision bounding-box string for deterministic lookups + const cacheKey = `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`; + + if (cacheKey !== layerState._retryBoundsKey) { + layerState._retryBoundsKey = cacheKey; + layerState._retryDelayMs = 1500; + } + layerState._roadError = null; + + // Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road + // fetch — sequential fetches doubled first-paint latency (field-test + // round 1). Failures are irrelevant; applyFlowToRoads settles the truth. + parts.flow.ensureFlowStatus().then(() => { + if ( + layerState._liveMode && + layerState._enabled && + generation === layerState._loadGeneration + ) { + fetchFlowForBounds(clamped, { signal: requestSignal }).catch(() => { + /* warm-up only */ + }); + } + }); + + layerState._fetching = true; + // Only COMMIT these on success. Committing up-front means a failed Overpass + // fetch (rate-limited / feed down) still trips the overlap gate in + // onCameraChanged, so a stationary user never retries (H3/H5). Stage the + // prospective values and roll back if nothing rendered. + const prevBounds = layerState._lastBounds; + const prevViewCenter = layerState._lastViewCenter; + layerState._lastBounds = clamped; + layerState._lastViewCenter = parts.viewport.getBoundsCenter(clamped); + let renderedSomething = false; + + try { + let cache = layerState._tileCache.get(cacheKey); + if (!cache) { + // LRU eviction: drop the oldest entry when cache exceeds the cap + if (layerState._tileCache.size >= TILE_CACHE_MAX_ENTRIES) { + const oldest = layerState._tileCache.keys().next().value; + layerState._tileCache.delete(oldest); + } + cache = { major: null, full: null }; + layerState._tileCache.set(cacheKey, cache); + } + + // Fast path: full road set already cached — render and return. + // Flow is (re)applied even on cache hits: roads cache for the session, + // but congestion data has a 120s shelf life. The race renders within + // FLOW_RENDER_RACE_MS either way; late flow recolors in place. + if (cache.full) { + renderedSomething = await parts.flow.applyFlowThenRender( + cache.full, + clamped, + generation, + altitude, + 'Cache full', + trace, + ); + return; + } + + // Intermediate path: render cached major roads while fetching the rest + if (cache.major) { + if ( + !(await parts.flow.applyFlowThenRender( + cache.major, + clamped, + generation, + altitude, + 'Cache major', + trace, + )) + ) + return; + renderedSomething = true; + } else { + // Fetch major roads first (smaller payload, faster response) + console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`); + const majorData = await fetchRoads( + clamped.south, + clamped.west, + clamped.north, + clamped.east, + { + majorOnly: true, + timeoutSec: 12, + signal: requestSignal, + }, + trace, + ); + // Discard stale response if a newer load was triggered while waiting + if (generation !== layerState._loadGeneration) return; + cache.major = layerState._parseRoads(majorData, trace); + if ( + !(await parts.flow.applyFlowThenRender( + cache.major, + clamped, + generation, + altitude, + 'Loaded major', + trace, + )) + ) + return; + renderedSomething = true; + } + + // At higher altitude, major roads provide sufficient motion density + if (altitude > FAST_FETCH_ALTITUDE) return; + + // Detailed pass: fetch the full road graph (tertiary, residential, etc.) + console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`); + const fullData = await fetchRoads( + clamped.south, + clamped.west, + clamped.north, + clamped.east, + { + majorOnly: false, + timeoutSec: 20, + signal: requestSignal, + }, + trace, + ); + if (generation !== layerState._loadGeneration) return; + + cache.full = layerState._parseRoads(fullData, trace); + if ( + !(await parts.flow.applyFlowThenRender( + cache.full, + clamped, + generation, + altitude, + 'Loaded full', + trace, + )) + ) + return; + renderedSomething = true; + } catch (e) { + if (e?.name === 'AbortError') return; + if (generation === layerState._loadGeneration && !renderedSomething) + layerState._roadError = 'Road data temporarily unavailable'; + console.warn('[Data:Traffic] Fetch error:', e); + } finally { + if (generation === layerState._loadGeneration) { + layerState._fetching = false; + // Roll back the bounds commit if this load rendered nothing (e.g. the + // Overpass fetch failed). Leaving them committed would make the overlap + // gate skip the retry while the user sits still. Guarded on generation so + // a superseding load's commit is not clobbered. + if (!renderedSomething) { + layerState._lastBounds = prevBounds; + layerState._lastViewCenter = prevViewCenter; + if (layerState._enabled) { + layerState._retryTimer = setTimeout(() => { + layerState._retryTimer = null; + parts.viewport.onCameraChanged(); + }, layerState._retryDelayMs); + layerState._retryDelayMs = Math.min( + layerState._retryDelayMs * 2, + 30000, + ); + } + } else { + layerState._retryDelayMs = 1500; + } + } + // Keep this generation's controller until superseded or disabled: flow + // can still be running after its paint deadline. An older finally must + // never clear the controller belonging to a newer destination. + } + } + const methods = { + /** + * No-op — traffic updates are entirely camera-driven, not timer-driven. + * @returns {Promise} + */ + async update() { + // No-op — updates are camera-driven + }, + }; + + return { fetchRoads, cancelActiveFetch, loadRoadsForBounds, methods }; +} diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js new file mode 100644 index 000000000..bb452a984 --- /dev/null +++ b/src/layers/traffic/lifecycle.js @@ -0,0 +1,202 @@ +import * as Cesium from 'cesium'; +import { TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { resetFlowTileCache } = source; + + const methods = { + /** + * One-time initialisation. Creates the PointPrimitiveCollection and adds it + * to the scene (hidden). The collection is never removed and re-added — only + * toggled via `.show` to avoid destroy-on-remove errors. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + init(viewer) { + layerState._viewer = viewer; + layerState._pointCollection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + // Add permanently — toggle with .show to avoid destroy-on-remove errors + viewer.scene.primitives.add(layerState._pointCollection); + layerState._pointCollection.show = false; + layerState._dots = []; + layerState._roads = []; + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastBounds = null; + layerState._fetching = false; + layerState._loadGeneration = 0; + layerState._densityScale = 1.0; + layerState._speedScale = 1.0; + layerState._lastViewCenter = null; + layerState._flowCoveragePct = 0; + layerState._flowError = null; + if (TRAFFIC_TIMING_ENABLED) { + layerState._trafficTimingCurrentAnchor = null; + layerState._trafficTimingSequence = 0; + layerState._trafficTimingTracesCreated = 0; + layerState._trafficTimingDroppedTraces = 0; + } + + // Preset-aware dot styling: adopt the active post-FX style (persisted + // style restore may run before layer registration, so read the dataset) + // and follow StyleManager's gev:style-change event thereafter. Guarded + // for non-browser contexts; bound once per page (init survives layer + // destroy/re-register). + if (typeof window !== 'undefined') { + layerState._stylePreset = + document?.documentElement?.dataset?.gevStyle || 'normal'; + if (!layerState._styleListenerBound) { + window.addEventListener('gev:style-change', (e) => + parts.style.setStylePreset(e?.detail?.style), + ); + layerState._styleListenerBound = true; + } + } + parts.style.refreshBucketColors(); + console.log('[Data:Traffic] Initialized'); + }, + + /** + * Enable the traffic layer. Shows the point collection, subscribes to the + * preRender animation loop and camera-change events, and kicks off an + * initial viewport check. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + enable(viewer) { + layerState._enabled = true; + holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + layerState._lastAnimTime = 0; + layerState._pointCollection.show = true; + + // One status check per session decides sim vs live-TomTom mode. + parts.flow.ensureFlowStatus(); + + layerState._preRenderRemover = viewer.scene.preRender.addEventListener( + parts.animation.animate, + ); + if (TRAFFIC_TIMING_ENABLED) { + parts.timing.clearTrafficTimingEntries(); + layerState._trafficTimingCurrentAnchor = null; + layerState._trafficTimingPostRenderRemovers = new Set(); + layerState._trafficTimingMoveEndRemover = + viewer.camera.moveEnd.addEventListener( + parts.timing.markTrafficTimingMoveEnd, + ); + } + + // Subscribe to camera changes with a 5% movement threshold. Save the prior + // value so disable() can restore it — percentageChanged is a shared global + // on the camera and leaving it mutated affects every other camera.changed + // listener in the app. + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + // Always inspect the final view, even when the last flight step is below + // camera.changed's movement threshold. + layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener( + parts.viewport.onCameraChanged, + ); + layerState._prevPercentageChanged = viewer.camera.percentageChanged; + viewer.camera.percentageChanged = 0.05; + + // Kick off initial viewport check + parts.viewport.onCameraChanged(); + + // Boot-order guard (field-test round 1: layer sat empty until the user + // moved): when the persisted layer state re-enables traffic during the + // intro flyTo, the initial check bails at high altitude — and a camera + // that then parks never re-fires camera.changed. Retry cheaply until the + // first load commits, then self-clear. Also acts as a safety kick if a + // failed first fetch left the viewport unloaded while parked. + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = setInterval(() => { + if (!layerState._enabled || layerState._lastUpdate) { + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + return; + } + if (!layerState._fetching && !layerState._retryTimer) + parts.viewport.onCameraChanged(); + }, 1500); + }, + + /** + * Disable the traffic layer. Cancels pending fetches, clears all dots, + * unsubscribes from events, and hides the point collection. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + disable(viewer) { + layerState._enabled = false; + releaseContinuousRender('traffic'); + clearTimeout(layerState._fetchTimeout); + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + layerState._retryDelayMs = 1500; + parts.ingestion.cancelActiveFetch(); + layerState._loadGeneration++; + layerState._fetching = false; + layerState._flowPending = 0; + layerState._roadError = null; + parts.animation.clearDots(); + layerState._lastViewCenter = null; + // A stale outage from the last session would misreport a fresh enable — + // the next load re-derives feed health from real evidence. + layerState._flowError = null; + + if (layerState._preRenderRemover) { + layerState._preRenderRemover(); + layerState._preRenderRemover = null; + } + if (TRAFFIC_TIMING_ENABLED) { + layerState._trafficTimingMoveEndRemover?.(); + layerState._trafficTimingMoveEndRemover = null; + for (const remove of layerState._trafficTimingPostRenderRemovers || []) + remove(); + layerState._trafficTimingPostRenderRemovers = null; + layerState._trafficTimingCurrentAnchor = null; + } + + viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged); + layerState._arrivalRemover?.(); + layerState._arrivalRemover = null; + // Restore the camera's global percentageChanged so other layers/listeners + // keep their expected sensitivity. + if (layerState._prevPercentageChanged != null) { + viewer.camera.percentageChanged = layerState._prevPercentageChanged; + layerState._prevPercentageChanged = null; + } + if (layerState._pointCollection) layerState._pointCollection.show = false; + }, + + /** + * Permanently tear down the layer. Disables it, removes the point collection + * from the scene, and clears the tile cache. + * + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + */ + destroy(viewer) { + this.disable(viewer); + if (layerState._pointCollection) { + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + } + parts.rendering.removeHeatLines(); + layerState._tileCache.clear(); + resetFlowTileCache(); + layerState._count = 0; + layerState._lastUpdate = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js new file mode 100644 index 000000000..db8f9536b --- /dev/null +++ b/src/layers/traffic/model.js @@ -0,0 +1,417 @@ +import * as Cesium from 'cesium'; +import { + flowDensityMult, + flowBucket, + flowSpeedScale, +} from '../../data/trafficFlowStyle.js'; +import { + MAX_WAYPOINTS_PER_ROAD, + DOT_HEIGHT_OFFSET, + DENSITY_MULT, + JAM_DOT_FAR_SCALE, + JAM_DOT_DEPTH_PUNCH, +} from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + // ─── Overpass API ────────────────────────────────────────── + + /** + * Build an Overpass QL query string to fetch road ways within a bounding box. + * + * The query uses a highway tag regex filter. When `majorOnly` is true, only + * motorway/trunk/primary/secondary are included; otherwise all drivable road + * classes are fetched. The `out geom qt;` suffix returns inline geometry + * (lat/lon per node) sorted by quadtile for faster server response. + * + * @param {number} south - Southern latitude bound (degrees). + * @param {number} west - Western longitude bound (degrees). + * @param {number} north - Northern latitude bound (degrees). + * @param {number} east - Eastern longitude bound (degrees). + * @param {Object} [opts] + * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only. + * @param {number} [opts.timeoutSec=25] - Overpass server-side timeout. + * @returns {string} Overpass QL query body. + */ + + /** + * Parse an Overpass `out geom;` JSON response into internal road objects. + * + * Each OSM `way` element carries an inline `geometry` array of `{lat, lon}` + * objects, so no separate node look-up or osmtogeojson conversion is needed. + * + * Processing per way: + * 1. Extract [lon, lat] coordinate pairs. + * 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices. + * 3. Sample terrain height once at the first vertex (avoids per-vertex cost). + * 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances. + * + * @param {Object} overpassData - Raw JSON response from the Overpass API. + * @param {Array} overpassData.elements - Array of OSM elements. + * @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} + * Parsed road objects ready for dot spawning. + */ + + function parseRoads(overpassData) { + if (!overpassData || !overpassData.elements) return []; + + const roads = []; + for (const el of overpassData.elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + + const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + + // Sub-sample long polylines: keep every Nth vertex to stay within budget + const simplifyStep = + rawCoords.length > MAX_WAYPOINTS_PER_ROAD + ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) + : 1; + const coords = []; + for (let i = 0; i < rawCoords.length; i += simplifyStep) { + coords.push(rawCoords[i]); + } + + // Ensure the original endpoint is always preserved + const last = rawCoords[rawCoords.length - 1]; + const tail = coords[coords.length - 1]; + if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { + coords.push(last); + } + + if (coords.length < 2) continue; + + const type = el.tags?.highway || 'unclassified'; + // One-way capture (field-test round 1: "a car would never go in + // reverse"): dots on one-way roads all travel the legal direction. + // OSM: oneway=yes/1/true → digitization order; '-1' → reversed; + // roundabouts are one-way by definition. 0 = two-way (alternate). + const onewayTag = el.tags?.oneway; + const oneway = + onewayTag === 'yes' || + onewayTag === '1' || + onewayTag === 'true' || + el.tags?.junction === 'roundabout' + ? 1 + : onewayTag === '-1' + ? -1 + : 0; + + // Sample terrain height once at the road start to avoid per-vertex cost + let baseHeight = 0; + const firstCoord = coords[0]; + if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) { + const carto = Cesium.Cartographic.fromDegrees( + firstCoord[0], + firstCoord[1], + ); + const sampled = layerState._viewer.scene.sampleHeight(carto); + if (Number.isFinite(sampled)) baseHeight = sampled; + } + + // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation + const waypoints = coords.map(([lng, lat]) => { + const h = baseHeight + DOT_HEIGHT_OFFSET; + return Cesium.Cartesian3.fromDegrees(lng, lat, h); + }); + + // Pre-compute segment distances in meters for speed-to-t conversion + const segmentDist = []; + for (let i = 0; i < waypoints.length - 1; i++) { + segmentDist.push( + Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + ); + } + + roads.push({ coords, type, oneway, waypoints, segmentDist }); + } + + return roads; + } + + // ─── Road Length Estimation ──────────────────────────────── + + /** + * Estimate the total length of a road in meters from its degree-based coordinates. + * + * Uses Euclidean distance in degree-space then multiplies by the equatorial + * approximation of 111 km per degree. Accurate enough for dot density spacing + * but not for navigation. + * + * @param {number[][]} coords - Array of [lon, lat] pairs. + * @returns {number} Approximate road length in meters. + */ + + function estimateRoadLengthDeg(coords) { + let len = 0; + for (let i = 0; i < coords.length - 1; i++) { + const dx = coords[i + 1][0] - coords[i][0]; + const dy = coords[i + 1][1] - coords[i][1]; + len += Math.sqrt(dx * dx + dy * dy); + } + // Rough conversion: 1 degree ~ 111,000 meters at equator + return len * 111000; + } + + // ─── Dot Spawning ────────────────────────────────────────── + + /** + * Compute the ideal number of dots for a single road at a given camera altitude. + * + * Spacing increases with altitude so fewer dots are rendered when zoomed out. + * The result is further scaled by the road-type density multiplier and the + * user-adjustable `_densityScale`. + * + * @param {{coords:number[][], type:string}} road - Parsed road object. + * @param {number} altitude - Current camera altitude in meters. + * @returns {number} Ideal dot count (minimum 1). + */ + + function computeDotCount(road, altitude) { + // Live flow: closed roads carry zero traffic; congestion packs more dots. + // `road.flow` is only ever set in live mode, so the keyless path is + // untouched (flow stays undefined → multiplier 1, identical output). + const flow = layerState._liveMode ? road.flow : null; + if (flow?.closure) return 0; + // Strict data-integrity view: uncovered roads spawn nothing when hidden. + if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide') + return 0; + + const lengthM = estimateRoadLengthDeg(road.coords); + + // Altitude-adaptive spacing: closer camera = denser dots + let spacing; + if (altitude < 1000) spacing = 30; + else if (altitude < 3000) spacing = 80; + else if (altitude < 5000) spacing = 150; + else spacing = 250; + + const mult = + (DENSITY_MULT[road.type] || 1) * + layerState._densityScale * + (flow + ? flowDensityMult(flow.level, { jamBoost: parts.style.jamDensityOn() }) + : 1); + return Math.max(1, Math.floor((lengthM / spacing) * mult)); + } + + /** + * Distribute a fixed dot budget fairly across all visible roads. + * + * Algorithm: + * 1. Compute ideal dot count per road via `computeDotCount`. + * 2. Seed one dot to every road that wants at least one (fairness pass). + * 3. Distribute remaining budget proportionally to each road's ideal count. + * 4. Assign leftover dots (from floor rounding) to roads with the highest + * fractional residuals (largest-remainder method). + * + * This prevents high-density motorways from starving smaller residential roads + * when the global MAX_DOTS cap is reached. + * + * @param {Array} roads - Parsed road objects. + * @param {number} altitude - Camera altitude in meters (affects spacing). + * @param {number} dotCap - Maximum total dots to allocate. + * @returns {number[]} Per-road dot budgets, same length as `roads`. + */ + + function allocateRoadDotBudgets(roads, altitude, dotCap) { + const planned = roads.map((road) => computeDotCount(road, altitude)); + const budgets = new Array(roads.length).fill(0); + let remaining = Math.max(0, dotCap); + + // Pass 1 — fairness seed: give one dot to every road (highest-demand first) + const firstPassOrder = planned + .map((count, index) => ({ count, index })) + .sort((a, b) => b.count - a.count); + + for (const entry of firstPassOrder) { + if (remaining <= 0) break; + if (entry.count <= 0) continue; + budgets[entry.index] = 1; + remaining -= 1; + } + + if (remaining <= 0) return budgets; + + // Pass 2 — proportional distribution of the remaining budget + let totalRemainder = 0; + for (let i = 0; i < planned.length; i++) { + totalRemainder += Math.max(0, planned[i] - budgets[i]); + } + if (totalRemainder <= 0) return budgets; + + const residuals = []; + let assigned = 0; + for (let i = 0; i < planned.length; i++) { + const cap = Math.max(0, planned[i] - budgets[i]); + if (cap <= 0) continue; + const ideal = (cap / totalRemainder) * remaining; + const add = Math.min(cap, Math.floor(ideal)); + budgets[i] += add; + assigned += add; + residuals.push({ index: i, residual: ideal - add }); + } + + // Pass 3 — largest-remainder: hand out leftover dots from floor rounding + let leftover = remaining - assigned; + if (leftover > 0 && residuals.length > 0) { + residuals.sort((a, b) => b.residual - a.residual); + let cursor = 0; + while (leftover > 0 && residuals.length > 0) { + const idx = residuals[cursor % residuals.length].index; + if (budgets[idx] < planned[idx]) { + budgets[idx] += 1; + leftover -= 1; + } + cursor += 1; + // Safety valve: avoid infinite loop if all roads are already at their ideal + if (cursor > residuals.length * 3 && leftover > 0) break; + } + } + + return budgets; + } + + /** + * Derive the layer's honest feed presentation from its live-flow state. + * + * The three states a user can be in, and what each must read as: + * - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a + * label that never claims live data; + * - live and healthy → LIVE with real coverage; + * - live but flow-down → an `error` string, so the chip degrades and says + * the colors on screen are simulated. Never a stale "LIVE · N% cov". + * + * @param {Object} [input] + * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. + * @param {boolean} [input.fetching] - A viewport load is in flight. + * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {number} [input.coveragePct] - Matched-road coverage, 0–100. + * @param {boolean} [input.statusUnavailable] - The status probe itself failed. + * @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}} + */ + + function trafficFeedPresentation({ + liveMode = false, + fetching = false, + flowError = null, + coveragePct = 0, + statusUnavailable = false, + } = {}) { + // `mode` is the CONFIGURED source (live key present vs keyless), not this + // instant's health — health rides on `error`. The qa-traffic harness pins + // that meaning. + const mode = liveMode ? 'live' : 'sim'; + if (liveMode && flowError) { + // One string for both fields. The manager's meta line renders `error` and + // drops `loadingLabel` in its error branch, so the owner's SIMULATED copy + // has to BE the error text or the steady state reverts to a bare + // "TomTom daily budget reached" that never says what is on screen. + const degraded = `SIMULATED — ${flowError}`; + return { mode, error: degraded, loadingLabel: degraded }; + } + if (liveMode) { + return { + mode, + error: null, + loadingLabel: fetching + ? 'syncing LIVE traffic flow' + : `LIVE · TomTom flow · ${coveragePct}% cov`, + }; + } + // Keyless simulation — one terse line that names the mode and the remedy + // (owner's copy shape). The chip's own progress text carries "working"; + // this line must never imply a live feed. + return { + mode, + error: null, + loadingLabel: statusUnavailable + ? 'SIMULATED — traffic service unreachable' + : 'SIMULATED — add TomTom key for live', + }; + } + + /** + * Apply late-arriving flow data to already-rendered dots without a respawn: + * color, jam size, and speed update in place; closed roads' dots hide. + * Density bunching intentionally waits for the next natural re-render — + * color and speed are the live signal, dot count is a refinement. + * @param {string} label - Render log label (for the console trace). + */ + + function recolorDotsInPlace(label) { + if (!layerState._liveMode || !layerState._dots.length) return; + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + let closedDots = 0; + const now = Date.now(); + for (const dot of layerState._dots) { + const flow = dot.road ? dot.road.flow : null; + if (flow?.closure) { + dot.point.show = false; + closedDots += 1; + continue; + } + const bucket = flow ? flowBucket(flow.level) : null; + dot.bucket = bucket; + dot.point.color = bucket + ? layerState._activeBucketColors[bucket] + : Cesium.Color.WHITE.withAlpha(0.85); + if (bucket === 'jam') { + dot.point.pixelSize = + parts.style.baseDotSize(dot.road?.type, bucket) + + 1 + + parts.style.activeSizeDelta('jam'); + } else if (bucket && parts.style.presetProfileActive()) { + // Preset profiles size-floor every bucket; the shipped normal path + // keeps its jam-only size touch (byte-identical behavior). + dot.point.pixelSize = + parts.style.baseDotSize(dot.road?.type, bucket) + + parts.style.activeSizeDelta(bucket); + } + // Late flow can move a dot between buckets — keep the preset halo in + // step (no-op writes under the normal profile, whose dots have none). + if (parts.style.presetProfileActive()) + parts.style.applyOutline(dot.point, bucket); + dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1); + // Late flow tags/untags stop-and-go creep + city-scale prominence the + // same way it rescales speed. Queue *positions* wait for the next + // natural re-render, like density bunching. + if (bucket === 'jam' && parts.style.jamDensityOn()) { + if (!dot.creep) + dot.creep = { + moving: Math.random() < 0.4, + until: now + Math.random() * 2000, + }; + dot.point.scaleByDistance = new Cesium.NearFarScalar( + 100, + 1.5, + layerState._fadeScaleFar, + JAM_DOT_FAR_SCALE, + ); + dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH; + } else { + dot.creep = null; + } + layerState._bucketCounts[bucket || 'sim'] += 1; + } + layerState._closedRoads = layerState._roads.reduce( + (n, r) => n + (r.flow?.closure ? 1 : 0), + 0, + ); + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + console.log( + `[Data:Traffic] Flow recolor (${label}): ${layerState._dots.length} dots, closedDots=${closedDots}`, + ); + } + return { + parseRoads, + estimateRoadLengthDeg, + computeDotCount, + allocateRoadDotBudgets, + trafficFeedPresentation, + recolorDotsInPlace, + }; +} diff --git a/src/layers/traffic/navigation.test.mjs b/src/layers/traffic/navigation.test.mjs new file mode 100644 index 000000000..3d594c4e5 --- /dev/null +++ b/src/layers/traffic/navigation.test.mjs @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createTrafficLayer } from './index.js'; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function setup(t, requestRoads) { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const camera = { + positionCartographic: Cesium.Cartographic.fromDegrees( + -97.744, + 30.267, + 3200, + ), + get positionWC() { + return Cesium.Cartesian3.fromRadians( + this.positionCartographic.longitude, + this.positionCartographic.latitude, + this.positionCartographic.height, + ); + }, + changed: new Cesium.Event(), + moveEnd: new Cesium.Event(), + percentageChanged: 0.5, + computeViewRectangle() { + const { longitude, latitude } = this.positionCartographic; + return new Cesium.Rectangle( + longitude - 0.0002, + latitude - 0.0002, + longitude + 0.0002, + latitude + 0.0002, + ); + }, + pickEllipsoid: () => null, + }; + const viewer = { + camera, + scene: { + canvas: { width: 100, height: 100 }, + preRender: new Cesium.Event(), + primitives: { add: (value) => value, remove: () => true }, + }, + }; + const layer = createTrafficLayer({ + services: { + credits: {}, + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + source: { + requestRoads, + getStatus: async () => ({ hasKey: false }), + fetchFlowForBounds: async () => [], + getFlowSessionStats: () => ({ tilesFetched: 0 }), + resetFlowTileCache() {}, + }, + }); + layer.init(viewer); + t.after(() => layer.destroy(viewer)); + const move = (lon, lat, height = 3200) => { + camera.positionCartographic = Cesium.Cartographic.fromDegrees( + lon, + lat, + height, + ); + camera.changed.raiseEvent(); + camera.moveEnd.raiseEvent(); + }; + const tick = async (ms) => { + t.mock.timers.tick(ms); + for (let i = 0; i < 30; i++) await Promise.resolve(); + }; + return { layer, viewer, move, tick }; +} +function roads(bounds) { + return { + ok: true, + json: async () => ({ + elements: [ + { + type: 'way', + tags: { highway: 'primary' }, + geometry: [ + { lat: bounds.south, lon: bounds.west }, + { lat: bounds.south + 0.005, lon: bounds.west + 0.005 }, + ], + }, + ], + }), + }; +} + +test('traffic recovers a failed destination request after another city has loaded', async (t) => { + let londonCalls = 0; + const { layer, viewer, move, tick } = setup(t, async (bounds) => { + if (bounds.south > 50 && ++londonCalls === 1) + throw new Error('temporary Overpass outage'); + return roads(bounds); + }); + layer.enable(viewer); + await tick(400); + await tick(2000); + assert.ok(layer.getStats().count > 0); + move(-40, 40, 1000000); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(londonCalls, 1); + for (let i = 0; i < 20; i++) await tick(1500); + assert.ok( + londonCalls >= 2, + 'the stationary destination must retry without a toggle', + ); + assert.ok(layer.getStats().count > 0); + assert.equal(layer.getStats().loading, false); +}); + +test('a superseded road response cannot release the current request controller', async (t) => { + const pending = []; + const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const result = deferred(); + pending.push({ ...result, bounds, signal }); + return result.promise; + }); + layer.enable(viewer); + await tick(400); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(pending.length, 2); + assert.equal(pending[0].signal.aborted, true); + pending[0].resolve(roads(pending[0].bounds)); + await tick(0); + layer.disable(viewer); + assert.equal( + pending[1].signal.aborted, + true, + 'disable must still cancel the destination request', + ); + pending[1].resolve(roads(pending[1].bounds)); + await tick(0); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().loading, false); +}); + +test('leaving traffic altitude cancels queued and in-flight work', async (t) => { + const pending = []; + const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const result = deferred(); + pending.push({ ...result, bounds, signal }); + return result.promise; + }); + layer.enable(viewer); + move(-40, 40, 1000000); + await tick(400); + assert.equal( + pending.length, + 0, + 'a departing city debounce must not fetch above traffic altitude', + ); + move(-0.1276, 51.5072); + await tick(400); + assert.equal(pending.length, 1); + move(-40, 40, 1000000); + assert.equal(pending[0].signal.aborted, true); + pending[0].resolve(roads(pending[0].bounds)); + await tick(0); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().loading, false); +}); + +test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => { + const seen = []; + const { layer, viewer, tick } = setup(t, async (bounds) => { + seen.push(bounds); + return roads(bounds); + }); + layer.enable(viewer); + await tick(400); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1276, + 51.5072, + 3200, + ); + viewer.camera.moveEnd.raiseEvent(); + await tick(400); + assert.ok(seen.some((bounds) => bounds.south > 50)); + layer.disable(viewer); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal(viewer.camera.changed.numberOfListeners, 0); +}); + +test('parked failures back off and disabling cancels the scheduled retry', async (t) => { + let calls = 0; + const { layer, viewer, tick } = setup(t, async () => { + calls++; + throw new Error('temporary Overpass outage'); + }); + layer.enable(viewer); + await tick(400); + assert.equal(calls, 1); + assert.equal(layer.getStats().loading, false); + assert.equal(layer.getStats().error, 'Road data temporarily unavailable'); + await tick(1500); + await tick(400); + assert.equal(calls, 2); + await tick(1500); + await tick(400); + assert.equal(calls, 2, 'second failure waits longer than the first'); + layer.disable(viewer); + await tick(60000); + assert.equal(calls, 2); + assert.equal(layer.getStats().error, null); +}); diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js new file mode 100644 index 000000000..003fbb5c3 --- /dev/null +++ b/src/layers/traffic/policy.js @@ -0,0 +1,215 @@ +import * as Cesium from 'cesium'; + +/** + * @file Street Traffic — animated dots along OSM road polylines, colored by + * live TomTom congestion when a key is configured. + * + * Road geometry: OSM Overpass API (free, no auth). Fetches road polylines for + * the camera viewport, spawns PointPrimitives that lerp along pre-computed + * Cartesian3 waypoints. Camera-gated: only active below ~8 km altitude. + * + * Two modes (decided once per session via `/api/tomtom/status`): + * - `sim` (keyless default): white dots at hardcoded per-road-class speeds — + * the original simulation, byte-identical behavior. + * - `live`: TomTom flow tiles (`flowTiles.js`) are matched onto the same + * Overpass roads (`flowMatch.js`); matched roads color/slow/densify their + * dots by real congestion (`trafficFlowStyle.js`), closed roads spawn no + * dots, and unmatched roads keep the simulated white. + * + * Architecture overview: + * - Camera-change listener triggers debounced road fetching per viewport tile. + * - Fetch bounds center on the camera's look-at point (`trafficBounds.js`, C4). + * - Roads are fetched in two passes: major-only (fast) then full graph (detailed). + * - Fetched tiles are cached by clamped bounding-box key to avoid re-fetching. + * - Dot budget allocation distributes a hard cap fairly across visible roads. + * - Each dot lerps along pre-computed Cartesian3 waypoints every preRender frame. + * + * @module data/traffic + */ + +/** @const {string} Proxy endpoint for Overpass API queries */ + +export const OVERPASS_URL = '/api/overpass'; + +/** @const {number} Meters — hide all traffic dots above this camera altitude */ + +export const ACTIVATION_ALTITUDE = 8000; + +/** @const {number} Meters — above this altitude, only major roads are fetched */ + +export const FAST_FETCH_ALTITUDE = 4500; + +/** @const {number} Milliseconds — debounce delay before fetching after camera settles */ + +export const FETCH_DEBOUNCE = 320; + +/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */ + +export const DOT_HEIGHT_OFFSET = 3.0; + +/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */ + +export const OVERLAP_THRESHOLD = 0.6; + +/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */ + +export const MAX_DOTS = 6000; + +/** @const {number} Polylines longer than this are simplified by sub-sampling */ + +export const MAX_WAYPOINTS_PER_ROAD = 80; + +/** @const {number} Km — minimum viewport center shift before allowing refresh */ + +export const MIN_CENTER_SHIFT_KM = 0.35; + +/** + * @const {number} Km — max great-circle distance the fetch center may sit from + * the camera nadir. Traffic only activates below ACTIVATION_ALTITUDE (8 km), + * so a look-at ground point farther than this is horizon-gazing and gets + * pulled back toward nadir (C4 oblique-bounds fix). + */ + +export const MAX_LOOKAT_PULL_KM = 12; + +/** + * Development-only causal timing. Vite folds `import.meta.env.DEV` to false + * in production, so the query-string read, nullable trace branches, and every + * debug helper are removed from production builds. The remaining load-path + * selectors point directly at the original functions: no marks, listeners, + * observers, timers, counters, or per-road timing checks are installed. + */ + +export const TRAFFIC_TIMING_ENABLED = + import.meta.env?.DEV && + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('trafficDebug') === '1'; + +/** @const {Object} Speed in meters per second by highway tag (approximate real-world values) */ + +export const SPEED_MPS = { + motorway: 25, // ~90 km/h + trunk: 20, // ~72 km/h + primary: 14, // ~50 km/h + secondary: 11, // ~40 km/h + tertiary: 8, // ~30 km/h + residential: 5, // ~18 km/h + unclassified: 5, +}; + +/** @const {Object} Density multiplier — higher values spawn more dots on important roads */ + +export const DENSITY_MULT = { + motorway: 3.0, + trunk: 2.5, + primary: 2.0, + secondary: 1.5, + tertiary: 1.0, + residential: 0.5, + unclassified: 0.4, +}; + +/** @const {Object} Pixel size per road type (scaled up 25% for screen-recording visibility) */ + +export const SIZE_BY_TYPE = { + motorway: 6, + trunk: 6, + primary: 5, + secondary: 5, + tertiary: 4, + residential: 4, + unclassified: 4, +}; + +/** + * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): + * green free flow / amber slow / red jam, all at 0.9 alpha. + * Roads without flow data (`road.flow == null`) keep the sim's white. + * @const {Object} + */ + +export const FLOW_BUCKET_COLORS = { + free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9), + slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9), + jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), +}; + +// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── +/** @const {number} Max congestion heat-line polylines per render (jam first). */ + +export const HEAT_LINE_CAP = 400; + +/** @const {number} Px — glowing jam corridor line width. */ + +export const HEAT_LINE_JAM_WIDTH = 9; + +/** @const {number} Px — flat slow corridor line width. */ + +export const HEAT_LINE_SLOW_WIDTH = 4; + +/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */ + +export const HEAT_JAM_BASE_ALPHA = 0.55; + +/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */ + +export const HEAT_JAM_PULSE_ALPHA = 0.2; + +/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ + +export const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); + +/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ + +export const HEAT_SLOW_COLOR = + Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2); + +/** + * @const {number} Meters — jam dots depth-test-punch through the 3D tiles out + * to this camera distance so queues stay visible at city scale. The single + * start-of-road terrain sample puts much of a road below the rendered mesh + * at oblique city views (first A/B capture: 396 jam dots, zero visible), so + * the shipped 2 km window hides exactly the congestion this prototype is + * meant to surface. Live jam dots only; sim dots keep the shipped 2 km. + */ + +export const JAM_DOT_DEPTH_PUNCH = 15000; + +/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ + +export const JAM_DOT_FAR_SCALE = 0.55; + +/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */ + +export const CREEP_BURST = 2.2; + +/** @const {number[]} Ms range a jam dot creeps forward before stopping. */ + +export const CREEP_MOVE_MS = [1200, 3000]; + +/** @const {number[]} Ms range a jam dot sits stopped between creeps. */ + +export const CREEP_STOP_MS = [1500, 5000]; + +/** + * @const {number} Minimum base pixel size for COLORED dots while a styled + * preset is active — residential-road dots spawn at 4 px and vanish into + * post-FX pixelation; presence is the dots' whole job there (owner round + * 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched. + */ + +export const STYLED_MIN_BASE_PX = 5; + +/** @const {number} Maximum tile cache entries before LRU eviction */ + +export const TILE_CACHE_MAX_ENTRIES = 64; + +/** + * Milliseconds the first dot paint will wait for flow data. Cached flow + * settles within this window (decode cache, 120 s TTL) and renders fully + * colored; a cold tile fetch loses the race, dots paint immediately in + * white, and recolorDotsInPlace applies the colors when flow arrives — + * field-test round 1's "takes forever to load" was the sequential wait. + */ + +export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js new file mode 100644 index 000000000..3a6a122b9 --- /dev/null +++ b/src/layers/traffic/rendering.js @@ -0,0 +1,318 @@ +import * as Cesium from 'cesium'; +import { flowBucket } from '../../data/trafficFlowStyle.js'; +import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; +import { + HEAT_LINE_CAP, + HEAT_JAM_COLOR, + HEAT_SLOW_COLOR, + HEAT_LINE_JAM_WIDTH, + HEAT_JAM_BASE_ALPHA, + HEAT_LINE_SLOW_WIDTH, + TRAFFIC_TIMING_ENABLED, + MAX_DOTS, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + /** + * At high altitude only major roads render — shared by the render pass and + * the late-flow heat-line rebuild so lines never mark roads without dots. + * @param {Array} roads - Parsed road objects. + * @param {number} altitude - Camera altitude in meters. + * @returns {Array} The roads visible at this altitude. + */ + + function visibleRoadsForAltitude(roads, altitude) { + return altitude > 5000 + ? roads.filter( + (r) => + r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary', + ) + : roads; + } + + /** Remove both heat-line ground primitives from the scene. */ + + function removeHeatLines() { + if (layerState._heatJamPrim) { + layerState._viewer?.scene.groundPrimitives.remove( + layerState._heatJamPrim, + ); + layerState._heatJamPrim = null; + } + if (layerState._heatSlowPrim) { + layerState._viewer?.scene.groundPrimitives.remove( + layerState._heatSlowPrim, + ); + layerState._heatSlowPrim = null; + } + layerState._heatLineCount = 0; + } + + /** + * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): + * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing + * pulsing red for jam, faint flat amber for slow — with the dots animating + * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam + * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam + * first, longest first); overflow is logged. No-op in sim mode, when the + * heatline mode is off, or without ground-primitive support. + * + * @param {Array} roads - Road objects visible in the current render. + */ + + function rebuildHeatLines(roads) { + removeHeatLines(); + if ( + !layerState._viewer || + !layerState._liveMode || + !parts.style.heatlineOn() + ) + return; + if (layerState._heatSupported === null) { + layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( + layerState._viewer.scene, + ); + if (!layerState._heatSupported) + console.warn( + '[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled', + ); + } + if (!layerState._heatSupported) return; + + const candidates = []; + for (const road of roads) { + const flow = road.flow; + if (!flow || flow.closure) continue; + const bucket = flowBucket(flow.level); + if (bucket === 'free') continue; + let len = 0; + for (const d of road.segmentDist) len += d; + candidates.push({ road, bucket, len }); + } + candidates.sort((a, b) => + a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1, + ); + const kept = candidates.slice(0, HEAT_LINE_CAP); + + const instancesFor = (bucket, width) => + kept + .filter((c) => c.bucket === bucket) + .map( + (c) => + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions: c.road.waypoints, + width, + }), + }), + ); + + // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in + // luminance like the dots: jam = white glow, slow = faint gray. + const monoHeat = + layerState._presetDots === 'on' && + trafficStyleProfile(layerState._stylePreset) === 'mono'; + const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; + const slowLineColor = monoHeat + ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) + : HEAT_SLOW_COLOR; + + const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); + if (jamInstances.length) { + layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: jamInstances, + classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, + appearance: new Cesium.PolylineMaterialAppearance({ + material: Cesium.Material.fromType('PolylineGlow', { + color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), + glowPower: 0.25, + }), + }), + }), + ); + } + const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); + if (slowInstances.length) { + layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: slowInstances, + classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, + appearance: new Cesium.PolylineMaterialAppearance({ + material: Cesium.Material.fromType('Color', { + color: slowLineColor, + }), + }), + }), + ); + } + + layerState._heatLineCount = kept.length; + if (candidates.length > kept.length) { + console.log( + `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, + ); + } + } + + /** + * Clear existing dots and re-spawn them for the given road set and altitude. + * + * When zoomed out (>5 km), only major road types are rendered to reduce clutter. + * Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`. + * + * @param {Array} roads - Parsed road objects to render. + * @param {number} altitude - Camera altitude in meters. + * @param {string} label - Logging label (e.g. "Cache full", "Loaded major"). + * @param {Object|null} [trace=null] - Development-only correlated load trace. + */ + + function renderRoadsForAltitude(roads, altitude, label, trace = null) { + const state = + TRAFFIC_TIMING_ENABLED && trace + ? parts.timing.trafficTimingRenderState(trace, label) + : null; + const renderId = state ? ++trace.renderSequence : null; + parts.animation.clearDots(); + layerState._roads = roads; + layerState._lastRenderAltitude = altitude; + + // At high altitude, drop minor roads to reduce visual noise + const filteredRoads = visibleRoadsForAltitude(roads, altitude); + + // Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count + // them here so the closure signal is visible in stats even at zero dots. + layerState._closedRoads = layerState._liveMode + ? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0) + : 0; + + // Fade distances must track the camera-to-AREA distance, not assume a + // nadir view: oblique pitches put the loaded roads many km away even at + // low altitude. Probe three roads and stretch the curves accordingly. + let areaDist = altitude; + if (layerState._viewer && filteredRoads.length) { + const probes = [ + filteredRoads[0], + filteredRoads[Math.floor(filteredRoads.length / 2)], + filteredRoads[filteredRoads.length - 1], + ]; + for (const probe of probes) { + const wp = probe?.waypoints?.[0]; + if (wp) + areaDist = Math.max( + areaDist, + Cesium.Cartesian3.distance( + layerState._viewer.camera.positionWC, + wp, + ), + ); + } + } + layerState._fadeScaleFar = Math.max(8000, areaDist * 1.5); + layerState._fadeTransFar = Math.max(10000, areaDist * 1.8); + + const dotStart = state + ? parts.timing.trafficTimingMark(state, 'dot-construction-start', { + renderId, + renderLabel: label, + roadCount: roads.length, + visibleRoadCount: filteredRoads.length, + }) + : null; + const roadBudgets = parts.model.allocateRoadDotBudgets( + filteredRoads, + altitude, + MAX_DOTS, + ); + for (let i = 0; i < filteredRoads.length; i++) { + const road = filteredRoads[i]; + const budget = roadBudgets[i] || 0; + if (budget <= 0) continue; + parts.animation.spawnDotsForRoad(road, altitude, budget); + if (layerState._dots.length >= MAX_DOTS) break; + } + + const renderMetrics = state + ? { + renderId, + renderLabel: label, + roadCount: roads.length, + visibleRoadCount: filteredRoads.length, + dotCount: layerState._dots.length, + } + : null; + if (state) { + const dotEnd = parts.timing.trafficTimingMark( + state, + 'dot-construction-end', + renderMetrics, + ); + parts.timing.trafficTimingMeasure( + 'dot-construction', + state, + dotStart, + dotEnd, + renderMetrics, + ); + } + + const heatStart = state + ? parts.timing.trafficTimingMark( + state, + 'rebuild-heat-lines-start', + renderMetrics, + ) + : null; + rebuildHeatLines(filteredRoads); + if (state) { + const heatEnd = parts.timing.trafficTimingMark( + state, + 'rebuild-heat-lines-end', + { + ...renderMetrics, + heatLineCount: layerState._heatLineCount, + }, + ); + parts.timing.trafficTimingMeasure( + 'rebuild-heat-lines', + state, + heatStart, + heatEnd, + { + ...renderMetrics, + heatLineCount: layerState._heatLineCount, + }, + ); + } + + layerState._count = layerState._dots.length; + layerState._lastUpdate = Date.now(); + console.log( + `[Data:Traffic] ${label}: ${layerState._count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`, + ); + if (state) { + const renderEnd = parts.timing.trafficTimingMark( + state, + 'render-return', + renderMetrics, + ); + parts.timing.scheduleTrafficTimingPostRender( + state, + renderEnd, + renderId, + renderMetrics, + ); + } + } + return { + visibleRoadsForAltitude, + removeHeatLines, + rebuildHeatLines, + renderRoadsForAltitude, + }; +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js new file mode 100644 index 000000000..616f4d0c7 --- /dev/null +++ b/src/layers/traffic/source.js @@ -0,0 +1,76 @@ +import { createFlowTileSource } from './flowSource.js'; +function buildOverpassQuery( + south, + west, + north, + east, + { majorOnly = false, timeoutSec = 25 } = {}, +) { + // Regex matches the OSM `highway` tag value against allowed road types + const regex = majorOnly + ? '^(motorway|trunk|primary|secondary)$' + : '^(motorway|trunk|primary|secondary|tertiary|residential|unclassified)$'; + return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`; +} + +/** Supply road responses, flow availability and one decoded flow cache. */ +export function createTrafficSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const flow = createFlowTileSource({ fetchImpl }); + return { + ...flow, + async requestRoads( + { south, west, north, east }, + { majorOnly = false, timeoutSec = 25, signal } = {}, + ) { + if ( + ![south, west, north, east].every(Number.isFinite) || + south < -90 || + north > 90 || + west < -180 || + east > 180 || + north <= south || + east <= west || + north - south > 10 || + east - west > 10 || + !Number.isInteger(timeoutSec) || + timeoutSec < 1 || + timeoutSec > 30 + ) + throw new TypeError('A bounded road viewport and timeout are required'); + signal?.throwIfAborted(); + const query = buildOverpassQuery(south, west, north, east, { + majorOnly, + timeoutSec, + }); + const response = await fetchImpl('/api/overpass', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'data=' + encodeURIComponent(query), + signal, + }); + signal?.throwIfAborted(); + return { + ok: response.ok, + status: response.status, + headers: response.headers, + async json() { + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + }; + }, + async getStatus({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/tomtom/status', { signal }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const status = await response.json(); + signal?.throwIfAborted(); + if (typeof status?.hasKey !== 'boolean') + throw new Error('Malformed traffic status'); + return status; + }, + }; +} diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs new file mode 100644 index 000000000..9be3315ba --- /dev/null +++ b/src/layers/traffic/source.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createTrafficSource } from './source.js'; +const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 }; +const fixture = readFileSync( + new URL( + '../../data/fixtures/tomtom-flow-austin-12-935-1686.pbf', + import.meta.url, + ), +); +test('flow caches and diagnostics belong to their constructed source', async () => { + let requestsA = 0, + requestsB = 0; + const a = createTrafficSource({ + fetchImpl: async () => { + requestsA++; + return new Response(fixture); + }, + }); + const b = createTrafficSource({ + fetchImpl: async () => { + requestsB++; + return new Response(fixture); + }, + }); + const first = await a.fetchFlowForBounds(bounds); + assert.ok(first.length > 0); + await a.fetchFlowForBounds(bounds); + assert.equal(requestsA, 1); + assert.equal(b.getFlowSessionStats().tilesFetched, 0); + b.resetFlowTileCache(); + await a.fetchFlowForBounds(bounds); + assert.equal(requestsA, 1); + await b.fetchFlowForBounds(bounds); + assert.equal(requestsB, 1); +}); +test('a cancelled flow body cannot refill its source cache', async () => { + const controller = new AbortController(); + let calls = 0; + const source = createTrafficSource({ + fetchImpl: async () => ({ + ok: true, + arrayBuffer: async () => { + calls++; + if (calls === 1) controller.abort(); + return fixture; + }, + }), + }); + await assert.rejects( + source.fetchFlowForBounds(bounds, { signal: controller.signal }), + { name: 'AbortError' }, + ); + await source.fetchFlowForBounds(bounds); + assert.equal( + calls, + 2, + 'cancelled bytes were not admitted to the decode cache', + ); +}); +test('road requests have finite bounds and retain the two-pass query', async () => { + const calls = []; + const source = createTrafficSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"elements":[]}'); + }, + }); + await assert.rejects( + source.requestRoads({ ...bounds, north: Infinity }), + /bounded road viewport/, + ); + await assert.rejects( + source.requestRoads(bounds, { timeoutSec: '25];out;' }), + /bounded road viewport/, + ); + assert.equal(calls.length, 0); + await source.requestRoads(bounds, { majorOnly: true, timeoutSec: 8 }); + assert.equal(calls[0][0], '/api/overpass'); + const query = new URLSearchParams(calls[0][1].body).get('data'); + assert.match(query, /\[timeout:8\]/); + assert.doesNotMatch(query, /residential/); + await source.requestRoads(bounds); + assert.match( + new URLSearchParams(calls[1][1].body).get('data'), + /residential/, + ); +}); +test('malformed availability is an unavailable source rather than a keyless response', async () => { + const source = createTrafficSource({ + fetchImpl: async () => new Response('{}'), + }); + await assert.rejects(source.getStatus(), /Malformed traffic status/); +}); + +test('traffic construction is inert and parameters belong to each layer', async () => { + const { createTrafficLayer } = await import('./index.js'); + const source = createTrafficSource({ + fetchImpl: () => assert.fail('construction fetched data'), + }); + const services = { credits: {}, render: {} }; + const a = createTrafficLayer({ services, source }); + const b = createTrafficLayer({ services, source }); + a.setParams({ densityScale: 2, speedScale: 3, uncoveredRoads: 'hide' }); + assert.equal(a.getParams().densityScale, 2); + assert.equal(b.getParams().densityScale, 1); + assert.equal(b.getParams().speedScale, 1); + assert.equal(b.getParams().uncoveredRoads, 'sim'); +}); + +test('road body parsing retains the source request cancellation signal', async () => { + const controller = new AbortController(); + const source = createTrafficSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => { + controller.abort(); + return { elements: [] }; + }, + }), + }); + const response = await source.requestRoads(bounds, { + signal: controller.signal, + }); + await assert.rejects(response.json(), { name: 'AbortError' }); +}); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js new file mode 100644 index 000000000..ad240b4bf --- /dev/null +++ b/src/layers/traffic/state.js @@ -0,0 +1,280 @@ +import * as Cesium from 'cesium'; +import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; + +export function createState({ services }) { + const layerState = {}; + + // ─── Module State ────────────────────────────────────────── + /** @type {Cesium.Viewer|null} */ + + layerState._viewer = null; + + /** @type {Cesium.PointPrimitiveCollection|null} */ + + layerState._pointCollection = null; + + /** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ + + layerState._dots = []; + + /** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ + + layerState._roads = []; + + /** @type {boolean} Whether the layer is currently enabled */ + + layerState._enabled = false; + + /** @type {Function|null} Disposer returned by preRender event subscription */ + + layerState._preRenderRemover = null; + + /** @type {Function|null} Disposer returned by camera.changed event subscription */ + + layerState._cameraRemover = null; + layerState._arrivalRemover = null; + + /** @type {ReturnType|null} Debounce timer for camera-change fetch */ + + layerState._fetchTimeout = null; + + /** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */ + + layerState._lastBounds = null; + + /** @type {boolean} True while an Overpass fetch is in flight */ + + layerState._fetching = false; + + /** @type {number} Current count of rendered dots */ + + layerState._count = 0; + + /** @type {number|null} Timestamp of last successful render */ + + layerState._lastUpdate = null; + + /** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */ + + layerState._loadGeneration = 0; + + /** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */ + + layerState._activeFetchAbort = null; + + /** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */ + + layerState._densityScale = 1.0; + + /** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */ + + layerState._speedScale = 1.0; + + /** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */ + + layerState._lastViewCenter = null; + + /** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */ + + layerState._prevPercentageChanged = null; + + /** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */ + + layerState._liveMode = false; + + /** + * Short user-facing reason live flow is currently unavailable, or null while + * healthy. Only ever set in live mode: keyless simulation is a designed + * fallback, not a fault, and must never read as an error. + * @type {string|null} + */ + + layerState._flowError = null; + + /** + * True when `/api/tomtom/status` itself could not be reached, so the layer is + * simulating because it could not ask — not because the server said "no key". + * @type {boolean} + */ + + layerState._flowStatusUnavailable = false; + + /** + * Flow requests this layer still owns. The 250 ms paint race lets a flow + * fetch outlive the road load that started it (cached roads settle + * instantly), so `_fetching` alone under-reports the work in flight: the + * loading batch would close with LOAD COMPLETE and a failure landing after + * it could never be announced. Counted, not boolean — recolor-after-timeout + * means two loads can overlap. + * @type {number} + */ + + layerState._flowPending = 0; + + /** + * Rendered-dot counts per flow bucket (sim = white ambient, no flow data). + * Reset with the dots in clearDots; drives the data-panel diagnostics and + * the qa-traffic harness color assertions. + * @type {{free:number, slow:number, jam:number, sim:number}} + */ + + layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; + + /** @type {number} Roads in the current render skipped entirely as closed. */ + + layerState._closedRoads = 0; + + /** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */ + + layerState._uncoveredMode = 'sim'; + + /** + * Jam-viz prototype mode: 'density' = deep-jam density boost + platoon queues + * + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both'; + * 'none' = shipped main behavior. Live mode only — the keyless simulation + * never has `road.flow`, so every jamViz path is unreachable there. + * Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available + * via setParams). + * @type {'none'|'density'|'heatline'|'both'} + */ + + layerState._jamViz = 'density'; + + /** + * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the + * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE) — polylines at the + * dots' single-sample heights depth-fail under the mesh across an oblique + * city view (first A/B capture: 198 lines rendered, zero visible). One + * primitive per bucket so the jam batch can pulse via a single shared + * material uniform. + * @type {Cesium.GroundPolylinePrimitive|null} + */ + + layerState._heatJamPrim = null; + + /** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ + + layerState._heatSlowPrim = null; + + /** @type {number} Heat-lines in the current render (stats). */ + + layerState._heatLineCount = 0; + + /** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ + + layerState._heatSupported = null; + + /** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ + + layerState._lastRenderAltitude = 0; + + /** + * Active post-FX style (StyleManager preset name), synced from + * `document.documentElement.dataset.gevStyle` at init and the + * `gev:style-change` window event thereafter. Drives the preset-aware dot + * styling (`trafficPresetStyle.js`): NVG/FLIR/noir re-encode congestion in + * luminance + size (their shaders discard hue), retro/CRT gets saturated + * hues + a size boost to survive pixelation. 'normal' → shipped palette. + * @type {string} + */ + + layerState._stylePreset = 'normal'; + + /** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */ + + layerState._presetDots = 'on'; + + /** @type {boolean} gev:style-change listener bound (bind once per page). */ + + layerState._styleListenerBound = false; + + /** + * Effective per-bucket dot colors: preset override when one applies, else + * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — + * spawn/recolor/restyle all read from here, no per-dot allocation. + * @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} + */ + + layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS }; + + /** + * Dot fade-out distances, recomputed per render from the camera-to-area + * distance. The original constants (8 km scale / 10 km translucency) + * assumed a nadir view; at oblique pitch the loaded area legitimately sits + * 7–12+ km from the CAMERA and every dot faded to invisible (field-test + * round 1: "the screen itself was empty"). + */ + + layerState._fadeScaleFar = 8000; + + layerState._fadeTransFar = 10000; + + /** @type {Promise|null} Session-cached status check (one fetch per session) */ + + layerState._flowStatusPromise = null; + + /** @type {ReturnType|null} Enable-time retry until the first load commits. */ + + layerState._enableKickTimer = null; + // Failed destinations retry while parked, with a bounded backoff. + layerState._retryTimer = null; + layerState._retryDelayMs = 1500; + layerState._retryBoundsKey = null; + layerState._roadError = null; + + /** @type {number} 0–100 int — matched roads / roads with any flow candidates */ + + layerState._flowCoveragePct = 0; + + /** @type {Function|null} Development-only camera moveEnd timing disposer. */ + + layerState._trafficTimingMoveEndRemover = null; + + /** @type {Set|null} Development-only one-shot postRender disposers. */ + + layerState._trafficTimingPostRenderRemovers = null; + + /** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */ + + layerState._trafficTimingCurrentAnchor = null; + + /** @type {number} Development-only unique mark/trace sequence. */ + + layerState._trafficTimingSequence = 0; + + /** @type {number} Development-only count of correlated trace objects created. */ + + layerState._trafficTimingTracesCreated = 0; + + /** @type {number} Development-only count of loads dropped for missing/stale anchors. */ + + layerState._trafficTimingDroppedTraces = 0; + + /** + * Tile cache keyed by "s,w,n,e" string. + * Each entry stores separately fetched major-only and full road sets + * so the major pass can be served from cache while a full fetch continues. + * @type {Map} + */ + + layerState._tileCache = new Map(); + + /** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ + + layerState._scratchLerp = new Cesium.Cartesian3(); + + // ─── Animation ───────────────────────────────────────────── + + /** @type {number} Timestamp of the last animation tick (ms) */ + + layerState._lastAnimTime = 0; + + /** @type {number} Running frame counter (for diagnostics) */ + + layerState._animFrame = 0; + + // Disabled-path contract: these references resolve directly to the original + // functions. Instrumentation adds no load-path callbacks or per-item checks. + + return layerState; +} diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js new file mode 100644 index 000000000..cb2f08349 --- /dev/null +++ b/src/layers/traffic/style.js @@ -0,0 +1,156 @@ +import { + trafficStyleProfile, + presetSizeDelta, + presetDotRgba, + presetDotOutline, +} from '../../data/trafficPresetStyle.js'; +import * as Cesium from 'cesium'; +import { + SIZE_BY_TYPE, + STYLED_MIN_BASE_PX, + FLOW_BUCKET_COLORS, +} from './policy.js'; + +export function createStyle({ state: layerState, services, parts, source }) { + /** @returns {boolean} A non-normal preset profile is active and enabled. */ + + function presetProfileActive() { + return ( + layerState._presetDots === 'on' && + trafficStyleProfile(layerState._stylePreset) !== 'normal' + ); + } + + /** + * Pixel-size delta the active preset adds for a bucket (0 when the kill + * switch is off or the profile is normal). + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket. + * @returns {number} Pixels to add on top of the shipped sizing. + */ + + function activeSizeDelta(bucket) { + return layerState._presetDots === 'on' + ? presetSizeDelta(layerState._stylePreset, bucket) + : 0; + } + + /** + * Base pixel size for a dot: shipped SIZE_BY_TYPE, floored at + * STYLED_MIN_BASE_PX for colored dots while a styled preset is active. + * @param {string} roadType - OSM highway class. + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). + * @returns {number} Base pixel size before jam/preset deltas. + */ + + function baseDotSize(roadType, bucket) { + const base = SIZE_BY_TYPE[roadType] || 4; + return bucket && presetProfileActive() + ? Math.max(base, STYLED_MIN_BASE_PX) + : base; + } + + /** Recompute `_activeBucketColors` from the active style + kill switch. */ + + function refreshBucketColors() { + for (const bucket of ['free', 'slow', 'jam']) { + const rgba = + layerState._presetDots === 'on' + ? presetDotRgba(layerState._stylePreset, bucket) + : null; + layerState._activeBucketColors[bucket] = rgba + ? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) + : FLOW_BUCKET_COLORS[bucket]; + } + } + + /** + * Apply the active preset's dark-halo outline to a colored dot (or clear + * it back to the shipped no-outline state). NVG's auto-gain saturates the + * scene, so brightness alone cannot separate a dot from a bright road — + * the dark ring restores local contrast through every luma-mapping shader. + * @param {Cesium.PointPrimitive} point - The dot primitive. + * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). + */ + + function applyOutline(point, bucket) { + const spec = + layerState._presetDots === 'on' + ? presetDotOutline(layerState._stylePreset, bucket) + : null; + if (spec) { + point.outlineColor = new Cesium.Color( + spec.rgba[0] / 255, + spec.rgba[1] / 255, + spec.rgba[2] / 255, + spec.rgba[3], + ); + point.outlineWidth = spec.width; + } else { + point.outlineWidth = 0; + } + } + + /** + * Re-apply dot styling in place after a style/param change: colored dots + * get the (new) effective bucket color and size; sim (white) dots are + * never touched. No refetch, no respawn — heat-lines rebuild for their + * per-preset colors. + */ + + function restyleDotsInPlace() { + refreshBucketColors(); + if (!layerState._dots.length && !layerState._heatLineCount) return; + for (const dot of layerState._dots) { + const bucket = dot.bucket; + if (!bucket) continue; // sim/uncovered dots stay byte-identical + dot.point.color = layerState._activeBucketColors[bucket]; + dot.point.pixelSize = + baseDotSize(dot.road?.type, bucket) + + (bucket === 'jam' ? 1 : 0) + + activeSizeDelta(bucket); + applyOutline(dot.point, bucket); + } + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + } + + /** + * Adopt a new active style preset (from the gev:style-change event or the + * dataset read at init) and restyle live dots immediately. + * @param {string|null|undefined} name - StyleManager preset name. + */ + + function setStylePreset(name) { + const next = typeof name === 'string' && name ? name : 'normal'; + if (next === layerState._stylePreset) return; + layerState._stylePreset = next; + restyleDotsInPlace(); + } + + /** @returns {boolean} Density/queue/creep prototype active. */ + + function jamDensityOn() { + return layerState._jamViz === 'density' || layerState._jamViz === 'both'; + } + + /** @returns {boolean} Heat-line prototype active. */ + + function heatlineOn() { + return layerState._jamViz === 'heatline' || layerState._jamViz === 'both'; + } + return { + presetProfileActive, + activeSizeDelta, + baseDotSize, + refreshBucketColors, + applyOutline, + restyleDotsInPlace, + setStylePreset, + jamDensityOn, + heatlineOn, + }; +} diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js new file mode 100644 index 000000000..59cb2d2c0 --- /dev/null +++ b/src/layers/traffic/timing.js @@ -0,0 +1,479 @@ +import * as Cesium from 'cesium'; +import { + TRAFFIC_TIMING_ENABLED, + MAX_WAYPOINTS_PER_ROAD, + DOT_HEIGHT_OFFSET, +} from './policy.js'; + +export function createTiming({ state: layerState, services, parts, source }) { + /** + * Return development timing counters for the capture harness and inertness test. + * This named export is unused by the application and removed from production. + * @returns {{enabled:boolean, marksInstalled:number, traceObjectsCreated:number, + * uncorrelatedTracesDropped:number}} + */ + + function getTrafficTimingDiagnostics() { + return { + enabled: Boolean(TRAFFIC_TIMING_ENABLED), + marksInstalled: layerState._trafficTimingMoveEndRemover ? 1 : 0, + traceObjectsCreated: layerState._trafficTimingTracesCreated, + uncorrelatedTracesDropped: layerState._trafficTimingDroppedTraces, + }; + } + + // ─── Development-only causal timing ─────────────────────── + + /** + * Return (and optionally update) the current trace's state for a render pass. + * This function is only reachable when `TRAFFIC_TIMING_ENABLED` is true. + * + * @param {Object|null} trace - Correlated load trace. + * @param {'major'|'full'} pass - Road-fetch/render pass. + * @param {string} [source] - Client cache or proxy/network source. + * @returns {Object|null} Mutable pass timing state. + */ + + function trafficTimingPass(trace, pass, source) { + if (!trace) return null; + let state = trace.passes.get(pass); + if (!state) { + state = { + trace, + pass, + source: source || 'unknown', + proxyCache: null, + proxyUpstream: null, + }; + trace.passes.set(pass, state); + } else if (source) { + state.source = source; + } + return state; + } + + /** Build a structured-clone-safe detail object for User Timing entries. */ + + function trafficTimingDetail(state, segment, extra = {}) { + return { + trafficTiming: true, + segment, + traceId: state?.trace?.id ?? null, + interactionId: state?.trace?.interactionId ?? null, + cameraChangeTimestamp: state?.trace?.cameraChangeTimestamp ?? null, + generation: state?.trace?.generation ?? null, + cacheKey: state?.trace?.cacheKey ?? null, + pass: state?.pass || 'load', + source: state?.source || 'unknown', + proxyCache: state?.proxyCache || null, + proxyUpstream: state?.proxyUpstream || null, + ...extra, + }; + } + + /** Add a uniquely named User Timing mark and return its name. */ + + function trafficTimingMark(state, phase, extra = {}, startTime) { + const traceId = state?.trace?.id ?? 'interaction'; + const pass = state?.pass || 'load'; + const name = `traffic:${traceId}:${pass}:${phase}:${++layerState._trafficTimingSequence}`; + const options = { detail: trafficTimingDetail(state, phase, extra) }; + if (Number.isFinite(startTime)) options.startTime = startTime; + performance.mark(name, options); + return name; + } + + /** Emit a named User Timing measure between two marks. */ + + function trafficTimingMeasure(segment, state, start, end, extra = {}) { + performance.measure(`traffic:${segment}:${state?.pass || 'load'}`, { + start, + end, + detail: trafficTimingDetail(state, segment, extra), + }); + } + + /** Emit an aggregate-duration measure without pretending its work was contiguous. */ + + function trafficTimingAggregate( + segment, + state, + anchorTime, + duration, + extra = {}, + ) { + const start = trafficTimingMark( + state, + `${segment}-aggregate-start`, + extra, + anchorTime, + ); + const end = trafficTimingMark( + state, + `${segment}-aggregate-end`, + extra, + anchorTime + duration, + ); + trafficTimingMeasure(segment, state, start, end, { + aggregate: true, + ...extra, + }); + } + + /** Clear only this module's stale User Timing entries before a new debug run. */ + + function clearTrafficTimingEntries() { + const markNames = new Set( + performance + .getEntriesByType('mark') + .filter((entry) => entry.name.startsWith('traffic:')) + .map((entry) => entry.name), + ); + const measureNames = new Set( + performance + .getEntriesByType('measure') + .filter((entry) => entry.name.startsWith('traffic:')) + .map((entry) => entry.name), + ); + for (const name of markNames) performance.clearMarks(name); + for (const name of measureNames) performance.clearMeasures(name); + } + + /** Return the stable User Timing mark name for a scheduling interaction. */ + + function trafficTimingCameraChangeMarkName(interactionId) { + return `traffic:interaction:${interactionId}:last-camera-change`; + } + + /** Mint and mark the exact camera-change interaction that armed a debounced load. */ + + function markTrafficTimingCameraChange() { + const interactionId = ++layerState._trafficTimingSequence; + const timestamp = performance.now(); + const anchor = { interactionId, timestamp }; + layerState._trafficTimingCurrentAnchor = anchor; + performance.mark(trafficTimingCameraChangeMarkName(interactionId), { + startTime: timestamp, + detail: { + trafficTiming: true, + segment: 'last-camera-change', + interactionId, + timestamp, + }, + }); + return anchor; + } + + /** + * Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this + * about 500 ms after stillness, so fetch has typically already started and + * never waits for this boundary. + */ + + function markTrafficTimingMoveEnd() { + const diagnosticId = ++layerState._trafficTimingSequence; + const timestamp = performance.now(); + const name = `traffic:diagnostic:${diagnosticId}:camera-move-end`; + performance.mark(name, { + startTime: timestamp, + detail: { + trafficTiming: true, + segment: 'camera-move-end', + diagnosticOnly: true, + cameraEventWaitTimeMs: 500, + fetchWaitsForMoveEnd: false, + timestamp, + }, + }); + } + + /** + * Instrumented twin of `parseRoads`. Operation ordering and road output match + * the normal function; debug-only clocks accumulate synchronous height and + * waypoint-materialization time independently. + */ + + function parseRoadsTimed(overpassData, trace) { + /* TRACE_ONLY_BEGIN */ + const _trafficTimingState = trafficTimingPass( + trace, + trace?.currentPass || 'full', + ); + const _trafficTimingParseStartTime = performance.now(); + const _trafficTimingParseStart = trafficTimingMark( + _trafficTimingState, + 'road-parse-start', + {}, + _trafficTimingParseStartTime, + ); + /* TRACE_ONLY_END */ + if (!overpassData || !overpassData.elements) { + /* TRACE_ONLY_BEGIN */ + const _trafficTimingParseEnd = trafficTimingMark( + _trafficTimingState, + 'road-parse-end', + { roadCount: 0 }, + ); + trafficTimingMeasure( + 'road-parse-total', + _trafficTimingState, + _trafficTimingParseStart, + _trafficTimingParseEnd, + { roadCount: 0 }, + ); + trafficTimingAggregate( + 'sample-height-total', + _trafficTimingState, + _trafficTimingParseStartTime, + 0, + { + sampleHeightCalls: 0, + sampleHeightMeanMs: 0, + distinctCells: 0, + roadCount: 0, + }, + ); + trafficTimingAggregate( + 'waypoint-materialization', + _trafficTimingState, + _trafficTimingParseStartTime, + 0, + { roadCount: 0 }, + ); + /* TRACE_ONLY_END */ + return []; + } + + const roads = []; + /* TRACE_ONLY_BEGIN */ + const _trafficTimingSampledCells = new Set(); + let _trafficTimingSampleHeightCalls = 0; + let _trafficTimingSampleHeightMs = 0; + let _trafficTimingWaypointMaterializationMs = 0; + /* TRACE_ONLY_END */ + for (const el of overpassData.elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + + const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + const simplifyStep = + rawCoords.length > MAX_WAYPOINTS_PER_ROAD + ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) + : 1; + const coords = []; + for (let i = 0; i < rawCoords.length; i += simplifyStep) { + coords.push(rawCoords[i]); + } + + const last = rawCoords[rawCoords.length - 1]; + const tail = coords[coords.length - 1]; + if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) { + coords.push(last); + } + if (coords.length < 2) continue; + + const type = el.tags?.highway || 'unclassified'; + const onewayTag = el.tags?.oneway; + const oneway = + onewayTag === 'yes' || + onewayTag === '1' || + onewayTag === 'true' || + el.tags?.junction === 'roundabout' + ? 1 + : onewayTag === '-1' + ? -1 + : 0; + + let baseHeight = 0; + const firstCoord = coords[0]; + if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) { + /* TRACE_ONLY_BEGIN */ + _trafficTimingSampleHeightCalls += 1; + _trafficTimingSampledCells.add( + `${firstCoord[1].toFixed(3)},${firstCoord[0].toFixed(3)}`, + ); + /* TRACE_ONLY_END */ + const carto = Cesium.Cartographic.fromDegrees( + firstCoord[0], + firstCoord[1], + ); + /* TRACE_ONLY_BEGIN */ + const _trafficTimingSampleStart = performance.now(); + /* TRACE_ONLY_END */ + const sampled = layerState._viewer.scene.sampleHeight(carto); + /* TRACE_ONLY_BEGIN */ + _trafficTimingSampleHeightMs += + performance.now() - _trafficTimingSampleStart; + /* TRACE_ONLY_END */ + if (Number.isFinite(sampled)) baseHeight = sampled; + } + + /* TRACE_ONLY_BEGIN */ + const _trafficTimingMaterializeStart = performance.now(); + /* TRACE_ONLY_END */ + const waypoints = coords.map(([lng, lat]) => { + const h = baseHeight + DOT_HEIGHT_OFFSET; + return Cesium.Cartesian3.fromDegrees(lng, lat, h); + }); + const segmentDist = []; + for (let i = 0; i < waypoints.length - 1; i++) { + segmentDist.push( + Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + ); + } + /* TRACE_ONLY_BEGIN */ + _trafficTimingWaypointMaterializationMs += + performance.now() - _trafficTimingMaterializeStart; + /* TRACE_ONLY_END */ + + roads.push({ coords, type, oneway, waypoints, segmentDist }); + } + + /* TRACE_ONLY_BEGIN */ + const _trafficTimingMetrics = { + roadCount: roads.length, + sampleHeightCalls: _trafficTimingSampleHeightCalls, + sampleHeightMeanMs: _trafficTimingSampleHeightCalls + ? _trafficTimingSampleHeightMs / _trafficTimingSampleHeightCalls + : 0, + distinctCells: _trafficTimingSampledCells.size, + }; + trafficTimingAggregate( + 'sample-height-total', + _trafficTimingState, + _trafficTimingParseStartTime, + _trafficTimingSampleHeightMs, + _trafficTimingMetrics, + ); + trafficTimingAggregate( + 'waypoint-materialization', + _trafficTimingState, + _trafficTimingParseStartTime, + _trafficTimingWaypointMaterializationMs, + _trafficTimingMetrics, + ); + const _trafficTimingParseEnd = trafficTimingMark( + _trafficTimingState, + 'road-parse-end', + _trafficTimingMetrics, + ); + trafficTimingMeasure( + 'road-parse-total', + _trafficTimingState, + _trafficTimingParseStart, + _trafficTimingParseEnd, + _trafficTimingMetrics, + ); + /* TRACE_ONLY_END */ + return roads; + } + + /** Resolve a render label into its correlated pass and data source. */ + + function trafficTimingRenderState(trace, label) { + const pass = label.toLowerCase().includes('major') ? 'major' : 'full'; + const source = label.startsWith('Cache') ? 'client-cache' : 'proxy'; + return trafficTimingPass(trace, pass, source); + } + + /** Record the first Cesium postRender following a completed dot render. */ + + function scheduleTrafficTimingPostRender( + state, + renderEnd, + renderId, + renderMetrics, + ) { + if (!layerState._viewer?.scene) return; + let remove = null; + remove = layerState._viewer.scene.postRender.addEventListener(() => { + remove?.(); + layerState._trafficTimingPostRenderRemovers?.delete(remove); + const visibleTime = performance.now(); + const postRender = trafficTimingMark( + state, + 'next-post-render', + { + renderId, + ...renderMetrics, + }, + visibleTime, + ); + const firstVisible = trafficTimingMark( + state, + 'first-visible-pixel', + { + renderId, + visibleBoundary: 'next-postRender', + ...renderMetrics, + }, + visibleTime, + ); + trafficTimingMeasure( + 'render-to-post-render', + state, + renderEnd, + postRender, + { + renderId, + visibleBoundary: 'next-postRender', + ...renderMetrics, + }, + ); + trafficTimingMeasure( + 'last-camera-change-to-first-visible', + state, + state.trace.cameraChangeMark, + firstVisible, + { renderId, visibleBoundary: 'next-postRender', ...renderMetrics }, + ); + }); + layerState._trafficTimingPostRenderRemovers?.add(remove); + } + + /** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */ + + async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) { + const generation = layerState._loadGeneration + 1; + if ( + !expectedAnchor || + expectedAnchor !== layerState._trafficTimingCurrentAnchor + ) { + layerState._trafficTimingDroppedTraces += 1; + await parts.ingestion.loadRoadsForBounds(bounds, altitude); + return; + } + layerState._trafficTimingCurrentAnchor = null; + const clamped = parts.viewport.clampBounds(bounds); + const trace = { + id: ++layerState._trafficTimingSequence, + interactionId: expectedAnchor.interactionId, + cameraChangeTimestamp: expectedAnchor.timestamp, + cameraChangeMark: trafficTimingCameraChangeMarkName( + expectedAnchor.interactionId, + ), + generation, + cacheKey: `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`, + currentPass: null, + renderSequence: 0, + passes: new Map(), + }; + layerState._trafficTimingTracesCreated += 1; + await parts.ingestion.loadRoadsForBounds(bounds, altitude, trace); + } + return { + getTrafficTimingDiagnostics, + trafficTimingPass, + trafficTimingDetail, + trafficTimingMark, + trafficTimingMeasure, + trafficTimingAggregate, + clearTrafficTimingEntries, + trafficTimingCameraChangeMarkName, + markTrafficTimingCameraChange, + markTrafficTimingMoveEnd, + parseRoadsTimed, + trafficTimingRenderState, + scheduleTrafficTimingPostRender, + loadRoadsForBoundsTimed, + }; +} diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js new file mode 100644 index 000000000..e84a1c045 --- /dev/null +++ b/src/layers/traffic/viewport.js @@ -0,0 +1,249 @@ +import * as Cesium from 'cesium'; +import { + deriveFetchCenter, + clampBoundsAroundCenter, +} from '../../data/trafficBounds.js'; +import { + MAX_LOOKAT_PULL_KM, + ACTIVATION_ALTITUDE, + OVERLAP_THRESHOLD, + MIN_CENTER_SHIFT_KM, + TRAFFIC_TIMING_ENABLED, + FETCH_DEBOUNCE, +} from './policy.js'; + +export function createViewport({ state: layerState, services, parts, source }) { + // ─── Camera Monitoring ───────────────────────────────────── + + /** + * Get the current camera altitude in meters above the ellipsoid. + * @returns {number} Camera height in meters, or Infinity if unavailable. + */ + + function getCameraAltitude() { + const carto = layerState._viewer.camera.positionCartographic; + return carto ? carto.height : Infinity; + } + + /** + * Compute the current camera view rectangle in degrees. + * @returns {{south:number, west:number, north:number, east:number}|null} + * Bounding box in degrees, or null if the rectangle cannot be computed. + */ + + function getViewBounds() { + const rect = layerState._viewer.camera.computeViewRectangle(); + if (!rect) return null; + return { + south: Cesium.Math.toDegrees(rect.south), + west: Cesium.Math.toDegrees(rect.west), + north: Cesium.Math.toDegrees(rect.north), + east: Cesium.Math.toDegrees(rect.east), + }; + } + + /** + * Derive the road-fetch center from the camera's look-at ground point. + * + * C4 fix: at oblique pitch `computeViewRectangle()` spans toward the horizon, + * so its midpoint can sit tens of km from what the user is looking at. Instead + * we pick the ellipsoid under the canvas center (`camera.pickEllipsoid` — this + * works with the globe hidden under Google 3D tiles, where `scene.globe.pick` + * is NOT reliable), fall back to the camera nadir on a sky/horizon look, and + * pull horizon-gaze hits back to MAX_LOOKAT_PULL_KM from nadir. + * + * @returns {{lat:number, lon:number, source:string}|null} Fetch center in + * degrees, or null when the camera position is unavailable. + */ + + function getFetchCenter() { + const carto = layerState._viewer.camera.positionCartographic; + if (!carto) return null; + const nadirLat = Cesium.Math.toDegrees(carto.latitude); + const nadirLon = Cesium.Math.toDegrees(carto.longitude); + + let hitLat; + let hitLon; + const canvas = layerState._viewer.scene.canvas; + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + if (width > 0 && height > 0) { + const hit = layerState._viewer.camera.pickEllipsoid( + new Cesium.Cartesian2(width / 2, height / 2), + Cesium.Ellipsoid.WGS84, + ); + if (hit) { + const hitCarto = Cesium.Cartographic.fromCartesian(hit); + hitLat = Cesium.Math.toDegrees(hitCarto.latitude); + hitLon = Cesium.Math.toDegrees(hitCarto.longitude); + } + } + + return deriveFetchCenter({ + nadirLat, + nadirLon, + hitLat, + hitLon, + maxPullKm: MAX_LOOKAT_PULL_KM, + }); + } + + /** + * Compute the geographic center of a bounding box. + * @param {{south:number, west:number, north:number, east:number}} bounds + * @returns {{lat:number, lon:number}} Center point in degrees. + */ + + function getBoundsCenter(bounds) { + return { + lat: (bounds.south + bounds.north) / 2, + lon: (bounds.west + bounds.east) / 2, + }; + } + + /** + * Approximate great-circle distance between two points in kilometres. + * + * Uses an equirectangular projection (cosine correction on longitude) + * with the 111 km/degree approximation. Sufficient for the small + * viewport-center shifts being compared. + * + * @param {{lat:number, lon:number}} a - First point. + * @param {{lat:number, lon:number}} b - Second point. + * @returns {number} Distance in kilometres. + */ + + function distanceKm(a, b) { + const dLat = (a.lat - b.lat) * 111; + const avgLat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dLon = (a.lon - b.lon) * 111 * Math.cos(avgLat); + return Math.sqrt(dLat * dLat + dLon * dLon); + } + + /** + * Check whether two bounding boxes overlap by at least a given fraction of + * the first box's area. Used to decide if a camera move is large enough to + * warrant a new road fetch. + * + * @param {{south:number, west:number, north:number, east:number}} a - Reference bounds. + * @param {{south:number, west:number, north:number, east:number}} b - Bounds to compare. + * @param {number} threshold - Minimum overlap fraction (0-1) relative to `a`. + * @returns {boolean} True if overlap area / a area >= threshold. + */ + + function boundsOverlap(a, b, threshold) { + // Compute the intersection rectangle + const overlapS = Math.max(a.south, b.south); + const overlapN = Math.min(a.north, b.north); + const overlapW = Math.max(a.west, b.west); + const overlapE = Math.min(a.east, b.east); + + // No intersection if the rectangle is degenerate + if (overlapN <= overlapS || overlapE <= overlapW) return false; + + const overlapArea = (overlapN - overlapS) * (overlapE - overlapW); + const aArea = (a.north - a.south) * (a.east - a.west); + + return aArea > 0 && overlapArea / aArea >= threshold; + } + + /** + * Clamp a bounding box to a maximum span to avoid overloading the Overpass API. + * + * The box is centered on the input's midpoint with each axis capped at 0.05 + * degrees (~5.5 km at the equator). This keeps query area and response size + * manageable while still covering the visible neighbourhood. + * + * NOTE (C4): the camera-driven path in `onCameraChanged` centers on the + * derived look-at point via `clampBoundsAroundCenter` instead; this + * midpoint-centered variant remains as the internal re-clamp guard in + * `loadRoadsForBounds` (idempotent on already-clamped bounds). + * + * @param {{south:number, west:number, north:number, east:number}} bounds + * @returns {{south:number, west:number, north:number, east:number}} Clamped bounds. + */ + + function clampBounds(bounds) { + return clampBoundsAroundCenter(bounds, getBoundsCenter(bounds)); + } + + /** + * Camera-change handler — the main entry point for viewport-driven road loading. + * + * Gating logic: + * 1. If the camera is above ACTIVATION_ALTITUDE, clear all dots and bail. + * 2. Clamp the view bounds and compute the viewport center. + * 3. Skip the fetch if the new viewport significantly overlaps the last-fetched + * bounds AND the center has shifted less than MIN_CENTER_SHIFT_KM. This + * prevents redundant fetches during small pans. + * 4. Otherwise, debounce and schedule `loadRoadsForBounds`. + */ + + function onCameraChanged() { + if (!layerState._enabled) return; + + const alt = getCameraAltitude(); + + // Above activation altitude — remove all traffic and stop. + // Also null the last-fetch gate: otherwise zooming back down to the SAME + // viewport hits the overlap/center-shift skip in step 3 and the dots + // (cleared here) never reload (H5). Clearing the gate forces a fresh fetch. + if (alt > ACTIVATION_ALTITUDE) { + clearTimeout(layerState._fetchTimeout); + clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + parts.ingestion.cancelActiveFetch(); + layerState._loadGeneration++; + layerState._fetching = false; + layerState._flowPending = 0; + layerState._roadError = null; + parts.animation.clearDots(); + layerState._lastBounds = null; + layerState._lastViewCenter = null; + return; + } + + const bounds = getViewBounds(); + if (!bounds) return; + // C4 fix: center the fetch box on the camera's look-at ground point (with + // nadir fallback + 12 km horizon-gaze pull-back), NOT the view rectangle's + // midpoint — at oblique pitch that midpoint drifts toward the horizon. + const fetchCenter = getFetchCenter(); + const clamped = fetchCenter + ? clampBoundsAroundCenter(bounds, fetchCenter) + : clampBounds(bounds); + const center = getBoundsCenter(clamped); + + // Skip re-fetch when viewport overlap is high and center shift is negligible + if ( + layerState._lastBounds && + layerState._lastViewCenter && + boundsOverlap(clamped, layerState._lastBounds, OVERLAP_THRESHOLD) && + distanceKm(center, layerState._lastViewCenter) < MIN_CENTER_SHIFT_KM + ) { + return; + } + + // Debounce: wait for camera to settle before triggering a fetch. In debug + // captures the final changed event that arms this exact timeout is its + // causal anchor; Cesium's later moveEnd notification is diagnostic only. + const interactionAnchor = TRAFFIC_TIMING_ENABLED + ? parts.timing.markTrafficTimingCameraChange() + : null; + clearTimeout(layerState._fetchTimeout); + layerState._fetchTimeout = setTimeout( + () => layerState._loadRoadsForBounds(clamped, alt, interactionAnchor), + FETCH_DEBOUNCE, + ); + } + return { + getCameraAltitude, + getViewBounds, + getFetchCenter, + getBoundsCenter, + distanceKm, + boundsOverlap, + clampBounds, + onCameraChanged, + }; +} diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js new file mode 100644 index 000000000..d4341d363 --- /dev/null +++ b/src/layers/vessels/cards.js @@ -0,0 +1,181 @@ +import { + accentForVesselType, + normalizeVesselType, +} from '../../data/vesselLabels.js'; + +export function createCards({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + 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. + */ + + 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: components.rendering.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. + */ + + 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} + */ + + 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`; + } + return { + updateSelectedVesselHud, + resetSelectedVesselHud, + trimHudValue, + buildVesselCard, + buildSelectedVesselCard, + vesselOverlayEntryId, + vesselTypeShort, + cardScreenSeparated, + displayVesselName, + formatSpeed, + formatHeading, + formatPositionTime, + }; +} diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js new file mode 100644 index 000000000..abab8821c --- /dev/null +++ b/src/layers/vessels/evidence.js @@ -0,0 +1,58 @@ +import * as Cesium from 'cesium'; +import { FOCUS_EVIDENCE_DEV } from './policy.js'; + +export function createEvidence({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + /** 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 }; + } + components.selection.clearVesselInspection(); + components.store.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, + }; + }); + } + return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot }; +} diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js new file mode 100644 index 000000000..d459aa873 --- /dev/null +++ b/src/layers/vessels/index.js @@ -0,0 +1,56 @@ +import { createVesselState } from './state.js'; +import { createLifecycle } from './lifecycle.js'; +import { createIngestion } from './ingestion.js'; +import { createStore } from './store.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createTracking } from './tracking.js'; +import { createCards } from './cards.js'; +import { createTesting } from './testing.js'; +import { createEvidence } from './evidence.js'; +import { createQueries } from './queries.js'; +/** Compose one vessel layer with application-owned scene services. */ +export function createVesselLayer({ source, services, options = {} } = {}) { + const vesselState = createVesselState({ source, services }); + const parts = {}; + const layer = {}; + const context = { vesselState, services, parts, layer, options }; + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + parts.store = createStore(context); + parts.rendering = createRendering(context); + parts.selection = createSelection(context); + parts.tracking = createTracking(context); + parts.cards = createCards(context); + parts.testing = createTesting(context); + parts.evidence = createEvidence(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + layer.source = source?.label || 'Vessels'; + Object.assign(layer, { + applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis, + buildVesselCard: parts.cards.buildVesselCard, + buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, + cardScreenSeparated: parts.cards.cardScreenSeparated, + }); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; +} +export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js'; +export { + VESSEL_OVERLAY_SOURCE_ID, + VESSEL_LABEL_GRID_PX, + VESSEL_DEFAULT_LABEL_LIMIT, + VESSEL_OVERLAY_MAX_COHORT, + VESSEL_CARD_FADE_DISTANCE_M, + normalizeVesselType, + vesselTypeCss, + accentForVesselType, + vesselOverlayCohortLimit, + applyVesselOverlayPolicy, +} from '../../data/vesselLabels.js'; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js new file mode 100644 index 000000000..ba0d6d538 --- /dev/null +++ b/src/layers/vessels/ingestion.js @@ -0,0 +1,184 @@ +import { AIS_FIRST_CONNECT_LABEL } from './policy.js'; + +export function createIngestion({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const aisLiveVesselsLayer = layer; + + 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 { + // 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 snapshot = await vesselState._source.getSnapshot( + { maxRows: components.rendering.renderRowLimit() }, + { signal }, + ); + if (!ownsAisRequest(requestController, requestSessionId)) return; + aisLiveVesselsLayer.source = snapshot.source; + // Map observations into the existing display store; source fields stop here. + applyAisFeedSnapshot(viewer, { + rows: snapshot.records.map(vesselDisplayRow), + observedAtMs: snapshot.observedAtMs, + freshness: snapshot.freshness, + complete: snapshot.complete, + rawRowCount: snapshot.rawRowCount, + reason: snapshot.reason, + status: snapshot.transportStatus, + lastMessageAt: snapshot.lastMessageAt, + nextAttemptAt: snapshot.nextAttemptAt, + refreshing: snapshot.stale, + newestPositionAt: + snapshot.observedAtMs == null + ? null + : new Date(snapshot.observedAtMs).toISOString(), + silentForMs: snapshot.silentForMs, + reconnectAttempt: snapshot.reconnectAttempt, + }); + } catch (error) { + if ( + ownsAisRequest(requestController, requestSessionId) && + error?.name !== 'AbortError' + ) { + components.lifecycle.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 = components.queries.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 ( + components.lifecycle.isDefinitiveTransportFailure( + snapshot.transportStatus, + ) + ) { + components.lifecycle.markAisUnavailable(snapshot.error); + return { reconciled: false, ...snapshot }; + } + if ( + state.firstConnectPhase === 'loading' && + components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus) + ) { + state.error = null; + state.loadingLabel = AIS_FIRST_CONNECT_LABEL; + return { reconciled: false, ...snapshot }; + } + if (state.firstConnectPhase === 'loading') { + components.lifecycle.markAisUnavailable(snapshot.error); + return { reconciled: false, ...snapshot }; + } + state.error = snapshot.error; + return { reconciled: false, ...snapshot }; + } + + components.lifecycle.settleFirstConnectPhase('ready'); + components.store.reconcileVessels(viewer, snapshot.acceptedRows, { + complete: payload?.complete !== false, + }); + state.count = state.vesselRecords.length; + state.stale = + Boolean(payload?.refreshing) || + payload?.complete === false || + payload?.freshness === 'unknown'; + 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 || payload?.reason || null; + state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') + ? payload.observedAtMs + : vesselState._aisRuntime.now(); + return { reconciled: true, ...snapshot }; + } + + function vesselDisplayRow(record) { + return { + mmsi: record.id, + reference: record.reference, + lat: record.latitude, + lon: record.longitude, + name: record.name, + imo: record.imo, + type: record.type, + destination: record.destination, + speed: record.speedMps == null ? null : record.speedMps / 0.514444, + course: record.courseDeg, + heading: record.headingDeg, + last_position_epoch: + record.observedAtMs == null ? null : record.observedAtMs / 1000, + last_position_UTC: + record.observedAtMs == null + ? '' + : new Date(record.observedAtMs).toISOString(), + }; + } + const methods = { + update(viewer) { + if (!state.enabled) return Promise.resolve(); + return loadLivePositions(viewer || state.viewer); + }, + }; + + return { + loadLivePositions, + ownsAisRequest, + applyAisFeedSnapshot, + vesselDisplayRow, + methods, + }; +} diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js new file mode 100644 index 000000000..c878b398c --- /dev/null +++ b/src/layers/vessels/lifecycle.js @@ -0,0 +1,253 @@ +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; +import { + AIS_FIRST_CONNECT_GRACE_MS, + AIS_FIRST_CONNECT_LABEL, + AIS_HEALTHY_STATUSES, +} from './policy.js'; + +export function createLifecycle({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + + function clearFirstConnectTimer() { + if (state.firstConnectTimer === null) return; + vesselState._aisRuntime.clearTimeout(state.firstConnectTimer); + state.firstConnectTimer = null; + } + + function invalidateAisSession() { + clearFirstConnectTimer(); + state.sessionId = ++vesselState._aisSessionSequence; + state.firstConnectPhase = 'idle'; + state.firstConnectStartedAt = null; + state.firstConnectDeadline = null; + } + + function beginAisSession() { + clearFirstConnectTimer(); + const sessionId = ++vesselState._aisSessionSequence; + const startedAt = vesselState._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 = vesselState._aisRuntime.setTimeout(() => { + if ( + !state.enabled || + state.sessionId !== sessionId || + state.firstConnectPhase !== 'loading' + ) + return; + const remainingMs = + state.firstConnectDeadline - vesselState._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; + } + + function resetState() { + state.abort?.abort(); + state.trailAbort?.abort(); + state.trailAbort = null; + 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 = ++vesselState._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; + } + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (state.viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + vesselState._source = source; + this.source = source.label || this.source; + }, + + init(viewer) { + if (typeof vesselState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + state.viewer = viewer; + components.rendering.ensureCollections(viewer); + vesselState._vesselOverlayHost.setVisible( + VESSEL_OVERLAY_SOURCE_ID, + false, + ); + components.selection.installInteraction(viewer); + components.rendering.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; + components.rendering.ensureCollections(activeViewer); + components.selection.installInteraction(activeViewer); + components.rendering.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 (!vesselState._geoidReady) { + const sessionId = state.sessionId; + ensureGeoidReady() + .then(() => { + if (!state.enabled || state.sessionId !== sessionId) return; + vesselState._geoidReady = true; + components.tracking.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 components.ingestion.loadLivePositions(activeViewer); + }, + + disable() { + state.enabled = false; + invalidateAisSession(); + releaseContinuousRender('ais-vessels'); + unregisterPickOwner('ais-live-vessels'); + components.rendering.setVisible(false); + vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); + components.selection.clearVesselInspection(); + components.tracking.destroySelectedVesselTrail(); + components.selection.removeVesselInteraction(); + if (state.abort) { + state.abort.abort(); + state.abort = null; + } + state.loading = false; + state.loadingLabel = ''; + }, + + destroy(viewer) { + const activeViewer = viewer || state.viewer; + invalidateAisSession(); + releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + if (state.abort) state.abort.abort(); + unregisterPickOwner('ais-live-vessels'); + components.selection.clearVesselInspection(); + components.tracking.destroySelectedVesselTrail(); + if (state.billboardCollection && activeViewer?.scene?.primitives) { + activeViewer.scene.primitives.remove(state.billboardCollection); + } + vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); + vesselState._vesselOverlayHost.setVisible( + VESSEL_OVERLAY_SOURCE_ID, + false, + ); + components.selection.removeVesselInteraction(); + if (state.preRenderRemover) { + state.preRenderRemover(); + } + resetState(); + }, + }; + + return { + clearFirstConnectTimer, + invalidateAisSession, + beginAisSession, + scheduleFirstConnectExpiry, + settleFirstConnectPhase, + isGraceEligibleTransport, + isDefinitiveTransportFailure, + markAisUnavailable, + resetState, + methods, + }; +} diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs new file mode 100644 index 000000000..1be09728a --- /dev/null +++ b/src/layers/vessels/ownership.test.mjs @@ -0,0 +1,267 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { createVesselState } from './state.js'; + +const noop = () => {}; +function services() { + return { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { releaseContinuousRender: noop }, + }; +} +function setup(source, options = {}) { + const layer = createVesselLayer({ source, services: services(), options }); + layer.testing._setVesselStateForTest({ + viewer: {}, + billboardCollection: { add: (options) => ({ ...options }), remove: noop }, + }); + let now = 1000; + layer.testing._setAisRuntimeForTest({ + now: () => now, + setTimeout, + clearTimeout, + }); + return { + layer, + advance: (ms) => { + now += ms; + }, + }; +} +const observation = (id, reference = id) => ({ + id, + reference, + latitude: 51.93, + longitude: 4.05, + name: id, + observedAtMs: 500, + speedMps: 4, + headingDeg: 180, + courseDeg: 170, +}); +const snapshot = (records, extra = {}) => ({ + records, + source: 'Fixture vessels', + complete: true, + stale: false, + freshness: 'current', + observedAtMs: 500, + transportStatus: 'live', + rawRowCount: records.length, + ...extra, +}); + +test('vessel construction is inert and each instance owns its records, icon cache and clock', () => { + let requested = 0; + const source = { + getSnapshot() { + requested++; + }, + }; + const one = createVesselState({ source, services: services() }); + const two = createVesselState({ source, services: services() }); + for (const key of ['state', 'shipIconCache', '_scratchFocusScreen']) + assert.notEqual(one[key], two[key]); + assert.notEqual(one.state.vesselMap, two.state.vesselMap); + assert.notEqual(one.state.trailPositions, two.state.trailPositions); + const { layer } = setup(source); + const other = createVesselLayer({ source, services: services() }); + layer.testing._beginAisSessionForTest(); + assert.equal(other.getStats().loading, false); + assert.equal(requested, 0); + layer.testing._setVesselStateForTest({ enabled: false }); +}); + +test('missing vessel source fails before mutating the viewer', () => { + const layer = createVesselLayer({ services: services() }); + assert.throws(() => layer.init({}), /snapshot source/); + layer.setSource({ getSnapshot: noop }); +}); + +test('partial observations retain missing contacts only until their receipt deadline', async () => { + let current = snapshot([observation('111'), observation('222')]); + const { layer, advance } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([observation('111')], { complete: false, rawRowCount: 2 }); + await layer.update(); + assert.equal(layer.hasContact('222'), true); + assert.equal(layer.getStats().stale, true); + assert.equal(layer.getStats().rawRowCount, 2); + assert.equal(layer.getStats().acceptedRowCount, 1); + assert.equal(layer.getStats().lastUpdate, 500); + advance(5 * 60 * 1000); + await layer.update(); + assert.equal(layer.hasContact('222'), false); +}); + +test('a complete feed still removes an absent unselected vessel immediately', async () => { + let current = snapshot([observation('111'), observation('222')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([observation('111')]); + await layer.update(); + assert.equal(layer.hasContact('222'), false); +}); + +test('an incomplete feed bounds its retained union to the configured row budget', async () => { + let current = snapshot( + Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))), + ); + const { layer } = setup( + { + async getSnapshot() { + return current; + }, + }, + { maxRows: 500 }, + ); + await layer.update(); + current = snapshot( + Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))), + { complete: false }, + ); + await layer.update(); + assert.equal(layer.getStats().count, 500); + assert.equal(layer.hasContact('1000'), false); + assert.equal(layer.hasContact('2000'), true); +}); + +test('unknown source time does not become fresh at the time of receipt', async () => { + const { layer } = setup({ + async getSnapshot() { + return snapshot([observation('111')], { + observedAtMs: null, + freshness: 'unknown', + }); + }, + }); + await layer.update(); + assert.equal(layer.getStats().lastUpdate, null); + assert.equal(layer.getStats().stale, true); +}); + +test('refresh replaces the history reference and clearing selection cancels late history', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let current = snapshot([observation('111', 'old-reference')]); + let request; + let resolveTrack; + const { layer } = setup({ + async getSnapshot() { + return current; + }, + getTrack(reference, { signal }) { + request = { reference, signal }; + return new Promise((resolve) => { + resolveTrack = resolve; + }); + }, + }); + await layer.update(); + current = snapshot([observation('111', 'new-reference')]); + await layer.update(); + assert.equal(layer.selectById('111'), true); + assert.equal(request.reference, 'new-reference'); + layer.clearSelection(); + assert.equal(request.signal.aborted, true); + resolveTrack({ records: [{ latitude: 51.94, longitude: 4.04 }] }); + await new Promise(setImmediate); + assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); +}); + +test('destroy cancels pending observations and ignores their later completion', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let requestSignal; + let resolve; + const { layer } = setup({ + getSnapshot(_query, { signal }) { + requestSignal = signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = layer.update(); + layer.destroy(); + assert.equal(requestSignal.aborted, true); + resolve(snapshot([observation('111')])); + await pending; + assert.equal(layer.getStats().count, 0); + assert.equal(layer.hasContact('111'), null); +}); + +test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let current = snapshot([observation('111'), observation('222')]); + let signal; + let finish; + const { layer, advance } = setup({ + async getSnapshot() { + return current; + }, + getTrack(_reference, options) { + signal = options.signal; + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + await layer.update(); + layer.selectById('222'); + current = snapshot([observation('111')], { complete: false }); + await layer.update(); + assert.equal(layer.getSelectedInfo().mmsi, '222'); + assert.equal(signal.aborted, false); + advance(5 * 60 * 1000); + await layer.update(); + assert.equal(layer.getSelectedInfo(), null); + assert.equal(signal.aborted, true); + finish({ records: [] }); + await new Promise(setImmediate); + assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); +}); diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js new file mode 100644 index 000000000..b13dcb947 --- /dev/null +++ b/src/layers/vessels/policy.js @@ -0,0 +1,105 @@ +import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; + +export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; + +export const DEFAULT_RENDER_ROWS = 12000; + +export const DEFAULT_ACTIVE_LABELS = 900; + +export const REFRESH_MS = 60000; + +/** Bounded wait for the first accepted vessel position in one enabled session. */ + +export const AIS_FIRST_CONNECT_GRACE_MS = 30000; + +export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…'; + +export const VISIBILITY_UPDATE_MS = 800; + +/** Focus alpha alone samples faster inside the existing preRender pass. */ + +export const FOCUS_UPDATE_MS = 80; + +export 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. + */ + +export const CARD_MIN_SEP_PX = 150; + +/** Number of consecutive refreshes a selected-but-vanished vessel is retained. */ + +export const SELECTED_PIN_REFRESHES = 3; + +/** Trail hue for the selected vessel (PRD F4, pinned to the AIS teal-green family). */ + +export const TRAIL_COLOR = '#39ffd5'; + +/** Slight lift (m) for trail vertices to avoid sea-surface z-fighting. */ + +export 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). + */ + +export const VESSEL_LIFT_M = 3; + +/** Combined cap on trail vertices (server backfill + live accumulation). */ + +export const TRAIL_MAX_POINTS = 400; + +/** Minimum movement (m) before a reconcile refresh appends a new trail point. */ + +export const TRAIL_MIN_MOVE_M = 25; + +export const DEFAULT_AIS_RUNTIME = Object.freeze({ + now: () => Date.now(), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer), +}); + +/** + * 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". + */ + +export 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. + */ + +export 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. + */ + +export const AIS_DEGRADED_STATUSES = new Set([ + 'stale', + 'reconnecting', + 'down', + 'auth-failed', +]); + +/** Missing records in an incomplete observation are retained for at most five minutes. */ +export const PARTIAL_RETENTION_MS = 5 * 60 * 1000; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js new file mode 100644 index 000000000..05109e2b2 --- /dev/null +++ b/src/layers/vessels/queries.js @@ -0,0 +1,527 @@ +import * as Cesium from 'cesium'; +import { + AIS_DEGRADED_STATUSES, + AIS_HEALTHY_STATUSES, + AIS_STATUS_REASON, + REFRESH_MS, + FOCUS_EVIDENCE_DEV, + AIS_FIRST_CONNECT_LABEL, +} from './policy.js'; + +export function createQueries({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { formatKnots } = services.labels; + + /** + * 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 - vesselState._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. + */ + + 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}} + */ + + 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: + Number.isInteger(payload?.rawRowCount) && + payload.rawRowCount >= rawRows.length + ? payload.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}} + */ + + 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, + }; + } + + /** + * 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). + */ + + 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'}} + */ + + 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; + } + const methods = { + mapAnalystRecord, + deriveAisFeedError, + classifyAisFeedSnapshot, + vesselDatumHeightM, + reduceVesselSelection, + + id: 'ais-live-vessels', + + name: 'Live AIS Vessels', + + icon: '◭', + + source: 'AISStream', + + updateInterval: REFRESH_MS, + + statsRefreshInterval: 1000, + + /** + * 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; + components.selection.selectVessel(record); + return true; + }, + + /** + * Clear the current vessel selection and reset the HUD readout. + * @returns {boolean} Always true. + */ + clearSelection() { + components.selection.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: components.evidence._setFocusEvidenceVessels, + snapshot: components.evidence._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(), + }; + }, + }; + + return { + aisRetryInSec, + describeDegradedAisFeed, + deriveAisFeedError, + hasUsableVesselCoordinates, + classifyAisFeedSnapshot, + mapAnalystRecord, + vesselDatumHeightM, + reduceVesselSelection, + normalizeSelectionMmsi, + methods, + }; +} diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js new file mode 100644 index 000000000..87f09537d --- /dev/null +++ b/src/layers/vessels/rendering.js @@ -0,0 +1,472 @@ +import * as Cesium from 'cesium'; +import { + vesselTypeCss, + vesselOverlayCohortLimit, + VESSEL_OVERLAY_SOURCE_ID, + applyVesselOverlayPolicy, + VESSEL_CARD_FADE_DISTANCE_M, +} from '../../data/vesselLabels.js'; +import { + cameraPoseSignature, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; +import { + DEFAULT_RENDER_ROWS, + DEFAULT_ACTIVE_LABELS, + VISIBILITY_UPDATE_MS, + FOCUS_UPDATE_MS, + LABEL_GRID_PX, + CARD_MIN_SEP_PX, +} from './policy.js'; + +export function createRendering({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { registerSpriteCollection } = services.sprites; + const { + forgetSpriteFocus, + focusNowMs, + getFocusTarget, + focusPassIsNeeded, + advanceSpriteFocus, + focusAlphaNeedsWrite, + } = services.focus; + + function renderRowLimit() { + const configured = Number(options.maxRows); + 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(options.maxLabels); + 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); + } + + /** + * 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, + }); + } + + /** + * 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 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 (vesselState.shipIconCache.has(key)) + return vesselState.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); + vesselState.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 !== vesselState._lastCamPoseSig; + if (doRotations) vesselState._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, + vesselState._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}} + */ + + 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 + ? [components.cards.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 ( + !components.cards.cardScreenSeparated( + accepted, + candidate, + CARD_MIN_SEP_PX, + ) + ) + continue; + accepted.push({ x: candidate.x, y: candidate.y }); + entries.push(components.cards.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(), + ); + vesselState._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; + components.selection.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 setVisible(show) { + if (state.billboardCollection) { + state.billboardCollection.show = show; + } + vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); + } + return { + renderRowLimit, + labelRowLimit, + ensureCollections, + addRecordPrimitives, + removeRecordPrimitives, + shipScale, + vesselCourseDeg, + shipIcon, + installRuntime, + updateVisibility, + applyVesselFocusDeemphasis, + makeOccluder, + isVisible, + updateClusteredLabels, + publishVesselOverlayEntries, + labelPriority, + hasUsefulName, + setVisible, + }; +} diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js new file mode 100644 index 000000000..1ef7cbd9c --- /dev/null +++ b/src/layers/vessels/selection.js @@ -0,0 +1,247 @@ +import * as Cesium from 'cesium'; +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; + +export function createSelection({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { requestWorldFocus } = services.worldFocus; + const { + selectEntityContext, + registerEntityContext, + clearSelectedEntityContextForLayer, + } = services.context; + const aisLiveVesselsLayer = layer; + + 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 + ? vesselState._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 = components.queries.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 = components.queries.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 = components.queries.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 = components.rendering.shipIcon(record, true); + record.billboard.scale = components.rendering.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. + components.rendering.updateVisibility(true); + components.cards.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) { + components.tracking.appendSelectedVesselTrailFix(record); + } else { + components.tracking.startSelectedVesselTrail(record); + } + } + + /** + * 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: aisLiveVesselsLayer.source, + label: components.cards.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 = components.rendering.shipIcon(record, false); + record.billboard.scale = components.rendering.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) components.rendering.updateVisibility(true); + if (!preserveTrail) components.tracking.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 }); + components.cards.resetSelectedVesselHud(); + } + return { + installInteraction, + bindVesselInteraction, + selectAndFocusVessel, + removeVesselInteraction, + onVesselKeyDown, + selectVessel, + registerSelectedContext, + clearSelection, + clearVesselInspection, + }; +} diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js new file mode 100644 index 000000000..5f17de65f --- /dev/null +++ b/src/layers/vessels/state.js @@ -0,0 +1,108 @@ +import * as Cesium from 'cesium'; +import { DEFAULT_AIS_RUNTIME } from './policy.js'; + +export function createVesselState({ source, services }) { + const { + setOverlayEntries, + setOverlaySourceVisible, + clearOverlaySource, + hitTestWorldOverlay, + } = services.overlay; + const vesselState = {}; + + vesselState._source = source; + + /** Camera pose signature at the last vessel rotation pass. */ + + vesselState._lastCamPoseSig = ''; + + vesselState._scratchFocusScreen = new Cesium.Cartesian2(); + + vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, + }); + + vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST; + + vesselState._aisRuntime = DEFAULT_AIS_RUNTIME; + + vesselState._aisSessionSequence = 0; + + /** + * 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} + */ + + vesselState._geoidReady = false; + + /** @type {Map} `${cssColor}:${variant}` -> chevron SVG data URL */ + + vesselState.shipIconCache = new Map(); + + vesselState.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, + trailAbort: null, + }; + return vesselState; +} diff --git a/src/layers/vessels/store.js b/src/layers/vessels/store.js new file mode 100644 index 000000000..80890452c --- /dev/null +++ b/src/layers/vessels/store.js @@ -0,0 +1,212 @@ +import * as Cesium from 'cesium'; +import { PARTIAL_RETENTION_MS } from './policy.js'; +import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js'; + +export function createStore({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + + /** + * 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, { complete = true } = {}) { + const receivedAtMs = vesselState._aisRuntime.now(); + components.rendering.ensureCollections(viewer); + + // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them. + for (const record of state.unkeyedRecords) { + components.rendering.removeRecordPrimitives(record); + } + state.unkeyedRecords = []; + + const occluder = components.rendering.makeOccluder(); + const seen = new Set(); + for (let index = 0; index < rows.length; index += 1) { + const next = normalizeVessel(rows[index]); + if (!next) continue; + next.receivedAtMs = receivedAtMs; + + if (!next.mmsi) { + components.rendering.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 { + components.rendering.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 ( + !complete && + Number.isFinite(record.receivedAtMs) && + receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS + ) { + if (record === state.selectedRecord) { + record.missedRefreshes = Math.max(1, record.missedRefreshes || 0); + components.cards.updateSelectedVesselHud(record); + } + continue; + } + if (record === state.selectedRecord) { + record.missedRefreshes = (record.missedRefreshes || 0) + 1; + if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) { + components.cards.updateSelectedVesselHud(record); // re-render with STALE marker + continue; + } + // Aged out of the feed after exhausting its pin — not a deselect. + components.selection.clearVesselInspection({ evicted: true }); + } + components.rendering.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) + components.tracking.clearSelectedVesselTrail(); + } + + const cap = components.rendering.renderRowLimit(); + if (state.vesselMap.size + state.unkeyedRecords.length > cap) { + for (const [mmsi, record] of state.vesselMap) { + if (state.vesselMap.size + state.unkeyedRecords.length <= cap) break; + if (seen.has(mmsi) || record === state.selectedRecord) continue; + components.rendering.removeRecordPrimitives(record); + state.vesselMap.delete(mmsi); + if (state.trailMmsi === mmsi) + components.tracking.clearSelectedVesselTrail(); + } + } + state.vesselRecords = [ + ...state.vesselMap.values(), + ...state.unkeyedRecords, + ]; + state.lastVisibilityUpdate = 0; + components.rendering.updateVisibility(true); + } + + /** + * 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 = components.rendering.shipIcon(record, selected); + + record.reference = next.reference; + record.receivedAtMs = next.receivedAtMs; + 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 = + components.rendering.shipScale(record) * (selected ? 1.2 : 1); + const nextIcon = components.rendering.shipIcon(record, selected); + if (nextIcon !== prevIcon) { + record.billboard.image = nextIcon; + } + } + if (record.mmsi === state.trailMmsi) { + components.tracking.appendSelectedVesselTrailFix(record); + } + if (selected) { + components.cards.updateSelectedVesselHud(record); + components.selection.registerSelectedContext(record); + } + } + + 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 = components.queries.vesselDatumHeightM( + components.tracking.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.mmsi || 'VESSEL'), + mmsi: String(row.mmsi || '').trim(), + reference: row.reference ?? String(row.mmsi || '').trim(), + imo: String(row.imo || ''), + type: String(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; + } + return { + reconcileVessels, + updateRecordInPlace, + normalizeVessel, + finiteNumber, + }; +} diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js new file mode 100644 index 000000000..e0c8f37fe --- /dev/null +++ b/src/layers/vessels/testing.js @@ -0,0 +1,179 @@ +import { DEFAULT_AIS_RUNTIME } from './policy.js'; + +export function createTesting({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const aisLiveVesselsLayer = layer; + + /** + * 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} + */ + + function _bindVesselInteractionForTest(viewer, handler, keyTarget) { + components.selection.bindVesselInteraction(viewer, handler, keyTarget); + } + + /** + * Prime the minimum live state needed by interaction/lifecycle wire tests. + * @param {Object} [options={}] - Test state values. + * @returns {void} + */ + + function _setVesselStateForTest(options = {}) { + components.lifecycle.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. */ + + function _setVesselOverlayHostForTest(host = null) { + vesselState._vesselOverlayHost = + host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST; + } + + /** Exercise the production selector/publisher through a test-owned state. */ + + function _updateVesselCardsForTest(records = []) { + components.rendering.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} + */ + + function _reconcileVesselsForTest(viewer, rows) { + components.store.reconcileVessels(viewer, rows); + } + + /** Apply one server snapshot through the production pre-reconcile health gate. */ + + function _applyAisFeedSnapshotForTest(viewer, payload) { + return components.ingestion.applyAisFeedSnapshot(viewer, payload); + } + + /** Exercise the request-owned live loader with a test-controlled fetch. */ + + function _loadLivePositionsForTest(viewer) { + return components.ingestion.loadLivePositions(viewer); + } + + /** Start the production first-connect grace state without installing UI. */ + + function _beginAisSessionForTest() { + components.lifecycle.beginAisSession(); + } + + /** Inject a deterministic clock/scheduler; null restores production runtime. */ + + function _setAisRuntimeForTest(runtime = null) { + components.lifecycle.clearFirstConnectTimer(); + vesselState._aisRuntime = runtime + ? { + now: runtime.now, + setTimeout: runtime.setTimeout, + clearTimeout: runtime.clearTimeout, + } + : DEFAULT_AIS_RUNTIME; + } + + /** Read feed-health fields without exposing mutable production state. */ + + 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}} + */ + + function _getVesselStateForTest() { + return { + trailMmsi: state.trailMmsi, + trailPositionCount: state.trailPositions.length, + vesselCount: state.vesselMap.size, + }; + } + return { + _bindVesselInteractionForTest, + _setVesselStateForTest, + _setVesselOverlayHostForTest, + _updateVesselCardsForTest, + _reconcileVesselsForTest, + _applyAisFeedSnapshotForTest, + _loadLivePositionsForTest, + _beginAisSessionForTest, + _setAisRuntimeForTest, + _getVesselFeedStateForTest, + _getVesselStateForTest, + }; +} diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js new file mode 100644 index 000000000..52de1e0de --- /dev/null +++ b/src/layers/vessels/tracking.js @@ -0,0 +1,214 @@ +import * as Cesium from 'cesium'; +import { + TRAIL_HEIGHT_M, + VESSEL_LIFT_M, + TRAIL_COLOR, + TRAIL_MAX_POINTS, + TRAIL_MIN_MOVE_M, +} from './policy.js'; + +export function createTracking({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + const { geoidHeight } = services.geoid; + const { createTrail } = services.trails; + + /** + * 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 vesselState._geoidReady ? geoidHeight(lat, lon) : null; + } + + /** + * 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 = components.queries.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 = components.queries.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.trailAbort?.abort(); + state.trailAbort = new AbortController(); + 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, + record.reference, + ); + } + + /** + * 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, reference = mmsi) { + const owner = state.trailAbort; + const signal = owner + ? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)]) + : AbortSignal.timeout(8000); + let samples = null; + try { + const track = await vesselState._source.getTrack?.(reference, { signal }); + samples = track?.records ?? null; + } catch { + return; // silent — keep the live-accumulated trail + } + if ( + signal.aborted || + owner !== state.trailAbort || + !samples || + token !== state.trailBackfillToken + ) + return; + if (state.trailMmsi !== mmsi) return; + + const older = []; + for (const sample of samples) { + const lat = Number(sample?.latitude); + const lon = Number(sample?.longitude); + 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 = components.queries.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.trailAbort?.abort(); + state.trailAbort = null; + 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; + } + } + return { + currentGeoidN, + vesselTrailPosition, + refloorVesselRecords, + startSelectedVesselTrail, + backfillVesselTrail, + appendSelectedVesselTrailFix, + clearSelectedVesselTrail, + destroySelectedVesselTrail, + }; +} diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index a5540e1ce..85eb38606 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -16,21 +16,34 @@ function finiteCount(value) { /** Normalize one manager layer into a small loading-feedback record. */ export function normalizeLayerLoading(layer = {}) { const stats = layer.stats || {}; - const lifecycleState = String(layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled')); + const lifecycleState = String( + layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'), + ); const status = String(stats.status || '').toLowerCase(); const disabling = lifecycleState === 'disabling'; - const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true; + const loading = + lifecycleState === 'enabling' || + disabling || + stats.loading === true || + stats.refreshing === true; const count = finiteCount(stats.count); - const stoppingInstallations = layer.id === 'military-installations' && disabling; + const stoppingInstallations = + layer.id === 'military-installations' && disabling; // Guidance statuses ask the user to act (zoom in, run a search). They are // normal operation, never a batch failure — mirrors layerFeedState's carve-out // so a prompt stored alongside the status cannot turn the chip red. const guidance = GUIDANCE_STATUSES.includes(status); - const error = stoppingInstallations ? null - : (!guidance && stats.error) || stats.lastError || stats.managerRefreshError || null; - const unavailable = !stoppingInstallations && (stats.unavailable === true - || stats.available === false - || ['unavailable', 'offline', 'down', 'error'].includes(status)); + const error = stoppingInstallations + ? null + : (!guidance && stats.error) || + stats.lastError || + stats.managerRefreshError || + null; + const unavailable = + !stoppingInstallations && + (stats.unavailable === true || + stats.available === false || + ['unavailable', 'offline', 'down', 'error'].includes(status)); const keyRequired = stats.keyRequired === true || stats.missingKey === true; const degraded = stats.degraded === true || Boolean(error); const accepted = Boolean(stats.lastUpdate) || count > 0; @@ -39,7 +52,8 @@ export function normalizeLayerLoading(layer = {}) { label: String(layer.name || layer.id || 'Layer'), loading, disabling, - refresh: loading && layer.enabled && (stats.refreshing === true || accepted), + refresh: + loading && layer.enabled && (stats.refreshing === true || accepted), lifecycleState, count, accepted, @@ -47,18 +61,27 @@ export function normalizeLayerLoading(layer = {}) { unavailable, keyRequired, degraded, - installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling - ? { retryAt: Number(stats.retryAt) || 0, retrying: stats.retrying === true, - failureReason: stats.failureReason, loading, status: stats.status } - : null, + installationRetry: + layer.id === 'military-installations' && layer.enabled && !disabling + ? { + retryAt: Number(stats.retryAt) || 0, + retrying: stats.retrying === true, + failureReason: stats.failureReason, + loading, + status: stats.status, + } + : null, }; } function terminalFromParticipantStats(summary, participantIds) { if (!participantIds?.length) return null; const participants = new Set(participantIds); - return summary.records.some((record) => participants.has(record.id) - && (record.error || record.unavailable || record.keyRequired)) + return summary.records.some( + (record) => + participants.has(record.id) && + (record.error || record.unavailable || record.keyRequired), + ) ? 'error' : null; } @@ -67,13 +90,17 @@ function terminalFromParticipantStats(summary, participantIds) { export function aggregateLayerLoading(layers = []) { const records = layers.map(normalizeLayerLoading); const active = records.filter((record) => record.loading); - const disabling = active.length > 0 && active.every((record) => record.disabling); + const disabling = + active.length > 0 && active.every((record) => record.disabling); return { records, active, activeIds: active.map((record) => record.id), disabling, - refresh: !disabling && active.length > 0 && active.every((record) => record.refresh), + refresh: + !disabling && + active.length > 0 && + active.every((record) => record.refresh), }; } @@ -92,11 +119,11 @@ export function createLoadingFeedbackState() { } /** Create a top-center status notice, optionally persistent until explicitly cleared. */ -export function createGlobalStatusNotice(message, nowMs = 0, { - state = 'error', - detail = '', - persistent = false, -} = {}) { +export function createGlobalStatusNotice( + message, + nowMs = 0, + { state = 'error', detail = '', persistent = false } = {}, +) { const label = String(message || '').trim(); if (!label) return null; return { @@ -117,9 +144,11 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) { const now = Number.isFinite(nowMs) ? nowMs : 0; if (!notice?.label) return null; if (!notice.persistent && !Number.isFinite(notice.hideAt)) { - notice.hideAt = now + (Number.isFinite(notice.dwellMs) - ? notice.dwellMs - : LOADING_FAILURE_DWELL_MS); + notice.hideAt = + now + + (Number.isFinite(notice.dwellMs) + ? notice.dwellMs + : LOADING_FAILURE_DWELL_MS); } if (Number.isFinite(notice.hideAt) && now >= notice.hideAt) return null; return { @@ -130,10 +159,16 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) { } /** Whether deferred notice work still owns the current presentation epoch. */ -export function canPresentDeferredStatusNotice(expectedGeneration, currentGeneration, disposed = false) { - return !disposed - && Number.isSafeInteger(expectedGeneration) - && expectedGeneration === currentGeneration; +export function canPresentDeferredStatusNotice( + expectedGeneration, + currentGeneration, + disposed = false, +) { + return ( + !disposed && + Number.isSafeInteger(expectedGeneration) && + expectedGeneration === currentGeneration + ); } /** @@ -141,9 +176,19 @@ export function canPresentDeferredStatusNotice(expectedGeneration, currentGenera * hide a terminal manager failure. Failure dwell starts when the manager * reports it, so it must remain the highest-priority presentation while live. */ -export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) { - const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs); - if (['error', 'retry'].includes(loadingPresentation?.state)) return loadingPresentation; +export function presentGlobalLoadingStatus( + notice, + loadingState, + summary, + nowMs = 0, +) { + const loadingPresentation = presentLoadingFeedback( + loadingState, + summary, + nowMs, + ); + if (['error', 'retry'].includes(loadingPresentation?.state)) + return loadingPresentation; return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation; } @@ -162,11 +207,11 @@ export function createTrafficSyncFeedbackState() { * Reduce one sampled Street Traffic status without extending completion on * every animation-loop poll. Coverage describes accepted data, not work. */ -export function reduceTrafficSyncFeedback(previous, { - enabled = false, - stats = {}, - forceShow = false, -} = {}, nowMs = 0) { +export function reduceTrafficSyncFeedback( + previous, + { enabled = false, stats = {}, forceShow = false } = {}, + nowMs = 0, +) { const state = previous || createTrafficSyncFeedbackState(); const now = Number.isFinite(nowMs) ? nowMs : 0; if (!enabled) return createTrafficSyncFeedbackState(); @@ -174,10 +219,13 @@ export function reduceTrafficSyncFeedback(previous, { const hasProgress = Number.isFinite(stats.phaseProgressPct); const progressPct = hasProgress ? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct))) - : (stats.loading ? 1 : 100); - const busy = stats.loading === true - || stats.worldJumping === true - || (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0)); + : stats.loading + ? 1 + : 100; + const busy = + stats.loading === true || + stats.worldJumping === true || + (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0)); const label = String(stats.phaseLabel || stats.loadingLabel || '').trim(); if (busy) { @@ -193,13 +241,13 @@ export function reduceTrafficSyncFeedback(previous, { }; } - const existingConfirmation = state.confirmationUntil > now - ? state.confirmationUntil - : 0; - const confirmationUntil = existingConfirmation || (state.busy || forceShow - ? now + TRAFFIC_SYNC_CONFIRM_MS - : 0); - const visible = confirmationUntil > now && progressPct >= 100 && Boolean(label); + const existingConfirmation = + state.confirmationUntil > now ? state.confirmationUntil : 0; + const confirmationUntil = + existingConfirmation || + (state.busy || forceShow ? now + TRAFFIC_SYNC_CONFIRM_MS : 0); + const visible = + confirmationUntil > now && progressPct >= 100 && Boolean(label); return { busy: false, visible, @@ -217,7 +265,8 @@ export function reduceTrafficSyncFeedback(previous, { function terminalFromEvent(event) { const type = String(event?.type || ''); - if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error) return 'error'; + if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error) + return 'error'; if (type === 'visibility-cancelled' || event?.cancelled) return 'cancelled'; if (type === 'visibility' || type === 'refresh') return 'complete'; return null; @@ -238,7 +287,9 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { const beginning = state.phase !== 'loading'; const startedAt = beginning ? now : state.startedAt; const priorParticipants = beginning ? [] : state.activeIds; - const activeIds = [...new Set([...priorParticipants, ...summary.activeIds])]; + const activeIds = [ + ...new Set([...priorParticipants, ...summary.activeIds]), + ]; const eventLayerId = String(event?.layerId || ''); const eventParticipates = eventLayerId && activeIds.includes(eventLayerId); const batchOutcome = mergeTerminalOutcome( @@ -257,27 +308,41 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { activeIds, batchOutcome, terminal: null, - operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', - failedEventIds: [...new Set([ - ...(beginning ? [] : state.failedEventIds || []), - ...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), - ])], + operation: summary.disabling + ? 'disabling' + : summary.refresh + ? 'refresh' + : 'loading', + failedEventIds: [ + ...new Set([ + ...(beginning ? [] : state.failedEventIds || []), + ...(eventParticipates && terminalFromEvent(event) === 'error' + ? [eventLayerId] + : []), + ]), + ], }; } if (state.phase === 'loading') { const eventLayerId = String(event?.layerId || ''); - const eventParticipates = eventLayerId && state.activeIds.includes(eventLayerId); - const terminal = mergeTerminalOutcome( + const eventParticipates = + eventLayerId && state.activeIds.includes(eventLayerId); + const terminal = mergeTerminalOutcome( - state.batchOutcome, - terminalFromParticipantStats(summary, state.activeIds), - ), - eventParticipates ? terminalFromEvent(event) : null, - ) || 'complete'; + mergeTerminalOutcome( + state.batchOutcome, + terminalFromParticipantStats(summary, state.activeIds), + ), + eventParticipates ? terminalFromEvent(event) : null, + ) || 'complete'; const wasVisible = state.visible || now >= state.showAt; - if (!wasVisible && terminal === 'complete') return createLoadingFeedbackState(); - const dwell = terminal === 'error' ? LOADING_FAILURE_DWELL_MS : LOADING_TERMINAL_DWELL_MS; + if (!wasVisible && terminal === 'complete') + return createLoadingFeedbackState(); + const dwell = + terminal === 'error' + ? LOADING_FAILURE_DWELL_MS + : LOADING_TERMINAL_DWELL_MS; return { ...state, phase: 'terminal', @@ -285,10 +350,14 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { hideAt: now + dwell, batchOutcome: terminal, terminal, - failedEventIds: [...new Set([ - ...(state.failedEventIds || []), - ...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), - ])], + failedEventIds: [ + ...new Set([ + ...(state.failedEventIds || []), + ...(eventParticipates && terminalFromEvent(event) === 'error' + ? [eventLayerId] + : []), + ]), + ], }; } @@ -298,11 +367,17 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { /** Build the user-facing status copy for the current loading state. */ export function presentLoadingFeedback(state, summary, nowMs) { - const site = summary.records.find(record => record.installationRetry?.retryAt > 0); - const otherFailure = summary.records.some(record => record.id !== 'military-installations' - && (state?.activeIds || []).includes(record.id) - && (record.error || record.unavailable || record.keyRequired)) - || (state?.failedEventIds || []).some(id => id !== 'military-installations'); + const site = summary.records.find( + (record) => record.installationRetry?.retryAt > 0, + ); + const otherFailure = + summary.records.some( + (record) => + record.id !== 'military-installations' && + (state?.activeIds || []).includes(record.id) && + (record.error || record.unavailable || record.keyRequired), + ) || + (state?.failedEventIds || []).some((id) => id !== 'military-installations'); // Keep the actual retry visible between attempts, without hiding another // participant's failure or pretending that a scheduled retry is fetching. if (site && !summary.active.length && !otherFailure) { @@ -312,28 +387,55 @@ export function presentLoadingFeedback(state, summary, nowMs) { } if (!state?.visible) return null; if (state.phase === 'terminal') { - const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' }; - const label = state.operation === 'disabling' && state.terminal === 'complete' - ? 'LIVE DATA OFF' - : state.terminal === 'complete' && state.activeIds?.length === 1 && state.activeIds[0] === 'military-installations' - ? 'MAPPED SITES LOADED' : labels[state.terminal] || 'LOAD COMPLETE'; + const labels = { + complete: 'LOAD COMPLETE', + cancelled: 'LOAD CANCELLED', + error: 'LOAD FAILED', + }; + const label = + state.operation === 'disabling' && state.terminal === 'complete' + ? 'LIVE DATA OFF' + : state.terminal === 'complete' && + state.activeIds?.length === 1 && + state.activeIds[0] === 'military-installations' + ? 'MAPPED SITES LOADED' + : labels[state.terminal] || 'LOAD COMPLETE'; return { state: state.terminal, label, detail: '' }; } const active = summary.active; - if (active.length === 1 && active[0].installationRetry && !summary.disabling) { - return { state: 'loading', label: active[0].installationRetry.retrying - ? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', detail: 'OpenStreetMap · Overpass' }; + if ( + active.length === 1 && + active[0].installationRetry && + !summary.disabling + ) { + return { + state: 'loading', + label: active[0].installationRetry.retrying + ? 'RETRYING MAPPED SITES' + : 'FETCHING MAPPED SITES', + detail: 'OpenStreetMap · Overpass', + }; } const elapsed = Math.max(0, nowMs - state.startedAt); const label = summary.disabling ? 'TURNING OFF LIVE DATA' - : summary.refresh ? 'REFRESHING LIVE DATA' : 'LOADING LIVE DATA'; - const names = active.slice(0, 2).map((record) => record.label).join(' · '); + : summary.refresh + ? 'REFRESHING LIVE DATA' + : 'LOADING LIVE DATA'; + const names = active + .slice(0, 2) + .map((record) => record.label) + .join(' · '); const suffix = active.length > 2 ? ` +${active.length - 2}` : ''; return { - state: elapsed >= LOADING_LONG_THRESHOLD_MS - ? 'long' - : summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', + state: + elapsed >= LOADING_LONG_THRESHOLD_MS + ? 'long' + : summary.disabling + ? 'disabling' + : summary.refresh + ? 'refresh' + : 'loading', label, detail: `${names}${suffix}`, }; diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 7537efb06..78e6b6bbd 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -89,15 +90,15 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {'); const end = ui.indexOf('\n _initGlobalContextPanel() {', start); const handler = ui.slice(start, end); assert.match(handler, /this\._showGlobalStatusNotice\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /requestAnimationFrame\(\(\) => \{/); - assert.match(handler, /startupCover\.addEventListener\('transitionend', showOnce, \{ once: true \}\)/); - assert.match(handler, /fallbackTimer = setTimeout\(showOnce, 1000\)/); + assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); assert.doesNotMatch(handler, /this\._showToast\(message\)/); assert.doesNotMatch(handler, /pushCockpitSignal/); assert.match(handler, /result\.classification === 'pending'/); @@ -106,7 +107,7 @@ test('share-follow failures use the universal top-center status instead of the b assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed' \|\| result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); }); test('universal notice masks active loading only for its own fixed dwell', () => { @@ -195,13 +196,13 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); - assert.match(dispose, /this\._globalStatusNotice = null;/); + assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/); assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/); assert.match(html, //); }); @@ -235,14 +236,14 @@ test('reveals sustained loading and then a bounded completion state', () => { }); test('terminal loading feedback centers its label without an empty detail slot', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, - /#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, ); assert.match( css, - /#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\) #global-loading-detail\s*\{\s*display:\s*none;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/, ); }); @@ -531,8 +532,8 @@ test('the settled traffic chip shows exactly one percentage — the coverage it }); test('the chip renderer clears the progress slot instead of stranding the last value', () => { - 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/shellFeedback.js', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const start = ui.indexOf(' _updateTrafficSyncChip('); assert.ok(start > 0, '_updateTrafficSyncChip is missing'); const body = ui.slice(start, ui.indexOf('\n }', start)); @@ -620,7 +621,7 @@ test('aggregates Mapped Installations refresh beside CCTV without changing eithe // stack layout contract — the ticker's lifecycle is pinned against ui.js // source. (perf rebase 2026-08-17) test('the loading ticker never runs hidden and stops after loading and notices settle', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'); // Scope every assertion to _armLoadingFeedbackTicker's own body. The // neighbouring _startTrafficChipTicker is a deliberately PERMANENT 500ms // safety-net poll, so its `if (document.hidden) return;` is correct there @@ -655,7 +656,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // Persistent ACQUIRING notices remain visible without a 60ms timer. assert.match( arm, - /const noticeNeedsTicker = Number\.isFinite\(this\._globalStatusNotice\?\.hideAt\);[\s\S]*?if \(this\._loadingFeedbackState\?\.phase === 'idle' && !noticeNeedsTicker\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/, + /const noticeNeedsTicker = Number\.isFinite\(\s*this\._globalStatusNotice\?\.hideAt,?\s*\);[\s\S]*?if \(\s*this\._loadingFeedbackState\?\.phase === 'idle'\s*&&\s*!noticeNeedsTicker\s*\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/, 'an idle phase with no expiring notice must stop the ticker', ); assert.match( @@ -667,21 +668,21 @@ test('the loading ticker never runs hidden and stops after loading and notices s // 4. Returning to visible resamples, so a batch that finished while hidden // is reconciled and a still-running one re-arms its ticker. The handler // must live on the class that OWNS _updateGlobalLoadingFeedback - // (StyleManager) — wiring it into a neighbouring controller instead + // (ShellFeedback) — wiring it into a neighbouring controller instead // throws on every visibilitychange — and must be torn down with the rest. assert.match( ui, - /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/, + /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*else this\._stopLoadingFeedbackTicker\(\);\s*\};\s*document\.addEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/, 'visibilitychange must resample the chip on return', ); - const styleManager = ui.slice(ui.indexOf('export class StyleManager')); + const styleManager = ui.slice(ui.indexOf('export class ShellFeedback')); assert.ok( styleManager.includes('this._loadingVisibilityHandler'), - 'the resample handler must be owned by StyleManager, which defines _updateGlobalLoadingFeedback', + 'the resample handler must be owned by ShellFeedback, which defines _updateGlobalLoadingFeedback', ); assert.match( ui, - /document\.removeEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/, + /document\.removeEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/, 'the resample handler must be removed on teardown', ); }); diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 8bcbf6955..7711de20b 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,3 +1,4 @@ +import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,12 +6,12 @@ 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 ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); /** Source of the free-text LOCATION search handler (Enter on #location-search). */ function locationSearchHandler() { - const start = ui.indexOf('searchAndFlyTo(this.viewer, query, {'); + const start = ui.indexOf(' _handleLocationSearchState(state, change) {'); assert.ok(start > 0, 'free-text location search handler is missing'); const end = ui.indexOf('_beginWorldJumpTransition() {', start); assert.ok(end > start, 'could not bound the location search handler'); @@ -24,7 +25,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else assert.equal(writes.length, 1, 'the style indicator must have exactly one writer'); assert.match( ui.slice(writes[0].index, writes[0].index + 160), - /this\._styleIndicator\.textContent = displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/, + /this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/, ); const handler = locationSearchHandler(); @@ -38,17 +39,19 @@ test('a free-text search records its destination for the LOCATION mini-status', // own refresh would otherwise repaint the readout as "Location: --". assert.match( handler, - /this\._searchedLocationLabel = destination\.label[\s\S]{0,120}?this\._setActiveLocation\(null\);/, + /this\._searchedLocationLabel = state\.destination\.label[\s\S]{0,120}?this\._setActiveLocation\(null\);/, ); assert.match(handler, /this\._updateLocationMiniStatus\(\);/); }); test('the mini-status reads its copy from the shared formatter', () => { - assert.match(ui, /import \{ locationMiniStatus \} from '\.\/locationStatus\.js';/); + const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); + assert.match(controls, /import \{ locationMiniStatus \} from '\.\.\/locationStatus\.js';/); + assert.match(controls, /const lines = locationMiniStatus\(state\)/); const start = ui.indexOf(' _updateLocationMiniStatus() {'); assert.ok(start > 0, '_updateLocationMiniStatus is missing'); const body = ui.slice(start, ui.indexOf('\n }', start)); - assert.match(body, /locationMiniStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/); + assert.match(body, /_locationControls\?\.renderStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/); // No second copy of the placeholder strings to drift out of sync. assert.doesNotMatch(body, /Location: --/); }); @@ -64,15 +67,15 @@ test('any other camera destination clears the search label too', () => { // Voice navigation, the globe reset, camera takeover and entity selection // all funnel through _stampNavigation; without a clear there, a searched // label outlives the place it named. - const start = ui.indexOf(' _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {'); + const start = ui.indexOf(' _stampNavigation('); assert.ok(start > 0, '_stampNavigation is missing'); - assert.match(ui.slice(start, start + 700), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/); + assert.match(StyleManager.prototype._stampNavigation.toString(), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/); // The shared funnel is what the reset and voice seams actually reach. - for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(noun, navigate']) { + for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) { const at = ui.indexOf(seam); assert.ok(at > 0, `missing navigation seam "${seam}"`); - assert.match(ui.slice(at, at + 900), /_stampNavigation\(/, `"${seam}" must stamp navigation`); + assert.match(StyleManager.prototype[seam.match(/^(\w+)/)[1]].toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`); } // Public seam, so a camera owner that flies on its own can invalidate it. @@ -84,11 +87,11 @@ test('a deferred lookup that never flies leaves the readout standing', () => { // the stamp blanked a still-true readout whenever the lookup failed, was // superseded, or was refused — no camera ever moved. The deferred begin opts // out; the reassert seam, reached only once the flight is granted, clears. - const begin = ui.indexOf(' _beginDeferredNavigation(noun = '); + const begin = ui.indexOf(' _beginDeferredNavigation('); assert.ok(begin > 0, '_beginDeferredNavigation is missing'); assert.match( - ui.slice(begin, begin + 700), - /stamp: \(\) => this\._stampNavigation\(\{ cancelPendingSelection, clearSearchedLocation: false \}\)/, + StyleManager.prototype._beginDeferredNavigation.toString(), + /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); diff --git a/src/locationStatus.js b/src/locationStatus.js index 2dcc80fcc..1d90582c9 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -55,7 +55,10 @@ export function locationMiniStatus({ // The remaining address is the place's context ("Japan", "Minato City, // Tokyo, Japan"); the readout is ellipsised in CSS, so a long tail is // safe. A one-segment geocode ("Japan") has no context to show. - poi: segments.length > 1 ? segments.slice(1).join(', ') : 'Searched location', + poi: + segments.length > 1 + ? segments.slice(1).join(', ') + : 'Searched location', }; } diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 985bd5658..f4a033442 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,27 +1,17 @@ +import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; // Exercise the installed event routes and central close method, without WebGL. -const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const locationMarkup = markup.slice(markup.indexOf(']*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); const locationToggleAttributes = Object.fromEntries( [...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]), ); -const initStart = source.indexOf(' _initAutoHoverPanel('); -const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart); -const escapeStart = source.indexOf(' _collapsePanelOnEscape('); -const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart); -const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {'); -const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart); -const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {'); -const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart); -assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart - && closeStart >= 0 && closeEnd > closeStart); -assert.ok(syncStart >= 0 && syncEnd > syncStart); function harness({ hidden = false, selected = true, noChips = false } = {}) { let now = 0; @@ -116,13 +106,15 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) { window.performance = { now: () => now }; document.defaultView = window; const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', - `return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)( + `return ({${StyleManager.prototype._initAutoHoverPanel.toString()},\n${StyleManager.prototype._collapsePanelOnEscape.toString()},\n${StyleManager.prototype.setPanelCollapsed.toString()},\n${StyleManager.prototype._syncPanelCollapseButton.toString()}});`)( createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, ); const saves = []; const claims = []; let shareSyncs = 0; const manager = { + _lifetime: { frame() {} }, + _panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null }, ...methods, _savePanelCollapsedState(...args) { saves.push(args); }, _scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {}, diff --git a/src/mapStackChips.js b/src/mapStackChips.js index c87e319b3..ac523700d 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -1,8 +1,8 @@ // MAP STACK source chips — the always-visible replacement for the `` // that used to sit in the Map Stack panel. One button per stack, rendered from -// `MapStackController.getStacks()`. The four owner-approved sources below are +// `MapStackController.getStacks()`. The approved sources below are // the whole shipped set; keeping the allowlist explicit means a stack added to -// `MAP_STACKS` for internal use cannot reach the tray until someone names it +// `MAP_STACKS` for another purpose cannot reach the tray until someone names it // here. // // The chips are a control SURFACE only: selecting one calls back into the same @@ -40,7 +40,9 @@ export function mapStackChipModel(stack, activeId) { const fallbackReason = requiresIon ? keySetupRequirement('cesium-ion') : `${label || 'This map stack'} is unavailable`; - const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason); + const unavailableHint = available + ? '' + : String(stack?.unavailableReason || fallbackReason); return { id: String(stack?.id ?? ''), label, @@ -60,13 +62,13 @@ export function mapStackChipModel(stack, activeId) { * @param {Array} stacks - `MapStackController.getStacks()` output. * @param {string|null} activeId - Currently active stack id. * @returns {Array} One chip model per approved presentation id, in - * `PRESENTED_MAP_STACK_IDS` order; internal and future stacks stay hidden. + * `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation. */ export function mapStackChipModels(stacks, activeId) { - const stacksById = new Map((Array.isArray(stacks) ? stacks : []) - .map((stack) => [stack?.id, stack])); - return PRESENTED_MAP_STACK_IDS - .map((id) => stacksById.get(id)) + const stacksById = new Map( + (Array.isArray(stacks) ? stacks : []).map((stack) => [stack?.id, stack]), + ); + return PRESENTED_MAP_STACK_IDS.map((id) => stacksById.get(id)) .filter(Boolean) .map((stack) => mapStackChipModel(stack, activeId)); } @@ -79,9 +81,20 @@ export function mapStackChipModels(stacks, activeId) { * @param {string|null} [options.activeId] - Currently active stack id. * @param {(stackId: string) => void} [options.onSelect] - Selection callback. * @param {Document} [options.doc] - Document override (tests). + * @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override. * @returns {Array} The rendered chip models. */ -export function renderMapStackChips(container, stacks, { activeId = null, onSelect = null, doc } = {}) { +export function renderMapStackChips( + container, + stacks, + { + activeId = null, + onSelect = null, + doc, + bind = (element, type, listener) => + element.addEventListener(type, listener), + } = {}, +) { if (!container) return []; const ownerDoc = doc || container.ownerDocument || globalThis.document; if (!ownerDoc?.createElement) return []; @@ -96,13 +109,18 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele MAP_STACK_CHIP_CLASS, model.active ? 'active' : '', model.available ? '' : 'unavailable', - ].filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); chip.dataset.stackId = model.id; chip.title = model.title; chip.setAttribute('aria-pressed', String(model.active)); chip.setAttribute('aria-disabled', String(!model.available)); if (!model.available) { - chip.setAttribute('aria-label', `${model.label} unavailable: ${model.unavailableHint}`); + chip.setAttribute( + 'aria-label', + `${model.label} unavailable: ${model.unavailableHint}`, + ); } const label = ownerDoc.createElement('span'); @@ -117,7 +135,7 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele chip.appendChild(requirement); } - chip.addEventListener('click', () => { + bind(chip, 'click', () => { if (!model.available) return; onSelect?.(model.id); }); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 9cf2f6966..7106cd2e7 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; // MAP STACK chip row — the dropdown's replacement control surface. // // The owner's complaint was two clicks (open panel → open dropdown) to change @@ -262,7 +263,7 @@ test('a missing row or document is inert rather than throwing during boot', () = }); test('the active cyan survives hover', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const hover = css.indexOf('.map-stack-chip:hover'); const active = css.indexOf('.map-stack-chip.active {'); const unavailable = css.indexOf('.map-stack-chip.unavailable'); @@ -282,7 +283,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // `outline: none` — so a focus state built only from those properties is // INVISIBLE on the active chip. The ring must live on a property no other // chip-state rule sets. - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ''); const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)] .map(([, selector, body], order) => ({ @@ -329,7 +330,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row'); assert.match( @@ -345,28 +346,24 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse ); const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8'); assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/); - assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/); + assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/); + + const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8'); + assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/, + 'the application action uses the component selection path'); + assert.match(controls, /onSelect: [\s\S]*?select\(id\)/, + 'chip clicks use the same component selection path'); + assert.match(controls, /await controller\.setStack\(stackId\)/, + 'selection still delegates source loading to the map controller'); + assert.match(controls, /render\(controller\.getState\(\)\)/, + 'the active chip is re-synced from actual controller state'); + assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/, + 'provider-driven fallback reaches the component without a user click'); + assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/, + 'the provider-driven subscription has an explicit remover'); + assert.match(controls, /unsubscribe\?\.\(\)/, + 'component destruction releases its subscription'); - assert.match( - ui, - /renderMapStackChips\(this\._mapStackChips, this\.mapStackController\.getStacks\(\), \{[\s\S]*?onSelect: \(stackId\) => \{ this\._setMapStack\(stackId\); \}/, - 'chips must dispatch through the same _setMapStack path the dropdown used', - ); - assert.match( - ui, - /_renderMapStackState\(state\) \{[\s\S]*?syncMapStackChips\(this\._mapStackChips, state\.activeId\)/, - 'the active chip must be re-synced from controller state', - ); - assert.match( - ui, - /window\.addEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/, - 'provider-driven fallback must re-sync the UI without a user click', - ); - assert.match( - ui, - /window\.removeEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/, - 'the provider-driven state listener must be released with StyleManager', - ); }); test('Esri fallbacks report and attribute the imagery source actually rendered', () => { diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index e24f4bda3..dac9abdcb 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -467,7 +468,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM 'the surface is runtime host-owned, not static markup', ); assert.match( - readFileSync(new URL('../../style.css', import.meta.url), 'utf8'), + readStylesheet(new URL('../../style.css', import.meta.url)), /#world-overlay-detection-surface,\n#world-overlay-canvas \{\n position: absolute;\n inset: 0;[\s\S]*?pointer-events: none;/, 'both host surfaces share absolute positioning and pointer passthrough', ); @@ -484,7 +485,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM }); /** The shipped stylesheet, read once — fixtures must stack as production does. */ -const SHIPPED_CSS = readFileSync(new URL('../../style.css', import.meta.url), 'utf8'); +const SHIPPED_CSS = readStylesheet(new URL('../../style.css', import.meta.url)); /** * Position + z-index the SHIPPED stylesheet gives a selector. Fixtures are built @@ -551,7 +552,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' // builds and evaluates each node against the shipped stylesheet. const env = installMockEnvironment(); initWorldOverlay(env.viewer); - const css = readFileSync(new URL('../../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../../style.css', import.meta.url)); const surface = env.document.getElementById('world-overlay-detection-surface'); assert.ok(surface, 'the host owns a detection surface'); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 3c7245688..c63e084f7 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -1,9 +1,14 @@ +import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.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 { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); function method(name, nextName) { const start = source.indexOf(` ${name}(`); @@ -135,7 +140,7 @@ test('panel chrome wires Escape for every declared collapse target', () => { }); test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => { - const onKeyDown = method('onKeyDown', 'enter'); + const onKeyDown = cockpitKeyDown.toString(); assert.match( onKeyDown, /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/, @@ -155,16 +160,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => { assert.match( - source, - /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, + radioBindings, + /const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, ); assert.match( - source, + radioBindings, /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/, 'Cockpit utility disclosures clear their own focus when Escape closes them', ); assert.match( - source, + radioBindings, /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, 'compact Radio disclosure clears its own focus when Escape closes it', ); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index f6e2b2644..465f3cf4a 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -135,11 +136,11 @@ test('minimum panel corridor expands upward without crossing the lower obstacle }); test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => { - 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)); assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/); assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/); - assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); + assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); assert.match(ui, /reconsiderAutoCollapse/); assert.match( ui, @@ -150,13 +151,13 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/, ); assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/); - assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); + assert.match(ui, /this\._panelLayout\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); assert.match( leftRail, /preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened left panel must receive primary allocation', ); - assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); + assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( rightRail, /panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, @@ -177,8 +178,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c /panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/, 'right intrinsic measurement must retain Display allocation while clearing other panel allocations', ); - assert.match(css, /var\(--left-panel-allocated-height/); - assert.match(css, /var\(--right-panel-allocated-height/); + assert.match(css, /var\(\s*--left-panel-allocated-height/); + assert.match(css, /var\(\s*--right-panel-allocated-height/); assert.match( css, /#left-panel-stack\.layout-focus > \[data-panel-id\]\.collapsed\s*\{\s*display:\s*none;/, @@ -189,7 +190,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); assert.match( @@ -212,11 +213,11 @@ test('share-panel state excludes responsive collapse and preserves recipient pre }); test('parameterized Display presets keep one stable scroll owner', () => { - 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)); assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); - assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); + assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); assert.match( ui, @@ -234,7 +235,7 @@ test('parameterized Display presets keep one stable scroll owner', () => { }); test('expanded Display uses its container shell instead of a nested header card', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, @@ -242,13 +243,13 @@ test('expanded Display uses its container shell instead of a nested header card' ); assert.match( css, - /#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(--right-collapsed-width, 132px\);/, + /#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(\s*--right-collapsed-width, 132px\);/, 'collapsed Display must retain its standalone launcher sizing', ); }); test('expanded left panels integrate their headers with the container shell', () => { - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, @@ -256,13 +257,13 @@ test('expanded left panels integrate their headers with the container shell', () ); assert.match( css, - /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-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, 0\.22\);/, + /#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-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\);/, ); }); test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.doesNotMatch(html, /id="stack-panel"/); assert.match(html, /id="control-panel"[\s\S]*?class="map-source-section"[\s\S]*?id="map-stack-chips"/); @@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', () test('expanded right panels highlight the title divider without changing collapsed launchers', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( html, @@ -283,10 +284,10 @@ test('expanded right panels highlight the title divider without changing collaps ); assert.match( css, - /#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-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, 0\.22\);/, + /#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-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( css, - /#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\);/, + /#param-slider-panel:not\(\.collapsed\) \.param-panel-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*\);/, ); }); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index d360ec2f1..eb21a0a5d 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,15 +1,23 @@ +import { readLayerSource } from './testSupport/readLayerSource.mjs'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; +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 assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import test from 'node:test'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); -const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); -const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8'); -const rocketLaunches = readFileSync(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); +const radio = ['playback', 'interaction'].map(name => + readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8') +).join('\n').replace(/layerState\.|parts\.\w+\./g, ''); +const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8'); const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n'); -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); /** Parse the Realtime tool array out of the Vite config as real data. */ function realtimeTools() { @@ -202,7 +210,7 @@ test('Radio volume and mission speed share the Sharpen slider visual language', assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/); assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/); assert.match(css, /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/); - assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: \.42;[\s\S]*?cursor: not-allowed;/); + assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/); assert.match(css, /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/); assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/); }); @@ -240,28 +248,28 @@ test('Radio is nested inside Context with separate disclosure and power controls assert.match(css, /\.radio-tuner\.is-static/); assert.match(css, /\.radio-tuner-tick\s*\{/); assert.doesNotMatch(css, /radio-tuner-scale-(?:left|right)/); - assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left \.18s ease-out;/); + assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left 0\.18s ease-out;/); assert.match(css, /\.radio-tuner\.is-dragging \.radio-tuner-needle,[\s\S]*?\.radio-tuner\.is-dragging \.radio-tuner-tick\s*\{\s*transition: none;/); assert.match(css, /\.radio-tuner\s*\{[\s\S]*?max-width: 100%;[\s\S]*?overflow: hidden;/); assert.match(css, /#radio-tuner-slider\s*\{[\s\S]*?max-width: 100%;[\s\S]*?touch-action: none;/); assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::before/); assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::after/); - assert.match(css, /--radio-broadcast-opacity: \.17/); + assert.match(css, /--radio-broadcast-opacity: 0\.17/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/); assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/); - assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); - const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); - const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart)); + assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); + const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); + const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart)); assert.doesNotMatch(cycleMethod, /refreshTunerBand/); - assert.match(ui, /_radioTunerBandPinnedForNavigation = true/); - assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/); - assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); - assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); - assert.match(ui, /radioLayer\.cancelTuning\(\)/); - assert.match(ui, /classList\.remove\('radio-broadcasting'\)/); - assert.match(ui, /radioLayer\.getTunerStations\(750\)/); - assert.match(ui, /radioTunerPointerPosition\(/); + assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/); + assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/); + assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); + assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/); + assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/); + assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); + assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); + assert.match(radioBindings, /radioTunerPointerPosition\(/); assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/); assert.match(css, /#global-context-panel #radio-panel\.collapsed/); assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/); @@ -277,16 +285,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb assert.match(voice, /'radio'/); assert.match(voice, /name:\s*'control_radio'/); assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']); - const enableStart = ui.lastIndexOf('\n _initRadioPanel()'); - const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart)); + const enableMethod = radioBindings; assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i); assert.match(enableMethod, /contextRadioToggleBtn/); assert.match(enableMethod, /contextRadioMiniEnableBtn/); assert.match(enableMethod, /contextRadioDetailsBtn/); assert.match(enableMethod, /contextRadioMiniCloseBtn/); assert.match(enableMethod, /contextRadioMiniPlayBtn/); - const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener'); - const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart); + const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,'); + const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart); const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd); assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing'); assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/); @@ -299,37 +306,34 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb ); assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/); assert.match(ui, /setAttribute\('aria-expanded', String\(/); - assert.match(ui, /\.hidden = !/); + assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/); assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/); }); test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => { - const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext'); - const revealEnd = ui.indexOf('\n /**', revealStart + 10); - const revealMethod = ui.slice(revealStart, revealEnd); + const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext'); + const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10); + const revealMethod = radioControlsSource.slice(revealStart, revealEnd); assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing'); assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/); - assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/); + assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/); assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/); assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/); - const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()'); - const syncEnd = ui.indexOf('\n /**', syncStart + 10); - const syncMethod = ui.slice(syncStart, syncEnd); + const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()'); + const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10); + const syncMethod = radioControlsSource.slice(syncStart, syncEnd); assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing'); - assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/); - assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/); - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); + assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/); + assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/); + const renderMethod = radioPresentation; assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/); assert.doesNotMatch(renderMethod, /compact Radio controls/); assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/); }); test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => { - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); - assert.ok(renderStart >= 0, 'Radio render method is missing'); + const renderMethod = radioPresentation; assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s); assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s); assert.match(ui, /contextRadioDetailsBtn/); @@ -339,15 +343,15 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb }); test('successful explicit user playback hands the speaker from voice to Radio', () => { - const playStart = radio.indexOf('export async function playSelectedRadio'); - const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10)); + const playStart = radio.indexOf('async function playSelectedRadio'); + const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' '); const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'"); const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)"); assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying); - assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/); - assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/); - assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); - assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); + assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/); + assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/); + assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); + assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/); }); diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index b007128c8..fa745c1ca 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -41,7 +41,9 @@ import { } from './scopeMask.js'; import { ShareLinkManager } from './sharelink.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'); const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); @@ -210,7 +212,7 @@ test('first run opens with detection on, in every style, using the one tactical // Normal used to start OFF while only CRT/NVG/FLIR auto-applied the preset. // It is now the baseline for all of them, reusing the SAME frozen object, so // "the tactical look" cannot fork into two definitions. - assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical look is still Dense @ 75%'); const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};'); assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/, diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 17d07f04c..2254ea24e 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -33,7 +34,7 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => { test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match( ui, diff --git a/src/scenes/director.js b/src/scenes/director.js index 4109c307f..d56a82e41 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -12,6 +12,8 @@ * State is persisted to localStorage and can be exported/imported as JSON. */ +import { createStateChannel } from '../app/stateChannel.js'; +import { SceneControls } from '../ui/scenes.js'; import * as Cesium from 'cesium'; import { SCENE_RECIPES } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; @@ -21,8 +23,6 @@ import { decodeBloomIntensity, } from '../bloom.js'; -/** @constant {string} Key code used to abort a running scene */ -const ESCAPE_KEY = 'Escape'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; /** @constant {number} Current schema version for project migration */ @@ -32,15 +32,6 @@ const DEFAULT_SHOT_DURATION_SEC = 4; /** @constant {number} Default hold/pause after a shot completes (seconds) */ const DEFAULT_HOLD_SEC = 0.9; -/** - * Clamp a numeric value to the [0, 1] range. - * @param {number} value - * @returns {number} - */ -function clamp01(value) { - return Math.max(0, Math.min(1, value)); -} - /** * Generate a short random identifier with the given prefix. * @param {string} prefix - e.g. 'shot', 'scene' @@ -298,7 +289,6 @@ export class SceneDirector { constructor(viewer, styleManager, dataManager) { this._destroyed = false; this._pendingWork = new Set(); - this._uiRemovers = []; this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; @@ -321,39 +311,16 @@ export class SceneDirector { this._lastRun = null; /** @type {string} JSON string of _lastRun for download */ this._lastRunJson = ''; - this._onKeyDown = this._onKeyDown.bind(this); this._project = this._loadProject(); this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; - // Cache DOM element references for the scene panel UI - this._scenePanel = document.getElementById('scene-panel'); - this._sceneSelect = document.getElementById('scene-select'); - this._sceneNewBtn = document.getElementById('scene-new-btn'); - this._sceneDeleteBtn = document.getElementById('scene-delete-btn'); - this._sceneCaptureBtn = document.getElementById('scene-capture-btn'); - this._sceneUpdateShotBtn = document.getElementById('scene-update-shot-btn'); - this._sceneShotList = document.getElementById('scene-shot-list'); - this._sceneStartBtn = document.getElementById('scene-start-btn'); - this._sceneStopBtn = document.getElementById('scene-stop-btn'); - this._sceneNextBtn = document.getElementById('scene-next-btn'); - this._sceneExportBtn = document.getElementById('scene-export-btn'); - this._sceneImportBtn = document.getElementById('scene-import-btn'); - this._sceneImportFile = document.getElementById('scene-import-file'); - this._sceneDownloadBtn = document.getElementById('scene-download-btn'); - this._sceneStatus = document.getElementById('scene-status'); - this._sceneProgressFill = document.getElementById('scene-progress-fill'); - this._sceneRuntime = document.getElementById('scene-runtime'); - + this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false }; + this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson })); this._initUI(); } - _listen(target, type, listener) { - if (!target) return; - target.addEventListener(type, listener); - this._uiRemovers.push(() => target.removeEventListener(type, listener)); - } _trackWork(promise) { this._pendingWork ||= new Set(); @@ -367,18 +334,16 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; + this._controls?.destroy(); + this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { this.stopScene('Stopped'); this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); - for (const remove of this._uiRemovers || []) remove(); - this._uiRemovers = []; - if (this._sceneShotList) this._sceneShotList.textContent = ''; clearTimeout(this._storageToastTimer); await Promise.allSettled(this._pendingWork || []); clearInterval(this._progressTimer); - document.removeEventListener('keydown', this._onKeyDown); }); return this._destroyPromise; } @@ -429,84 +394,66 @@ export class SceneDirector { } catch { /* toast is best-effort */ } } + /** Immutable playback snapshots and completed editing actions. */ + subscribe(listener, options) { return this._state.subscribe(listener, options); } + + _publish(change) { this._state?.publish(change); } + + _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) { + if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`; + this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index }); + } + /** * Wire up all scene-panel DOM event listeners and render the initial UI state. * Exits silently if the scene-select element is missing (headless/test mode). */ _initUI() { - if (!this._sceneSelect) return; - - this._renderSceneSelect(); - this._renderShotList(); - - this._listen(this._sceneSelect, 'change', () => { - this._selectedSceneId = this._sceneSelect.value; - const scene = this._getSelectedScene(); - this._selectedShotId = scene?.shots[0]?.id || null; - this._renderShotList(); + this._controls = new SceneControls({ + subscribe: (listener) => this.subscribe(listener), + read: () => ({ + scenes: this._project.scenes, + selectedSceneId: this._selectedSceneId, + selectedShotId: this._selectedShotId, + running: this._running, + hasRun: !!this._lastRunJson, + }), + actions: { + selectScene: (id) => { + this._selectedSceneId = id; + this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null; + this._renderShotList(); + }, + selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); }, + renameShot: (sceneId, shotId, title) => { + const { scene, shot } = this._getShot(sceneId, shotId); + if (!shot) return; + shot.title = title.trim() || shot.title; + this._saveProject(); + this._shotOutcome('shot-renamed', scene, shot); + }, + create: (name) => this._createScene(name), + deleteScene: () => this._deleteSelectedScene(), + capture: () => this.captureShot(), + update: () => this.updateSelectedShot(), + start: (id) => this.startScene(id), + stop: (reason) => this.stopScene(reason), + next: () => this.runNextScene(), + export: () => this.exportProject(), + import: (file) => this.importProjectFile(file), + download: () => this.downloadLastRunMetadata(), + load: (sceneId, shotId) => this.loadShot(sceneId, shotId), + deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId), + }, }); - - this._listen(this._sceneNewBtn, 'click', () => this._createScene()); - this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene()); - this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot()); - this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot()); - - this._listen(this._sceneStartBtn, 'click', () => { - this.startScene(this._selectedSceneId); - }); - - this._listen(this._sceneStopBtn, 'click', () => { - this.stopScene('Stopped'); - }); - - this._listen(this._sceneNextBtn, 'click', () => { - this.runNextScene(); - }); - - this._listen(this._sceneExportBtn, 'click', () => { - this.exportProject(); - }); - - this._listen(this._sceneImportBtn, 'click', () => { - this._sceneImportFile?.click(); - }); - - this._listen(this._sceneImportFile, 'change', async () => { - const file = this._sceneImportFile?.files?.[0]; - if (!file) return; - await this.importProjectFile(file); - this._sceneImportFile.value = ''; - }); - - this._listen(this._sceneDownloadBtn, 'click', () => { - this.downloadLastRunMetadata(); - }); - - this._updateStatus('Ready'); - this._setProgress(0); - this._setButtons(false); } /** Rebuild the scene dropdown options and sync the selected value. */ _renderSceneSelect() { - if (!this._sceneSelect) return; - - this._sceneSelect.innerHTML = ''; - for (const scene of this._project.scenes) { - const option = document.createElement('option'); - option.value = scene.id; - option.textContent = scene.title; - this._sceneSelect.appendChild(option); - } - - // Reset selection if the previously selected scene no longer exists if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) { this._selectedSceneId = this._project.scenes[0]?.id || null; } - - if (this._selectedSceneId) { - this._sceneSelect.value = this._selectedSceneId; - } + this._publish({ type: 'scene-options-changed' }); } /** @@ -515,79 +462,11 @@ export class SceneDirector { * LOAD/DEL action buttons. Supports click-to-select and double-click rename. */ _renderShotList() { - if (!this._sceneShotList) return; - const scene = this._getSelectedScene(); - this._sceneShotList.innerHTML = ''; - - if (!scene || scene.shots.length === 0) { - const empty = document.createElement('div'); - empty.className = 'scene-shot-empty'; - empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.'; - this._sceneShotList.appendChild(empty); - return; - } - - // Auto-select first shot if current selection is stale - if (!scene.shots.some((shot) => shot.id === this._selectedShotId)) { + if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) { this._selectedShotId = scene.shots[0].id; } - - for (const shot of scene.shots) { - const row = document.createElement('div'); - row.className = 'scene-shot-row'; - row.classList.toggle('active', shot.id === this._selectedShotId); - - const top = document.createElement('div'); - top.className = 'scene-shot-top'; - - const label = document.createElement('div'); - label.className = 'scene-shot-label'; - label.textContent = shot.title; - label.addEventListener('click', () => { - this._selectedShotId = shot.id; - this._renderShotList(); - }); - label.addEventListener('dblclick', () => { - const nextTitle = window.prompt('Shot title', shot.title); - if (!nextTitle) return; - shot.title = nextTitle.trim() || shot.title; - this._saveProject(); - this._renderShotList(); - }); - - const actions = document.createElement('div'); - actions.className = 'scene-shot-actions'; - - const loadBtn = document.createElement('button'); - loadBtn.className = 'scene-shot-btn'; - loadBtn.textContent = 'LOAD'; - loadBtn.addEventListener('click', () => { - this.loadShot(scene.id, shot.id); - }); - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'scene-shot-btn scene-shot-danger'; - deleteBtn.textContent = 'DEL'; - deleteBtn.addEventListener('click', () => { - this.deleteShot(scene.id, shot.id); - }); - - actions.appendChild(loadBtn); - actions.appendChild(deleteBtn); - top.appendChild(label); - top.appendChild(actions); - - const meta = document.createElement('div'); - meta.className = 'scene-shot-meta'; - const mode = shot.visual?.detection?.mode || 'OFF'; - const style = shot.visual?.style || 'normal'; - meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`; - - row.appendChild(top); - row.appendChild(meta); - this._sceneShotList.appendChild(row); - } + this._publish({ type: 'shots-changed' }); } /** @@ -610,9 +489,8 @@ export class SceneDirector { return { scene, shot }; } - /** Prompt the user for a name and append a new empty scene to the project. */ - _createScene() { - const sceneName = window.prompt('New scene name', `Scene ${this._project.scenes.length + 1}`); + /** Append a named empty scene after the controls accept the creation prompt. */ + _createScene(sceneName) { if (!sceneName) return; const scene = { @@ -625,8 +503,7 @@ export class SceneDirector { this._selectedSceneId = scene.id; this._selectedShotId = null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'scene-created', scene }); } /** Delete the currently selected scene after user confirmation. Resets to defaults if empty. */ @@ -634,9 +511,6 @@ export class SceneDirector { const scene = this._getSelectedScene(); if (!scene) return; - const ok = window.confirm(`Delete scene "${scene.title}" and all shots?`); - if (!ok) return; - this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id); // Restore default recipes if the user deleted all scenes if (!this._project.scenes.length) { @@ -646,8 +520,7 @@ export class SceneDirector { this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'scene-deleted', scene }); } /** @@ -693,7 +566,7 @@ export class SceneDirector { scene.shots.push(shot); this._selectedShotId = shot.id; this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-captured', scene, shot); this._updateStatus(`Captured: ${scene.title} / ${shot.title}`); } @@ -719,7 +592,7 @@ export class SceneDirector { shot.layers = this._captureLayerStates(); this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-updated', scene, shot); this._updateStatus(`Updated: ${scene.title} / ${shot.title}`); } @@ -732,13 +605,11 @@ export class SceneDirector { const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; - const ok = window.confirm(`Delete shot "${shot.title}"?`); - if (!ok) return; - + const index = scene.shots.indexOf(shot); scene.shots = scene.shots.filter((item) => item.id !== shot.id); this._selectedShotId = scene.shots[0]?.id || null; this._saveProject(); - this._renderShotList(); + this._shotOutcome('shot-deleted', scene, shot, index); } /** @@ -789,7 +660,7 @@ export class SceneDirector { if (token.cancelled) return; if (this._loadAbort === controller) this._loadAbort = null; - this._updateStatus(`Loaded: ${scene.title} / ${shot.title}`); + this._shotOutcome('shot-loaded', scene, shot); this._updateRuntime(''); } @@ -893,6 +764,9 @@ export class SceneDirector { return { running: this._running, selectedSceneId: this._selectedSceneId, + selectedShotId: this._selectedShotId, + elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null, + estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null, sceneCount: this._project.scenes.length, }; } @@ -949,7 +823,7 @@ export class SceneDirector { // Transition to running state this._running = true; - document.body.classList.add('scene-playback-mode'); + this._setPlaybackActive(true); this._setButtons(true); this._setProgress(0); @@ -984,7 +858,7 @@ export class SceneDirector { this._startProgressTicker(estimatedDurationSec || 1); this._logEvent('scene_run_start', { count: queue.length }); - document.addEventListener('keydown', this._onKeyDown); + this._setPlaybackKeyboardEnabled(true); try { // Main shot sequencing loop @@ -1106,6 +980,8 @@ export class SceneDirector { link.click(); link.remove(); URL.revokeObjectURL(url); + this._presentation.status = 'Project exported'; + this._publish({ type: 'project-exported', project: this._project }); } /** @@ -1122,8 +998,7 @@ export class SceneDirector { this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._saveProject(); - this._renderSceneSelect(); - this._renderShotList(); + this._publish({ type: 'project-imported', project: this._project }); this._updateStatus(`Imported ${file.name}`); } catch { this._updateStatus('Import failed (invalid JSON)'); @@ -1340,14 +1215,14 @@ export class SceneDirector { _finishRun() { clearInterval(this._progressTimer); this._progressTimer = null; - document.removeEventListener('keydown', this._onKeyDown); + this._setPlaybackKeyboardEnabled(false); // Covers the error path too: a run that threw mid-shot must not leave a // layer transition running against a director that has stopped watching. this._runAbort?.abort(); this._runAbort = null; this.styleManager.setRecordingMode(false); - document.body.classList.remove('scene-playback-mode'); + this._setPlaybackActive(false); this._updateRuntime(''); this._running = false; @@ -1369,21 +1244,16 @@ export class SceneDirector { * Editing controls are disabled during a run; stop is disabled when idle. * @param {boolean} isRunning */ - _setButtons(isRunning) { - if (this._sceneStartBtn) this._sceneStartBtn.disabled = isRunning; - if (this._sceneNextBtn) this._sceneNextBtn.disabled = isRunning; - if (this._sceneSelect) this._sceneSelect.disabled = isRunning; + _setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); } - if (this._sceneNewBtn) this._sceneNewBtn.disabled = isRunning; - if (this._sceneDeleteBtn) this._sceneDeleteBtn.disabled = isRunning; - if (this._sceneCaptureBtn) this._sceneCaptureBtn.disabled = isRunning; - if (this._sceneUpdateShotBtn) this._sceneUpdateShotBtn.disabled = isRunning; - if (this._sceneExportBtn) this._sceneExportBtn.disabled = isRunning; - if (this._sceneImportBtn) this._sceneImportBtn.disabled = isRunning; + _setPlaybackActive(active) { + this._presentation.playbackActive = active; + this._publish({ type: 'playback-presentation' }); + } - if (this._sceneStopBtn) this._sceneStopBtn.disabled = !isRunning; - if (this._sceneDownloadBtn) this._sceneDownloadBtn.disabled = !this._lastRunJson; - if (this._scenePanel) this._scenePanel.classList.toggle('running', isRunning); + _setPlaybackKeyboardEnabled(enabled) { + this._presentation.keyboardEnabled = enabled; + this._publish({ type: 'playback-keyboard' }); } /** @@ -1391,10 +1261,8 @@ export class SceneDirector { * @param {number} progress - Value in [0, 1] */ _setProgress(progress) { - if (!this._sceneProgressFill) return; - const pct = Math.round(clamp01(progress) * 100); - this._sceneProgressFill.style.width = `${pct}%`; - this._sceneProgressFill.textContent = `${pct}%`; + this._presentation.progress = progress; + this._publish({ type: 'progress-changed' }); } /** @@ -1402,7 +1270,8 @@ export class SceneDirector { * @param {string} text */ _updateStatus(text) { - if (this._sceneStatus) this._sceneStatus.textContent = text; + this._presentation.status = text; + this._publish({ type: 'status-changed' }); } /** @@ -1410,9 +1279,8 @@ export class SceneDirector { * @param {string} text - Empty string hides the label */ _updateRuntime(text) { - if (!this._sceneRuntime) return; - this._sceneRuntime.textContent = text; - this._sceneRuntime.classList.toggle('active', !!text); + this._presentation.runtime = text; + this._publish({ type: 'runtime-changed' }); } /** @@ -1422,6 +1290,7 @@ export class SceneDirector { */ _logEvent(type, payload) { if (!this._activeRun) return; + this._publish({ type: 'run-event', event: type, detail: payload || null }); this._activeRun.events.push({ t: new Date().toISOString(), type, @@ -1429,13 +1298,4 @@ export class SceneDirector { }); } - /** - * Global keydown handler registered during a run. Escape cancels the run. - * @param {KeyboardEvent} event - */ - _onKeyDown(event) { - if (event.key === ESCAPE_KEY && this._running) { - this.stopScene('Stopped (Esc)'); - } - } } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index e7e35ff38..5dd5be213 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -483,7 +483,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // another setStack() arrives, and a winning state that omits `mapStack` // never issues one — every normalized scene shot omits it — so a stale // switch would otherwise stand on the globe. - const source = fs.readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); + const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8'); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); assert.ok(method, 'applyVisualState is missing from ui.js'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); @@ -645,3 +645,55 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as assert.equal((await director.startScene('scene-1')).reason, 'destroyed'); await director.destroy(); }); + +test('Scene snapshots are immutable and editing outcomes retain the affected shot and index', async () => { + const { director, restore } = makeDirector(); + try { + director._activeRun = null; + const seen = []; + const unsubscribe = director.subscribe((notification) => seen.push(notification)); + assert.equal(seen[0].initial, true); + assert.equal(seen[0].state.selectedShotId, 'shot-a'); + assert.ok(Object.isFrozen(seen[0].state)); + director.captureShot(); + const captured = seen.find(({ change }) => change?.type === 'shot-captured'); + assert.equal(captured.change.index, 2); + assert.ok(Object.isFrozen(captured.change.shot.camera)); + const id = captured.change.shot.id; + director.deleteShot('scene-1', id); + const deleted = seen.find(({ change }) => change?.type === 'shot-deleted'); + assert.equal(deleted.change.index, 2); + assert.equal(deleted.change.shot.id, id); + assert.equal(captured.state.selectedShotId, id); + unsubscribe(); + const count = seen.length; + director.captureShot(); + assert.equal(seen.length, count); + await director.destroy(); + } finally { restore(); } +}); + +test('Scene load outcomes exclude superseded and disposed completions', async () => { + const { director, styleManager, restore } = makeDirector(); + try { + director._activeRun = null; + const seen = []; + director.subscribe(({ change }) => { if (change) seen.push(change); }); + const pending = []; + styleManager.applyVisualState = () => new Promise((resolve) => pending.push(resolve)); + const old = director.loadShot('scene-1', 'shot-a'); + const current = director.loadShot('scene-1', 'shot-b'); + pending[1](); + await current; + pending[0](); + await old; + assert.deepEqual(seen.filter((change) => change.type === 'shot-loaded').map((change) => change.shot.id), ['shot-b']); + const late = director.loadShot('scene-1', 'shot-a'); + const disposal = director.destroy(); + const count = seen.length; + pending[2](); + await Promise.all([late, disposal]); + director.subscribe(() => assert.fail('disposed director must not notify')); + assert.equal(seen.length, count); + } finally { restore(); } +}); diff --git a/src/scenes/scenePolicy.test.mjs b/src/scenes/scenePolicy.test.mjs index 172ca0b75..fbe240cac 100644 --- a/src/scenes/scenePolicy.test.mjs +++ b/src/scenes/scenePolicy.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; @@ -24,10 +25,10 @@ function sweepLayerParamKeys() { const dataDir = new URL('../data/', import.meta.url); for (const entry of fs.readdirSync(dataDir)) { if (!entry.endsWith('.js')) continue; - const source = fs.readFileSync(new URL(entry, dataDir), 'utf8'); + const source = readLayerSource(new URL(entry, dataDir)); // Every getParams() in this codebase is a plain object return; take the // body up to its closing brace and read the keys it publishes. - const body = source.match(/\n {2}getParams\(\)\s*\{[\s\S]*?\n {2}\},/); + const body = source.match(/\n\s+getParams\(\)\s*\{[\s\S]*?\n\s+\},/); if (!body) continue; // A key always follows `{` or `,` — which matches both the multi-line // returns and the single-line `return { passive: … }` form, while a diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index c655401cf..5defb11a6 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -1,12 +1,17 @@ +import { StyleManager } from './ui/applicationShell.js'; +import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); function sourceBlock(start, end) { + const name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1]; + if (typeof StyleManager.prototype[name] === 'function') return StyleManager.prototype[name].toString(); const startIndex = uiSource.indexOf(start); const endIndex = uiSource.indexOf(end, startIndex + start.length); assert.ok(startIndex >= 0, `missing source block start: ${start}`); @@ -192,7 +197,7 @@ test('a shared view reserves its own camera without cancelling its saved Follow' ); // The shared view's own `cancelPendingSelection` must reach the stamp; other // stamp options may ride alongside it. - assert.match(deferred, /_stampNavigation\(\{ cancelPendingSelection[^)]*\}\)/); + assert.match(deferred, /_stampNavigation\(\{\s*cancelPendingSelection[^)]*\}\)/); }); test('copy timestamp parsing is strict and rejects malformed or future values', () => { @@ -464,19 +469,17 @@ test('explicit Context transitions claim the visual restore lane before transiti assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`); }; - const helper = sourceBlock( - ' _claimContextVisualAuthority() {', - ' async _selectContextMode(mode, {', - ); + const helper = _claimContextVisualAuthority.toString(); assert.ok( - helper.includes("claimRestoreLane?.('visual')"), + helper.includes('this.actions.claimVisualAuthority()'), 'the Context authority helper must claim the visual lane', ); - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); + assert.match(uiSource, /claimVisualAuthority: \(\) =>\s*this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/); + const contextPanel = _initGlobalContextPanel.toString(); for (const [start, end, label] of [ - ["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'], - ["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], + ["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'], + ["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], ]) { const startIndex = contextPanel.indexOf(start); const endIndex = end === 'CONTEXT_PANEL_END' @@ -487,7 +490,7 @@ test('explicit Context transitions claim the visual restore lane before transiti } // The voice/tool facade validates the mode first, then transitions. - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const facade = setContextMode.toString(); assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade'); // Authority is taken per validated branch, never ahead of validation. The // OFF branch is validated by its own guard; the named-mode branch must claim @@ -517,8 +520,8 @@ test('explicit Context transitions claim the visual restore lane before transiti // detection: `_detectionUserOverridden` is what suppresses the military-style // auto-enable for the rest of the session. Contacts entry is not that. test('Context lane claims never set the session detection-override flag', () => { - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const contextPanel = _initGlobalContextPanel.toString(); + const facade = setContextMode.toString(); for (const [block, label] of [ [contextPanel, 'Context panel'], [facade, 'setContextMode facade'], @@ -690,6 +693,11 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.ok(firstAwait > 0); const synchronous = disposal.slice(0, firstAwait); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); + assert.match(synchronous, /this\._visualEffects\.stop\(\)/); + assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/); + assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/); + assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/); + assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/); assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); }); diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js new file mode 100644 index 000000000..9534a5c3c --- /dev/null +++ b/src/sources/live/aircraft.js @@ -0,0 +1,180 @@ +import { + admitRecords, + cleanText, + coordinates, + epoch, + finite, +} from './contract.js'; + +/** Normalize the OpenSky state-vector route, including its regional fallback. */ +export function normalizeOpenSkyAircraft(row) { + if (!Array.isArray(row)) return null; + const id = cleanText(row[0]).toLowerCase(); + if (!id || !coordinates(row[6], row[5])) return null; + return { + id, + reference: id, + latitude: row[6], + longitude: row[5], + callsign: cleanText(row[1]), + originCountry: cleanText(row[2]), + positionTimeMs: epoch(row[3], 1000), + contactTimeMs: epoch(row[4], 1000), + baroAltitudeM: finite(row[7]), + ellipsoidAltitudeM: finite(row[13]), + onGround: row[8] === true, + speedMps: finite(row[9]), + courseDeg: finite(row[10]), + verticalRateMps: finite(row[11]), + category: finite(row[17]), + typeCode: null, + registration: null, + operator: null, + }; +} + +/** Normalize readsb observations. The caller supplies the source snapshot epoch. */ +export function normalizeReadsbAircraft(row, snapshotTimeMs) { + const id = cleanText(row?.hex).toLowerCase(); + const latitude = finite(row?.lat), + longitude = finite(row?.lon); + if (!id || !coordinates(latitude, longitude)) return null; + const baroFt = finite(row.alt_baro), + geoFt = finite(row.alt_geom); + const speedKts = finite(row.gs), + rateFtMin = finite(row.baro_rate); + const seen = finite(row.seen), + seenPos = finite(row.seen_pos); + return { + id, + reference: id, + latitude, + longitude, + callsign: cleanText(row.flight), + originCountry: null, + positionTimeMs: + snapshotTimeMs == null ? null : snapshotTimeMs - (seenPos ?? 0) * 1000, + contactTimeMs: + snapshotTimeMs == null || seen == null + ? null + : snapshotTimeMs - seen * 1000, + baroAltitudeM: baroFt == null ? null : baroFt * 0.3048, + ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048, + onGround: cleanText(row.alt_baro).toLowerCase() === 'ground', + speedMps: speedKts == null ? null : speedKts * 0.514444, + courseDeg: finite(row.track), + verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508, + category: row.category ?? null, + typeCode: cleanText(row.t), + registration: cleanText(row.r), + operator: cleanText(row.ownOp), + }; +} + +export function openSkySnapshot( + payload, + { + source = 'OpenSky Network', + coverage = 'worldwide upstream snapshot', + now = Date.now(), + stale = false, + } = {}, +) { + const admitted = admitRecords( + payload?.states, + normalizeOpenSkyAircraft, + 'OpenSky', + ); + const observedAtMs = epoch(payload?.time, 1000); + const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs); + return { + ...admitted, + source, + coverage, + observedAtMs, + ageMs, + stale: stale || (ageMs != null && ageMs > 120000), + freshness: + observedAtMs == null + ? 'unknown' + : stale || ageMs > 120000 + ? 'stale' + : 'current', + }; +} + +export function readsbSnapshot( + payload, + { + observedAtMs, + source = 'adsb.lol', + coverage = 'military upstream snapshot', + now = Date.now(), + stale = false, + } = {}, +) { + const admitted = admitRecords( + payload?.ac, + (row) => normalizeReadsbAircraft(row, observedAtMs), + 'adsb.lol', + ); + const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs); + return { + ...admitted, + source, + coverage, + observedAtMs, + ageMs, + stale, + freshness: observedAtMs == null ? 'unknown' : stale ? 'stale' : 'current', + }; +} + +/** Track altitude remains barometric; renderers choose a visual ground fallback. */ +export function normalizeAircraftTrack( + rows, + { baseTimeMs = 0, readsb = false } = {}, +) { + if (!Array.isArray(rows)) return []; + return rows.flatMap((row) => { + if (!Array.isArray(row)) return []; + const latitude = finite(row[1]), + longitude = finite(row[2]); + const offset = finite(row[0]); + const observedAtMs = + offset == null ? null : epoch(baseTimeMs + offset * 1000); + if ( + !coordinates(latitude, longitude) || + observedAtMs == null || + observedAtMs <= 0 + ) + return []; + const altitude = finite(row[3]); + return [ + { + latitude, + longitude, + observedAtMs, + baroAltitudeM: + altitude == null ? null : altitude * (readsb ? 0.3048 : 1), + ellipsoidAltitudeM: null, + onGround: readsb + ? cleanText(row[3]).toLowerCase() === 'ground' + : row[5] === true, + }, + ]; + }); +} + +/** Known source identities are useful for classification even without positions. */ +export function readsbIdentities(payload) { + const admitted = admitRecords( + payload?.ac, + (row) => { + const id = cleanText(row?.hex).toLowerCase(); + return id ? { id } : null; + }, + 'aircraft identities', + ); + return admitted.records.map((record) => record.id); +} diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js new file mode 100644 index 000000000..2415ff43c --- /dev/null +++ b/src/sources/live/contract.js @@ -0,0 +1,125 @@ +/** + * Browser live-source contract. Coordinates are WGS84 degrees; lengths and + * velocities are metres and metres/second; times are Unix milliseconds. + * Barometric altitude and ellipsoid altitude are separate, nullable observations. + * A missing timestamp is unknown, never the time the response was received. + * + * Sources expose getSnapshot(query, { signal }) and optionally + * getTrack(reference, { signal }). References are opaque to consumers. + * Snapshots describe coverage and completeness independently of freshness. + * Records contain observation data only, with no scene objects or transport data. + */ +export class LiveSourceError extends Error { + constructor( + code, + message, + { status = null, retryAfterMs = 20000, source = null } = {}, + ) { + super(message); + this.name = 'LiveSourceError'; + this.code = code; + this.status = status; + this.retryAfterMs = retryAfterMs; + this.source = source; + } +} + +export function finite(value) { + if (value == null || value === '' || typeof value === 'boolean') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +export function epoch(value, scale = 1) { + const number = finite(value); + const milliseconds = number == null ? null : number * scale; + return milliseconds != null && + milliseconds > 0 && + milliseconds <= 8640000000000000 + ? milliseconds + : null; +} + +export function cleanText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function coordinates(latitude, longitude) { + return ( + Number.isFinite(latitude) && + Math.abs(latitude) <= 90 && + Number.isFinite(longitude) && + Math.abs(longitude) <= 180 + ); +} + +/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */ +export function admitRecords(rows, normalize, label) { + if (!Array.isArray(rows)) + throw new LiveSourceError('malformed', `Malformed ${label} response`); + const records = []; + const ids = new Set(); + for (const row of rows) { + const record = normalize(row); + if (record && !ids.has(record.id)) { + records.push(record); + ids.add(record.id); + } + } + if (rows.length && !records.length) { + throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`); + } + return { + records, + complete: records.length === rows.length, + rejectedCount: rows.length - records.length, + }; +} + +/** Cancellation is checked after body parsing even when a transport ignores it. */ +export async function readResponse( + fetchImpl, + url, + { signal, ...init } = {}, + source = 'Live source', +) { + signal?.throwIfAborted(); + let response; + try { + response = await fetchImpl(url, { ...init, signal }); + signal?.throwIfAborted(); + } catch (error) { + signal?.throwIfAborted(); + if (error?.name === 'AbortError') throw error; + throw new LiveSourceError('unavailable', `${source} network error`, { + source, + }); + } + let payload = null; + try { + payload = await response.json(); + } catch { + /* classified below */ + } + signal?.throwIfAborted(); + return { response, payload }; +} + +export function httpError(response, source) { + const status = response.status; + const code = + status === 429 + ? 'limited' + : status === 401 || status === 403 + ? 'denied' + : 'unavailable'; + return new LiveSourceError( + code, + status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`, + { + status, + source, + retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000, + }, + ); +} diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs new file mode 100644 index 000000000..671a2e5e4 --- /dev/null +++ b/src/sources/live/contract.test.mjs @@ -0,0 +1,315 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createOpenSkySource, + createAdsbLolSource, + createAisStreamSource, + normalizeReadsbAircraft, + normalizeVesselObservation, + openSkySnapshot, + readsbSnapshot, +} from './index.js'; + +const now = 1800000000000; +const aircraft = [ + 'AbC123', + 'TEST', + 'Example', + now / 1000 - 15, + now / 1000 - 2, + -97, + 30, + 1000, + false, + 100, + 250, + -2, + null, + 1040, +]; +const response = (payload, headers = {}, status = 200) => + Response.json(payload, { headers, status }); + +test('civil observations retain identity, separate altitude datums and source epochs', () => { + const snapshot = openSkySnapshot( + { time: now / 1000 - 130, states: [aircraft] }, + { now, source: 'Test source', coverage: 'regional' }, + ); + const row = snapshot.records[0]; + assert.equal(row.id, 'abc123'); + assert.equal(row.baroAltitudeM, 1000); + assert.equal(row.ellipsoidAltitudeM, 1040); + assert.equal(row.positionTimeMs, now - 15000); + assert.equal(row.contactTimeMs, now - 2000); + assert.equal(snapshot.ageMs, 130000); + assert.equal(snapshot.freshness, 'stale'); + assert.equal(snapshot.coverage, 'regional'); + const missing = openSkySnapshot( + { states: [[...aircraft.slice(0, 3), null, null, ...aircraft.slice(5)]] }, + { now }, + ); + assert.equal(missing.observedAtMs, null); + assert.equal(missing.freshness, 'unknown'); + assert.equal(missing.records[0].positionTimeMs, null); +}); + +test('mixed-invalid and duplicate admissions are explicitly incomplete; all-invalid rejects', () => { + const invalid = [...aircraft]; + invalid[5] = 181; + const partial = openSkySnapshot({ states: [aircraft, invalid, aircraft] }); + assert.equal(partial.records.length, 1); + assert.equal(partial.complete, false); + assert.equal(partial.rejectedCount, 2); + assert.throws(() => openSkySnapshot({ states: [invalid] }), { + code: 'malformed', + }); + assert.throws(() => openSkySnapshot({}), { code: 'malformed' }); + assert.equal(openSkySnapshot({ states: [] }).complete, true); +}); + +test('readsb units and position/contact ages are normalized once against the source epoch', () => { + const row = normalizeReadsbAircraft( + { + hex: 'abc123', + lat: '30', + lon: '-97', + alt_baro: 10000, + alt_geom: 10100, + gs: 120, + baro_rate: -600, + seen: 2, + seen_pos: 12, + }, + now, + ); + assert.equal(row.baroAltitudeM, 3048); + assert.equal(row.ellipsoidAltitudeM, 3078.48); + assert.equal(row.speedMps, 120 * 0.514444); + assert.equal(row.verticalRateMps, -600 * 0.00508); + assert.equal(row.positionTimeMs, now - 12000); + assert.equal(row.contactTimeMs, now - 2000); + const ground = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97, alt_baro: 'ground' }, + null, + ); + assert.equal(ground.onGround, true); + assert.equal(ground.baroAltitudeM, null); + assert.equal(ground.positionTimeMs, null); + assert.equal( + readsbSnapshot({ ac: [] }, { observedAtMs: null }).freshness, + 'unknown', + ); +}); + +test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => { + const row = normalizeVesselObservation( + { + mmsi: '123456789', + lat: 30, + lon: -97, + speed: 10, + heading: 45, + course: 60, + last_position_epoch: now / 1000, + extra: 'discard', + }, + 'opaque-reference', + ); + assert.equal(row.id, '123456789'); + assert.equal(row.reference, 'opaque-reference'); + assert.equal(row.headingDeg, 45); + assert.equal(row.courseDeg, 60); + assert.equal(row.speedMps, 10 * 0.514444); + assert.equal(row.altitudeDatum, 'sea-surface'); + assert.equal(row.observedAtMs, now); + assert.equal('extra' in row, false); + assert.equal( + normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), + null, + ); +}); + +test('construction is inert; adapters preserve routes, viewport query, cache epoch and history datum', async () => { + const requests = []; + const fetchImpl = async (url, init) => { + requests.push({ url, init }); + if (url.startsWith('/api/opensky?')) + return response( + { time: now / 1000, states: [aircraft] }, + { + 'x-flight-source': 'adsb.lol', + 'x-flight-coverage': '250 nm regional', + }, + ); + if (url.startsWith('/api/opensky-track')) + return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] }); + if (url === '/api/adsblol/mil') + return response( + { ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, + { 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' }, + ); + return response({ + timestamp: now / 1000 - 60, + trace: [[10, 30, -97, 1000]], + }); + }; + const civil = createOpenSkySource({ fetchImpl, now: () => now }); + const military = createAdsbLolSource({ fetchImpl, now: () => now }); + assert.equal(requests.length, 0); + const signal = new AbortController().signal; + const snapshot = await civil.getSnapshot( + { latitude: 30.123456, longitude: -97 }, + { signal }, + ); + assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000'); + assert.equal(requests[0].init.signal, signal); + assert.equal(snapshot.source, 'adsb.lol'); + assert.equal(snapshot.coverage, '250 nm regional'); + assert.equal((await civil.getTrack('abc123')).records[0].baroAltitudeM, 1000); + const mil = await military.getSnapshot(); + assert.equal(mil.observedAtMs, now - 60000); + assert.equal(mil.records[0].positionTimeMs, now - 75000); + assert.equal(mil.stale, true); + const track = await military.getTrack('abc123'); + assert.equal(track.complete, false); + assert.equal(track.records[0].observedAtMs, now - 50000); + assert.equal(track.records[0].baroAltitudeM, 304.8); + assert.equal(track.records[0].ellipsoidAltitudeM, null); +}); + +test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { + let release; + const controller = new AbortController(); + const source = createOpenSkySource({ + fetchImpl: async () => ({ + ok: true, + json: () => + new Promise((resolve) => { + release = resolve; + }), + }), + }); + const pending = source.getSnapshot({}, { signal: controller.signal }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + release({ states: [aircraft] }); + await assert.rejects(pending, { name: 'AbortError' }); +}); + +test('denials and outages never start another source and do not echo arbitrary response bodies', async () => { + for (const status of [401, 403, 429, 502]) { + let calls = 0; + const source = createOpenSkySource({ + fetchImpl: async () => { + calls++; + return response({ error: 'sensitive upstream detail' }, {}, status); + }, + }); + await assert.rejects(source.getSnapshot(), (error) => { + assert.equal(error.status, status); + assert.equal(error.message.includes('sensitive'), false); + assert.ok(error.retryAfterMs >= 20000); + return true; + }); + assert.equal(calls, 1); + } +}); + +test('AIS reports limited received coverage and keeps connection state separate from positions', async () => { + const source = createAisStreamSource({ + origin: () => 'http://example.test', + fetchImpl: async (url) => { + if (url.includes('/track?')) + return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] }); + assert.equal(url, 'http://example.test/api/ais-live?maxRows=500'); + return response({ + status: 'reconnecting', + refreshing: true, + rows: [ + { + mmsi: '123456789', + lat: 30, + lon: -97, + last_position_UTC: new Date(now).toISOString(), + }, + ], + newestPositionAt: new Date(now).toISOString(), + }); + }, + }); + const snapshot = await source.getSnapshot({ maxRows: 500 }); + assert.equal(snapshot.complete, false); + assert.equal(snapshot.stale, true); + assert.equal(snapshot.observedAtMs, now); + assert.equal(snapshot.transportStatus, 'reconnecting'); + assert.equal( + (await source.getTrack('123456789')).records[0].observedAtMs, + now, + ); +}); + +test('a malformed vessel row cannot prevent admission of valid positions', async () => { + const source = createAisStreamSource({ + fetchImpl: async () => + response({ + status: 'live', + rows: [null, { mmsi: '123456789', lat: 30, lon: -97 }], + }), + }); + const snapshot = await source.getSnapshot(); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.rejectedCount, 1); + assert.equal(snapshot.complete, false); +}); + +test('out-of-range source epochs do not reach Date or globe time constructors', () => { + const row = [...aircraft]; + row[3] = 1e100; + row[4] = 1e100; + const snapshot = openSkySnapshot({ time: 1e100, states: [row] }); + assert.equal(snapshot.observedAtMs, null); + assert.equal(snapshot.records[0].positionTimeMs, null); + assert.equal(snapshot.records[0].contactTimeMs, null); +}); + +test('classification identities include positionless aircraft without admitting them to rendering', async () => { + const source = createAdsbLolSource({ + fetchImpl: async () => + response({ + ac: [ + { hex: ' ABC123 ' }, + { hex: 'abc123' }, + { hex: 'def456', lat: 30, lon: -97 }, + ], + }), + }); + assert.deepEqual(await source.getIdentities(), ['abc123', 'def456']); + const snapshot = await source.getSnapshot(); + assert.deepEqual( + snapshot.records.map((record) => record.id), + ['def456'], + ); + assert.equal(snapshot.complete, false); +}); + +test('identity lookup validates its response and honors body-parse cancellation', async () => { + const malformed = createAdsbLolSource({ + fetchImpl: async () => response({ ac: [{}] }), + }); + await assert.rejects(malformed.getIdentities(), /Malformed/); + const abort = new AbortController(); + const source = createAdsbLolSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Headers(), + async json() { + abort.abort(); + return { ac: [{ hex: 'abc123' }] }; + }, + }), + }); + await assert.rejects(source.getIdentities({}, { signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/sources/live/index.js b/src/sources/live/index.js new file mode 100644 index 000000000..73f492500 --- /dev/null +++ b/src/sources/live/index.js @@ -0,0 +1,19 @@ +export { LiveSourceError } from './contract.js'; +export { + normalizeOpenSkyAircraft, + normalizeReadsbAircraft, + normalizeAircraftTrack, + openSkySnapshot, + readsbSnapshot, + readsbIdentities, +} from './aircraft.js'; +export { + normalizeVesselObservation, + normalizeVesselTrack, + vesselSnapshot, +} from './vessels.js'; +export { + createOpenSkySource, + createAdsbLolSource, + createAisStreamSource, +} from './standalone.js'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js new file mode 100644 index 000000000..ecee2a405 --- /dev/null +++ b/src/sources/live/standalone.js @@ -0,0 +1,212 @@ +import { + epoch, + finite, + httpError, + LiveSourceError, + readResponse, +} from './contract.js'; +import { + normalizeAircraftTrack, + openSkySnapshot, + readsbSnapshot, + readsbIdentities, +} from './aircraft.js'; +import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; + +const defaultFetch = (...args) => globalThis.fetch(...args); +const header = (response, name) => response.headers?.get?.(name); + +function openSkyError(response) { + const error = httpError(response, 'OpenSky'); + const mode = String( + header(response, 'x-opensky-auth-mode-used') || + header(response, 'x-opensky-auth') || + '', + ).toLowerCase(); + const reason = String( + header(response, 'x-opensky-auth-reason') || '', + ).toLowerCase(); + if (response.status === 429 && (!mode || mode === 'anon')) + error.message = 'OpenSky rate limited (anonymous)'; + if (response.status === 401 || response.status === 403) { + const reasons = { + oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', + oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', + basic_invalid_credentials: 'OpenSky username/password rejected', + missing_basic_creds: 'OpenSky auth missing', + missing_oauth_and_basic_creds: 'OpenSky auth missing', + auth_required: 'OpenSky auth required', + forced_anonymous: 'OpenSky auth required', + }; + error.message = + reasons[reason] || + (/^(oauth_|basic_)/.test(reason) + ? 'OpenSky auth invalid' + : mode === 'anon' + ? 'OpenSky auth required' + : 'OpenSky auth failed'); + } + return error; +} + +/** Existing same-origin aircraft routes; no request starts during construction. */ +export function createOpenSkySource({ + fetchImpl = defaultFetch, + now = () => Date.now(), +} = {}) { + return { + label: 'OpenSky Network', + async getSnapshot(query = {}, { signal } = {}) { + const params = new URLSearchParams(); + if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { + params.set('lat', query.latitude.toFixed(4)); + params.set('lon', query.longitude.toFixed(4)); + } + const { response, payload } = await readResponse( + fetchImpl, + `/api/opensky${params.size ? '?' + params : ''}`, + { signal }, + 'OpenSky', + ); + if (!response.ok) throw openSkyError(response); + return { + ...openSkySnapshot(payload, { + source: header(response, 'x-flight-source') || 'OpenSky Network', + coverage: + header(response, 'x-flight-coverage') || + 'worldwide upstream snapshot', + now: now(), + }), + status: response.status, + }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/opensky-track?icao24=' + encodeURIComponent(reference), + { signal }, + 'OpenSky', + ); + if (!response.ok) throw httpError(response, 'OpenSky'); + return { + records: normalizeAircraftTrack(payload?.path), + complete: false, + }; + }, + async getEnrichment(query, { signal } = {}) { + if (!['type', 'route'].includes(query.kind)) + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + const { response, payload } = await readResponse( + fetchImpl, + `/api/adsbdb/${query.kind}/${encodeURIComponent(query.id)}`, + { signal }, + 'adsbdb', + ); + if (!response.ok) throw httpError(response, 'adsbdb'); + return payload; + }, + }; +} + +export function createAdsbLolSource({ + fetchImpl = defaultFetch, + now = () => Date.now(), +} = {}) { + return { + label: 'adsb.lol', + async getIdentities(_query = {}, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/mil', + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + return readsbIdentities(payload); + }, + async getSnapshot(_query = {}, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/mil', + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + const age = finite(header(response, 'x-ads-b-cache-age-ms')); + return { + ...readsbSnapshot(payload, { + observedAtMs: now() - (age != null && age > 0 ? age : 0), + now: now(), + stale: header(response, 'x-ads-b-cache') === 'STALE', + }), + status: response.status, + }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/trace?hex=' + encodeURIComponent(reference), + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + const baseTimeMs = epoch(payload?.timestamp, 1000); + return { + records: + baseTimeMs == null + ? [] + : normalizeAircraftTrack(payload?.trace, { + baseTimeMs, + readsb: true, + }), + complete: false, + }; + }, + }; +} + +export function createAisStreamSource({ + fetchImpl = defaultFetch, + apiUrl = '/api/ais-live', + origin = () => globalThis.location?.origin || 'http://localhost', +} = {}) { + return { + label: 'AISStream', + async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { + const url = new URL(apiUrl, origin()); + url.searchParams.set('maxRows', String(maxRows)); + const { response, payload } = await readResponse( + fetchImpl, + url.toString(), + { signal, cache: 'no-store' }, + 'AIS live', + ); + if (!response.ok) { + const error = httpError(response, 'AIS live'); + const reasons = { + 'missing-key': 'AISSTREAM_API_KEY not set', + 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', + unsupported: 'live feed unsupported', + error: 'feed down', + closed: 'feed disconnected', + }; + error.message = reasons[payload?.status] || error.message; + throw error; + } + return { ...vesselSnapshot(payload), status: response.status }; + }, + async getTrack(reference, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/ais-live/track?mmsi=' + encodeURIComponent(reference), + { signal }, + 'AIS live', + ); + if (!response.ok) throw httpError(response, 'AIS live'); + return { + records: normalizeVesselTrack(payload?.samples), + complete: false, + }; + }, + }; +} diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js new file mode 100644 index 000000000..affdcce7a --- /dev/null +++ b/src/sources/live/vessels.js @@ -0,0 +1,96 @@ +import { coordinates, epoch, finite, LiveSourceError } from './contract.js'; + +/** AIS positions are sea-surface observations; heading and course are distinct. */ +export function normalizeVesselObservation(row, reference = null) { + const id = String(row?.mmsi || row?.input_identifier || '').trim(); + const latitude = finite(row?.lat), + longitude = finite(row?.lon); + if (!id || !coordinates(latitude, longitude)) return null; + const speedKts = finite(row.speed); + return { + id, + reference: reference ?? id, + latitude, + longitude, + name: String(row.name || row.input_name || id), + imo: String(row.imo || ''), + type: String(row.type_specific || row.type || ''), + destination: String(row.destination || ''), + speedMps: speedKts == null ? null : speedKts * 0.514444, + courseDeg: finite(row.course), + headingDeg: finite(row.heading), + observedAtMs: + epoch(row.last_position_epoch, 1000) ?? + epoch(Date.parse(row.last_position_UTC)), + altitudeDatum: 'sea-surface', + }; +} + +export function vesselSnapshot( + payload, + { + source = 'AISStream', + coverage = 'received AIS positions', + referenceFor = (row) => String(row.mmsi || row.input_identifier || ''), + } = {}, +) { + if (!Array.isArray(payload?.rows)) + throw new LiveSourceError('malformed', 'Malformed vessel response'); + const rows = payload.rows; + const records = [], + ids = new Set(); + for (const row of rows) { + const record = normalizeVesselObservation(row); + if (record) record.reference = referenceFor(row); + if (record && !ids.has(record.id)) { + records.push(record); + ids.add(record.id); + } + } + const observedAtMs = + epoch(payload?.newestPositionAt) ?? + epoch(Date.parse(payload?.newestPositionAt)) ?? + records.reduce( + (newest, row) => Math.max(newest || 0, row.observedAtMs || 0) || null, + null, + ); + return { + records, + source, + coverage, + complete: records.length === rows.length && !payload?.refreshing, + rejectedCount: rows.length - records.length, + observedAtMs, + freshness: payload?.refreshing + ? 'stale' + : observedAtMs == null + ? 'unknown' + : 'current', + stale: Boolean(payload?.refreshing), + // Connection state is distinct from receipt of a usable position. + transportStatus: + typeof payload?.status === 'string' ? payload.status : null, + lastMessageAt: payload?.lastMessageAt ?? null, + nextAttemptAt: finite(payload?.nextAttemptAt), + silentForMs: finite(payload?.silentForMs), + reconnectAttempt: finite(payload?.reconnectAttempt), + rawRowCount: rows.length, + }; +} + +export function normalizeVesselTrack(samples) { + if (!Array.isArray(samples)) return []; + return samples.flatMap((sample) => { + const latitude = finite(sample?.lat), + longitude = finite(sample?.lon); + if (!coordinates(latitude, longitude)) return []; + return [ + { + latitude, + longitude, + observedAtMs: epoch(sample?.t, 1000), + altitudeDatum: 'sea-surface', + }, + ]; + }); +} diff --git a/src/splitFlap.js b/src/splitFlap.js index c57da5f23..17d677999 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -107,7 +107,8 @@ function positiveNumber(value, fallback) { } function nowMs() { - return typeof performance !== 'undefined' && typeof performance.now === 'function' + return typeof performance !== 'undefined' && + typeof performance.now === 'function' ? performance.now() : Date.now(); } @@ -235,7 +236,9 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) { const turnRatio = Number.isFinite(Number(options.turnRatio)) ? Number(options.turnRatio) : FLAP_TURN_RATIO; - const elapsed = Number.isFinite(Number(elapsedMs)) ? Math.max(0, Number(elapsedMs)) : 0; + const elapsed = Number.isFinite(Number(elapsedMs)) + ? Math.max(0, Number(elapsedMs)) + : 0; const turn = charMs * turnRatio; let out = ''; for (const cell of plan?.cells || []) { @@ -246,9 +249,11 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) { /** Whether the viewer asked for reduced motion. */ function prefersReducedMotion() { - return typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); } /** @@ -274,7 +279,11 @@ function isVisible(element) { if (element.getClientRects().length === 0) return false; const view = element.ownerDocument?.defaultView; if (typeof view?.getComputedStyle !== 'function') return true; - for (let node = element; node && node.nodeType === 1; node = node.parentElement) { + for ( + let node = element; + node && node.nodeType === 1; + node = node.parentElement + ) { const style = view.getComputedStyle(node); if (style.visibility === 'hidden' || style.display === 'none') return false; if (Number(style.opacity) === 0) return false; @@ -301,10 +310,12 @@ function ensureHost(element) { // nothing changed. const text = element.firstElementChild; const cells = text?.nextElementSibling; - if (text?.classList?.contains(TEXT_CLASS) - && text.firstChild?.nodeType === 3 - && cells?.classList?.contains(CELLS_CLASS) - && !cells.nextElementSibling) { + if ( + text?.classList?.contains(TEXT_CLASS) && + text.firstChild?.nodeType === 3 && + cells?.classList?.contains(CELLS_CLASS) && + !cells.nextElementSibling + ) { return { text, cells }; } @@ -363,7 +374,8 @@ function easeWidth(element, fromWidth, toWidth, durationMs) { element.style.width = `${toWidth}px`; const finish = (event) => { - if (event && (event.target !== element || event.propertyName !== 'width')) return; + if (event && (event.target !== element || event.propertyName !== 'width')) + return; cancelWidthEase(element); clearSizing(element); }; @@ -449,16 +461,19 @@ export function setSplitFlapText(element, text, options = {}) { // previous label's glyphs, and those are what must flap away. const state = flapStates.get(element); const displayed = state - ? visibleGlyphs(state.plan, nowMs() - state.startedAt, { charMs: state.charMs }) + ? visibleGlyphs(state.plan, nowMs() - state.startedAt, { + charMs: state.charMs, + }) : settled; clearFlapTimer(element); const beforeWidth = measureWidth(element); - const animate = SPLIT_FLAP_ENABLED - && options.immediate !== true - && !prefersReducedMotion() - && isVisible(element); + const animate = + SPLIT_FLAP_ENABLED && + options.immediate !== true && + !prefersReducedMotion() && + isVisible(element); const plan = animate ? planSplitFlap(displayed, next, options) : null; @@ -519,3 +534,13 @@ export function setSplitFlapText(element, text, options = {}) { }); return true; } + +/** Stop decoration without replacing the label's permanent accessible text. */ +export function disposeSplitFlap(element) { + if (!element) return; + clearFlapTimer(element); + cancelWidthEase(element); + clearSizing(element); + const cells = element.querySelector?.('.gev-flap-cells'); + if (cells) rest(element, { cells }); +} diff --git a/src/standalone/ui.js b/src/standalone/ui.js new file mode 100644 index 000000000..7f46543bb --- /dev/null +++ b/src/standalone/ui.js @@ -0,0 +1,153 @@ +/** Compose UI controls with the application's existing engines and layer instances. */ +import { StyleManager as ApplicationShell } from '../ui/applicationShell.js'; +import { LocationSearch } from '../ui/location.js'; +import { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, +} from '../locations.js'; +import { interruptCameraMotion } from '../cameraVerbs.js'; +import { IntelHUD } from '../hud.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { OrbitController } from '../orbit.js'; +import { + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, +} from '../celestialRing.js'; +import { + destroyTrackedReadout, + initTrackedReadout, +} from '../data/trackedReadout.js'; +import { + destroyWorldOverlay, + initWorldOverlay, +} from '../overlays/worldOverlay.js'; +import { + destroyDetection, + initDetection, + cycleMode as cycleDetectionMode, + getDetectionDiagnostics as readDetectionDiagnostics, + getDetectionTuning, + getMode as getDetectionMode, + setMode as setDetectionModeByLabel, + suspendDetection, + resumeDetection, + setDetectionStyle, + setDetectionTuning, +} from '../data/detection.js'; +import trafficLayer from '../data/traffic.js'; +import flightsLayer from '../data/flights.js'; +import militaryFlightsLayer from '../data/militaryFlights.js'; +import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js'; +import satellitesLayer from '../data/satellites.js'; +import cctvLayer from '../data/cctv.js'; +import radioLayer from '../data/radio.js'; +import bikeshareLayer from '../data/bikeshare.js'; +import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; +import militaryAwarenessLayer from '../data/militaryAwareness.js'; +import militaryInstallationsLayer from '../data/militaryInstallations.js'; +import rocketLaunchesLayer from '../data/rocketLaunches.js'; +import { + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, +} from '../data/groundFloor.js'; +import { sampleMeshFloorCells } from '../data/meshFloorSampler.js'; +import { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, +} from '../renderGovernor.js'; +import { + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, +} from '../scopeMask.js'; +import { + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, +} from '../data/regionalBrief.js'; + +export class StyleManager extends ApplicationShell { + constructor(viewer, options = {}) { + super(viewer, { + ...options, + services: { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, + interruptCameraMotion, + IntelHUD, + ShareLinkManager, + OrbitController, + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, + destroyTrackedReadout, + initTrackedReadout, + destroyWorldOverlay, + initWorldOverlay, + destroyDetection, + initDetection, + cycleDetectionMode, + readDetectionDiagnostics, + getDetectionTuning, + getDetectionMode, + setDetectionModeByLabel, + suspendDetection, + resumeDetection, + setDetectionStyle, + setDetectionTuning, + trafficLayer, + flightsLayer, + militaryFlightsLayer, + isTr3b, + toggleTr3b, + satellitesLayer, + cctvLayer, + radioLayer, + bikeshareLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + militaryInstallationsLayer, + rocketLaunchesLayer, + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, + sampleMeshFloorCells, + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, + LocationSearch, + }, + }); + } +} diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 21a3cd190..8ab7eb914 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -13,7 +13,7 @@ export const nightVisionShader = { name: 'surveillance', uniforms: { gain: { default: 0.55, min: 0, max: 1, label: 'Gain' }, - bloom: { default: 0.30, min: 0, max: 1, label: 'Bloom' }, + bloom: { default: 0.3, min: 0, max: 1, label: 'Bloom' }, scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' }, pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' }, }, diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs new file mode 100644 index 000000000..4d07e0407 --- /dev/null +++ b/src/testSupport/readLayerSource.mjs @@ -0,0 +1,53 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Read actual component owners for structural regression assertions. */ +export function readLayerSource(file) { + const path = file instanceof URL ? fileURLToPath(file) : file; + if ( + ![ + 'flights.js', + 'militaryFlights.js', + 'aisLiveVessels.js', + 'firmsHeatmap.js', + 'satellites.js', + 'rocketLaunches.js', + 'militaryInstallations.js', + 'militaryAwareness.js', + 'traffic.js', + 'bikeshare.js', + 'cctv.js', + ].includes(basename(path)) + ) + return readFileSync(path, 'utf8'); + const directory = join( + dirname(path), + basename(path) === 'flights.js' + ? '../layers/flights' + : basename(path) === 'militaryFlights.js' + ? '../layers/military' + : basename(path) === 'firmsHeatmap.js' + ? '../layers/firms' + : basename(path) === 'satellites.js' + ? '../layers/satellites' + : basename(path) === 'rocketLaunches.js' + ? '../layers/launches' + : basename(path) === 'militaryInstallations.js' + ? '../layers/installations' + : basename(path) === 'militaryAwareness.js' + ? '../layers/awareness' + : basename(path) === 'traffic.js' + ? '../layers/traffic' + : basename(path) === 'bikeshare.js' + ? '../layers/bikeshare' + : basename(path) === 'cctv.js' + ? '../layers/cctv' + : '../layers/vessels', + ); + return readdirSync(directory) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => readFileSync(join(directory, name), 'utf8')) + .join('\n'); +} diff --git a/src/testSupport/readStylesheet.mjs b/src/testSupport/readStylesheet.mjs new file mode 100644 index 000000000..6ce02400e --- /dev/null +++ b/src/testSupport/readStylesheet.mjs @@ -0,0 +1,16 @@ +/** Resolve the actual ordered local CSS imports used by the application entry. */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +export function readStylesheet(file, ancestors = new Set()) { + const resolved = path.resolve( + file instanceof URL ? fileURLToPath(file) : file, + ); + if (ancestors.has(resolved)) throw new Error('Circular stylesheet import'); + const next = new Set(ancestors).add(resolved); + return readFileSync(resolved, 'utf8').replace( + /@import ['"]([^'"]+)['"];\s*/g, + (_, imported) => + readStylesheet(path.resolve(path.dirname(resolved), imported), next), + ); +} diff --git a/src/ui.js b/src/ui.js index e614eae70..ada8689fe 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,9853 +1,2 @@ -import { bindDisplayControls } from './ui/displayControls.js'; -import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js'; -import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; -import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; -import * as Cesium from 'cesium'; -import { retroShader } from './styles/retro.js'; -import { animeShader } from './styles/anime.js'; -import { noirShader } from './styles/noir.js'; -import { snowShader } from './styles/snow.js'; -import { nightVisionShader } from './styles/surveillance.js'; -import { thermalShader } from './styles/thermal.js'; -import { - BLOOM_INTENSITY_DEFAULT, - BLOOM_SCALE_VERSION, - bloomStrengthFromIntensity, - clampBloomIntensity, - decodeBloomIntensity, -} from './bloom.js'; -import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; -import { locationMiniStatus } from './locationStatus.js'; -import { interruptCameraMotion } from './cameraVerbs.js'; -import { - aircraftTrackingTarget, - enterCockpitWithTracking, -} from './cockpitTracking.js'; -import { IntelHUD } from './hud.js'; -import { ShareLinkManager } from './sharelink.js'; -import { - isExplicitLayerStateOrigin, - LayerStateCoordinator, -} from './data/layerState.js'; -import { renderMapStackChips, syncMapStackChips } from './mapStackChips.js'; -import { OrbitController } from './orbit.js'; -import { - CelestialRing, - getKeyholeFadeTuning, - isCelestialRingStyleSupported, - setKeyholeFadeTuning, -} from './celestialRing.js'; -import { destroyTrackedReadout, initTrackedReadout } from './data/trackedReadout.js'; -import { destroyWorldOverlay, initWorldOverlay } from './overlays/worldOverlay.js'; -import { - destroyDetection, - initDetection, - cycleMode as cycleDetectionMode, - getDetectionDiagnostics as readDetectionDiagnostics, - getDetectionTuning, - getMode as getDetectionMode, - setMode as setDetectionModeByLabel, - suspendDetection, - resumeDetection, - setDetectionStyle, - setDetectionTuning, -} from './data/detection.js'; -import { - ALLOCATION_STRATEGIES, - canonicalizeDensity, - defaultDensityForProfile, - normalizeAllocationStrategy, - normalizeProfile, - profileForDensity, -} from './data/detectionPolicy.js'; -import trafficLayer from './data/traffic.js'; -import flightsLayer from './data/flights.js'; -import militaryFlightsLayer from './data/militaryFlights.js'; -import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js'; -import satellitesLayer from './data/satellites.js'; -import cctvLayer from './data/cctv.js'; -import radioLayer, { - buildRadioTunerTicks, - radioTunerCommitSlot, - radioTunerPointerPosition, - radioTunerSlot, -} from './data/radio.js'; -import bikeshareLayer from './data/bikeshare.js'; -import aisLiveVesselsLayer from './data/aisLiveVessels.js'; -import militaryAwarenessLayer from './data/militaryAwareness.js'; -import militaryInstallationsLayer from './data/militaryInstallations.js'; -import rocketLaunchesLayer from './data/rocketLaunches.js'; -import { - aggregateLayerLoading, - canPresentDeferredStatusNotice, - createGlobalStatusNotice, - createLoadingFeedbackState, - createTrafficSyncFeedbackState, - presentGlobalLoadingStatus, - presentGlobalStatusNotice, - presentLoadingFeedback, - reduceLoadingFeedback, - reduceTrafficSyncFeedback, -} from './loadingFeedback.js'; -import { setSplitFlapText } from './splitFlap.js'; -import { - cockpitEntryAllowed, - contextAllowedLayerIds, - contextLayerEnableBlockReason, - contextRestoreLayerIds, - contextSnapshotLayerIds, - isExplicitUserIntentOrigin, - mergeContextTransitionErrors, - recordContextRestoreExplicitChange, - recordContextSessionUserChange, - runWithContextModeChanging, - settleContextModeChange, - settleContextIntentReplay, - settleUserFacingContextAction, - shouldCaptureContextSession, - shouldDeferContextEntryDuringClear, - shouldExitContextForLayerChange, - spaceMissionEntryCancellationDisposition, - contextModeWord, -} from './contextModePolicy.js'; -import { - shouldExpandGlobalContextPanel, -} from './rightRailPolicy.js'; -import { - resolveCockpitUtilityAnchor, - resolveCockpitUtilityLayout, -} from './cockpitUtilityLayout.js'; -import { - applyCockpitVisionStageIntensities, - captureCockpitVisionBaseline, - COCKPIT_VISION_MODES, - normalizeCockpitVisionMode, -} from './cockpitVisionPolicy.js'; -import { - applyContactsDetection, - shareCacheNeedsHeal, - shareableDetectionState, -} from './contactsDetectionPolicy.js'; -import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; -import { runCctvLayerEnableTransition } from './cctvFocusPolicy.js'; -import { - registerCctvFocusRequestListener, - routeCctvFocusRequest, -} from './cctvFocusRequest.js'; -import { - flyToWorldTarget, - registerWorldFocusRequestListener, - routeWorldFocusRequest, -} from './worldFocus.js'; -import { - beginDeferredNavigation, - reassertNavigationHandoff, - registerNavigationAuthorityListener, - runExplicitNavigation, - stampInitialShareGesture, -} from './navigationPolicy.js'; -import { - cachedGroundFloor, - cachedMeshFloor, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, -} from './data/groundFloor.js'; -import { sampleMeshFloorCells } from './data/meshFloorSampler.js'; -import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from './renderGovernor.js'; -import { - setScopeMaskEnabled, - isScopeMaskEnabled, - setScopeMaskFeather, - getScopeMaskFeather, - setScopeTerminusOverride, - getScopeTerminusOverride, - clampScopeTerminusPct, -} from './scopeMask.js'; -import { - fetchRegionalBrief, - regionalDistanceM, - weatherCodeLabel, -} from './data/regionalBrief.js'; -import { - altitudeRulerCurveInset, - altitudeRulerTicks, - bearingBetweenCoordinates, - cockpitAnchorCorrectionStep, - cockpitAltitudeDisplayFt, - cockpitGroundSafeHeight, - cockpitSurfaceWaitExpired, - cockpitUiUpdateDue, - compassDivisions, - formatAltitudeRulerTick, - formatCockpitContextScope, - formatCompassDivision, - formatSpeedRulerTick, - normalizeHeading, - relativeBearing, - resolveCockpitContextReadout, - resolveHudRailLayout, - resolveTrackedAircraftInfo, - slewHeading, - speedRulerTicks, -} from './cockpitMath.js'; - -/** Duration (ms) for shader intensity crossfade between style presets. */ -const TRANSITION_DURATION_MS = 500; -/** Map of style name to its GLSL shader module for post-process stages. */ -const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; -/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ -const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; -const SHARE_PANEL_STATE_SPECS = Object.freeze([ - { id: 'control-panel', pinnable: true }, - { id: 'location-bar', pinnable: true }, - { id: 'data-panel' }, - { id: 'cctv-panel' }, - { id: 'radio-panel' }, - { id: 'scene-panel' }, - { id: 'global-context-panel' }, - { id: 'pp-toggles' }, - { id: 'param-slider-panel' }, -]); -/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */ -const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ - 'data-panel', - 'cctv-panel', - 'scene-panel', - 'pp-toggles', - 'global-context-panel', - 'radio-panel', -]); -/** - * Position keys are versioned separately from collapsed-state keys so layout - * default changes (e.g. right-rail origin) can reset positions without also - * resetting every panel's open/closed preference. - */ -const PANEL_POSITION_STORAGE_VERSION = 'v8'; -const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; -/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ -const PANEL_Z_BASE = 100; -const PANEL_Z_MAX = 139; -const COCKPIT_HEADING_SLEW_DPS = 28; -const COCKPIT_FORWARD_OFFSET_M = 7; -const COCKPIT_UP_OFFSET_M = 2.6; -const COCKPIT_MIN_GROUND_CLEARANCE_M = 12; -const COCKPIT_VIEW_PITCH_DEG = -4; -const COCKPIT_CAMERA_UPDATE_MS = 50; -const COCKPIT_HUD_UPDATE_MS = 100; -const COCKPIT_CONTEXT_UPDATE_MS = 250; -const COCKPIT_LAYOUT_SETTLE_MS = 240; -// Cockpit's Display/Radio strip hangs off the REC readout it shares the right -// margin with, and never touches the briefing card underneath it. -const COCKPIT_UTILITY_REC_GAP_PX = 12; -const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8; -const COCKPIT_UTILITY_MIN_TOP_PX = 96; -const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12; -const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50; -const COCKPIT_GROUND_PROBE_MS = 500; -const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; -const COCKPIT_BRIEF_ROTATE_MS = 9000; -const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; -const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; -const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; -const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; -const COCKPIT_BRIEF_PAGES = [ - { - id: 'signals', - kicker: 'LIVE SIGNALS', - subtitle: 'OBSERVED / MAPPED PINGS', - source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', - }, - { - id: 'news', - kicker: 'REGIONAL NEWS', - subtitle: 'LATEST LOCATION-MATCHED REPORTING', - source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', - }, - { - id: 'local', - kicker: 'LOCAL INFO', - subtitle: 'PLACE / CONDITIONS / POSITION', - source: 'OPENSTREETMAP · OPEN-METEO · UTC', - }, -]; -/** - * Fixed UI regions that can occupy the left accordion's vertical lane. - * Rectangles are filtered at runtime for visibility and horizontal overlap, - * so right-side/center controls do not reduce the lane unless they actually - * intersect it at the current viewport size. - */ -const LEFT_STACK_OBSTACLE_SELECTOR = [ - '#cockpit-hud .cockpit-topline', - '#cockpit-hud .cockpit-topline > div', - '#title-bar', - '#style-indicator', - '#top-center-actions', - '#traffic-sync-chip', - '#cctv-sync-chip', - '#intel-hud .hud-top-left', - '#intel-hud .hud-top-right', - '#intel-hud .hud-bottom-left', - '#intel-hud .hud-bottom-right', - '#intel-hud .hud-top-bar', - '#intel-hud .hud-bottom-bar', - '#intel-hud .hud-left-edge', - '#intel-hud .hud-right-edge', - '#cockpit-context', - '#cesium-credits .cesium-credit-logoContainer', - '#cesium-credits .cesium-credit-textContainer', - '#location-bar', - '#control-panel', - '#gev-voice-control', - '#pp-toggles', - '#param-slider-panel', -].join(', '); -/** - * Whether an element currently occupies screen space a layout must respect. - * - * A rect alone is not enough: `visibility: hidden` and `opacity: 0` (how the - * Intel HUD retires as a whole) leave the rect intact, so anything anchoring to - * a HUD readout must walk the ancestors as well. - * - * @param {Element|null|undefined} element - Candidate element. - * @returns {boolean} True when the element is painted and has area. - */ -function isRenderedOnScreen(element) { - if (!element) return false; - for (let node = element; node instanceof Element; node = node.parentElement) { - const style = getComputedStyle(node); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { - return false; - } - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} -/** - * Fixed UI regions that can occupy the right control lane. Runtime rectangle - * filtering keeps the rail clear of whichever HUD variant is currently - * visible without tying the layout to one screen height. - */ -const RIGHT_STACK_OBSTACLE_SELECTOR = [ - '#cockpit-hud .cockpit-topline', - '#cockpit-hud .cockpit-topline > div', - '#title-bar', - '#style-indicator', - '#top-center-actions', - '#traffic-sync-chip', - '#cctv-sync-chip', - '#intel-hud .hud-top-left', - '#intel-hud .hud-top-right', - '#intel-hud .hud-bottom-left', - '#intel-hud .hud-bottom-right', - '#intel-hud .hud-top-bar', - '#intel-hud .hud-bottom-bar', - '#intel-hud .hud-left-edge', - '#intel-hud .hud-right-edge', - '#cockpit-context', - '#cockpit-signal-stream', - '#cesium-credits .cesium-credit-logoContainer', - '#cesium-credits .cesium-credit-textContainer', - '#command-dock', - '#gev-voice-control', -].join(', '); -/** Display labels shown in the mini-status readout for each active style. */ -const STYLE_STATUS_LABELS = { - normal: 'NORMAL', - retro: 'CRT', - surveillance: 'NVG', - thermal: 'FLIR', - anime: 'ANIME', - noir: 'NOIR', - snow: 'SNOW', -}; -/** - * The tactical detection look: Dense at 75%. - * - * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade - * and 5% outside, whatever we had. I want that as the default. It should just - * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever - * we had" still needs nothing here — but they are 7% and 1% now, the outside - * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the - * baseline of the day, not the two numbers it happened to name. - * - * ONE object, shared by the first-load baseline below, by every military style, - * AND by the Contacts context mode (which OWNS detection while active and - * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit - * deliberately does NOT touch detection: entering it with SPARSE selected leaves - * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads - * from it. - */ -const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); - -/** Baseline post-processing settings applied on first load (before share-link restore). */ -const GLOBAL_POST_DEFAULTS = { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - hudVariant: 'tactical', - hudVisible: true, - // Detection is ON for EVERY style on a first run, Normal included (owner - // directive 2026-08-22: "detect should also be on by default"). It is the - // same preset object the military styles and Contacts already apply, so there - // is one tactical look, not several that can drift. - // - // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` - // runs before any share-link restore, so a link's `dm`/`dd` still lands on top - // of it. It also deliberately leaves `_detectionUserOverridden` alone — the - // flag means the OPERATOR hand-edited detection, and a factory default is not - // that. Turning detection off by hand therefore still sets the flag and still - // suppresses the military-style auto-enable for the rest of the session. - detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), - detectionDensity: MILITARY_DETECTION_PRESET.densityPct, - detectionAllocation: 'ELASTIC', - detectionFadePct: 7, - detectionOutsideOpacityPct: 1, - celestialRing: false, -}; - -// Tactical style defaults applied when users select military style presets. -const STYLE_PRESET_DEFAULTS = { - retro: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - retro: { - pixelation: 1.0, - distortion: 0, - instability: 0.42, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - surveillance: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - surveillance: { - gain: 0.18, - bloom: 0.22, - scanlineStr: 0.96, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - thermal: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - thermal: { - sensitivity: 0.85, - bloom: 0.2, - mode: 0.33, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, -}; - -/** - * GLSL fragment shader implementing an unsharp-mask sharpening filter. - * Samples a 3x3 neighborhood, computes box blur, then adds the - * difference (center - blur) scaled by `amount` for edge enhancement. - */ -const SHARPEN_SHADER = /* glsl */ ` - uniform sampler2D colorTexture; - uniform vec2 colorTextureDimensions; - uniform float amount; - in vec2 v_textureCoordinates; - - void main() { - vec2 uv = v_textureCoordinates; - vec2 texel = 1.0 / colorTextureDimensions; - vec4 center = texture(colorTexture, uv); - vec4 blur = ( - texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + - texture(colorTexture, uv + vec2( 0.0, -texel.y)) + - texture(colorTexture, uv + vec2( texel.x, -texel.y)) + - texture(colorTexture, uv + vec2(-texel.x, 0.0)) + - center + - texture(colorTexture, uv + vec2( texel.x, 0.0)) + - texture(colorTexture, uv + vec2(-texel.x, texel.y)) + - texture(colorTexture, uv + vec2( 0.0, texel.y)) + - texture(colorTexture, uv + vec2( texel.x, texel.y)) - ) / 9.0; - vec4 sharpened = center + (center - blur) * amount; - out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); - } -`; - -/** - * Central UI orchestrator for the God's Eye View application. - * - * Responsibilities: - * - CesiumJS PostProcessStage pipeline: registers per-style GLSL stages - * (NVG, FLIR, CRT, anime, noir, snow) and manages intensity crossfades. - * - Bloom and sharpen post-processing toggle/intensity control. - * - Draggable/collapsible panel system with localStorage persistence, - * z-order stacking, and viewport-clamped positioning. - * - CCTV panel: camera selection, coverage toggle, projection, calibration - * sliders, auto-hop, and summary typewriter effect. - * - Location bar with city/POI preset pills, QWERTY key navigation, - * geocoding search, and inter-city world-jump transitions. - * - Orbit controller integration for POI fly-around. - * - Recording mode with safe-frame overlay and HUD mode switching. - * - Share link encoding/decoding (delegates to ShareLinkManager). - * - Detection overlay mode cycling and density tuning. - * - Toast notification system. - * - Intel HUD lifecycle and variant switching. - */ - -/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ -const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; - -/** - * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the - * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old - * "HEADING 0°" nonsense); typed values convert back to calibration offsets - * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon - * typing is user-hostile). - */ -const CCTV_CAL_FIELDS = { - heading: { - label: 'HDG', unit: '°', decimals: 1, - get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), - }, - pitch: { - label: 'PITCH', unit: '°', decimals: 1, - get: (cam) => cam.pitchDeg, - toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), - }, - fov: { - label: 'FOV', unit: '°', decimals: 0, - get: (cam) => cam.fovDeg, - toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), - }, - range: { - label: 'RANGE', unit: 'm', decimals: 0, - get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), - }, - height: { - label: 'HGT', unit: 'm', decimals: 0, - get: (cam) => cam.mountHeightM, - toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), - }, - north: { - label: 'ΔN', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetNorthM || 0, - toPatch: (value) => ({ offsetNorthM: value }), - }, - east: { - label: 'ΔE', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetEastM || 0, - toPatch: (value) => ({ offsetEastM: value }), - }, -}; - -function formatCockpitBriefAge(value) { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; - const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); - if (minutes < 60) return `${minutes}M AGO`; - const hours = Math.round(minutes / 60); - return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`; -} - -function formatCockpitWindDirection(value) { - if (!Number.isFinite(value)) return 'DIR UNKNOWN'; - const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - const normalized = ((value % 360) + 360) % 360; - return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; -} - -function setCockpitRollingValue(element, text, numericValue, { - circularRange = null, - immediate = false, -} = {}) { - if (!element) return; - const nextText = String(text); - const previousText = element.dataset.rollingText; - const previousValue = Number(element.dataset.rollingValue); - const nowMs = performance.now(); - const lastRollMs = Number(element.dataset.rollingAt); - if (!immediate - && previousText !== undefined - && previousText !== nextText - && Number.isFinite(lastRollMs) - && nowMs - lastRollMs < 220) { - return; - } - element.dataset.rollingText = nextText; - element.dataset.rollingAt = String(nowMs); - if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); - else delete element.dataset.rollingValue; - element.setAttribute('aria-label', nextText); - - if (immediate || previousText === undefined || previousText === nextText) { - if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { - element.replaceChildren(...Array.from(nextText, (character) => { - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - token.textContent = character; - return token; - })); - } - return; - } - - let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) - ? numericValue - previousValue - : 0; - if (Number.isFinite(circularRange) && circularRange > 0) { - const halfRange = circularRange / 2; - if (delta > halfRange) delta -= circularRange; - else if (delta < -halfRange) delta += circularRange; - } - const direction = delta < 0 ? 'down' : 'up'; - const width = Math.max(previousText.length, nextText.length); - const from = previousText.padStart(width, ' '); - const to = nextText.padStart(width, ' '); - const fragment = document.createDocumentFragment(); - - for (let index = 0; index < width; index += 1) { - const previousCharacter = from[index]; - const nextCharacter = to[index]; - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - - if (previousCharacter === nextCharacter - || !/\d/.test(previousCharacter) - || !/\d/.test(nextCharacter)) { - token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; - fragment.append(token); - continue; - } - - token.classList.add('is-rolling', `roll-${direction}`); - const track = document.createElement('span'); - track.className = 'cockpit-roll-track'; - const first = document.createElement('span'); - const second = document.createElement('span'); - first.textContent = direction === 'up' ? previousCharacter : nextCharacter; - second.textContent = direction === 'up' ? nextCharacter : previousCharacter; - track.append(first, second); - token.append(track); - fragment.append(token); - } - - element.replaceChildren(fragment); -} - -class CockpitViewController { - constructor(viewer, { - onVisionChange = null, - onCameraTakeover = null, - isEntryAllowed = null, - onEntered = null, - onExited = null, - getInheritedVisionLabel = null, - restoreTrackingFrame = null, - } = {}) { - this.viewer = viewer; - this.active = false; - this.trackedEntity = null; - this.trackedEntityWasShown = true; - this.heading = null; - this.lastFrameMs = 0; - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastGroundProbeMs = 0; - this.contextNavigationDeadlineMs = 0; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.entry = document.getElementById('cockpit-entry'); - this.tr3bToggle = document.getElementById('tr3b-toggle'); - this._tr3bSignature = null; - this.mapViewButton = document.getElementById('map-view-switch'); - this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); - this.hud = document.getElementById('cockpit-hud'); - this.entryFocusOrigin = null; - this.callsign = document.getElementById('cockpit-callsign'); - this.speed = document.getElementById('cockpit-speed-value'); - this.speedRim = document.getElementById('cockpit-speed-rim'); - this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); - this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); - this.altitude = document.getElementById('cockpit-altitude-value'); - this.altitudeRim = document.getElementById('cockpit-altitude-rim'); - this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); - this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); - this.headingValue = document.getElementById('cockpit-heading-value'); - this.compassTape = document.getElementById('cockpit-compass-tape'); - this.clock = document.getElementById('cockpit-clock'); - this.position = document.getElementById('cockpit-position'); - this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); - this.route = document.getElementById('cockpit-route'); - this.routeFrom = document.getElementById('cockpit-route-from'); - this.routeTo = document.getElementById('cockpit-route-to'); - this.routeStatus = document.getElementById('cockpit-route-status'); - this.routeDirection = document.getElementById('cockpit-route-direction'); - this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); - this.visionPrevious = document.getElementById('cockpit-vision-previous'); - this.visionCurrent = document.getElementById('cockpit-vision-current'); - this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); - this.visionNext = document.getElementById('cockpit-vision-next'); - this.visionMode = 'optical'; - this.onVisionChange = onVisionChange; - this.onCameraTakeover = onCameraTakeover; - this.onEntered = onEntered; - this.onExited = onExited; - this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' - ? getInheritedVisionLabel - : () => 'NORMAL'; - this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' - ? restoreTrackingFrame - : () => false; - this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; - this.context = document.getElementById('cockpit-context'); - this.contextSubject = document.getElementById('cockpit-context-subject'); - this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); - this.contextBearing = document.getElementById('cockpit-context-bearing'); - this.contextDistance = document.getElementById('cockpit-context-distance'); - this.contextDirection = document.getElementById('cockpit-context-direction'); - this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); - this.contextUpdated = document.getElementById('cockpit-context-updated'); - this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) - .map((element) => [element.dataset.contextCohort, element])); - this.contextPrevious = document.getElementById('cockpit-context-previous'); - this.contextNext = document.getElementById('cockpit-context-next'); - this.contextToggle = document.getElementById('cockpit-context-toggle'); - this.weatherToggle = document.getElementById('cockpit-weather-toggle'); - this.weatherState = document.getElementById('cockpit-weather-state'); - this.contextCollapsed = false; - this.signalStream = document.getElementById('cockpit-signal-stream'); - this.signalList = document.getElementById('cockpit-signal-list'); - this.signalToggle = document.getElementById('cockpit-signal-toggle'); - this.briefKicker = document.getElementById('cockpit-brief-kicker'); - this.briefSubtitle = document.getElementById('cockpit-brief-subtitle'); - this.briefPrevious = document.getElementById('cockpit-brief-previous'); - this.briefNext = document.getElementById('cockpit-brief-next'); - this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); - this.briefPosition = document.getElementById('cockpit-brief-position'); - this.briefSource = document.getElementById('cockpit-brief-source'); - this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); - this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); - this.newsStatus = document.getElementById('cockpit-news-status'); - this.newsList = document.getElementById('cockpit-news-list'); - this.localPlace = document.getElementById('cockpit-local-place'); - this.localCoordinates = document.getElementById('cockpit-local-coordinates'); - this.localTemperature = document.getElementById('cockpit-local-temperature'); - this.localWind = document.getElementById('cockpit-local-wind'); - this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); - this.localCondition = document.getElementById('cockpit-local-condition'); - this.localCloud = document.getElementById('cockpit-local-cloud'); - this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); - this.signalCollapsed = false; - this.signalUserCollapsed = false; - this.signalItems = []; - this.signalSignatures = new Map(); - this.briefPageIndex = 0; - this.briefAutoRotateEnabled = false; - this.briefTimer = null; - this.lastAircraftInfo = null; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - this.regionalBriefAbort = null; - this.regionalBriefRequestToken = 0; - this.regionalBriefSubjectId = null; - this.contextLayoutFrame = null; - this.contextLayoutStamp = null; - this.scratchTarget = new Cesium.Cartesian3(); - this.cockpitAnchor = new Cesium.Cartesian3(); - this.cockpitAnchorValid = false; - this.scratchCamera = new Cesium.Cartesian3(); - this.scratchAdvance = new Cesium.Cartesian3(); - this.scratchCorrection = new Cesium.Cartesian3(); - this.scratchForward = new Cesium.Cartesian3(); - this.scratchHorizontal = new Cesium.Cartesian3(); - this.scratchUp = new Cesium.Cartesian3(); - this.scratchLocal = new Cesium.Cartesian3(); - this.scratchEnu = new Cesium.Matrix4(); - this.scratchAnchorCartographic = new Cesium.Cartographic(); - this.scratchCameraCartographic = new Cesium.Cartographic(); - this.scratchTargetCartographic = new Cesium.Cartographic(); - this._listenerRemovers = []; - - // Camera mutations belong before scene update/culling. Changing the camera - // from preRender makes 3D Tiles discover a new view after traversal and can - // create a self-sustaining refinement loop under a moving cockpit camera. - this._listenerRemovers.push( - viewer.scene.preUpdate.addEventListener(() => this.update()), - viewer.trackedEntityChanged.addEventListener(() => { - if (this.active) this._adoptTrackedEntity(performance.now()); - else this.syncEntry(); - }), - ); - this._listen(this.entry, 'click', () => this.enter()); - this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); - this._listen(this.mapViewButton, 'click', () => this.exit()); - this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); - this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); - this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); - this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); - this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); - this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); - this._listen(this.weatherToggle, 'click', () => { - const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; - this.syncWeatherToggle(enabled); - window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { - detail: { enabled }, - })); - }); - this._listen(window, 'gev:cockpit-weather-state', (event) => { - this.syncWeatherToggle(event?.detail?.enabled !== false); - }); - this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( - !this.signalCollapsed, - { user: true }, - )); - this._listen(this.signalList, 'click', (event) => { - const target = event.target.closest('button[data-signal-layer][data-signal-id]'); - if (!target) return; - event.preventDefault(); - militaryAwarenessLayer.focusTarget?.( - target.dataset.signalLayer, - target.dataset.signalId, - { origin: 'user' }, - ); - }); - this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); - this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); - this._listen(this.briefAutoToggle, 'click', () => { - this.setBriefAutoRotate(!this.briefAutoRotateEnabled); - }); - this.briefTabs.forEach((button) => this._listen(button, 'click', () => { - this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); - })); - this._listen(document, 'visibilitychange', () => { - if (document.hidden) this.stopBriefRotation(); - else if (this.briefAutoRotateEnabled) this.startBriefRotation(); - }); - this._listen(window, 'resize', () => this.scheduleContextLayout()); - this._listen(document, 'keydown', (event) => this.onKeyDown(event), true); - } - - _listen(target, type, handler, options) { - if (!target?.addEventListener) return; - target.addEventListener(type, handler, options); - this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); - } - - syncWeatherToggle(enabled) { - if (!this.weatherToggle) return; - const active = !!enabled; - this.weatherToggle.setAttribute('aria-pressed', String(active)); - this.weatherToggle.setAttribute( - 'aria-label', - `${active ? 'Disable' : 'Enable'} cockpit weather effects`, - ); - this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; - if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; - } - - readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; - return resolveTrackedAircraftInfo({ - civilian: flightsLayer.getTrackedInfo?.() || null, - military: militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: trackedEntity?.gevTrackedId || '', - }); - } - - dispatchCockpitModeChanged(active, info = null) { - const subjectId = active - ? String(info?.icao24 || '').trim().toLowerCase() || null - : null; - const layerId = active && ['flights', 'military'].includes(info?.layerId) - ? info.layerId - : null; - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { - detail: { active: active === true, subjectId, layerId }, - })); - } - - /** - * TR-3B Easter egg: flip the tracked contact between its real silhouette and - * the black triangle. The registry owns the session state; the owning layer - * re-derives what renders. - * @returns {boolean} True when a tracked contact was converted/restored. - */ - toggleTrackedTr3b() { - const info = this.readAircraftInfo(); - const icao24 = String(info?.icao24 || '').trim(); - if (!icao24) return false; - toggleTr3b(icao24); - const layer = info.layerId === 'military' ? militaryFlightsLayer : flightsLayer; - layer.refreshTr3b?.(icao24); - this._tr3bSignature = null; // force the chip to repaint on the next sync - this.syncTr3bToggle(info); - return true; - } - - /** - * Show the 🛸 chip whenever a contact is tracked (its own gate — converting - * does not depend on the cockpit entry policy) and mirror the conversion - * state. Change-only DOM writes: this runs on the preUpdate cadence. - * @param {object|null} info Tracked-aircraft descriptor, or null. - */ - syncTr3bToggle(info) { - if (!this.tr3bToggle) return; - const icao24 = String(info?.icao24 || '').trim(); - const converted = !!icao24 && isTr3b(icao24); - const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; - if (this._tr3bSignature === signature) return; - this._tr3bSignature = signature; - this.tr3bToggle.hidden = !icao24; - this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); - this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; - } - - syncEntry() { - if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); - // Change-only DOM writes: this runs on a preUpdate cadence, and - // unconditional `hidden` assignments invalidate style/layout every frame - // even when nothing changed. (perf item 9) - if (this._entryAvailable === available) return; - this._entryAvailable = available; - if (this.entry) this.entry.hidden = !available; - if (this.mapViewButton) this.mapViewButton.hidden = true; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; - } - - /** - * Navigate Contacts through one Cockpit-owned funnel. A short grace window - * covers feed-refresh handoffs where the old source releases before the new - * source publishes its entity; trackedEntityChanged adopts synchronously as - * soon as the replacement exists, so the normal path does not wait. - */ - navigateContext(direction, options = {}) { - const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; - const wasActive = this.active; - if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; - const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; - const changed = Boolean(militaryAwarenessLayer?.[method]?.(navigationOptions)); - if (!changed) { - this.contextNavigationDeadlineMs = 0; - return false; - } - if (wasActive) this._adoptTrackedEntity(performance.now()); - return true; - } - - /** Adopt a newly selected aircraft without ever leaving Cockpit. */ - _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; - if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; - const info = suppliedInfo || this.readAircraftInfo(); - if (!info) return false; - if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { - this.trackedEntity.show = this.trackedEntityWasShown; - } - this.trackedEntity = nextEntity; - this.trackedEntityWasShown = nextEntity.show; - nextEntity.show = false; - this.viewer.trackedEntity = undefined; - this.cockpitAnchorValid = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = nowMs; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastCameraUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.dispatchCockpitModeChanged(true, info); - return true; - } - - setVisionMode(mode, { revealParameters = false } = {}) { - const next = normalizeCockpitVisionMode(mode); - this.visionMode = next; - const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); - const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; - const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; - if (this.visionCurrent) { - this.visionCurrent.dataset.cockpitVision = next; - this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); - this.visionCurrent.title = `Current style: ${names[next]} — click for next`; - } - if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; - this.onVisionChange?.(next, this.active, { revealParameters }); - } - - cycleVisionMode(direction = 1) { - const modes = COCKPIT_VISION_MODES; - const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); - const step = direction < 0 ? -1 : 1; - const nextIndex = (currentIndex + step + modes.length) % modes.length; - this.setVisionMode(modes[nextIndex], { revealParameters: true }); - } - - clearPredictiveRoute() { - if (this.routeDirection) this.routeDirection.hidden = true; - } - - onKeyDown(event) { - if (event.repeat || event.isComposing) return; - if (event.key === 'Escape' && this.active) { - // The credit lightbox owns Escape while its Close control or links hold - // focus. Its target handler closes the overlay and restores attribution - // focus; Cockpit must stay active behind it. - if (event.target?.closest?.('.cesium-credit-lightbox')) return; - if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; - if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; - if (this.context?.contains(event.target) && !this.contextCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setContextCollapsed(true); - if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { - this.contextToggle?.blur?.(); - } else { - this.contextToggle?.focus({ preventScroll: true }); - } - return; - } - if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setSignalCollapsed(true, { user: true }); - if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { - this.signalToggle?.blur?.(); - } else { - this.signalToggle?.focus({ preventScroll: true }); - } - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - this.exit(); - return; - } - if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; - const key = event.key?.toLowerCase(); - if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { - if (!this.active) { - const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); - if (!cockpitAttempt) return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); - return; - } - } - - enter() { - if (this.active) return false; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; - this.entryFocusOrigin = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - // Retire deferred navigation before cancelFlight can run its callbacks. - this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); - this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = performance.now(); - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastGroundProbeMs = 0; - this.surfaceWaitStartedMs = performance.now(); - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.cockpitAnchorValid = false; - this.lastCameraUpdateMs = 0; - this.active = true; - // Cockpit animates the camera from preUpdate every frame — preUpdate only - // runs on rendered frames, so idle mode would freeze the cockpit solid. - // (perf wave 2) - holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; - document.body.classList.add('cockpit-mode'); - // Activation writes entry/quick/map visibility directly, bypassing - // syncEntry's change-only cache — invalidate it so the exit-path - // syncEntry re-applies every write (notably re-hiding mapViewButton). - this._entryAvailable = undefined; - if (this.entry) this.entry.hidden = true; - if (this.tr3bToggle) { - this.tr3bToggle.hidden = true; - this._tr3bSignature = null; - } - if (this.mapViewButton) this.mapViewButton.hidden = false; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; - if (this.hud) this.hud.hidden = false; - if (this.signalStream) this.signalStream.hidden = false; - this.hud?.classList.add('signals-active'); - this.signalItems = []; - this.signalSignatures.clear(); - this.showBriefPage(0); - this.startBriefRotation(); - const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); - this.pushCockpitSignal( - 'track', - 'track', - 'TRACK ACQUIRED', - `${trackLabel} · COURSE ${trackHeading}°`, - ); - this.updateHud(info, performance.now(), true); - this.setVisionMode(this.visionMode); - this.scheduleContextLayout(); - this.mapViewButton?.focus({ preventScroll: true }); - this.onEntered?.(); - this.dispatchCockpitModeChanged(true, info); - return true; - } - - exit({ restoreTracking = true } = {}) { - if (!this.active) return false; - const entity = this.trackedEntity; - this.active = false; - releaseContinuousRender('cockpit'); - this.trackedEntity = null; - this.heading = null; - this.cockpitAnchorValid = false; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastCompassSignature = ''; - this.stopBriefRotation(); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); - this.onExited?.(); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - if (this.hud) this.hud.hidden = true; - if (this.route) this.route.hidden = true; - this.clearPredictiveRoute(); - this.setVisionMode('optical'); - if (this.signalStream) this.signalStream.hidden = true; - this.hud?.classList.remove('signals-active'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; - if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; - this.trackedEntityWasShown = true; - this.dispatchCockpitModeChanged(false); - if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; - this.restoreTrackingFrame(entity); - } - this.syncEntry(); - const restoreTarget = this.entryFocusOrigin === this.entry - ? this.entry - : (this.entry || this.entryFocusOrigin); - this.entryFocusOrigin = null; - if (restoreTarget?.isConnected && !restoreTarget.hidden) { - restoreTarget.focus({ preventScroll: true }); - } - return true; - } - - update() { - if (!this.active) { - // Entry availability changes on a human timescale (tracking start/stop, - // info arriving after a poll) — polling it every rendered frame ran - // readAircraftInfo() + DOM pokes at display rate in plain map mode. - // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still - // fires syncEntry immediately on the events that matter. (perf item 9) - const nowMs = performance.now(); - if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { - this._lastEntrySyncMs = nowMs; - this.syncEntry(); - } - return; - } - - const nowMs = performance.now(); - const info = this.readAircraftInfo(); - - // Adopt a newly selected track before the camera cadence gate. A context - // NEXT/PREV selection can otherwise spend one frame driving the old - // aircraft with the new aircraft's metadata. - this._adoptTrackedEntity(nowMs, info); - if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { - if (nowMs < this.contextNavigationDeadlineMs) return; - this.exit({ restoreTracking: false }); - return; - } - if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; - this.lastCameraUpdateMs = nowMs; - - const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); - if (!target) return; - const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); - this.lastFrameMs = nowMs; - if (Number.isFinite(info.track)) { - this.heading = slewHeading( - this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, - ); - } - - if (!this.cockpitAnchorValid) { - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - this.cockpitAnchorValid = true; - } - - // First-person motion cannot use the delayed feed correction as a raw - // camera destination: a harmless icon re-anchor becomes a whole-world - // surge/reversal in cockpit view. Advance the camera anchor inertially - // from the reported course/speed and converge on the authoritative layer - // display position at a bounded rate. This preserves the layer's required - // 15/30-second interpolation and per-frame cache without exposing its - // sample-boundary corrections to the camera. - const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); - const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; - - if (info.stale) { - // A feed backoff has no authoritative velocity epoch to advance from. - // Hold the cockpit on the exact layer-rendered position so the camera - // cannot coast away while the icon correctly remains fixed. - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - } else { - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); - Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); - const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); - const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); - if (correctionStepM > 0 && correctionDistanceM > 0) { - Cesium.Cartesian3.multiplyByScalar( - this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); - } - } - - // The inertial anchor is independent of the layer's render-floor clamp and - // can otherwise coast into a photoreal mesh while a landing contact is - // between fixes. Clamp it against the same mesh-first shared floor used by - // aircraft rendering. For a slow contact whose floor cell is still cold, - // its already-clamped render position is a conservative temporary floor. - const anchorCartographic = Cesium.Cartographic.fromCartesian( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, - ); - const targetCartographic = Cesium.Cartographic.fromCartesian( - target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, - ); - let cockpitFloorM = cachedGroundFloor(info.latitude, info.longitude); - if (info.onGround === true) { - const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; - warmGroundFloor(groundPoint); - const meshFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (meshFloorPreferred() && !Number.isFinite(meshFloorM)) { - if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { - this.lastGroundProbeMs = nowMs; - const viewerCartographic = this.viewer.camera.positionCartographic; - sampleMeshFloorCells(this.viewer.scene, groundPoint, { - excludeObjects: [this.trackedEntity], - viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), - viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), - }); - } - cockpitFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (!Number.isFinite(cockpitFloorM)) { - if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; - if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { - // Keep the already-safe map camera in place while the photoreal - // surface under a parked aircraft is acquired. The bounded wait - // prevents a permanently cold mesh cell from freezing cockpit. - this.surfaceAcquiring = true; - this.surfaceFallback = false; - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - return; - } - this.surfaceAcquiring = false; - this.surfaceFallback = true; - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - } - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - if (!Number.isFinite(cockpitFloorM) - && speedMps < 90 - && Number.isFinite(targetCartographic?.height)) { - cockpitFloorM = targetCartographic.height - GROUND_FLOOR_LIFT_M; - } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { - const minimumAnchorHeightM = cockpitGroundSafeHeight( - anchorCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, - ); - if (minimumAnchorHeightM !== anchorCartographic.height) { - anchorCartographic.height = minimumAnchorHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); - } - } - - // Rebuild the local frame at the stabilized anchor after advancing it. - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); - this.scratchLocal.z = Math.sin(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); - Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); - this.scratchLocal.z = Math.cos(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); - Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); - - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); - Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); - Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); - Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); - Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); - - // Recheck at the final forward-offset camera coordinate because a taxiing - // aircraft can cross into an adjacent coarse floor cell between updates. - const cameraCartographic = Cesium.Cartographic.fromCartesian( - this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, - ); - if (cameraCartographic) { - const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); - const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); - const cameraFloorM = cachedGroundFloor(cameraLat, cameraLon); - if (Number.isFinite(cameraFloorM)) { - cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); - } - const safeHeightM = cockpitGroundSafeHeight( - cameraCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, - ); - if (safeHeightM !== cameraCartographic.height) { - cameraCartographic.height = safeHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); - } - } - - this.viewer.camera.setView({ - destination: this.scratchCamera, - orientation: { direction: this.scratchForward, up: this.scratchUp }, - }); - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - } - - updateHud(info, nowMs = performance.now(), forceContext = false) { - this.lastAircraftInfo = info; - const heading = normalizeHeading(this.heading ?? info.track ?? 0); - if (this.callsign) { - this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - } - const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; - setCockpitRollingValue( - this.speed, - formatSpeedRulerTick(speedKt), - speedKt, - { immediate: forceContext }, - ); - if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); - if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); - const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); - this.speedRimTicks.forEach((element, index) => { - const tick = speedTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); - }); - const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); - if (this.altitude) { - const displayedAltitudeFt = Number.isFinite(altitudeFt) - ? Math.round(altitudeFt) - : null; - setCockpitRollingValue( - this.altitude, - displayedAltitudeFt !== null - ? displayedAltitudeFt.toLocaleString('en-US') - : '-----', - displayedAltitudeFt, - { immediate: forceContext }, - ); - } - if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); - if (this.altitudeRimValue) { - this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); - } - const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); - this.altitudeRimTicks.forEach((element, index) => { - const tick = altitudeTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); - }); - setCockpitRollingValue( - this.headingValue, - String(Math.round(heading) % 360).padStart(3, '0'), - heading, - { circularRange: 360, immediate: forceContext }, - ); - if (this.compassTape) { - const divisions = compassDivisions(heading); - const signature = divisions.join(','); - if (signature !== this.lastCompassSignature) { - this.lastCompassSignature = signature; - this.compassTape.innerHTML = divisions - .map((division, index) => { - const slot = index - 3; - return `${formatCompassDivision(division)}`; - }) - .join(''); - } - } - if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; - if (this.position) { - const lat = Number.isFinite(info.latitude) - ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; - const lon = Number.isFinite(info.longitude) - ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; - this.position.textContent = `${lat} · ${lon}`; - } - if (this.aircraftMeta) { - const feedState = this.surfaceAcquiring - ? 'ACQUIRING SURFACE' - : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; - } - this.updateRoute(info); - if (forceContext - || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { - this.lastContextUpdateMs = nowMs; - this.updateLocalPosition(info); - this.maybeRefreshRegionalBrief(info); - this.updateContext(info, heading); - } - if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; - } - - updateRoute(info) { - const origin = info?.route?.origin; - const destination = info?.route?.destination; - const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); - const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; - if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); - if (this.routeTo) this.routeTo.textContent = routeLabel(destination); - if (this.routeStatus) { - this.routeStatus.textContent = validDestination - ? 'ARROW · ESTIMATED DIRECTION' - : 'ROUTE DATA UNAVAILABLE'; - } - if (this.route) this.route.hidden = !origin && !destination; - if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { - this.clearPredictiveRoute(); - return; - } - const destinationBearing = bearingBetweenCoordinates( - info.latitude, - info.longitude, - destination.lat, - destination.lon, - ); - const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); - if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { - this.clearPredictiveRoute(); - return; - } - if (this.routeDirection) { - const displayedRelative = Math.max(-120, Math.min(120, relative)); - this.routeDirection.hidden = false; - this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); - } - if (this.routeDirectionLabel) { - this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; - } - } - - updateContext(info, heading) { - if (!this.context) return; - const snapshot = militaryAwarenessLayer.getContextSnapshot?.() || null; - const trackedId = info.icao24 || info.id; - const readout = resolveCockpitContextReadout({ snapshot, info }); - if (!readout.visible) { - this.context.hidden = true; - this.hud?.classList.remove('context-active'); - this.contextLayoutStamp = null; - this.pushCockpitSignal( - 'context-status', - 'info', - 'CONTEXT STANDBY', - 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', - ); - return; - } - - this.context.hidden = false; - this.hud?.classList.add('context-active'); - if (this.contextSubject) { - const installationCoverage = snapshot.cohorts - .find((cohort) => cohort.id === 'military-installations')?.coverage; - this.contextSubject.textContent = formatCockpitContextScope( - snapshot.subject.label || trackedId, - snapshot.radiusM, - installationCoverage, - ); - } - // Navigation stays wired in every state — the operator must always be able - // to step off the current contact from the panel that hosts the controls. - if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; - if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; - - if (readout.contactLost) { - // The subject left its source. Every number below is measured against a - // position that stopped updating, so hold the last rendered readout and - // say so instead of re-deriving stale geometry as if it were live. - const enteringLost = this.context.dataset.state !== 'lost'; - this.context.dataset.state = 'lost'; - if (this.contextUncertainty) { - this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; - } - // The cue changes the footer's height; re-run layout once on the way in - // rather than every frame the contact stays lost. - if (enteringLost) this.scheduleContextLayout(); - this.pushCockpitSignal( - 'context-status', - 'warning', - `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, - 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', - ); - return; - } - - let unknownCount = 0; - const nearest = []; - for (const cohort of snapshot.cohorts) { - const element = this.contextCohorts.get(cohort.id); - const value = element?.querySelector('strong'); - if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); - element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); - if (cohort.count === null) unknownCount += 1; - for (const item of cohort.nearest) nearest.push({ ...item, cohort }); - } - nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); - const closest = nearest[0] || null; - const closestLabel = formatAwarenessLabel(closest); - if (this.contextNearestLabel) { - this.contextNearestLabel.textContent = closest - ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; - this.contextNearestLabel.setAttribute( - 'aria-label', - closest && closestLabel === '—' - ? `${closest.cohort.label}, Unavailable` - : this.contextNearestLabel.textContent, - ); - } - if (this.contextDistance) { - const distanceM = closest?.distanceM; - this.contextDistance.textContent = Number.isFinite(distanceM) - ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; - this.contextDistance.setAttribute( - 'aria-label', - Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', - ); - } - - // The arrow and BRG are nose-relative to the tracked aircraft. When the - // subject is a vessel, an installation, or another aircraft, the rest of - // this row is measured from that subject — so the aircraft-frame half is - // dashed rather than presented alongside subject-frame distances as if the - // two shared an origin. - let relative = null; - if (readout.aircraftRelative - && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { - const cartographic = Cesium.Cartographic.fromCartesian(closest.position); - const bearing = cartographic ? bearingBetweenCoordinates( - info.latitude, - info.longitude, - Cesium.Math.toDegrees(cartographic.latitude), - Cesium.Math.toDegrees(cartographic.longitude), - ) : null; - relative = relativeBearing(bearing, heading); - } - if (this.contextDirection) { - this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; - this.contextDirection.classList.toggle('unknown', relative === null); - } - if (this.contextBearing) { - if (relative === null) this.contextBearing.textContent = 'BRG —'; - else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; - else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; - } - if (this.contextUncertainty) { - this.contextUncertainty.textContent = unknownCount - ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` - : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; - } - if (this.contextUpdated) { - this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) - ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - } - this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; - this.updateCockpitSignals(snapshot, unknownCount); - if (this.contextLayoutStamp !== snapshot.evaluatedAt) { - this.contextLayoutStamp = snapshot.evaluatedAt; - this.scheduleContextLayout(); - } - } - - scheduleContextLayout() { - const contextVisible = this.context && !this.context.hidden; - const signalVisible = this.signalStream && !this.signalStream.hidden; - if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; - this.contextLayoutFrame = requestAnimationFrame(() => { - this.contextLayoutFrame = null; - this.syncContextLayout(); - this.syncSignalLayout(); - }); - } - - showBriefPage(index, { manual = false } = {}) { - const count = COCKPIT_BRIEF_PAGES.length; - this.briefPageIndex = ((Number(index) % count) + count) % count; - const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; - this.briefPages.forEach((element) => { - element.hidden = element.dataset.cockpitBriefPage !== page.id; - }); - this.briefTabs.forEach((button) => { - const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; - button.setAttribute('aria-current', current ? 'true' : 'false'); - }); - if (this.briefKicker) { - const indicator = this.briefKicker.querySelector('i'); - this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); - } - if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; - if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; - if (this.briefSource) this.briefSource.textContent = page.source; - if (this.signalStream) this.signalStream.dataset.briefPage = page.id; - if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - this.scheduleContextLayout(); - } - - setBriefAutoRotate(enabled) { - this.briefAutoRotateEnabled = Boolean(enabled); - if (this.briefAutoToggle) { - this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); - const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; - this.briefAutoToggle.textContent = label; - const help = this.briefAutoRotateEnabled - ? COCKPIT_BRIEF_CYCLE_ON_HELP - : COCKPIT_BRIEF_CYCLE_OFF_HELP; - this.briefAutoToggle.setAttribute('aria-label', label); - this.briefAutoToggle.title = help; - } - if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - else this.stopBriefRotation(); - } - - startBriefRotation({ reset = false } = {}) { - if (reset) this.stopBriefRotation(); - if (!this.briefAutoRotateEnabled - || this.briefTimer - || !this.active - || this.signalCollapsed - || document.hidden) return; - this.briefTimer = window.setTimeout(() => { - this.briefTimer = null; - const hasPointer = this.signalStream?.matches(':hover') === true; - const hasFocus = this.signalStream?.contains(document.activeElement) === true; - const isInteracting = hasPointer || hasFocus; - if (!isInteracting) { - this.showBriefPage(this.briefPageIndex + 1); - } - this.startBriefRotation(); - }, COCKPIT_BRIEF_ROTATE_MS); - } - - stopBriefRotation() { - if (this.briefTimer) window.clearTimeout(this.briefTimer); - this.briefTimer = null; - } - - updateLocalPosition(info) { - if (!this.localCoordinates) return; - if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { - this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; - return; - } - const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; - const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; - this.localCoordinates.textContent = `${lat} · ${lon}`; - } - - maybeRefreshRegionalBrief(info) { - if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; - if (subjectId !== this.regionalBriefSubjectId) { - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = subjectId; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - } - const point = { latitude: info.latitude, longitude: info.longitude }; - const ageMs = Date.now() - this.regionalBriefFetchedAt; - const distanceM = regionalDistanceM(this.regionalBriefAnchor, point); - if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS - && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; - - this.regionalBriefAnchor = point; - this.regionalBriefFetchedAt = Date.now(); - const controller = new AbortController(); - const requestToken = ++this.regionalBriefRequestToken; - this.regionalBriefAbort = controller; - if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); - fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) - .then((payload) => { - if (!this.active - || requestToken !== this.regionalBriefRequestToken - || subjectId !== this.regionalBriefSubjectId) return; - this.regionalBrief = payload; - this.renderRegionalBrief(payload, info); - }) - .catch((error) => { - if (error?.name !== 'AbortError' - && this.active - && requestToken === this.regionalBriefRequestToken - && subjectId === this.regionalBriefSubjectId) { - this.renderRegionalBriefStatus('unavailable', info); - } - }) - .finally(() => { - if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; - }); - } - - renderRegionalBriefStatus(status, info) { - if (this.newsStatus) { - this.newsStatus.hidden = false; - this.newsStatus.dataset.state = status; - this.newsStatus.textContent = status === 'loading' - ? 'ACQUIRING REGIONAL NEWS' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (status === 'unavailable') this.newsList?.replaceChildren(); - if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; - if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; - this.updateLocalPosition(info); - } - - renderRegionalBrief(payload, info) { - const articles = Array.isArray(payload?.articles) ? payload.articles : []; - if (this.newsStatus) { - this.newsStatus.hidden = articles.length > 0; - this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; - this.newsStatus.textContent = payload?.newsStatus === 'empty' - ? 'NO RECENT LOCATION MATCHES' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (this.newsList) { - this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { - const entry = document.createElement('li'); - const link = document.createElement('a'); - link.href = article.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - const title = document.createElement('strong'); - title.textContent = article.title; - const metadata = document.createElement('span'); - metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`; - link.append(title, metadata); - entry.append(link); - return entry; - })); - } - - const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; - if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); - this.updateLocalPosition(info); - const weather = payload?.weather; - if (this.localTemperature) { - this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) - ? `${Math.round(weather.temperatureC)}°C` : '—'; - } - if (this.localWind) { - this.localWind.textContent = Number.isFinite(weather?.windKph) - ? `${Math.round(weather.windKph)} KM/H` : '—'; - } - if (this.localWindDirection) { - this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); - } - if (this.localCondition) this.localCondition.textContent = weatherCodeLabel(weather?.weatherCode); - if (this.localCloud) { - this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) - ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; - } - if (this.localPrecipitation) { - this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) - ? weather.precipitationMm.toFixed(1) : '—'; - } - if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; - if (this.briefPageIndex === 1 && this.briefSource) { - this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; - } - this.scheduleContextLayout(); - } - - renderCockpitSignals() { - if (!this.signalList) return; - const existing = [...this.signalList.children]; - const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); - const entriesByKey = new Map(); - for (const entry of existing) { - const key = entry.dataset.signalKey; - if (!entriesByKey.has(key)) entriesByKey.set(key, []); - entriesByKey.get(key).push(entry); - } - const setText = (element, value) => { - if (element.textContent !== value) element.textContent = value; - }; - const entries = this.signalItems.map((item) => { - // Identity includes the action target: a reused status key must never - // silently turn a focused flight button into a different selection. - const key = JSON.stringify([ - item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), - ]); - let entry = entriesByKey.get(key)?.shift(); - if (!entry) { - entry = document.createElement('li'); - entry.dataset.signalKey = key; - const time = document.createElement('time'); - const body = document.createElement('div'); - const heading = document.createElement(item.target ? 'button' : 'strong'); - if (item.target) { - heading.type = 'button'; - heading.className = 'cockpit-signal-target'; - const label = document.createElement('span'); - label.className = 'cockpit-signal-target-label'; - const rule = document.createElement('span'); - rule.className = 'cockpit-signal-target-rule'; - rule.setAttribute('aria-hidden', 'true'); - const chevron = document.createElement('span'); - chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - chevron.textContent = 'chevron_right'; - heading.append(label, rule, chevron); - } - body.append(heading, document.createElement('span')); - entry.append(time, body); - } - const [time, body] = entry.children; - const [heading, copy] = body.children; - const className = item.target ? `${item.tone} actionable` : item.tone; - if (entry.className !== className) entry.className = className; - setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); - if (item.target) { - heading.dataset.signalLayer = item.target.layerId; - heading.dataset.signalId = item.target.id; - const label = `Select flight ${item.title}`; - if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); - setText(heading.children[0], item.title); - } else { - setText(heading, item.title); - } - setText(copy, item.detail); - return entry; - }); - const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; - if (focusedEntry && !retainedFocus) { - // A departed contact cannot remain selectable. Continue from the stable - // briefing footer instead of dropping keyboard traversal to the page top. - (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); - } - for (const entry of existing) if (!entries.includes(entry)) entry.remove(); - - // Ordinary insertBefore moves disconnect an element and lose its focus. - // Reorder the other rows around the focused row, which stays connected. - const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; - if (retainedFocus) { - let anchor = retainedFocus; - for (let index = focusIndex - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); - anchor = entry; - } - } - let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; - for (let index = focusIndex + 1; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry === anchor) anchor = anchor.nextElementSibling; - else this.signalList.insertBefore(entry, anchor); - } - this.scheduleContextLayout(); - } - - pushCockpitSignal(key, tone, title, detail, target = null) { - if (!this.signalList || !title || !detail) return; - const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; - if (this.signalSignatures.get(key) === signature) return; - this.signalSignatures.set(key, signature); - this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); - this.signalItems = this.signalItems.slice(0, 5); - this.renderCockpitSignals(); - } - - updateCockpitSignals(snapshot, unknownCount) { - const previous = new Map(this.signalItems.map((item) => [item.key, item])); - const contacts = []; - const subject = snapshot.subject; - if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { - contacts.push({ - key: `flight:${subject.layerId}:${subject.id}`, - tone: 'track', - title: subject.label || subject.id, - detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, - target: { layerId: subject.layerId, id: String(subject.id) }, - distanceM: -1, - }); - } - for (const cohort of snapshot.cohorts) { - if (!['flights', 'military'].includes(cohort.id)) continue; - for (const item of cohort.nearest) { - const id = item.icao24 || item.id; - if (!id) continue; - contacts.push({ - key: `flight:${cohort.id}:${id}`, - tone: cohort.id === 'military' ? 'nearby military' : 'nearby', - // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: - // the row's own `id` already carries the layer's label convention - // (callsign → registration → hex), so a callsign-less enriched - // contact reads as its registration here too. Same helper the - // Context panel's nearest list uses. - title: formatAwarenessLabel(item), - detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ - Number.isFinite(item.distanceM) - ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` - : 'DISTANCE UNKNOWN' - }`, - target: { layerId: cohort.id, id: String(id) }, - distanceM: item.distanceM ?? Infinity, - }); - } - } - contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ - ...item, - timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), - })); - if (unknownCount) { - const sources = snapshot.cohorts - .filter((cohort) => cohort.count === null) - .map((cohort) => cohort.source) - .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', - tone: 'warning', - title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, - detail: sources || 'SOURCE STATUS UNAVAILABLE', - target: null, - timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), - }); - } - this.signalItems = nextItems; - this.signalSignatures.clear(); - this.renderCockpitSignals(); - } - - setContextCollapsed(collapsed) { - const wasCollapsed = this.contextCollapsed; - this.contextCollapsed = Boolean(collapsed); - if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); - if (this.contextToggle) { - const expanded = !this.contextCollapsed; - this.contextToggle.setAttribute('aria-expanded', String(expanded)); - this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); - this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; - const icon = this.contextToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; - } - if (this.active && wasCollapsed && !this.contextCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); - } - this.scheduleContextLayout(); - } - - setSignalCollapsed(collapsed, { user = false } = {}) { - const wasCollapsed = this.signalCollapsed; - this.signalCollapsed = Boolean(collapsed); - if (user) this.signalUserCollapsed = this.signalCollapsed; - if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); - if (this.signalToggle) { - const expanded = !this.signalCollapsed; - this.signalToggle.setAttribute('aria-expanded', String(expanded)); - this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); - this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; - const icon = this.signalToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; - } - if (this.signalCollapsed) this.stopBriefRotation(); - else this.startBriefRotation({ reset: true }); - if (this.active && wasCollapsed && !this.signalCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); - } - this.scheduleContextLayout(); - } - - syncContextLayout() { - if (!this.context || this.context.hidden) return; - if (window.matchMedia('(max-width: 760px)').matches) { - this.context.dataset.layoutMode = 'compact-bottom'; - this.context.style.removeProperty('--cockpit-context-left'); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.context.dataset.layoutMode = 'bottom-left'; - this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - } - - syncSignalLayout() { - if (!this.signalStream || this.signalStream.hidden) return; - const utilityControls = document.getElementById('cockpit-utility-controls'); - if (window.matchMedia('(max-width: 760px)').matches) { - this.signalStream.dataset.layoutMode = 'compact-top'; - utilityControls?.classList.remove('layout-primary-only'); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = Boolean( - utilityControls.querySelector('.cockpit-utility-control.is-expanded') - && !control.classList.contains('is-expanded') - ); - control.setAttribute('aria-hidden', String(hiddenSibling)); - }); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); - this.signalStream.style.removeProperty('--cockpit-signal-right'); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.signalStream.dataset.layoutMode = 'bottom-right'; - this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - const signalBounds = this.signalStream.getBoundingClientRect(); - const utilityBounds = utilityControls?.getBoundingClientRect(); - if (utilityBounds) { - const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); - const collapsedControl = utilityControls?.querySelector( - '.cockpit-utility-control:not(.is-expanded)', - ); - const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); - const expandedHeight = expandedControl - ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) - : 0; - const collapsedHeight = collapsedLauncher - ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) - : 0; - // Cockpit owns this anchor outright. The strip used to inherit the left - // accordion's committed top, which is solved against left-lane obstacles - // and dropped the strip straight through the briefing card below it. - // The readout only anchors the strip while it is genuinely on screen: - // the Minimal variant drops it with `display:none`, but HUD Off hides the - // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. - const recReadout = document.querySelector('#intel-hud .hud-top-right'); - const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; - const utilityAnchor = resolveCockpitUtilityAnchor({ - recBottom: recBounds ? recBounds.bottom : 0, - signalTop: signalBounds.top, - stripHeight: utilityBounds.height, - viewportHeight: window.innerHeight, - collapsedHeight, - recGap: COCKPIT_UTILITY_REC_GAP_PX, - signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, - minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, - minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, - }); - const availableHeight = utilityAnchor.maxHeight; - this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); - this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); - const utilityLayout = expandedControl && collapsedControl - ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) - : { primaryOnly: false, expandedMaxHeight: availableHeight }; - utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, - ); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; - control.setAttribute('aria-hidden', String(hiddenSibling)); - if (hiddenSibling && control.contains(document.activeElement)) { - expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); - } - }); - } - } - - dispose() { - this.exit({ restoreTracking: false }); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.stopBriefRotation(); - if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); - this.contextLayoutFrame = null; - for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); - } -} - -export class StyleManager { - /** - * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. - * @param {object} [options] - */ - constructor(viewer, { mapStackController = null, placeSearch } = {}) { - this.viewer = viewer; - this.mapStackController = mapStackController; - this.placeSearch = placeSearch; - this.stages = {}; - this.activeStyle = 'normal'; - document.documentElement.dataset.gevStyle = this.activeStyle; - this.transitions = new Map(); - this.startTime = Date.now(); - - // True once the user manually changes detection (button/key/slider/voice). - // Gates per-style detection defaults so they never stomp an explicit choice. - this._detectionUserOverridden = false; - - // Bloom/sharpen state - this.bloomEnabled = false; - this.sharpenEnabled = false; - this._bloomStage = null; - this._sharpenStage = null; - this._recordingMode = false; - this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' }; - this._preRecordingHudState = null; - this._panelZCounter = PANEL_Z_BASE + 10; - this._animFrameId = null; - this._lastLoadingFeedbackUpdateAt = 0; - this._loadingFeedbackState = createLoadingFeedbackState(); - this._loadingFeedbackEvent = null; - this._loadingFeedbackTicker = null; - this._globalStatusNotice = null; - this._shareTrackingAcquiringKey = null; - this._shareTrackingNoticeGeneration = 0; - this._globeResetPromise = null; - this._globeResetHandler = null; - this._clearSelectedLayersPromise = null; - this._clearSelectedLayersManagerPromise = null; - this._clearSelectedLayersHandler = null; - this._dataManager = null; - this._cctvUnsubscribe = null; - this._radioUnsubscribe = null; - this._radioState = null; - this._radioCategorySignature = ''; - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerDragging = false; - this._radioTunerDragStartSlot = 0; - this._radioTunerDragSnapshot = null; - this._radioTunerLastSlot = 0; - this._radioTunerDragDirection = 0; - this._radioTunerCoordinate = 0; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - this._radioTunerAbort = null; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerCameraRemove = null; - this._refreshRadioTunerBand = null; - this._cctvState = null; - this._cctvSummaryTypingTimer = null; - this._lastCctvSummaryText = ''; - // Auto-expand guard: last active camera id seen while the layer was - // enabled; routine state notifications with the same id never re-expand. - this._lastSeenCctvActiveId = null; - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = false; - this._leftStackLayoutFrame = null; - this._leftStackReconsiderAutoCollapse = false; - this._leftStackResizeObserver = null; - this._leftStackMutationObserver = null; - this._leftStackHudTransitionHandler = null; - this._leftStackCollapsedHeights = new Map(); - this._leftStackPreferredPanelId = null; - this._rightPanelStack = document.getElementById('right-context-rail'); - this._rightStackLayoutFrame = null; - this._rightStackReconsiderAutoCollapse = false; - this._rightStackResizeObserver = null; - this._rightStackMutationObserver = null; - this._rightStackHudTransitionHandler = null; - this._rightStackPreferredPanelId = null; - this._adaptivePanelSettleTimer = null; - this._windowResizeHandler = null; - this._loadingVisibilityHandler = null; - this._cctvRequestFocusHandler = null; - this._removeCctvRequestFocusListener = null; - this._worldRequestFocusHandler = null; - this._removeWorldRequestFocusListener = null; - this._removeNavigationAuthorityListener = null; - this._navigationOwnerChangedRemover = null; - this._navigationGeneration = 0; - this._activeLocationSearchGeneration = null; - this._initialShareState = null; - this._initialShareNavigationGeneration = null; - this._initialShareRestoreTimeout = null; - this._layerStateCoordinator = null; - this._layerStateRestorePromise = null; - this._awarenessSelectedHandler = null; - this._awarenessClearedHandler = null; - this._disposed = false; - this._draggableResizeObserver = null; - - // DOM refs - this._styleIndicator = document.getElementById('active-style-name'); - this._sliderPanel = document.getElementById('param-slider-panel'); - this._sliderContainer = document.getElementById('param-sliders'); - this._ppToggles = document.getElementById('pp-toggles'); - this._bloomBtn = document.getElementById('bloom-toggle'); - this._bloomSliderRow = document.getElementById('bloom-slider-row'); - this._bloomSlider = document.getElementById('bloom-intensity-slider'); - this._bloomSliderValue = document.getElementById('bloom-intensity-value'); - this._sharpenBtn = document.getElementById('sharpen-toggle'); - this._sharpenSliderRow = document.getElementById('sharpen-slider-row'); - this._sharpenSlider = document.getElementById('sharpen-intensity-slider'); - this._sharpenSliderValue = document.getElementById('sharpen-intensity-value'); - this._hudBtn = document.getElementById('hud-toggle'); - this._hudLayoutRow = document.getElementById('hud-layout-row'); - this._hudLayoutSelect = document.getElementById('hud-layout-select'); - this._detectionSliderRow = document.getElementById('detection-slider-row'); - this._detectionDensitySlider = document.getElementById('detection-density-slider'); - this._detectionDensityValue = document.getElementById('detection-density-value'); - this._detectionAllocationRow = document.getElementById('detection-allocation-row'); - this._detectionAllocationBtns = [ - document.getElementById('detection-allocation-elastic'), - document.getElementById('detection-allocation-weighted'), - ].filter(Boolean); - this._detectionFadeRow = document.getElementById('detection-fade-row'); - this._detectionFadeSlider = document.getElementById('detection-fade-slider'); - this._detectionFadeValue = document.getElementById('detection-fade-value'); - this._detectionOpacityRow = document.getElementById('detection-opacity-row'); - this._detectionOpacitySlider = document.getElementById('detection-opacity-slider'); - this._detectionOpacityValue = document.getElementById('detection-opacity-value'); - let storedDetectionAllocation = 'ELASTIC'; - try { - storedDetectionAllocation = localStorage.getItem(DETECTION_ALLOCATION_STORAGE_KEY) || 'ELASTIC'; - } catch { /* storage can be unavailable in privacy/test contexts */ } - this._detectionAllocationPreference = normalizeAllocationStrategy(storedDetectionAllocation); - this._celestialBtn = document.getElementById('celestial-toggle'); - this._scopeBtn = document.getElementById('scope-toggle'); - this._scopeFeatherSlider = document.getElementById('scope-feather-slider'); - this._scopeFeatherValue = document.getElementById('scope-feather-value'); - this._mapStackChips = document.getElementById('map-stack-chips'); - this._mapStackStatus = document.getElementById('map-stack-status'); - this._mapStackChangeHandler = null; - this._cleanViewBtn = document.getElementById('clean-view-toggle'); - this._cleanViewExitBtn = document.getElementById('clean-view-exit'); - this._dataPanel = document.getElementById('data-panel'); - this._scenePanel = document.getElementById('scene-panel'); - this._cctvPanel = document.getElementById('cctv-panel'); - this._radioPanel = document.getElementById('radio-panel'); - this._contextRadioDock = document.getElementById('context-radio-dock'); - this._contextRadioToggleBtn = document.getElementById('context-radio-toggle-btn'); - this._contextRadioMini = document.getElementById('context-radio-mini'); - this._contextRadioMiniEnableBtn = document.getElementById('context-radio-mini-enable-btn'); - this._contextRadioDetailsBtn = document.getElementById('context-radio-details-btn'); - this._contextRadioMiniCloseBtn = document.getElementById('context-radio-mini-close-btn'); - this._contextRadioMiniStation = document.getElementById('context-radio-mini-station'); - this._contextRadioMiniPrevBtn = document.getElementById('context-radio-mini-prev-btn'); - this._contextRadioMiniPlayBtn = document.getElementById('context-radio-mini-play-btn'); - this._contextRadioMiniNextBtn = document.getElementById('context-radio-mini-next-btn'); - this._contextRadioMiniVolume = document.getElementById('context-radio-mini-volume'); - this._contextRadioMiniVolumeValue = document.getElementById('context-radio-mini-volume-value'); - this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls'); - this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn'); - this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel'); - this._cockpitDisplayPortalRecords = []; - this._cockpitDisplayPortalActive = false; - this._cockpitDisplayModeHandler = null; - this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn'); - this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel'); - this._cockpitRadioEnableBtn = document.getElementById('cockpit-radio-enable-btn'); - this._cockpitRadioStation = document.getElementById('cockpit-radio-station'); - this._cockpitRadioPrevBtn = document.getElementById('cockpit-radio-prev-btn'); - this._cockpitRadioPlayBtn = document.getElementById('cockpit-radio-play-btn'); - this._cockpitRadioNextBtn = document.getElementById('cockpit-radio-next-btn'); - this._cockpitRadioVolume = document.getElementById('cockpit-radio-volume'); - this._cockpitRadioVolumeValue = document.getElementById('cockpit-radio-volume-value'); - this._radioLayerState = document.getElementById('radio-layer-state'); - this._radioEnableBtn = document.getElementById('radio-enable-btn'); - this._radioFilter = document.getElementById('radio-filter'); - this._radioStationName = document.getElementById('radio-station-name'); - this._radioStationMeta = document.getElementById('radio-station-meta'); - this._radioStationTags = document.getElementById('radio-station-tags'); - this._radioTuner = document.getElementById('radio-tuner'); - this._radioTunerSlider = document.getElementById('radio-tuner-slider'); - this._radioTunerNeedle = document.getElementById('radio-tuner-needle'); - this._radioTunerBandLabel = document.getElementById('radio-tuner-band-label'); - this._radioTunerValue = document.getElementById('radio-tuner-value'); - this._radioTunerStation = document.getElementById('radio-tuner-station'); - this._radioPrevBtn = document.getElementById('radio-prev-btn'); - this._radioPlayBtn = document.getElementById('radio-play-btn'); - this._radioNextBtn = document.getElementById('radio-next-btn'); - this._radioStopBtn = document.getElementById('radio-stop-btn'); - this._radioVolume = document.getElementById('radio-volume'); - this._radioVolumeValue = document.getElementById('radio-volume-value'); - this._radioPlaybackState = document.getElementById('radio-playback-state'); - this._radioStationHomepage = document.getElementById('radio-station-homepage'); - this._globalContextFlightsBtn = document.getElementById('global-context-flights-btn'); - this._globalContextMissionsBtn = document.getElementById('global-context-missions-btn'); - this._contextModeStandby = document.getElementById('context-mode-standby'); - this._contextFlightsView = document.getElementById('context-flights-view'); - this._contextMissionsView = document.getElementById('context-missions-view'); - this._contextMode = null; - this._contextModeChanging = false; - this._contextModeGeneration = 0; - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeDeferredEntryIntent = null; - this._contextSessionSnapshot = null; - this._contextRestoreState = null; - this._contextLayerReactionPromises = new Set(); - this._preservePanelStateDuringLayerClear = false; - this._userFacingContextNotificationTokens = new Set(); - this._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe = null; - this._installationsSearchBtn = document.getElementById('installations-search-btn'); - this._leftPanelStack = document.getElementById('left-panel-stack'); - this._cctvEnableBtn = document.getElementById('cctv-enable-btn'); - this._cctvNearestBtn = document.getElementById('cctv-nearest-btn'); - this._cctvPrevBtn = document.getElementById('cctv-prev-btn'); - this._cctvNextBtn = document.getElementById('cctv-next-btn'); - this._cctvSelect = document.getElementById('cctv-camera-select'); - this._cctvFocusBtn = document.getElementById('cctv-focus-btn'); - this._cctvCoverageBtn = document.getElementById('cctv-coverage-btn'); - this._cctvAutoHopBtn = document.getElementById('cctv-auto-hop-btn'); - this._cctvProjectionBtn = document.getElementById('cctv-projection-btn'); - this._cctvQualityChip = document.getElementById('cctv-quality-chip'); - this._cctvAdjustBtn = document.getElementById('cctv-adjust-btn'); - this._cctvCalReadout = document.getElementById('cctv-cal-readout'); - this._cctvCalibSaveBtn = document.getElementById('cctv-calib-save-btn'); - this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn'); - this._cctvFrame = document.getElementById('cctv-frame'); - this._cctvFrameWrap = document.getElementById('cctv-frame-wrap'); - this._cctvFrameRequestToken = 0; - this._cctvFramePreloader = null; - this._cctvSourceBadge = document.getElementById('cctv-source-badge'); - this._cctvMeta = document.getElementById('cctv-meta'); - this._cctvSummary = document.getElementById('cctv-summary'); - this._shareBtn = document.getElementById('share-btn'); - this._clearSelectedLayersBtn = document.getElementById('clear-selected-layers'); - this._globalLoadingStatus = document.getElementById('global-loading-status'); - this._globalLoadingLabel = document.getElementById('global-loading-label'); - this._globalLoadingDetail = document.getElementById('global-loading-detail'); - this._resetGlobeBtn = document.getElementById('reset-globe-view'); - this._cockpitResetGlobeBtn = document.getElementById('cockpit-reset-globe'); - this._styleButtons = document.getElementById('style-buttons'); - this._trafficSyncChip = document.getElementById('traffic-sync-chip'); - this._trafficSyncLabel = document.getElementById('traffic-sync-label'); - this._trafficSyncProgress = document.getElementById('traffic-sync-progress'); - this._cctvSyncChip = document.getElementById('cctv-sync-chip'); - this._cctvSyncLabel = document.getElementById('cctv-sync-label'); - this._cctvSyncProgress = document.getElementById('cctv-sync-progress'); - this._toast = document.getElementById('toast'); - this._locationSearch = document.getElementById('location-search'); - this._searchToggle = document.getElementById('search-toggle'); - this._locationPills = document.getElementById('location-pills'); - this._poiRow = document.getElementById('poi-row'); - this._locationBarDivider = document.getElementById('location-bar-divider'); - this._styleMiniValue = document.getElementById('style-mini-value'); - this._locationMiniCity = document.getElementById('location-mini-city'); - this._locationMiniPoi = document.getElementById('location-mini-poi'); - this._safeFrameOverlay = document.getElementById('safe-frame-overlay'); - this._safeFrameBox = document.getElementById('safe-frame-box'); - this._activeLocationId = null; - this._expandedCityId = null; - this._activePoiIndex = null; - this._currentTarget = null; // Cesium.Cartesian3 of current POI target - this._currentPoi = null; // Current POI data object - // Formatted address of the last free-text geocode search. Preset pills set - // _activeLocationId instead; a search has no preset record, so this is the - // only thing the mini-status can report for it. - this._searchedLocationLabel = null; - this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); - this._trafficTransitionTimer = null; - this._lastTrafficChipUpdateAt = 0; - - // Orbit controller - this.orbitController = new OrbitController(viewer); - this._orbitIndicator = null; - - // Intel HUD - this.hud = new IntelHUD(viewer); - this._cockpitVisionMode = 'optical'; - this._cockpitVisionRestore = null; - this._cockpitPanelRestore = null; - // True only while the open Data Layers panel is the reason Cockpit's - // Contact panel is collapsed. A user-collapsed Contact panel must remain - // collapsed when Data Layers closes. - this._cockpitContextCollapsedForDataPanel = false; - /** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */ - this._contactsDetectionRestore = null; - this.cockpitView = new CockpitViewController(viewer, { - onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), - onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), - getInheritedVisionLabel: () => ( - STYLE_STATUS_LABELS[this.activeStyle] - || String(this.activeStyle || 'normal').toUpperCase() - ), - isEntryAllowed: () => cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }), - onEntered: () => { - // A new Cockpit session owns both side rails. Clear standard map-view - // panels once on entry; NEXT/PREVIOUS never reaches this callback, so - // panels the operator opens while already inside remain untouched. - this._cockpitPanelRestore = new Map(); - this._cockpitContextCollapsedForDataPanel = false; - for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) { - const panel = document.getElementById(panelId); - if (panel) { - this._cockpitPanelRestore.set(panelId, panel.classList.contains('collapsed')); - } - this.setPanelCollapsed(panelId, true, { - persist: false, - syncShare: false, - }); - } - this.cockpitView?.setContextCollapsed(false); - this.cockpitView?.setSignalCollapsed(false, { user: true }); - }, - onExited: () => { - const restore = this._cockpitPanelRestore; - this._cockpitPanelRestore = null; - this._cockpitContextCollapsedForDataPanel = false; - if (!restore) return; - for (const [panelId, wasCollapsed] of restore) { - this.setPanelCollapsed(panelId, wasCollapsed, { - persist: false, - syncShare: false, - }); - } - }, - restoreTrackingFrame: (entity) => { - const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(':'); - const trackedId = idParts.join(':'); - if (!trackedId) return false; - if (layerId === 'flights') return flightsLayer.refocusTrackedById?.(trackedId) === true; - if (layerId === 'military') return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true; - return false; - }, - }); - - // Full-globe sun/moon ring. It is a crisp screen-space overlay above the - // Cesium canvas but below the HUD/detection/readout z ladder. - this.celestialRing = new CelestialRing(viewer, { - enabled: false, - onAutoDisable: () => this.setCelestialRingEnabled(false, { - syncShare: !!this.shareLinkManager, - focus: false, - }), - }); - - // Share Link Manager - this.shareLinkManager = new ShareLinkManager(viewer, { - onRestore: async (state) => { - const { - style, - bloom, - sharpen, - bloomIntensity, - bloomVersion, - sharpenIntensity, - hudVariant, - hudVisible, - detectionMode, - detectionDensity, - detectionAllocation, - detectionFadePct, - detectionOutsideOpacityPct, - celestialRing, - scopeEnabled, - scopeFeatherPct, - scopeTerminusPct, - mapStack, - panelState, - styleParams, - } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { - this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true }); - } - if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { - for (const [uniformName, uniformValue] of Object.entries(styleParams)) { - if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue; - this.stages[style].uniforms[uniformName] = uniformValue; - } - this._updateSliderPanel(style, { reveal: false }); - } - if (typeof bloomIntensity === 'number' && this._bloomSlider) { - const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion); - this._setBloomIntensity(intensity, { syncShare: false }); - } - if (typeof sharpenIntensity === 'number' && this._sharpenSlider) { - const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity))); - this._sharpenSlider.value = String(pct); - this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - } - if (typeof bloom === 'boolean') this._setBloomEnabled(bloom); - if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen); - if (hudVariant) this._setHudVariant(hudVariant); - if (typeof hudVisible === 'boolean') { - this.hud.setMode(hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - if (typeof detectionDensity === 'number' && this._detectionDensitySlider) { - const pct = canonicalizeDensity(detectionDensity); - this._detectionDensitySlider.value = String(pct); - this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - } - if (detectionAllocation) { - this._setDetectionAllocation(detectionAllocation, { syncShare: false, persist: false }); - } - if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(detectionFadePct); - } - if (typeof detectionOutsideOpacityPct === 'number' && this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct); - } - this._applyDetectionFadeFromUi(); - if (detectionMode) this._setDetectionMode(detectionMode); - if (typeof celestialRing === 'boolean') { - this.setCelestialRingEnabled(celestialRing, { syncShare: false, focus: false }); - } - if (typeof scopeEnabled === 'boolean') { - setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); - } - if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); - this._scopeFeatherSlider.value = String(pct); - if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; - setScopeMaskFeather(pct / 100); - } - // null restores the altitude-adaptive ramp; a number pins the terminus - // (clamped to the supported 94..100 band, same as the `sce` hash key). - if (scopeTerminusPct === null) setScopeTerminusOverride(null); - else if (typeof scopeTerminusPct === 'number') { - const pinned = clampScopeTerminusPct(scopeTerminusPct); - setScopeTerminusOverride(pinned == null ? null : pinned / 100); - } - const mapStackRestore = mapStack - ? this._setMapStack(mapStack, { syncShare: false }) - : Promise.resolve(); - if (panelState) this._restorePanelState(panelState); - await mapStackRestore; - this._syncShareState(); - }, - isNavigationCurrent: (generation) => generation === this._navigationGeneration, - cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), - }); - this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState()); - this.shareLinkManager.setStyleParamStateProvider((styleName) => { - const shader = STYLES[styleName]; - const stage = this.stages[styleName]; - if (!shader?.uniforms || !stage) return null; - return Object.fromEntries( - Object.keys(shader.uniforms).map((uniformName) => [uniformName, stage.uniforms[uniformName]]), - ); - }); - // Parse before panel chrome initializes so every valid share URL starts - // from deterministic markup defaults instead of recipient-local panel - // preferences. Encoded panel fields are applied after all panels exist. - this._initialShareState = this.shareLinkManager.parseInitialHash(); - - this._detectionBtn = document.getElementById('detection-toggle'); - this._models3dBtn = document.getElementById('models3d-toggle'); - this._models3dModeRow = document.getElementById('models3d-mode-row'); - this._models3dModeBtns = [ - document.getElementById('models3d-mode-proximity'), - document.getElementById('models3d-mode-all'), - ]; - // DISPLAY-rail 3D-aircraft toggle (flights layer param). DEFAULT-ON in - // PROXIMITY (owner directive 2026-08-22) — mirrors the `models3d` default in - // layerState.js and `_models3dEnabled` in both flight layers, and the `active` - // class the button carries in index.html. A fresh boot skips layer-state - // restoration, so these initializers are the only thing keeping the lit - // button and the armed layer in agreement. - this._models3dEnabled = true; - this._models3dMode = 'proximity'; // 'proximity' (nearest in view) | 'all' (every in-view plane) - - // The shared world-overlay host must own its one postRender lane before - // detection and tracked-readout initialize. It stays transparent until a - // production source explicitly registers entries. - initWorldOverlay(viewer); - - // Initialize detection overlay BEFORE style stages so the composite - // stage is first in the post-process pipeline - initDetection(viewer, [trafficLayer, flightsLayer, militaryFlightsLayer, satellitesLayer, cctvLayer, bikeshareLayer, aisLiveVesselsLayer], (modeLabel) => { - this._updateDetectionButton(modeLabel); - }); - initTrackedReadout(viewer); - setDetectionStyle(this.activeStyle); - this._applyDetectionDensityFromUi(); - - this._initStages(); - this._initBloomSharpen(); - this._initUI(); - this._initMapStackControl(); - this._initPanelChrome(); - this._initLeftPanelAdaptiveLayout(); - this._initRightPanelAdaptiveLayout(); - this._initRadioPanel(); - this._initCctvPanel(); - this._initGlobalContextPanel(); - this._initLocationBar(); - this._initShareButton(); - this._initClearSelectedLayersButton(); - this._initResetGlobeButton(); - this._initHUDToggle(); - this._initModels3dToggle(); - this._applyGlobalPostDefaults(); - this._initOrbit(); - this._initRecordingOverlay(); - this._startAnimationLoop(); - this._startTrafficChipTicker(); - this._updateStyleMiniStatus(); - this._updateLocationMiniStatus(); - - // Restore from URL hash if present - const savedState = this._initialShareState; - this._initialShareRestorePromise = savedState - ? new Promise((resolve) => { this._resolveInitialShareRestore = resolve; }) - : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); - if (savedState) { - this._hasShareState = true; - // Reserve camera authority now; the delayed mesh-friendly flight may - // run only if no newer user, voice, or tracking navigation has won. - this._initialShareNavigationGeneration = this._beginDeferredNavigation( - 'shared view', - { cancelPendingSelection: false }, - ); - this._initialShareRestoreTimeout = setTimeout(() => { - this._initialShareRestoreTimeout = null; - if (this._disposed) return; - const generation = this._initialShareNavigationGeneration; - const applyCamera = Number.isInteger(generation) - && this._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || Promise.resolve([])); - const tracking = share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ status: 'settled', share, layers, tracking }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); - }, 1500); - } else { - this._syncShareState(); - } - // A recipient can orbit before or during the delayed share flight. That - // gesture keeps ordinary layer state but revokes the passive base camera - // and selected-subject Follow so delayed work cannot seize navigation. - this._initialShareGestureHandler = () => { - if ( - this._disposed - || !this._hasShareState - || !this._resolveInitialShareRestore - ) return; - stampInitialShareGesture((options) => this._stampNavigation(options)); - }; - this.viewer?.canvas?.addEventListener('pointerdown', this._initialShareGestureHandler, { - passive: true, - }); - this.viewer?.canvas?.addEventListener('wheel', this._initialShareGestureHandler, { - passive: true, - }); - - // Keep the parameter panel from overlapping toggle controls. - this._layoutRightPanels(); - this._syncCctvPanelViewport(); - this._windowResizeHandler = () => { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this._syncCctvPanelViewport(); - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - }; - window.addEventListener('resize', this._windowResizeHandler); - // The loading-chip ticker is stopped while the tab is hidden (it can do no - // useful work off-screen and must not hold a 60ms timer there). Resample on - // return so the time-driven reducer catches up on real elapsed time — and - // re-arms its own ticker if the batch is still running. - this._loadingVisibilityHandler = () => { - if (!document.hidden) this._updateGlobalLoadingFeedback(); - }; - document.addEventListener('visibilitychange', this._loadingVisibilityHandler); - this._cctvRequestFocusHandler = (event) => routeCctvFocusRequest( - event, - (activate, focus) => this._runExplicitCctvFocus(activate, focus), - (cameraId, durationSec) => cctvLayer.focusCamera(cameraId, durationSec), - ); - this._removeCctvRequestFocusListener = registerCctvFocusRequestListener( - window, - this._cctvRequestFocusHandler, - ); - this._worldRequestFocusHandler = (event) => routeWorldFocusRequest( - event, - (detail, fly) => this._runExplicitWorldFocus(detail, fly), - (detail) => flyToWorldTarget(this.viewer, detail), - ); - this._removeWorldRequestFocusListener = registerWorldFocusRequestListener( - window, - this._worldRequestFocusHandler, - ); - this._navigationOwnerChangedRemover = viewer.trackedEntityChanged.addEventListener((entity) => { - if (entity && !this._disposed) this._stampNavigation({ cancelPendingSelection: false }); - }); - // Vessel/installation focus flies without ever assigning a tracked entity, - // so it cannot reach the listener above. It announces instead. - this._removeNavigationAuthorityListener = registerNavigationAuthorityListener( - window, - (event) => { - if (this._disposed) return; - this._stampNavigation({ - cancelPendingSelection: event?.detail?.cancelPendingSelection !== false, - }); - }, - ); - } - - /** Advance camera authority and settle any older search UI immediately. */ - _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { - this._navigationGeneration += 1; - // A newer destination owns the camera, so the last free-text search is no - // longer where we are. DEFERRED navigation opts out here and clears at the - // reassert seam instead: a geocode that never resolves moves no camera, and - // a lookup that fails must not blank a readout that is still true. - if (clearSearchedLocation) this.clearSearchedLocation(); - if (cancelPendingSelection) { - if (this._hasShareState && this._resolveInitialShareRestore && !this._layerStateCoordinator) { - this._initialShareSelectionSuperseded = true; - } - const passivelyClearedShareSelection = this._layerStateCoordinator - ?.cancelPendingShareTracking?.( - 'superseded-by-explicit-navigation', - { clearSelection: true }, - ) === true; - try { flightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - try { militaryFlightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - try { satellitesLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } - // A deliberate destination supersedes share-selected entities that have - // not arrived yet. Active owners publish their clear when released. - if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('flights', { - selectedFlightsTrackingId: null, - }, { origin: 'tool' }); - } - if (!passivelyClearedShareSelection && !militaryFlightsLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('military', { - selectedMilitaryTrackingId: null, - }, { origin: 'tool' }); - } - if (!passivelyClearedShareSelection && !satellitesLayer.getTrackedInfo?.()) { - this._dataManager?.setLayerParams('satellites', { - selectedSatTrackingId: null, - }, { origin: 'tool' }); - } - } - if (this._activeLocationSearchGeneration !== null) { - this._settleLocationSearchUi(this._activeLocationSearchGeneration); - } - return this._navigationGeneration; - } - - /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { - if (this._activeLocationSearchGeneration !== generation) return; - this._activeLocationSearchGeneration = null; - this._locationSearch?.classList.remove('searching', 'expanded'); - if (this._locationSearch) this._locationSearch.value = ''; - this._locationSearch?.blur(); - } - - /** Release every follow owner while preserving Contact and vessel selection. */ - _releaseFollowCamera({ - preserveVesselSelection = true, - preserveCameraFlight = false, - trackingOrigin = 'tool', - } = {}) { - let contactSelected = false; - try { - contactSelected = Boolean( - militaryAwarenessLayer.releaseCameraOwnership?.({ - preserveVesselSelection, - origin: trackingOrigin, - }), - ); - } catch { - try { flightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - try { militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - if (!preserveVesselSelection) { - try { aisLiveVesselsLayer.clearSelection?.(); } catch { /* best-effort release */ } - } - } - try { satellitesLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } - try { rocketLaunchesLayer.releaseCameraOwnership?.(); } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; - interruptCameraMotion('explicit-navigation'); - this._stopOrbit(); - if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); - try { - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - } catch { /* teardown race */ } - return contactSelected; - } - - /** Run one immediate destination through the shared ownership policy. */ - _runExplicitNavigation(noun, navigate, releaseOptions = undefined) { - return runExplicitNavigation({ - disposed: this._disposed, - cockpitActive: !!this.cockpitView?.active, - noun, - showToast: (text) => this._showToast(text), - stamp: () => this._stampNavigation(), - release: () => this._releaseFollowCamera(releaseOptions), - navigate, - }); - } - - /** Accept a delayed lookup without releasing its current camera owner. */ - _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) { - return beginDeferredNavigation({ - disposed: this._disposed, - cockpitActive: !!this.cockpitView?.active, - noun, - showToast: (text) => this._showToast(text), - // The searched-location readout survives the STAMP; only a flight that - // actually starts invalidates it (see the release hook below). - stamp: () => this._stampNavigation({ cancelPendingSelection, clearSearchedLocation: false }), - }); - } - - /** Final authority check and release immediately before a delayed flight. */ - _reassertNavigationHandoff(generation) { - return reassertNavigationHandoff({ - generation, - currentGeneration: this._navigationGeneration, - cockpitActive: !!this.cockpitView?.active, - disposed: this._disposed, - showToast: (text) => this._showToast(text), - // Reached only once the handoff is granted, immediately before the - // deferred flight starts — so a lookup that failed, was superseded, or - // was refused by the cockpit leaves the old readout standing. - release: () => { - this.clearSearchedLocation(); - return this._releaseFollowCamera(); - }, - }); - } - - /** Public lifecycle seam used by voice location navigation. */ - beginDeferredLocationNavigation() { - return this._beginDeferredNavigation('location'); - } - - /** Public final-authority seam used by voice geocoding. */ - reassertDeferredLocationNavigation(generation) { - return this._reassertNavigationHandoff(generation); - } - - /** Public immediate route used by voice destinations. */ - runImmediateLocationNavigation(navigate) { - return this.runImmediateNavigation('location', navigate); - } - - /** Public authority facade used by validated voice camera destinations. */ - runImmediateNavigation(noun, navigate, releaseOptions = undefined) { - return this._runExplicitNavigation(noun, navigate, releaseOptions); - } - - /** Supersede deferred work when an owner-specific route handles release. */ - supersedeDeferredNavigation() { - return this._stampNavigation(); - } - - /** Route a valid vessel/fire request through the shared navigation policy. */ - _runExplicitWorldFocus(detail, fly) { - return this._runExplicitNavigation(detail?.kind || 'target', fly); - } - - /** Return the aircraft tracker owned before a multi-step Cockpit transaction. */ - getAircraftTrackingTarget() { - return aircraftTrackingTarget(this.cockpitView?.readAircraftInfo?.()); - } - - /** - * On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall - * outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and - * clamp its top. No-op until the panel has been positioned (explicit inline top). - * @returns {void} - */ - _reclampDraggablePanels() { - const el = this._ppToggles; - if (!el || !el.style.top || el.style.top === 'auto') return; - const top = parseInt(el.style.top, 10); - if (!Number.isFinite(top)) return; - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); - } - - /** - * Creates one CesiumJS PostProcessStage per visual style and registers - * it with the scene. Each stage starts with intensity 0 (invisible) - * so crossfade transitions can animate it in later. - * @returns {void} - */ - _initStages() { - for (const [name, shader] of Object.entries(STYLES)) { - const uniforms = { intensity: 0.0 }; - - // Auto-detect time uniform — animated shaders (CRT scanlines, snow, etc.) - // declare `uniform float time` and receive elapsed seconds each frame. - if (shader.fragmentShader.includes('uniform float time')) { - uniforms.time = 0.0; - } - - // Initialize custom uniforms from shader metadata (e.g. gain, pixelation) - if (shader.uniforms) { - for (const [uName, uMeta] of Object.entries(shader.uniforms)) { - uniforms[uName] = uMeta.default; - } - } - - const stage = new Cesium.PostProcessStage({ - name: `godsEyeView_${name}`, - fragmentShader: shader.fragmentShader, - uniforms, - }); - - // Zero-intensity stages are DISABLED (perf wave 1). History: the - // first attempt at this deleted the product's signature scope — the - // circular starfield mask was an EMERGENT artifact of these six - // stacked "identity" passes, not an implemented feature. The owner - // ruled to reimplement the scope explicitly (src/scopeMask.js, a - // featherable zero-per-frame canvas), which frees these passes for - // real. If the scope ever looks wrong, look there — not here. - stage.enabled = false; - this.viewer.scene.postProcessStages.add(stage); - this.stages[name] = stage; - } - // Frozen after init — cached so the per-frame animation loop doesn't - // rebuild Object.entries arrays every frame. - this._stageEntries = Object.entries(this.stages); - } - - /** - * Single write path for style-stage intensity: keeps `enabled` in - * lockstep so zero-intensity stages cost nothing (safe now that the - * scope is explicit — see _initStages). The stage enables on the same - * frame the first non-zero intensity lands, so crossfades never pop. - * @param {Cesium.PostProcessStage} stage - Style post-process stage. - * @param {number} value - Intensity in [0, 1]. - * @returns {void} - */ - _setStageIntensity(stage, value) { - if (!stage) return; - stage.uniforms.intensity = value; - stage.enabled = value > 0.001; - // An animated shader becoming visible needs the style loop (its clock) - // running again; the loop self-stops when nothing visible animates. - if (stage.enabled && stage.uniforms.time !== undefined) this._startAnimationLoop(); - governorRequestRender('style-stage'); - } - - /** - * Re-sync every stage's `enabled` flag from its CURRENT intensity. - * - * The cockpit-vision policy helpers (src/cockpitVisionPolicy.js) are pure - * intensity math — they write `uniforms.intensity` directly and know - * nothing about the enabled/intensity lockstep _setStageIntensity owns. - * Without this sweep a stage the policy raised to 1 would stay DISABLED - * and cockpit NVG/FLIR/CRT would render nothing at all. (Inert while the - * chain is permanently enabled; load-bearing again once the explicit - * scope frees the zero-intensity stages — see _initStages.) - * @returns {void} - */ - _syncStagesEnabledFromIntensity() { - if (!this.stages) return; - for (const stage of Object.values(this.stages)) { - this._setStageIntensity(stage, stage.uniforms.intensity); - } - } - - /** - * 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"). - * - * The scope is the CONTACTS SESSION, not Cockpit. Cockpit enter/exit and - * third-person tracking are moves WITHIN that session and deliberately do not - * touch detection — an earlier build hooked this to cockpit enter/exit, which - * is exactly what turned detections off when the owner left the cockpit. - * - * Called from `_syncContextModeButtons`, the single funnel every - * `_contextMode` mutation routes through, and gated on the transaction having - * SETTLED (`!_contextModeChanging`) so a failed activation can never strand - * detection on. - * @returns {void} - */ - _syncContactsDetection() { - if (this._contextModeChanging) return; - const result = applyContactsDetection({ - active: this._contextMode === 'flights', - restore: this._contactsDetectionRestore, - // A map style picked DURING the session owns detection on the way out — - // its auto-enable preset is younger than the entry snapshot. - styleOwnsDetection: !this._detectionUserOverridden - && Boolean(STYLE_PRESET_DEFAULTS[this.activeStyle]?.detection), - // The snapshot must cover everything activation mutates — the preset - // writes DENSITY as well as mode, so a mode-only snapshot returned - // OFF @ 25% as OFF @ 75% and the next manual enable came back Dense. - getState: () => { - const state = this.getDetectionState(); - return { mode: state.detectionMode, densityPct: state.densityPct }; - }, - // Owner playtest: the force-on lands on the tactical look the military - // styles apply — the SAME preset object — not on whatever profile the - // operator last happened to leave detection at. - applyPreset: () => this._applyDetectionPreset(MILITARY_DETECTION_PRESET), - // The preset applier IS the state replayer: same density-then-mode order, - // same slider writes, so a restore round-trips exactly. - restoreState: (state) => this._applyDetectionPreset(state), - }); - const hadOwnership = Boolean(this._contactsDetectionRestore); - this._contactsDetectionRestore = result.restore; - // Serialization reads that ownership: while Contacts holds it the link - // carries the SAVED snapshot, and once released it carries live state. The - // share cache therefore goes stale on any ownership transition, whether or - // not the detection engine itself moved — and it does not always move. - // Exiting while a military style owns detection returns changed:false (the - // style's preset already matches), and returning early there left a copied - // link claiming the operator's pre-Contacts values while the map showed - // Dense @ 75%. - if (!shareCacheNeedsHeal({ - changed: result.changed, - hadOwnership, - hasOwnership: Boolean(result.restore), - })) return; - if (result.changed) this._syncDetectionUiFromEngine(); - this._syncShareState(); - } - - /** Apply a temporary cockpit-only CRT/NVG/FLIR/NOIR post-process override. */ - _setCockpitVision(mode, active, { revealParameters = false } = {}) { - const next = active ? normalizeCockpitVisionMode(mode) : 'optical'; - if (!this.stages) return; - if (!active) { - if (this._cockpitVisionRestore) { - for (const [name, intensity] of Object.entries(this._cockpitVisionRestore)) { - if (this.stages[name]) this._setStageIntensity(this.stages[name], intensity); - } - } - this._cockpitVisionRestore = null; - this._cockpitVisionMode = 'optical'; - this._syncIrBoost(); // Cockpit exit: fall back to the map preset's IR state - this._updateSliderPanel(this.activeStyle, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - return; - } - if (!this._cockpitVisionRestore) { - this._cockpitVisionRestore = captureCockpitVisionBaseline(this.stages, this.transitions); - } - if (next === 'optical') { - applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); - this._syncStagesEnabledFromIntensity(); - this._cockpitVisionMode = next; - this._syncIrBoost(); - this._updateSliderPanel(this.activeStyle, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - return; - } - const target = applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); - this._syncStagesEnabledFromIntensity(); - this._cockpitVisionMode = next; - this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear) - this._updateSliderPanel(target || null, { reveal: false }); - this._revealCockpitStyleParameters({ openDisplay: revealParameters }); - } - - /** IR hot-target boost (owner playtest 2026-08-16): under the luminance- - * mapped NVG/FLIR looks the 3D fleets flip to flat white so contacts read - * HOT instead of vanishing mid-gray; restored when the look exits. The - * EFFECTIVE look is Cockpit's vision override while Cockpit is active - * ('nvg'/'thermal', which can differ from the map preset in BOTH - * directions), otherwise the map preset ('surveillance'/'thermal'). */ - _syncIrBoost() { - const cockpitMode = this.cockpitView?.active ? this._cockpitVisionMode : null; - const effective = cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle; - const irBoost = effective === 'surveillance' || effective === 'thermal' || effective === 'nvg'; - this._dataManager?.setLayerParams('flights', { irBoost }); - this._dataManager?.setLayerParams('military', { irBoost }); - // Fog blends distant geometry toward an effectively-BLACK color in this - // app (the Cesium globe is hidden), so beyond ~100 km every 3D aircraft - // fogs to a black silhouette — lighting and shaders can't reach past it - // (owner cockpit-FLIR field rounds, 2026-08-16). IR sensors see through - // haze, so the boost styles simply turn fog off; the prior state restores - // on exit. Transition-guarded so repeated syncs don't clobber the saved value. - const scene = this.viewer?.scene; - if (scene?.fog && irBoost !== this._irBoostActive) { - this._irBoostActive = irBoost; - if (irBoost) { - this._irFogWasEnabled = scene.fog.enabled; - scene.fog.enabled = false; - } else if (this._irFogWasEnabled != null) { - scene.fog.enabled = this._irFogWasEnabled; - this._irFogWasEnabled = null; - } - scene.requestRender?.(); - } - } - - /** Keep Cockpit's inherited label and restore target aligned with the active map preset. */ - _syncCockpitInheritedStyle() { - if (!this.cockpitView?.active || !this.stages) return; - this._cockpitVisionRestore = Object.fromEntries( - Object.keys(this.stages).map((name) => [name, name === this.activeStyle ? 1 : 0]), - ); - for (const name of Object.keys(this.stages)) this.transitions.delete(name); - this.cockpitView.setVisionMode(this.cockpitView.visionMode); - } - - /** Reveal shared style parameters, optionally opening Cockpit Display first. */ - _revealCockpitStyleParameters({ openDisplay = false } = {}) { - if (!this.cockpitView?.active || !this._sliderPanel?.classList.contains('active')) return; - if (openDisplay && this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') { - this._setCockpitDisclosure?.('display', true); - return; - } - if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') return; - this._sliderPanel.classList.remove('collapsed'); - this._syncPanelCollapseButton(this._sliderPanel); - requestAnimationFrame(() => requestAnimationFrame(() => { - this._sliderPanel?.scrollIntoView?.({ block: 'nearest' }); - })); - } - - /** - * Configures Cesium's built-in bloom stage and adds a custom unsharp-mask - * sharpen stage to the post-process pipeline. Both start disabled. - * @returns {void} - */ - _initBloomSharpen() { - // Bloom — use Cesium's built-in bloom - this._bloomStage = this.viewer.scene.postProcessStages.bloom; - this._bloomStage.enabled = false; - this._bloomStage.uniforms.glowOnly = false; - this._bloomStage.uniforms.contrast = 256.0; - this._bloomStage.uniforms.brightness = -0.35; - this._bloomStage.uniforms.delta = 0.25; - this._bloomStage.uniforms.sigma = 0.35; - this._bloomStage.uniforms.stepSize = 1.0; - - // Sharpen — custom unsharp mask PostProcessStage - this._sharpenStage = new Cesium.PostProcessStage({ - name: 'godsEyeView_sharpen', - fragmentShader: SHARPEN_SHADER, - uniforms: { - amount: 1.3, - }, - }); - this._sharpenStage.enabled = false; - this.viewer.scene.postProcessStages.add(this._sharpenStage); - if (this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } - } - - /** - * Reads the current bloom intensity percentage from the UI slider. - * @returns {number} Clamped bloom intensity (0-200). - */ - _getBloomIntensity() { - return clampBloomIntensity(parseInt(this._bloomSlider?.value || `${BLOOM_INTENSITY_DEFAULT}`, 10)); - } - - /** - * Enables or disables the Cesium bloom stage based on both the user toggle - * and whether the computed strength exceeds the perceptual threshold (0.06). - * @returns {void} - */ - _syncBloomStageEnabled() { - if (!this._bloomStage) return; - const strength = bloomStrengthFromIntensity(this._getBloomIntensity()); - this._bloomStage.enabled = this.bloomEnabled && strength > 0.06; - } - - /** - * Sets the bloom intensity, updates the slider UI, and applies the value. - * @param {number} intensity - Raw intensity percentage. - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - Whether to push state to the share link. - * @returns {void} - */ - _setBloomIntensity(intensity, { syncShare = true } = {}) { - governorRequestRender('bloom'); - const clamped = clampBloomIntensity(intensity); - if (this._bloomSlider) this._bloomSlider.value = String(clamped); - if (this._bloomSliderValue) this._bloomSliderValue.textContent = `${clamped}%`; - this._applyBloomIntensity(clamped); - if (syncShare) this._syncShareState(); - } - - /** - * Maps a bloom intensity percentage to Cesium bloom stage uniforms. - * Uses smoothstep easing (Hermite interpolation: 3t^2 - 2t^3) to - * produce a perceptually linear glow ramp from zero to full strength. - * @param {number} intensity - Bloom intensity percentage (0-200). - * @returns {void} - */ - _applyBloomIntensity(intensity) { - if (!this._bloomStage) return; - const rawStrength = bloomStrengthFromIntensity(intensity); - // Dead-zone: strengths below 0.06 are imperceptible, clamp to zero. - const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); - // Smoothstep easing for perceptually linear bloom ramp - const eased = strength * strength * (3.0 - 2.0 * strength); - - // Mapping tuned for intuitive UX: - // 0 => effectively no glow, 200 => strong glow. - // Keep threshold strict at low values so only very bright highlights bloom. - this._bloomStage.uniforms.contrast = 255.0 - (eased * 168.0); - this._bloomStage.uniforms.brightness = -0.5 + (eased * 0.36); - this._bloomStage.uniforms.sigma = 0.28 + (eased * 6.3); - this._bloomStage.uniforms.delta = 0.2 + (eased * 2.25); - this._bloomStage.uniforms.stepSize = 1.0 + (eased * 1.25); - this._syncBloomStageEnabled(); - } - - /** - * Toggles bloom on/off, syncs button state, and reveals/hides the intensity slider row. - * @param {boolean} enabled - Whether bloom should be active. - * @returns {void} - */ - _setBloomEnabled(enabled) { - governorRequestRender('bloom'); - this.bloomEnabled = !!enabled; - this._syncBloomStageEnabled(); - this._bloomBtn.classList.toggle('active', this.bloomEnabled); - this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled); - if (this.bloomEnabled) { - this._applyBloomIntensity(this._getBloomIntensity()); - } - this._syncShareState(); - this._layoutRightPanels(); - } - - /** - * Maps a normalized sharpen value (0-1) to the unsharp-mask `amount` uniform. - * Range: 0.1 (subtle) to 2.1 (aggressive edge enhancement). - * @param {number} val - Normalized sharpen intensity (0.0 to 1.0). - * @returns {void} - */ - _applySharpenIntensity(val) { - governorRequestRender('sharpen'); - if (!this._sharpenStage || !this._sharpenStage.uniforms) return; - this._sharpenStage.uniforms.amount = 0.1 + val * 2.0; - } - - /** - * Toggles sharpening on/off, syncs button state, and reveals/hides the intensity slider row. - * @param {boolean} enabled - Whether sharpening should be active. - * @returns {void} - */ - _setSharpenEnabled(enabled) { - governorRequestRender('sharpen'); - this.sharpenEnabled = !!enabled; - this._sharpenStage.enabled = this.sharpenEnabled; - this._sharpenBtn.classList.toggle('active', this.sharpenEnabled); - if (this._sharpenSliderRow) { - this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled); - } - if (this.sharpenEnabled && this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } - this._syncShareState(); - this._layoutRightPanels(); - } - - /** - * Wires up all primary UI event listeners: style buttons, keyboard shortcuts - * (1-8 style keys, H/O/V/F/D/C hotkeys, Escape), AI prompt input with - * debounce, bloom/sharpen/HUD toggles, detection density slider, and - * clean-view toggle. - * @returns {void} - */ - _initUI() { - this._applicationShortcuts?.destroy(); - this._applicationShortcuts = bindApplicationShortcuts({ - documentRef: document, - searchInput: this._locationSearch, - actions: { - setStyle: (style) => this.setStyle(style), - dismissSearch: () => { - if (this._locationSearch.classList.contains('expanded')) { - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); - } - }, - toggleHud: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.toggle(); - this._updateHudButtonState(); - this._syncShareState(); - }, - toggleOrbit: () => this._toggleOrbit(), - toggleCleanView: () => this.toggleCleanView(), - toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'), - cycleDetection: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - cycleDetectionMode(); - this._syncShareState(); - }, - toggleCctv: () => this._toggleCctvEnabled(), - }, - }); - - this._displayControls?.destroy(); - this._displayControls = bindDisplayControls({ - elements: { - styleButtons: document.querySelectorAll('.style-btn'), - bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider, - sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, - scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, - hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, - cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn, - densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn, - allocationButtons: this._detectionAllocationBtns, - fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider], - celestialButton: this._celestialBtn, - modelsButton: this._models3dBtn, - modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [], - }, - actions: { - setStyle: (style) => this.setStyle(style), - toggleBloom: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomEnabled(!this.bloomEnabled); - }, - setBloomIntensity: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setBloomIntensity(value); - }, - toggleSharpen: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setSharpenEnabled(!this.sharpenEnabled); - }, - toggleScope: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const next = !isScopeMaskEnabled(); - setScopeMaskEnabled(next); - this._scopeBtn.classList.toggle('active', next); - this._scopeBtn.setAttribute('aria-pressed', String(next)); - this._syncShareState(); - }, - setScopeFeather: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = Math.max(0, Math.min(100, value || 0)); - if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; - setScopeMaskFeather(pct / 100); - this._syncShareState(); - }, - setSharpenIntensity: (pct) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - this._syncShareState(); - }, - setHudLayout: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(value); - }, - toggleCleanView: () => this.toggleCleanView(), - exitCleanView: () => this.toggleCleanView(false), - setDensity: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - const pct = canonicalizeDensity(value); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - this._syncShareState(); - }, - setAllocation: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - this._setDetectionAllocation(value); - }, - setFade: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._applyDetectionFadeFromUi(); - this._syncShareState(); - }, - toggleCelestial: () => { - const ringIsVisible = !!this.celestialRing?.visible; - if (!this.celestialRingEnabled || !ringIsVisible) { - this.setCelestialRingEnabled(true, { focus: true }); - } else { - this.setCelestialRingEnabled(false); - } - }, - toggleHud: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.toggle(); - this._updateHudButtonState(); - this._syncShareState(); - }, - cycleDetection: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - cycleDetectionMode(); - this._syncShareState(); - }, - toggleModels: () => { - this._setModels3dEnabled(!this._models3dEnabled); - this._syncModels3dModeRow(); - }, - setModelsMode: (mode) => this._setModels3dMode(mode), - }, - }); - } - - /** - * Renders the owner-approved map stack chip row from the matching controller - * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, - * with an accessible explanation, until a CESIUM_ION_TOKEN is configured. - * @returns {void} - */ - _initMapStackControl() { - if (!this._mapStackChips || !this.mapStackController) return; - - if (!this._mapStackChangeHandler) { - // Provider-driven transitions (notably Esri tile-error fallback) do not - // pass through `_setMapStack()`. Follow the controller's existing public - // event so the lit tile, the status line, AND the durable share state all - // describe the rendered source — without the share sync, a silent - // fallback leaves copyLink() encoding a stack that is no longer shown. - this._mapStackChangeHandler = (event) => { - this._renderMapStackState(event.detail); - this._syncShareState(); - }; - window.addEventListener('gev:map-stack-changed', this._mapStackChangeHandler); - } - - renderMapStackChips(this._mapStackChips, this.mapStackController.getStacks(), { - activeId: this.mapStackController.getActiveId(), - onSelect: (stackId) => { this._setMapStack(stackId); }, - }); - - this._renderMapStackState(this.mapStackController.getState()); - } - - /** - * Switches the active map/globe source stack. - * @param {string} stackId - Map stack id. - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - Whether to update the share link. - * @returns {Promise} - */ - async _setMapStack(stackId, { syncShare = true } = {}) { - if (!this.mapStackController) return; - if (syncShare) this.shareLinkManager?.claimRestoreLane?.('map'); - const before = this.mapStackController.getActiveId(); - this._renderMapStackState(this.mapStackController.getState('switching')); - const state = await this.mapStackController.setStack(stackId); - this._renderMapStackState(state); - - if (state?.activeId === before && stackId !== before && state?.lastError) { - this._showToast(state.lastError); - } - if (syncShare) this._syncShareState(); - } - - /** - * Syncs the map stack chip row and status chip with controller state. The - * lit chip always follows `state.activeId`, never the click — a rejected or - * superseded switch therefore leaves the genuinely active stack lit. - * @param {object} state - Map stack controller state. - * @returns {void} - */ - _renderMapStackState(state) { - if (!state) return; - syncMapStackChips(this._mapStackChips, state.activeId); - if (this._mapStackStatus) { - const stack = state.activeStack; - const label = state.status === 'switching' - ? '...' - : (stack?.shortLabel || stack?.label || 'MAP'); - this._mapStackStatus.textContent = label; - this._mapStackStatus.classList.toggle('warn', !!state.lastError); - } - } - - /** - * Reads and canonicalizes the five-stop density control. The engine derives - * Sparse/Balanced/Dense from the same stop. - * @returns {void} - */ - _applyDetectionDensityFromUi() { - if (!this._detectionDensitySlider) return; - const pct = canonicalizeDensity(this._detectionDensitySlider.value); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - setDetectionTuning({ densityPct: pct }); - this._updateDetectionButton(getDetectionMode()); - } - - /** Apply responsive keyhole fade controls from normalized UI percentages. */ - _applyDetectionFadeFromUi() { - const fadePct = Math.max(0, Math.min(40, Math.round(Number(this._detectionFadeSlider?.value) || 0))); - const outsideOpacityValue = this._detectionOpacitySlider?.value; - const outsideOpacityPct = Math.max( - 0, - Math.min(100, Math.round(outsideOpacityValue == null ? 3 : Number(outsideOpacityValue) || 0)), - ); - if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(fadePct); - if (this._detectionFadeValue) this._detectionFadeValue.textContent = `${fadePct}%`; - if (this._detectionOpacitySlider) this._detectionOpacitySlider.value = String(outsideOpacityPct); - if (this._detectionOpacityValue) this._detectionOpacityValue.textContent = `${outsideOpacityPct}%`; - setKeyholeFadeTuning({ - fadeRatio: fadePct / 100, - outsideOpacity: outsideOpacityPct / 100, - }); - this.viewer.scene.requestRender?.(); - } - - _setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) { - const raw = String(strategy || '').trim().toUpperCase(); - if (!ALLOCATION_STRATEGIES.includes(raw)) return false; - const normalized = normalizeAllocationStrategy(raw); - this._detectionAllocationPreference = normalized; - setDetectionTuning({ allocationStrategy: normalized }); - for (const button of this._detectionAllocationBtns) { - const active = button.dataset.allocation === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } - if (persist) { - try { localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized); } catch { /* best effort */ } - } - if (syncShare) this._syncShareState(); - return true; - } - - _syncDetectionUiFromEngine() { - const tuning = getDetectionTuning(); - if (this._detectionDensitySlider) this._detectionDensitySlider.value = String(tuning.densityPct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${tuning.densityPct}%`; - this._setDetectionAllocation(tuning.allocationStrategy, { syncShare: false, persist: false }); - const fadeTuning = getKeyholeFadeTuning(); - if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(Math.round(fadeTuning.fadeRatio * 100)); - if (this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(Math.round(fadeTuning.outsideOpacity * 100)); - } - this._applyDetectionFadeFromUi(); - this._updateDetectionButton(getDetectionMode()); - } - - /** - * Activates a detection overlay mode by label (e.g. 'OFF', 'SPARSE', 'PANOPTIC'). - * @param {string} modeLabel - Detection mode label to set. - * @returns {void} - */ - _setDetectionMode(modeLabel) { - if (!modeLabel) return; - setDetectionModeByLabel(modeLabel); - this._syncDetectionUiFromEngine(); - this._syncShareState(); - } - - /** - * Switches the HUD layout variant (e.g. 'tactical', 'minimal') and syncs - * the layout dropdown if present. - * @param {string} variantName - HUD variant identifier. - * @returns {void} - */ - _setHudVariant(variantName) { - if (!variantName) return; - this.hud.setVariant(variantName); - if (this._hudLayoutSelect && this._hudLayoutSelect.value !== this.hud.getVariant()) { - this._hudLayoutSelect.value = this.hud.getVariant(); - } - this._syncShareState(); - this._scheduleAdaptivePanelLayout({ settle: true }); - } - - /** - * Keeps both responsive panel lanes and Cockpit's utility strip on the same - * measured layout commit. HUD visibility transitions can outlive the first - * animation frame, so variant changes receive one bounded settling pass. - * @param {{settle?: boolean}} [options] Whether to remeasure after transitions. - * @returns {void} - */ - _scheduleAdaptivePanelLayout({ settle = false } = {}) { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this.cockpitView?.scheduleContextLayout(); - if (!settle) return; - clearTimeout(this._adaptivePanelSettleTimer); - this._adaptivePanelSettleTimer = setTimeout(() => { - this._adaptivePanelSettleTimer = null; - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - this.cockpitView?.scheduleContextLayout(); - }, COCKPIT_LAYOUT_SETTLE_MS); - } - - /** - * Applies preset defaults (bloom, sharpen, shader uniforms, HUD variant) - * when a military-class style (CRT, NVG, FLIR) is selected. Does nothing - * for styles without entries in STYLE_PRESET_DEFAULTS. - * @param {string} styleName - The style whose defaults to apply. - * @returns {void} - */ - _applyStylePresetDefaults(styleName) { - const preset = STYLE_PRESET_DEFAULTS[styleName]; - if (!preset) return; - - if (preset.styleParams && typeof preset.styleParams === 'object') { - for (const [targetStyle, params] of Object.entries(preset.styleParams)) { - const stage = this.stages[targetStyle]; - if (!stage || !params || typeof params !== 'object') continue; - for (const [uniformName, uniformValue] of Object.entries(params)) { - if (stage.uniforms[uniformName] === undefined) continue; - stage.uniforms[uniformName] = uniformValue; - governorRequestRender('style-param'); - } - } - } - - const bloomInput = preset.bloom || {}; - if (typeof bloomInput.intensity === 'number' && this._bloomSlider) { - this._setBloomIntensity(clampBloomIntensity(bloomInput.intensity), { syncShare: false }); - } - if (typeof bloomInput.enabled === 'boolean') { - this._setBloomEnabled(bloomInput.enabled); - } - - const sharpenInput = preset.sharpen || {}; - if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { - const sharpenPct = Math.max(0, Math.min(100, Math.round(sharpenInput.intensity))); - this._sharpenSlider.value = String(sharpenPct); - this._sharpenSliderValue.textContent = `${sharpenPct}%`; - this._applySharpenIntensity(sharpenPct / 100); - } - if (typeof sharpenInput.enabled === 'boolean') { - this._setSharpenEnabled(sharpenInput.enabled); - } - - if (preset.hudVariant) { - this._setHudVariant(preset.hudVariant); - } - if (typeof preset.hudVisible === 'boolean') { - this.hud.setMode(preset.hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - - // A style may set a detection default (e.g. military styles -> Dense for - // the "epic" default view), but ONLY if the user hasn't manually changed - // detection this session. Detection is user-controlled and persists across - // style switches, so an explicit Sparse/Off choice is never stomped. - if (preset.detection && !this._detectionUserOverridden) { - this._applyDetectionPreset(preset.detection); - } - } - - /** - * Apply a detection preset's density and mode through the real UI path. - * - * Deliberately does NOT consult `_detectionUserOverridden` — the CALLER owns - * that decision. The style path checks it (an explicit Sparse/Off must - * survive a style switch); Cockpit entry does not (owner: detection is on in - * the cockpit "regardless"). - * @param {{mode?: string, densityPct?: number}} det Preset detection config. - * @returns {void} - */ - _applyDetectionPreset(det) { - if (!det) return; - if (typeof det.densityPct === 'number' && this._detectionDensitySlider) { - const pct = canonicalizeDensity(det.densityPct); - this._detectionDensitySlider.value = String(pct); - if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; - this._applyDetectionDensityFromUi(); - } - if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase()); - } - - /** - * Applies the global post-processing baseline (GLOBAL_POST_DEFAULTS) at - * startup before any share-link restore runs. Sets bloom, sharpen, HUD, - * and detection to their factory defaults. - * @returns {void} - */ - _applyGlobalPostDefaults() { - const defaults = GLOBAL_POST_DEFAULTS; - if (typeof defaults.bloom?.intensity === 'number' && this._bloomSlider) { - this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), { syncShare: false }); - } - if (typeof defaults.bloom?.enabled === 'boolean') { - this._setBloomEnabled(defaults.bloom.enabled); - } - - if (typeof defaults.sharpen?.intensity === 'number' && this._sharpenSlider) { - const sharpenPct = Math.max(0, Math.min(100, Math.round(defaults.sharpen.intensity))); - this._sharpenSlider.value = String(sharpenPct); - this._sharpenSliderValue.textContent = `${sharpenPct}%`; - this._applySharpenIntensity(sharpenPct / 100); - } - if (typeof defaults.sharpen?.enabled === 'boolean') { - this._setSharpenEnabled(defaults.sharpen.enabled); - } - - if (defaults.hudVariant) { - this._setHudVariant(defaults.hudVariant); - } - if (typeof defaults.hudVisible === 'boolean') { - this.hud.setMode(defaults.hudVisible ? 'on' : 'off'); - this._updateHudButtonState(); - } - - if (defaults.detectionMode) { - this._setDetectionMode(defaults.detectionMode); - } - if (typeof defaults.detectionDensity === 'number' && this._detectionDensitySlider) { - const density = canonicalizeDensity(defaults.detectionDensity); - this._detectionDensitySlider.value = String(density); - this._detectionDensityValue.textContent = `${density}%`; - this._applyDetectionDensityFromUi(); - } - this._setDetectionAllocation( - this._detectionAllocationPreference || defaults.detectionAllocation || 'ELASTIC', - { syncShare: false, persist: false }, - ); - if (this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(defaults.detectionFadePct ?? 7); - } - if (this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(defaults.detectionOutsideOpacityPct ?? 1); - } - this._applyDetectionFadeFromUi(); - if (typeof defaults.celestialRing === 'boolean') { - this.setCelestialRingEnabled(defaults.celestialRing, { syncShare: false, focus: false }); - } - } - - /** - * Pushes the current visual state (bloom, sharpen, HUD, detection) to - * the ShareLinkManager so the URL hash stays in sync. - * @returns {void} - */ - /** - * Detection as a DURABLE preference, for serialization into a share link. - * - * While Contacts is active it OWNS detection and forces Dense @ 75%. That is - * a session-scoped override, not something the operator chose: it is undone - * verbatim on deactivation. Serializing the forced values shipped a link that - * pinned Dense @ 75% on the recipient — as a durable preference, with no - * Contacts mode present to explain or undo it — even though the author's own - * setting was (say) OFF @ 50%. Publish what deactivation would restore. - * - * `_contactsDetectionRestore` is exactly that snapshot and is null whenever - * Contacts does not own detection, so the live values are used normally. - */ - _shareableDetectionState() { - return shareableDetectionState({ - owned: this._contactsDetectionRestore, - liveMode: getDetectionMode(), - liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10), - }); - } - - _syncShareState() { - const detection = this._shareableDetectionState(); - this.shareLinkManager.onToggleChange(this.bloomEnabled, this.sharpenEnabled, { - bloomIntensity: this._getBloomIntensity(), - bloomVersion: BLOOM_SCALE_VERSION, - sharpenIntensity: parseInt(this._sharpenSlider?.value || '49', 10), - hudVariant: this.hud.getVariant(), - hudVisible: this.hud.visible, - detectionMode: detection.mode, - detectionDensity: detection.densityPct, - detectionAllocation: getDetectionTuning().allocationStrategy, - detectionFadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - detectionOutsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '1', 10), - celestialRingEnabled: this.celestialRingEnabled, - scopeEnabled: isScopeMaskEnabled(), - scopeFeatherPct: Math.round(getScopeMaskFeather() * 100), - // null when adaptive — the share layer omits `sce` entirely in that case. - scopeTerminusPct: getScopeTerminusOverride() == null - ? null - : Math.round(getScopeTerminusOverride() * 100), - mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', - }); - } - - /** - * Updates the traffic sync status chip with loading phase label and progress. - * Auto-hides after 1.5s when loading completes; stays visible while busy. - * @param {boolean} [forceShow=false] - Force the chip visible regardless of busy state. - * @returns {void} - */ - _updateTrafficSyncChip(forceShow = false, now = performance.now()) { - if (!this._trafficSyncChip || !this._trafficSyncLabel || !this._trafficSyncProgress) return; - const layers = this._dataManager?.getAll?.(); - const traffic = Array.isArray(layers) ? layers.find((layer) => layer.id === 'traffic') : null; - this._trafficSyncFeedbackState = reduceTrafficSyncFeedback( - this._trafficSyncFeedbackState, - { enabled: traffic?.enabled === true, stats: traffic?.stats || {}, forceShow }, - now, - ); - const presentation = this._trafficSyncFeedbackState; - // setSplitFlapText carries the same unchanged-text guard internally, and - // the flap keeps textContent equal to the settled label throughout, so - // this stays a no-op on the repeat ticks exactly as it did before. - if (presentation.label) setSplitFlapText(this._trafficSyncLabel, presentation.label); - // Written on every change INCLUDING the empty settled value — the reducer - // clears the progress number once the sync lands, and a truthiness guard - // here would strand the last "..." beside the settled label. - if (this._trafficSyncProgress.textContent !== presentation.progressText) { - this._trafficSyncProgress.textContent = presentation.progressText; - } - this._trafficSyncChip.classList.toggle('visible', presentation.visible); - } - - /** - * Updates the CCTV loading chip (same pattern as the traffic sync chip) - * with staggered initial-load progress, e.g. "LOADING FRAMES 12/36". - * Shows a brief "camera grid ready" confirmation, then hides. - * @param {{active: boolean, loaded: number, total: number}|null|undefined} loading - * Loading progress from the CCTV layer UI state. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @returns {void} - */ - _updateCctvSyncChip(loading, enabled) { - if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; - const total = Number(loading?.total) || 0; - const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); - const busy = !!enabled && !!loading?.active && total > 0; - - if (busy) { - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = true; - setSplitFlapText(this._cctvSyncLabel, 'loading frames'); - // The counter is left plain on purpose: it ticks every few frames - // during a grid load, and flapping it would read as a slot machine. - this._cctvSyncProgress.textContent = `${loaded}/${total}`; - this._cctvSyncChip.classList.add('visible'); - return; - } - - if (this._cctvChipWasBusy && enabled && total > 0) { - // Load just completed — flash the final count, then auto-hide. - this._cctvChipWasBusy = false; - setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); - this._cctvSyncProgress.textContent = `${total}/${total}`; - this._cctvSyncChip.classList.add('visible'); - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = window.setTimeout(() => { - this._cctvChipHideTimer = null; - this._cctvSyncChip.classList.remove('visible'); - }, 1500); - return; - } - - if (!this._cctvChipHideTimer) { - this._cctvChipWasBusy = false; - this._cctvSyncChip.classList.remove('visible'); - } - } - - /** - * Initializes panel collapse buttons and restores persisted collapsed state. - * Also sets up hover-expand behavior for the style presets and location bar panels. - * @returns {void} - */ - _initPanelChrome() { - for (const control of this._panelDisclosureControls || []) control.destroy(); - this._panelDisclosureControls = []; - const targets = new Map(); - document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((button) => { - const targetId = button.dataset.collapseTarget; - if (!targetId) return; - if (!targets.has(targetId)) targets.set(targetId, []); - targets.get(targetId).push(button); - }); - for (const [targetId, buttons] of targets) { - const panel = document.getElementById(targetId); - if (!panel) continue; - this._panelDisclosureControls.push(bindPanelDisclosure({ - panel, - buttons, - onChange: (collapsed, options) => this.setPanelCollapsed(targetId, collapsed, options), - onEscape: (event) => this._collapsePanelOnEscape(event, targetId), - })); - this._restorePanelCollapsedState(targetId, { - allowStored: !this._initialShareState, - }); - } - // The command dock always starts compact; either wing reveals on hover, - // focus, or click and collapses again after the interaction moves away. - this.setPanelCollapsed('control-panel', true, { syncShare: false, persist: false }); - this.setPanelCollapsed('location-bar', true, { syncShare: false, persist: false }); - this._initAutoHoverPanel('control-panel', { openDelayMs: 140, closeDelayMs: 420 }); - this._initAutoHoverPanel('location-bar', { openDelayMs: 140, closeDelayMs: 420 }); - this._initCommandDockPins(); - this._initCommandDockTrayMetrics(); - this._maybeNotifyLayoutReset(); - } - - /** - * Collapses the nearest expanded panel that owns keyboard focus on Escape. - * Nested panels consume the event first, so one key closes one level and - * returns focus to that level's disclosure. If Escape was pressed on the - * disclosure itself, remove focus after closing so the collapsed button does - * not keep a stale keyboard ring. - * @param {KeyboardEvent} event - Candidate Escape key event. - * @param {string} panelId - Collapsible panel containing the listener. - * @returns {boolean} Whether this panel handled the key. - */ - _collapsePanelOnEscape(event, panelId) { - return collapsePanelOnEscape(event, { - panel: document.getElementById(panelId), - onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), - beforeCollapse: () => { - if (panelId !== 'location-bar' || !this._locationSearch) return; - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); - }, - }); - } - - /** - * Allows either command-dock tray to remain open until explicitly unpinned. - * Both trays may be pinned; transient and error trays stack above them. - * @returns {void} - */ - _initCommandDockPins() { - document.querySelectorAll('.dock-pin-btn[data-pin-target]').forEach((button) => { - button.addEventListener('click', (event) => { - event.stopPropagation(); - const panelId = button.dataset.pinTarget; - this._setCommandDockPanelPinState(panelId); - }); - }); - } - - _setCommandDockPanelPinState(panelId, pin, { - restore = false, - persist = true, - syncShare = true, - } = {}) { - const panelEl = document.getElementById(panelId); - const button = document.querySelector(`.dock-pin-btn[data-pin-target="${panelId}"]`); - if (!panelEl || !button) return undefined; - const shouldPin = typeof pin === 'boolean' - ? pin - : !panelEl.classList.contains('dock-pinned'); - panelEl.classList.toggle('dock-pinned', shouldPin); - button.setAttribute('aria-pressed', String(shouldPin)); - document.querySelectorAll('#command-dock .dock-pinned-top').forEach((pinnedPanel) => { - pinnedPanel.classList.remove('dock-pinned-top'); - }); - if (shouldPin) { - panelEl.classList.add('dock-pinned-top'); - this.setPanelCollapsed(panelId, false, { - explicit: !restore, - restore, - persist, - syncShare: false, - }); - } else { - const remainingPinnedPanel = document.querySelector('#command-dock .dock-pinned'); - remainingPinnedPanel?.classList.add('dock-pinned-top'); - if (!restore && !panelEl.matches(':hover')) { - this.setPanelCollapsed(panelId, true, { - explicit: true, - persist, - syncShare: false, - }); - } - } - this._updateCommandDockTrayStack(); - if (syncShare) { - if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); - this.shareLinkManager?.onPanelStateChange?.(); - } - return shouldPin; - } - - /** - * Tracks the live pinned-tray height so a hovered sibling can stack above it - * without hardcoded content dimensions. - * @returns {void} - */ - _initCommandDockTrayMetrics() { - const dock = document.getElementById('command-dock'); - if (!dock) return; - this._commandDockTrayObserver?.disconnect?.(); - if (typeof ResizeObserver === 'function') { - this._commandDockTrayObserver = new ResizeObserver(() => this._updateCommandDockTrayStack()); - dock.querySelectorAll('.dock-popover-content').forEach((tray) => { - this._commandDockTrayObserver.observe(tray); - }); - } - this._updateCommandDockTrayStack(); - } - - /** - * Writes each pinned tray height and their combined stack height as CSS - * variables. The most recently pinned tray forms the upper level. - * @returns {void} - */ - _updateCommandDockTrayStack() { - const dock = document.getElementById('command-dock'); - if (!dock) return; - const locationPanel = dock.querySelector('#location-bar.dock-pinned:not(.collapsed)'); - const presetsPanel = dock.querySelector('#control-panel.dock-pinned:not(.collapsed)'); - const locationHeight = locationPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; - const presetsHeight = presetsPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; - const pinnedCount = Number(locationHeight > 0) + Number(presetsHeight > 0); - const locationHeightPx = Math.ceil(locationHeight); - const presetsHeightPx = Math.ceil(presetsHeight); - const topPinnedPanel = dock.querySelector('.dock-pinned-top.dock-pinned:not(.collapsed)'); - const lowerPinnedPanel = topPinnedPanel?.id === 'location-bar' ? presetsPanel : locationPanel; - const lowerPinnedHeight = lowerPinnedPanel - ?.querySelector('.dock-popover-content') - ?.getBoundingClientRect().height || 0; - const stackHeight = pinnedCount > 1 - ? `calc(${locationHeightPx}px + ${presetsHeightPx}px + 1.2rem)` - : `${locationHeightPx + presetsHeightPx}px`; - dock.style.setProperty('--dock-location-pinned-height', `${locationHeightPx}px`); - dock.style.setProperty('--dock-presets-pinned-height', `${presetsHeightPx}px`); - dock.style.setProperty('--dock-lower-pinned-height', `${Math.ceil(lowerPinnedHeight)}px`); - dock.style.setProperty('--dock-pinned-stack-height', stackHeight); - dock.classList.toggle('dock-has-pinned-tray', pinnedCount > 0); - dock.classList.toggle('dock-has-two-pinned-trays', pinnedCount > 1); - } - - /** - * One-time toast when stored v6 panel positions are superseded by the v7 - * layout defaults (positions reset; collapsed states are preserved). - * @returns {void} - */ - _maybeNotifyLayoutReset() { - try { - const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`; - if (localStorage.getItem(marker)) return; - localStorage.setItem(marker, '1'); - const hadOldPositions = Object.keys(localStorage) - .some((key) => key.startsWith('godsEyeView.v6.panelPos.')); - if (hadOldPositions) { - this._showToast('Panel layout updated — positions reset to new defaults'); - } - } catch { - // storage unavailable - } - } - - /** - * Configures intentional hover-expand / leave-collapse behavior on a panel. - * Uses separate open/close timers to prevent accidental flicker from fast - * mouse passes. Wheel events cancel pending opens to avoid surprise expansion - * during scroll-through. - * @param {string} panelId - DOM id of the panel element. - * @param {object} [options] - * @param {number} [options.openDelayMs=850] - Hover dwell time before auto-expanding. - * @param {number} [options.closeDelayMs=1000] - Delay after pointer leaves before collapsing. - * @returns {void} - */ - _initAutoHoverPanel(panelId, { openDelayMs = 850, closeDelayMs = 1000 } = {}) { - const panel = document.getElementById(panelId); - if (!panel) return; - this._hoverPanelControls ??= new Map(); - this._hoverPanelControls.get(panelId)?.destroy(); - const controller = createHoverDisclosure({ - panel, - documentRef: document, - disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), - openDelayMs, - closeDelayMs, - isActive: () => !this._disposed, - onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), - onEscape: (event) => this._collapsePanelOnEscape(event, panelId), - focusTarget: panelId === 'control-panel' ? () => ( - panel.querySelector('.map-stack-chip.active') || panel.querySelector('.map-stack-chip') - ) : null, - }); - this._hoverPanelControls.set(panelId, controller); - if (panelId === 'control-panel') { - this._cancelMapSourceFocus?.(); - this._cancelMapSourceFocus = controller.cancelPendingFocus; - } - } - - /** - * Sets up drag-to-reposition for legacy floating controls. The right rail - * and left accordion remain fixed so their HUD alignment is deterministic. - * @returns {void} - */ - _initPanelDrag() { - const dragSpecs = [ - { - id: 'pp-toggles', - panel: this._ppToggles, - handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), - }, - ].filter(Boolean); - - for (const spec of dragSpecs) { - if (!spec.panel || !spec.handle) continue; - this._restorePanelPosition(spec.id, spec.panel); - this._makePanelDraggable(spec.id, spec.panel, spec.handle); - } - // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its - // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and - // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. - if (this._ppToggles && typeof ResizeObserver !== 'undefined') { - this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels()); - this._draggableResizeObserver.observe(this._ppToggles); - } - } - - _persistAwarenessSelection(event, cleared = false) { - if (!this._dataManager) return; - const origin = String(event?.detail?.origin || 'programmatic'); - if (!isExplicitLayerStateOrigin(origin)) return; - const layerId = String(event?.detail?.layerId || ''); - const config = { - flights: { - key: 'selectedFlightsTrackingId', - normalize: (value) => String(value ?? '').trim().toLowerCase() || null, - }, - military: { - key: 'selectedMilitaryTrackingId', - normalize: (value) => String(value ?? '').trim().toLowerCase() || null, - }, - satellites: { - key: 'selectedSatTrackingId', - normalize: (value) => { - const candidate = Number(value); - return Number.isFinite(candidate) && candidate > 0 ? Math.trunc(candidate) : null; - }, - }, - }[layerId]; - if (!config) return; - const selectedValue = cleared ? null : config.normalize(event?.detail?.id); - if (cleared || selectedValue === null) { - this._dataManager.adoptLayerParams?.(layerId, { - [config.key]: selectedValue, - }, { origin }); - return; - } - // A direct selection promotes a Context-owned tracker dependency into - // durable visibility before its selected ID is normalized. Context exit - // also keeps this adopted layer instead of tearing down the user's track. - const visibilityAdopted = this._dataManager.adoptLayerVisibility?.( - layerId, - true, - { origin, adoptedFromSelection: true }, - ); - if (visibilityAdopted === false) return; - // Clear the prior family before publishing the replacement. Otherwise the - // coordinator briefly sees two IDs and correctly treats them as an - // ambiguous incoming state, which would discard the new durable target. - for (const [otherLayerId, otherKey] of [ - ['flights', 'selectedFlightsTrackingId'], - ['military', 'selectedMilitaryTrackingId'], - ['satellites', 'selectedSatTrackingId'], - ]) { - if (otherLayerId === layerId) continue; - this._dataManager.setLayerParams(otherLayerId, { [otherKey]: null }, { origin }); - } - this._dataManager.adoptLayerParams?.(layerId, { - [config.key]: selectedValue, - }, { origin }); - } - - /** - * Connects the layer data manager for traffic sync, CCTV state subscription, - * and layer enable/disable operations. - * @param {object|null} dataManager - The DataManager instance, or null to detach. - * @returns {void} - */ - attachDataManager(dataManager) { - if (this._dataManagerBeforeDestroyUnsubscribe) { - this._dataManagerBeforeDestroyUnsubscribe(); - this._dataManagerBeforeDestroyUnsubscribe = null; - } - if (this._dataManagerVisibilityGuardUnsubscribe) { - this._dataManagerVisibilityGuardUnsubscribe(); - this._dataManagerVisibilityGuardUnsubscribe = null; - } - if (this._dataManagerVisibilityRequestUnsubscribe) { - this._dataManagerVisibilityRequestUnsubscribe(); - this._dataManagerVisibilityRequestUnsubscribe = null; - } - this._dataManager = dataManager || null; - this.hud.attachDataManager(this._dataManager); - this._updateTrafficSyncChip(); - if (this._dataManagerUnsubscribe) { - this._dataManagerUnsubscribe(); - this._dataManagerUnsubscribe = null; - } - if (typeof this._dataManager?.subscribe === 'function') { - this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (String(change?.type || '').startsWith('visibility')) { - this._handleContextLayerChange(change); - } - this._loadingFeedbackEvent = change; - this._updateGlobalLoadingFeedback(performance.now()); - }); - } - this._updateGlobalLoadingFeedback(performance.now()); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { - if (shouldCaptureContextSession(change)) { - // This event is synchronous with intent publication, before an - // awaited guard or Clear All can alter the rest of the layer set. - // Manager effective visibility already includes both the new entry - // intent and Clear's reserved OFF baseline. - this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); - if (shouldDeferContextEntryDuringClear({ - change, - clearInFlight: Boolean(this._clearSelectedLayersPromise), - })) { - this._contextModeDeferredEntryIntent = { - layerId: change.layerId, - intentEpoch: change.intentEpoch, - origin: change.origin, - }; - this._contextModeEntering = 'space-missions'; - this._syncContextModeButtons(); - } - } else if ( - change?.layerId === 'rocket-launches' - && change.enabled === false - && isExplicitUserIntentOrigin(change.origin, change.layerId) - ) { - this._contextModeDeferredEntryIntent = null; - if (this._clearSelectedLayersPromise) { - this._contextSessionSnapshot = null; - this._contextModeEntering = null; - this._syncContextModeButtons(); - } - } - }); - } - if (typeof this._dataManager?.addVisibilityGuard === 'function') { - this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { - const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; - const reason = contextLayerEnableBlockReason({ - contextMode: this._contextModeEntering || this._contextMode, - change, - layerName, - }); - if (reason) return reason; - if ( - change.enabled - && ['military-awareness', 'rocket-launches'].includes(change.layerId) - && shouldCaptureContextSession(change) - && ( - !this._contextModeChanging - || ( - change.layerId === 'rocket-launches' - && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch - ) - ) - ) { - const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; - const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; - // A deferred entry owns the state after Clear settles. Restoring - // Clear's transient busy flag here would leave Context stuck. - const priorChanging = deferredClearEntry ? false : this._contextModeChanging; - const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); - const ownsNotificationToken = !change.notificationToken; - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.add(notificationToken); - } - this._contextModeEntering = entryMode; - this._contextModeChanging = true; - try { - if (deferredClearEntry) { - await this._clearSelectedLayersManagerPromise; - if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; - this._contextModeDeferredEntryIntent = null; - } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); - } catch (error) { - this._contextModeEntering = null; - console.warn(`[Context] ${change.layerId} isolation failed`, error); - try { - await this._restoreContextSession({ - excludeLayerIds: [change.layerId], - notificationToken, - }); - } catch (restoreError) { - console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); - } - return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; - } finally { - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - settleContextModeChange(this, priorChanging); - } - } - return null; - }); - } - if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { - this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { - if (!this._contextSessionSnapshot) return; - await runWithContextModeChanging(this, async () => { - this._contextMode = null; - this.cockpitView?.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); - }); - }); - } - this._syncContextModeButtons(); - if (this._cctvUnsubscribe) { - this._cctvUnsubscribe(); - this._cctvUnsubscribe = null; - } - if (typeof cctvLayer.subscribe === 'function') { - this._cctvUnsubscribe = cctvLayer.subscribe((state) => { - this._renderCctvState(state); - }); - } - if (typeof cctvLayer.getUIState === 'function') { - this._renderCctvState(cctvLayer.getUIState()); - } - if (this._radioUnsubscribe) { - this._radioUnsubscribe(); - this._radioUnsubscribe = null; - } - if (typeof radioLayer.subscribe === 'function') { - this._radioUnsubscribe = radioLayer.subscribe((state) => { - this._renderRadioState(state); - }); - } - if (!this._awarenessSelectedHandler) { - this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); - this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true); - window.addEventListener('gev:awareness-subject-selected', this._awarenessSelectedHandler); - window.addEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler); - } - this._layerStateCoordinator?.destroy(); - this._layerStateCoordinator = null; - this._layerStateRestorePromise = null; - if (this._dataManager) { - this._layerStateCoordinator = new LayerStateCoordinator( - this._dataManager, - this.shareLinkManager, - { - onDurableStateChange: (state) => this._syncModels3dFromLayerState(state), - onTrackingRestoreStatus: (result) => this._handleShareTrackingRestoreStatus(result), - }, - ); - this._layerStateRestorePromise = this._layerStateCoordinator.start({ - shareLayerState: this._initialShareState?.layerState || null, - shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null, - // Any valid camera/style share isolates recipient-local preferences, - // including legacy and malformed-v2 layer payloads. - allowLocalState: !this._initialShareState, - }); - if (this._initialShareSelectionSuperseded) { - this._layerStateCoordinator.cancelPendingShareTracking( - 'superseded-before-layer-coordinator-start', - { clearSelection: true }, - ); - } - void this._layerStateRestorePromise.then(() => { - this._syncModels3dFromLayerState(this._layerStateCoordinator?.getDurableState()); - }); - } - } - - _handleShareTrackingRestoreStatus(result) { - if (!result || this._disposed) return; - const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`; - if (result.classification === 'pending') { - this._shareTrackingNoticeGeneration += 1; - this._shareTrackingAcquiringKey = trackingKey; - this._showGlobalStatusNotice('ACQUIRING', { - state: 'acquiring', - detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`, - persistent: true, - }); - return; - } - const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey; - if (ownsAcquiringNotice) { - this._shareTrackingNoticeGeneration += 1; - this._shareTrackingAcquiringKey = null; - if (this._globalStatusNotice?.state === 'acquiring') { - this._globalStatusNotice = null; - this._updateGlobalLoadingFeedback(); - } - } - if (result.classification === 'followed' || result.classification === 'cancelled') return; - // A stale terminal result must never replace a newer target's acquisition. - if (this._shareTrackingAcquiringKey) return; - const noticeGeneration = ownsAcquiringNotice - ? this._shareTrackingNoticeGeneration - : ++this._shareTrackingNoticeGeneration; - const subject = result.label || 'entity'; - const message = result.classification === 'expired' - ? `Shared ${subject} follow expired` - : result.classification === 'source-unavailable' - ? `Shared ${subject} could not be restored — feed unavailable` - : `Shared ${subject} is unavailable`; - const showAfterStartupCover = () => { - requestAnimationFrame(() => { - if (!canPresentDeferredStatusNotice( - noticeGeneration, - this._shareTrackingNoticeGeneration, - this._disposed, - )) return; - const startupCover = document.getElementById('loading-screen'); - if (!startupCover || getComputedStyle(startupCover).visibility === 'hidden') { - this._showGlobalStatusNotice(message); - return; - } - let fallbackTimer = null; - const showOnce = () => { - startupCover.removeEventListener('transitionend', showOnce); - if (fallbackTimer) clearTimeout(fallbackTimer); - if (canPresentDeferredStatusNotice( - noticeGeneration, - this._shareTrackingNoticeGeneration, - this._disposed, - )) this._showGlobalStatusNotice(message); - }; - startupCover.addEventListener('transitionend', showOnce, { once: true }); - fallbackTimer = setTimeout(showOnce, 1000); - }); - }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } - showAfterStartupCover(); - } - - _initGlobalContextPanel() { - const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); - contextTabs.forEach((tab, index) => tab.addEventListener('keydown', (event) => { - let nextIndex = null; - if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; - else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = contextTabs.length - 1; - if (nextIndex === null) return; - event.preventDefault(); - contextTabs[nextIndex].focus({ preventScroll: true }); - contextTabs[nextIndex].click(); - })); - this._globalContextFlightsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'flights' ? null : 'flights'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Contacts could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'contacts', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._globalContextMissionsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Space Missions could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'space-missions', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._installationsSearchBtn?.addEventListener('click', () => { - if (!this._dataManager?.layers?.has('military-installations')) return; - const button = this._installationsSearchBtn; - if (button.getAttribute('aria-busy') === 'true') return; - button.setAttribute('aria-disabled', 'true'); - button.setAttribute('aria-busy', 'true'); - void this._runUserFacingContextAction(async (notificationToken) => { - const enabled = await this._dataManager.setEnabled('military-installations', true, { - origin: 'user', - notificationToken, - }); - if (enabled === false || !this._dataManager.isEnabled('military-installations')) return false; - const searched = await militaryInstallationsLayer.searchNearby?.(); - if (searched === false) return false; - const stats = militaryInstallationsLayer.getStats?.(); - this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in' - ? 'Zoom in to search mapped installations' - : 'Nearby installations refreshed')); - return true; - }, 'Nearby installations could not be refreshed; try again').finally(() => { - button.setAttribute('aria-disabled', 'false'); - button.setAttribute('aria-busy', 'false'); - }); - }); - } - - async _runUserFacingContextAction( - operation, - message = 'Context could not restore every layer; try again', - { falseIsFailure = true } = {}, - ) { - const notificationToken = Symbol('user-facing-context-action'); - this._userFacingContextNotificationTokens.add(notificationToken); - try { - return await settleUserFacingContextAction({ - operation: () => operation(notificationToken), - falseIsFailure, - onFailure: (error) => { - console.warn('[Context] user-facing transition failed', error); - this._showToast(message); - }, - }); - } finally { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - } - - _trackContextLayerReaction(promise) { - const tracked = Promise.resolve(promise); - this._contextLayerReactionPromises.add(tracked); - void tracked.finally(() => this._contextLayerReactionPromises.delete(tracked)); - return tracked; - } - - async _waitForContextLayerSettlement() { - while (this._contextLayerReactionPromises.size > 0) { - await Promise.allSettled([...this._contextLayerReactionPromises]); - } - } - - _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { - if (!this._dataManager || this._contextSessionSnapshot) return; - const params = {}; - for (const layerId of ['military-awareness', 'satellites']) { - const value = this._dataManager.getLayerParams(layerId); - if (value) params[layerId] = value; - } - this._contextSessionSnapshot = { - enabledLayerIds: contextSnapshotLayerIds( - this._dataManager.getEnabledLayerIds(), - this._contextRestoreState?.enabledLayerIds, - excludeLayerIds, - ), - userAdded: new Set(), - userRemoved: new Set(), - params, - }; - } - - async _restoreContextSession({ - excludeLayerIds = [], - notificationToken = null, - signal = null, - } = {}) { - const snapshot = this._contextSessionSnapshot; - if (!snapshot || !this._dataManager) return; - // Clear the stored session before emitting restore notifications so none - // of those transitions can be mistaken for a fresh Context entry. - this._contextSessionSnapshot = null; - const restoreState = { - enabledLayerIds: contextRestoreLayerIds(snapshot), - explicitLayerStates: new Map(), - }; - this._contextRestoreState = restoreState; - for (const [layerId, params] of Object.entries(snapshot.params)) { - this._dataManager.setLayerParams(layerId, params); - } - let restoreError = null; - const restoreSnapshot = async (restoreSignal = null) => { - // Contacts owns the dependency intents it starts. Settle that - // coordinator before restoring the remaining snapshot, otherwise its - // dependency releases can supersede the restore's same-target requests - // and make a valid Contacts-to-Missions handoff look like a failure. - const contactsCoordinatorId = 'military-awareness'; - const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) - && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); - if (settleContactsCoordinator) { - const coordinatorSettled = await this._dataManager.setEnabled( - contactsCoordinatorId, - false, - { - origin: 'context-restore', - ...(notificationToken ? { notificationToken } : {}), - ...(restoreSignal ? { signal: restoreSignal } : {}), - }, - ); - if (coordinatorSettled === false) { - const error = new Error('Failed to settle Contacts before restoring Context'); - error.failedLayerIds = [contactsCoordinatorId]; - throw error; - } - } - await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { - origin: 'context-restore', - excludeLayerIds: settleContactsCoordinator - ? [...excludeLayerIds, contactsCoordinatorId] - : excludeLayerIds, - notificationToken, - ...(restoreSignal ? { signal: restoreSignal } : {}), - }); - }; - try { - await restoreSnapshot(signal); - } catch (error) { - restoreError = error; - // A caller abort can arrive after only part of the exact restore has - // settled. Finish that same target without the stale caller signal while - // this restoreState still records newer explicit intents; replay below - // then gives those newer intents final authority. - if (signal?.aborted && !restoreState.cancelled) { - try { - await restoreSnapshot(null); - restoreError = null; - } catch (compensationError) { - restoreError = mergeContextTransitionErrors(restoreError, compensationError); - } - } - } finally { - if (this._contextRestoreState === restoreState) this._contextRestoreState = null; - } - // Clear Selected Layers owns a newer global OFF intent. A restore that was - // already awaiting lifecycle work must not replay its captured companion - // intent or recreate the discarded session after Clear invalidates it. - if (restoreState.cancelled) return; - // A direct Radio command may finish after restore has already copied its - // target and queued the opposite state. Replay that newer intent only - // after the stale queue drains; the replay origin cannot recurse here. - const replaySignal = signal?.aborted ? null : signal; - const replayError = await settleContextIntentReplay({ - restoreState, - setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( - layerId, - enabled, - { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, - ), - notificationToken, - }); - restoreError = mergeContextTransitionErrors(restoreError, replayError); - if (restoreError && !this._contextSessionSnapshot) { - // Keep the exact still-pending target so a later exit/teardown can retry - // instead of silently losing the user's pre-Context layer state. - this._contextSessionSnapshot = { - enabledLayerIds: new Set(restoreState.enabledLayerIds), - userAdded: new Set(), - userRemoved: new Set(), - params: snapshot.params, - }; - } - if (restoreError) throw restoreError; - } - - /* - * Cross-mode cancellation and failure deliberately settle on Context OFF. - * - * A reinstatement transaction lived here for three review rounds and was - * removed on purpose. Restoring the prior mode is genuinely racy: the prior - * mode has to be read before the teardown, but a second request arriving - * while the first reinstatement is mid-activation reads `_contextMode` as - * null and inherits nothing, so two overlapping cancellations still land on - * OFF — and the only fix is a cross-transaction "logical prior mode" chain, - * which is new shared mutable state read while an earlier transaction is - * still awaiting. That trades a rare wrong resting state for a permanent - * interleaving hazard. - * - * The defect that started this was the LIE, not the OFF: the transition - * claimed to have cancelled cleanly while silently leaving Context off. So - * the resting state stays OFF and is REPORTED as such, with the failed layer - * ids preserved. A restore feature can be rebuilt post-launch on the - * generation discipline the surrounding transaction already follows. - */ - - async _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { - await this._dataManager?.waitForLayerSettled?.(layerId); - return this._restoreContextSession({ notificationToken }); - } - - /** - * Claim the visual restore lane for an explicit Context transition. - * - * Contacts OWNS detection while active (forced Dense @ 75%), so entering or - * leaving it is a visual-lane gesture exactly like the HUD or detection - * controls. Without this claim, the shared-view restore that lands 1.5 s into - * startup re-applied the link's `dm`/`dd` over the forced preset and Contacts - * lost its own overlay mid-session. - * - * Deliberately does NOT set `_detectionUserOverridden`: that flag means the - * OPERATOR hand-edited detection and suppresses the military-style - * auto-enable for the rest of the session. Context entry is not that, and - * conflating them would silently disable a separate landed behavior. - * - * Call only for VALIDATED explicit transitions — never for programmatic or - * restore-driven ones, which must stay eligible for the shared visual state. - */ - _claimContextVisualAuthority() { - this.shareLinkManager?.claimRestoreLane?.('visual'); - } - - async _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { - if (!this._dataManager) return false; - if (this._clearSelectedLayersPromise) return false; - this._contextTransitionFailedLayerIds = []; - const generation = ++this._contextModeGeneration; - const isCurrent = () => generation === this._contextModeGeneration; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeChanging = true; - this._syncContextModeButtons(); - try { - if (mode !== 'flights' && this.cockpitView?.active) { - this.cockpitView.exit({ restoreTracking: false }); - } - if (!mode) { - this._contextMode = null; - this._syncContextModeButtons(); - await this._restoreContextSession({ notificationToken, signal }); - return isCurrent(); - } - // A cross-mode switch dismantles the prior mode BEFORE the new one is - // committed. If the caller aborts in that window the switch never lands, - // and the resting state is Context OFF — reported as such by - // setContextMode rather than dressed up as a clean cancellation. See the - // note above _restoreContextSessionAfterLayerSettles. - const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); - if (crossModeSwitch) { - this._contextMode = null; - await this._restoreContextSession({ notificationToken, signal }); - if (!isCurrent()) return false; - if (signal?.aborted) return false; - } - this._captureContextSessionSnapshot(); - this._contextMode = mode; - this._syncContextModeButtons(); - // Replay isolation must settle before Space Missions starts. Contacts - // keeps its non-dependency teardown in the background so slow source - // shutdown does not delay cockpit entry. - try { - await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); - } catch (error) { - if (!isCurrent()) return false; - let transitionError = error; - console.warn(`[Context] ${mode} isolation failed`, error); - this._contextMode = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; - throw transitionError; - } - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - return false; - } - if (!isCurrent()) return false; - // Entry is one transaction: isolation succeeded above, so a failed mode - // activation must roll the cleared layers back instead of stranding the - // user in a half-entered mode with an orphaned snapshot. - const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; - if (mode === 'flights') { - this._dataManager.setLayerParams('military-awareness', { passive: false }); - } - let activated = false; - let activationError = null; - let activationIntent = null; - let terminalIntentOutcome = null; - try { - activationIntent = this._dataManager._setEnabledWithIntent( - entryLayerId, - true, - { notificationToken, ...(signal ? { signal } : {}) }, - ); - this._contextModeEntryIntent = { - generation, - layerId: entryLayerId, - intentEpoch: activationIntent.intentEpoch, - }; - activated = await activationIntent.promise; - terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - activationIntent.intentEpoch, - ); - } catch (error) { - activationError = error; - } - if (!isCurrent()) return false; - let replacementIntent = mode === 'space-missions' - && this._contextModeReplacementIntent?.generation === generation - && this._contextModeReplacementIntent.layerId === entryLayerId - ? this._contextModeReplacementIntent - : null; - while (replacementIntent) { - const outcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - replacementIntent.intentEpoch, - ); - terminalIntentOutcome = outcome; - if (!isCurrent()) return false; - const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch - && outcome.enabled === true - && outcome.succeeded === true; - if (replacementOwnsMode) { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - return true; - } - const successorEpoch = outcome?.cancellationReason === 'superseded' - && outcome.successorEnabled === true - && Number.isInteger(outcome.successorIntentEpoch) - && outcome.successorIntentEpoch > replacementIntent.intentEpoch - ? outcome.successorIntentEpoch - : null; - replacementIntent = successorEpoch === null ? null : { - generation, - layerId: entryLayerId, - intentEpoch: successorEpoch, - }; - } - if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { - const cancelledAndSettled = terminalIntentOutcome?.succeeded === false - && ['caller-abort', 'resource-abort', 'superseded'].includes( - terminalIntentOutcome.cancellationReason, - ); - let transitionError = null; - if (!cancelledAndSettled) { - transitionError = activationError instanceof Error - ? activationError - : new Error(`Context activation failed for: ${entryLayerId}`); - transitionError.failedLayerIds = [...new Set([ - ...(transitionError.failedLayerIds || []), - entryLayerId, - ])]; - this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; - console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); - } - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ - excludeLayerIds: [entryLayerId], - notificationToken, - }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - throw transitionError; - } - // `null` means the requested entry was cancelled and its exact rollback - // completed. The action wrapper treats that as a silent non-commit, - // while callers still require literal `true` before expanding Context. - return cancelledAndSettled ? null : false; - } - this._contextModeEntryIntent = null; - return true; - } finally { - if (isCurrent()) { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - } - - async _deactivateContextForLayerChange({ notificationToken = null } = {}) { - this._contextModeGeneration += 1; - this._contextModeChanging = true; - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } finally { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - - async _clearLayersOutsideContextMode( - mode = null, - { notificationToken = null, signal = null } = {}, - ) { - const allowed = contextAllowedLayerIds(mode); - const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); - } - } - const results = await Promise.all(pending.map(({ transition }) => transition)); - const failed = pending - .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) - .map(({ layerId }) => layerId); - if (failed.length > 0) { - const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); - error.failedLayerIds = failed; - throw error; - } - } - - _handleContextLayerChange(change) { - if (change?.layerId === 'radio' && [ - 'visibility-transition', - 'visibility', - 'visibility-cancelled', - 'visibility-failed', - ].includes(change.type)) { - this._renderRadioState(radioLayer.getUIState()); - } - if (change?.type === 'visibility-transition') return; - // The effective mode must be read BEFORE the entering flag is cleared: - // the entry layer's own enable event is the one that clears it, and the - // session bookkeeping below needs to know a mode was being entered. - const effectiveContextMode = this._contextModeEntering || this._contextMode; - if (change?.type === 'visibility-cancelled') { - const cancellationDisposition = spaceMissionEntryCancellationDisposition({ - change, - }); - if ( - this._contextModeDeferredEntryIntent?.layerId === change.layerId - && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch - ) { - if (cancellationDisposition !== 'replacement') { - this._contextModeDeferredEntryIntent = null; - } - } - if (cancellationDisposition === 'replacement') { - this._contextModeEntering = 'space-missions'; - const entryIntent = this._contextModeEntryIntent; - if ( - entryIntent?.generation === this._contextModeGeneration - && entryIntent.layerId === change.layerId - && entryIntent.intentEpoch === change.intentEpoch - ) { - this._contextModeReplacementIntent = { - generation: entryIntent.generation, - layerId: change.layerId, - intentEpoch: change.successorIntentEpoch, - }; - } - } else if (cancellationDisposition === 'restore') { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this._contextSessionSnapshot && !this._contextModeChanging) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - return true; - }, - 'Space Missions cancellation could not restore the previous layer state', - )); - } - } - this._syncContextModeButtons(); - return; - } - if ( - change?.layerId === 'rocket-launches' - && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) - ) { - this._contextModeEntering = null; - } - if (change?.type === 'visibility-blocked') { - if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(change.reason || 'That layer is unavailable in the current Context mode'); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-failed') { - const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; - // A failed direct Context-shell START has already had its siblings - // cleared by the visibility guard. Wait outside the synchronous manager - // notification for this queue to settle, then reconcile the complete - // snapshot, including an uncertain failed shell. - const needsDeferredShellRestore = ( - ['military-awareness', 'rocket-launches'].includes(change.layerId) - && change.enabled - && this._contextSessionSnapshot - && !this._contextModeChanging - ); - if (needsDeferredShellRestore) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - // The wrapper owns failure announcements. On a successful rollback - // announce the original activation failure here so the same direct - // action still produces exactly one accessible notification. - this._showToast(failureMessage); - return true; - }, - failureMessage, - )); - } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(failureMessage); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-will-change') { - // Explicit entry capture happens on the synchronous visibility-requested - // boundary. Keeping this later branch side-effect free prevents an - // awaited Clear/guard from replacing that authoritative pre-entry view. - return; - } - // Session bookkeeping must run BEFORE any exit path below: the exit - // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes - // a layer resurrected against the user's explicit disable. - recordContextSessionUserChange({ - snapshot: this._contextSessionSnapshot, - change, - effectiveContextMode, - }); - recordContextRestoreExplicitChange({ - restoreState: this._contextRestoreState, - change, - }); - if (shouldExitContextForLayerChange({ - contextMode: this._contextMode, - globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), - change, - })) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - return; - } - if (!this._contextModeChanging) { - if (change.layerId === 'military-awareness') { - // The coordinator remains manager-addressable for restoration and - // programmatic routes, but Contacts is selected only from the - // dedicated right-side Global Context chooser. - this._contextMode = change.enabled - ? null - : (this._contextMode === 'flights' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if (change.layerId === 'rocket-launches') { - const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) - || this._contextMode === 'space-missions' - || effectiveContextMode === 'space-missions'; - if (!ownsContextEntry) return; - this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if ( - this._contextMode === 'flights' - && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) - && !change.enabled - ) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } - if (this.cockpitView?.active && !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - })) { - this.cockpitView.exit({ restoreTracking: false }); - } - this._syncContextModeButtons(); - } - - _syncContextModeButtons() { - const flightsActive = this._contextMode === 'flights'; - const missionsActive = this._contextMode === 'space-missions'; - const panel = document.getElementById('global-context-panel'); - panel?.classList.toggle('context-enabled', flightsActive || missionsActive); - panel?.setAttribute('data-context-mode', this._contextMode || 'none'); - this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); - this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); - this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); - this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); - const transitionBusy = Boolean(this._contextModeChanging); - // Both Context choices stay in the ordinary Tab sequence. Arrow keys still - // provide tablist navigation, but must not be the only way to reach Space - // Missions from the keyboard. Semantic busy state keeps them perceivable - // while synchronous click guards prevent a second transition. - for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { - if (!button) continue; - button.disabled = false; - button.tabIndex = 0; - button.setAttribute('aria-disabled', String(transitionBusy)); - button.setAttribute('aria-busy', String(transitionBusy)); - } - if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; - if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; - if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; - this.cockpitView?.syncEntry(); - // Every _contextMode mutation funnels through here; the sync no-ops until - // the transaction settles, so this is the activation/deactivation edge. - this._syncContactsDetection(); - this._scheduleRightPanelLayout(); - } - - /** Wire the independent Radio companion controls. */ - _initRadioPanel() { - if (!this._radioPanel) return; - this._radioTunerAbort?.abort(); - this._radioTunerAbort = new AbortController(); - const tunerListenerOptions = { signal: this._radioTunerAbort.signal }; - const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { - const open = Boolean(expanded); - this._contextRadioDock?.classList.toggle('disclosure-open', open); - if (this._contextRadioMini) this._contextRadioMini.hidden = !open; - this._syncContextRadioLauncherState(); - if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); - }; - this._setRadioDisclosure = setRadioDisclosure; - const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { - const displayOpen = kind === 'display' && Boolean(expanded); - const radioOpen = kind === 'radio' && Boolean(expanded); - if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true); - if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; - if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; - this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', displayOpen); - this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', radioOpen); - this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); - this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); - if (displayOpen) this._revealCockpitStyleParameters(); - if (this._cockpitDisplayToggleBtn) { - const action = displayOpen ? 'Collapse' : 'Expand'; - this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; - this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); - this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; - } - if (this._cockpitRadioToggleBtn) { - const action = radioOpen ? 'Collapse' : 'Expand'; - this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; - this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); - this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; - } - if (!expanded && returnFocus) { - (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) - ?.focus({ preventScroll: true }); - } - if (!displayOpen && !radioOpen - && this.cockpitView?.active - && !this.cockpitView.signalUserCollapsed) { - this.cockpitView.setSignalCollapsed(false); - } - this.cockpitView?.scheduleContextLayout(); - }; - this._setCockpitDisclosure = setCockpitDisclosure; - const syncTunerTape = (coordinate) => { - const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); - const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); - if (!scale || !dial) return null; - const model = buildRadioTunerTicks( - coordinate, - this._radioTunerStations.length, - dial.getBoundingClientRect().width, - ); - while (scale.children.length < model.ticks.length) { - const tick = document.createElement('span'); - tick.className = 'radio-tuner-tick'; - scale.append(tick); - } - while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); - model.ticks.forEach((entry, index) => { - const tick = scale.children[index]; - tick.style.left = `${entry.xPx}px`; - tick.textContent = entry.label; - tick.dataset.stationIndex = String(entry.stationIndex); - tick.classList.toggle('is-current', entry.current); - }); - scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); - return model; - }; - const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { - const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); - const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; - const resolvedCoordinate = this._radioTunerStations.length <= 1 - ? 0 - : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); - const ratio = this._radioTunerStations.length === 1 - ? 0.5 - : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); - this._radioTunerCoordinate = resolvedCoordinate; - this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); - this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); - syncTunerTape(resolvedCoordinate); - if (this._radioTunerValue) { - this._radioTunerValue.textContent = station - ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` - : 'NO STATIONS'; - } - if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; - if (this._radioTunerSlider) { - this._radioTunerSlider.setAttribute('aria-valuetext', station - ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` - : 'No station available'); - } - if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate }); - return station; - }; - const setTunerDirectory = (pool) => { - this._radioTunerPool = [...pool]; - this._radioTunerStations = [...pool]; - this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); - if (this._radioTunerSlider) { - this._radioTunerSlider.min = '0'; - this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); - this._radioTunerSlider.step = '1'; - } - }; - const refreshTunerBand = ({ force = false } = {}) => { - if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; - const selectedId = this._radioState?.selected?.id || null; - const pool = radioLayer.getTunerStations(750); - const poolSignature = pool.map((station) => station.id).join('|'); - const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); - if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; - setTunerDirectory(pool); - this._radioTunerSelectedId = selectedId; - const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); - const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); - return true; - }; - this._refreshRadioTunerBand = refreshTunerBand; - const beginTuner = () => { - if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; - refreshTunerBand(); - if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false; - // A tuner-owned camera preview must never replace the frozen directory. - // Only an explicit globe pointer/wheel gesture releases camera pinning. - this._radioTunerBandPinnedForNavigation = true; - this._radioTunerDragging = true; - this._radioTuner?.classList.add('is-dragging'); - const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); - const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - this._radioTunerDragStartSlot = slot.slot; - this._radioTunerDragSnapshot = { - stations: [...this._radioTunerStations], - bandSignature: this._radioTunerBandSignature, - selectedId: this._radioTunerSelectedId, - slot: slot.slot, - coordinate: this._radioTunerCoordinate, - }; - this._radioTunerLastSlot = slot.slot; - this._radioTunerDragDirection = 0; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - return true; - }; - const finishTuner = (commit) => { - if (!this._radioTunerDragging) return; - const dragSnapshot = this._radioTunerDragSnapshot; - if (!commit && dragSnapshot) { - this._radioTunerStations = [...dragSnapshot.stations]; - this._radioTunerPool = [...dragSnapshot.stations]; - this._radioTunerBandSignature = dragSnapshot.bandSignature; - this._radioTunerSelectedId = dragSnapshot.selectedId; - if (this._radioTunerSlider) { - const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); - this._radioTunerSlider.max = String(startSlot.max); - this._radioTunerSlider.value = String(startSlot.slot); - } - this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) - ? dragSnapshot.coordinate - : dragSnapshot.slot; - } else if (!commit && this._radioTunerSlider) { - this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); - this._radioTunerCoordinate = this._radioTunerDragStartSlot; - } - let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); - if (commit && !station && this._radioTunerSlider) { - const snapped = radioTunerCommitSlot( - this._radioTunerSlider.value, - this._radioTunerStations.length, - ); - this._radioTunerSlider.value = String(snapped.slot); - this._radioTunerCoordinate = snapped.stationIndex; - station = tunerPreview({ coordinate: this._radioTunerCoordinate }); - } - let result = null; - if (commit && station) { - // Keep the exact band used by the drag so the selected channel cannot - // jump to a refreshed catalog slot while its camera flight settles. - this._radioTunerBandPinnedForNavigation = true; - result = radioLayer.commitTuningStation(station.id, { origin: 'user' }); - } else if (!commit) { - radioLayer.cancelTuning(); - } else { - radioLayer.endTuning(); - } - // Radio emits selection/tuning state synchronously. Keep both the logical - // drag and the no-transition class active until that state has settled, - // then restore the exact selected slot before permitting CSS motion. - this._radioTunerDragging = false; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - if (commit && (!result || result.ok)) refreshTunerBand(); - this._radioTunerDragSnapshot = null; - // Flush the snapped position while transitions are still disabled so - // removing the drag class cannot interpolate from the released gap. - void this._radioTunerNeedle?.offsetLeft; - this._radioTuner?.classList.remove('is-dragging'); - if (result && !result.ok) { - this._radioTunerBandPinnedForNavigation = false; - if (result.reason === 'station-unavailable') { - if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; - if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; - this._radioTunerSlider?.setAttribute( - 'aria-valuetext', - 'Station unavailable after directory refresh', - ); - } - } - }; - const cycleRadio = (direction, { rotate = true } = {}) => { - this._radioTunerBandPinnedForNavigation = true; - const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - const cycled = radioLayer.cycleStation(direction, { - rotate, - stationIds: pool.map((station) => station.id), - origin: 'user', - }); - if (!cycled) { - this._radioTunerBandPinnedForNavigation = false; - return; - } - }; - const toggleRadio = async (trigger) => { - if (!this._dataManager?.layers?.has('radio')) return; - if (trigger.getAttribute('aria-busy') === 'true') return; - const enabling = !this._dataManager.isEnabled('radio'); - const revealAfterEnable = enabling && trigger === this._radioEnableBtn; - trigger.setAttribute('aria-disabled', 'true'); - trigger.setAttribute('aria-busy', 'true'); - try { - const toggled = await this._runUserFacingContextAction( - (notificationToken) => this._dataManager.setEnabled('radio', enabling, { - origin: 'user', - notificationToken, - }), - `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, - ); - if (toggled === false) return; - if (enabling && trigger === this._radioEnableBtn - && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - } - if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); - } finally { - trigger.setAttribute('aria-disabled', 'false'); - trigger.setAttribute('aria-busy', 'false'); - if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); - } - }; - this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn)); - this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); - this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn)); - this._contextRadioToggleBtn?.addEventListener('click', () => { - const contextPanel = document.getElementById('global-context-panel'); - if (contextPanel && !contextPanel.classList.contains('collapsed')) { - setRadioDisclosure(false); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - void this._revealRadioPanelInsideContext({ - focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), - }); - return; - } - setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); - }); - this._contextRadioMiniCloseBtn?.addEventListener('click', () => { - setRadioDisclosure(false, { returnFocus: true }); - }); - this._contextRadioDetailsBtn?.addEventListener('click', () => { - if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - setRadioDisclosure(false); - this._radioEnableBtn?.focus({ preventScroll: true }); - }); - this._cockpitRadioToggleBtn?.addEventListener('click', () => { - const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; - setCockpitDisclosure('radio', !open); - }); - document.addEventListener('pointerdown', (event) => { - if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; - if (event.target?.closest?.('#context-radio-dock')) return; - setRadioDisclosure(false); - }, tunerListenerOptions); - document.addEventListener('pointerdown', (event) => { - if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; - if (event.target?.closest?.('.cockpit-vision-controls')) return; - if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; - event.preventDefault(); - // Immediate: a plain stopPropagation() still lets every LATER listener on - // this same document run, so closing the disclosure ALSO dismissed the - // first-run launcher — one key, two actions. Matches the cockpit - // disclosure handler directly below. - event.stopImmediatePropagation(); - const escapedFromDisclosure = event.target === this._contextRadioToggleBtn - || this._contextRadioToggleBtn?.contains?.(event.target); - setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape') return; - const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; - const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; - if (!displayOpen && !radioOpen) return; - const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); - if (nestedPanel) return; - event.preventDefault(); - event.stopImmediatePropagation(); - const kind = displayOpen ? 'display' : 'radio'; - const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; - const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) disclosure?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - window.addEventListener('gev:cockpit-mode-changed', (event) => { - if (event?.detail?.active) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-signal-expanded', () => { - setCockpitDisclosure('display', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-context-expanded', () => { - this.setPanelCollapsed('data-panel', true); - }, tunerListenerOptions); - this._radioFilter?.addEventListener('change', () => { - const presentation = radioLayer.getUIState(); - if (!presentation.presentationActive) { - this._radioFilter.value = presentation.filter; - return; - } - if (this._radioTunerDragging) finishTuner(false); - if (!this._dataManager?.setLayerParams('radio', { - filter: this._radioFilter.value, - }, { origin: 'user' })) { - this._radioFilter.value = radioLayer.getUIState().filter; - return; - } - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerPool = []; - refreshTunerBand({ force: true }); - }); - this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._radioNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' })); - this._radioVolume?.addEventListener('input', () => { - const value = Number(this._radioVolume.value); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - // Cockpit owns the Cesium camera even though it intentionally clears - // viewer.trackedEntity. Station changes must never start the map-view - // rotation/fallback flights that would compete with its preUpdate pose. - this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false })); - this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false })); - this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._contextRadioMiniVolume?.addEventListener('input', () => { - const value = Number(this._contextRadioMiniVolume.value); - if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._cockpitRadioVolume?.addEventListener('input', () => { - const value = Number(this._cockpitRadioVolume.value); - if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - const updateTunerFromPointer = (event) => { - const rect = this._radioTunerSlider?.getBoundingClientRect(); - if (!rect || !this._radioTunerStations.length) return false; - const position = radioTunerPointerPosition( - event.clientX, - rect.left, - rect.width, - this._radioTunerStations.length, - ); - if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = position.stationIndex; - this._radioTunerCoordinate = position.coordinate; - this._radioTunerSlider.value = String(position.stationIndex); - tunerPreview({ coordinate: position.coordinate }); - return true; - }; - this._radioTunerSlider?.addEventListener('pointerdown', (event) => { - if (!beginTuner()) return; - this._radioTunerPointerId = event.pointerId; - this._radioTunerSlider.focus({ preventScroll: true }); - try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointermove', (event) => { - if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('input', () => { - if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; - if (!this._radioTunerDragging && !beginTuner()) return; - const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); - if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = inputSlot.slot; - this._radioTunerCoordinate = inputSlot.stationIndex; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('change', () => { - if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointerup', (event) => { - if (this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - finishTuner(true); - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointercancel', (event) => { - if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => { - if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this._radioTunerDragging) { - event.preventDefault(); - finishTuner(false); - return; - } - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; - if (!this._radioTunerDragging && !beginTuner()) return; - event.preventDefault(); - this._radioTunerKeyboardKey = event.key; - const max = Math.max(0, this._radioTunerStations.length - 1); - const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; - const page = Math.max(1, Math.round(max / 10)); - const next = event.key === 'Home' ? 0 - : event.key === 'End' ? max - : event.key === 'PageUp' ? current + page - : event.key === 'PageDown' ? current - page - : current + (event.key === 'ArrowRight' ? 1 : -1); - const slot = radioTunerSlot(next, this._radioTunerStations.length); - if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = slot.slot; - this._radioTunerCoordinate = slot.stationIndex; - this._radioTunerSlider.value = String(slot.slot); - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keyup', (event) => { - if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; - event.preventDefault(); - finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions); - const releaseNavigationBand = () => { - this._radioTunerBandPinnedForNavigation = false; - }; - this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions); - this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions); - // The directory order is catalog/filter authority, not camera authority. - // Globe motion therefore never rebuilds or re-ranks the frequency band. - this._radioTunerCameraRemove?.(); - this._radioTunerCameraRemove = null; - this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false); - document.addEventListener('gev:radio-selected', this._radioSelectedHandler); - } - - /** - * Reveal the newly enabled directory and transport inside Context without - * moving focus, the page, or the globe. Only the expanded Enable path calls - * this helper. - * @param {HTMLElement} trigger Initiating Radio Enable button. - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioControlsAfterExplicitEnable(trigger) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - const directory = this._radioPanel?.querySelector('.radio-directory-row'); - const transport = this._radioPanel?.querySelector('.radio-transport'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !directory || !transport || !this._radioState?.enabled) return false; - - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (!this._radioState?.enabled || !trigger?.isConnected) return false; - - const viewport = scroller.getBoundingClientRect(); - const directoryRect = directory.getBoundingClientRect(); - const transportRect = transport.getBoundingClientRect(); - const margin = 10; - const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); - const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); - const desired = minimum <= maximum - ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) - : minimum; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - if (Math.abs(next - scroller.scrollTop) < 1) return false; - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - return true; - } - - /** - * Bring the embedded Radio section into the expanded Context scroller. - * This never changes Radio power, playback, selection, or Context mode. - * @param {{focusTarget?: HTMLElement|null}} [options] - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; - - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; - - const viewport = scroller.getBoundingClientRect(); - const radioRect = this._radioPanel.getBoundingClientRect(); - const desired = scroller.scrollTop + radioRect.top - viewport.top - 10; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - const moved = Math.abs(next - scroller.scrollTop) >= 1; - if (moved) { - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - } - focusTarget?.focus?.({ preventScroll: true }); - return moved; - } - - /** Keep the Context header Radio shortcut truthful for its current route. */ - _syncContextRadioLauncherState() { - if (!this._contextRadioToggleBtn) return; - const contextPanel = document.getElementById('global-context-panel'); - const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); - if (contextExpanded) { - const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); - const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; - this._contextRadioToggleBtn.setAttribute('aria-label', label); - this._contextRadioToggleBtn.title = label; - return; - } - const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); - const action = compactOpen ? 'Close' : 'Open'; - this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); - this._contextRadioToggleBtn.title = `${action} compact Radio controls`; - } - - /** Render Radio state without making playback or Context decisions. */ - _renderRadioState(state) { - if (!state || !this._radioPanel) return; - const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null; - const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); - state = { - ...state, - enabled: lifecycle ? lifecycle.enabled : state.enabled, - lifecycleState, - lifecycleUncertain: lifecycle?.uncertain || false, - }; - this._radioState = state; - const enabled = Boolean(state.enabled); - const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; - const uncertain = Boolean(state.lifecycleUncertain); - const interactive = enabled && !transitioning && !uncertain; - const selected = state.selected || null; - const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); - document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); - this._radioPanel.classList.toggle('radio-enabled', enabled); - this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); - this._contextRadioDock?.classList.toggle('active', enabled); - if (this._contextRadioToggleBtn) { - this._contextRadioToggleBtn.classList.toggle('active', enabled); - } - this._syncContextRadioLauncherState(); - this._radioLayerState?.classList.toggle('active', enabled); - if (this._radioLayerState) { - this._radioLayerState.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); - } - if (this._radioEnableBtn) { - this._radioEnableBtn.classList.toggle('active', enabled); - this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._radioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._radioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._radioEnableBtn.disabled = false; - this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._contextRadioMiniEnableBtn) { - this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); - this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._contextRadioMiniEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._contextRadioMiniEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._contextRadioMiniEnableBtn.disabled = false; - this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._cockpitRadioEnableBtn) { - this._cockpitRadioEnableBtn.classList.toggle('active', enabled); - this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._cockpitRadioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._cockpitRadioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._cockpitRadioEnableBtn.disabled = false; - this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - - if (this._radioFilter) { - const prior = state.filter || 'all'; - const categorySignature = state.categories - .map((category) => `${category.id}:${category.count}:${category.color}`) - .join('|'); - if (categorySignature !== this._radioCategorySignature) { - this._radioFilter.replaceChildren(...state.categories.map((category) => { - const option = document.createElement('option'); - option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; - option.dataset.radioColor = category.color; - option.style.color = category.color; - option.setAttribute('aria-label', `${category.label} (${category.count})`); - return option; - })); - this._radioCategorySignature = categorySignature; - } - this._radioFilter.value = prior; - const activeCategory = state.categories.find((category) => category.id === prior); - this._radioFilter.style.color = activeCategory?.color || ''; - this._radioFilter.disabled = !interactive || !state.stationCount; - } - - const tunerAvailable = interactive && state.filteredCount > 0; - if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; - if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; - if (this._radioTunerBandLabel) { - const activeCategory = state.categories.find((category) => category.id === state.filter); - this._radioTunerBandLabel.textContent = state.filter === 'all' - ? 'DIRECTORY BAND' - : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; - } - this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); - if (tunerAvailable) this._refreshRadioTunerBand?.(); - if (!tunerAvailable && this._radioTunerDragging) { - this._radioTunerDragging = false; - this._radioTunerDragSnapshot = null; - this._radioTunerStations = []; - this._radioTuner?.classList.remove('is-static', 'is-dragging'); - } - if (!tunerAvailable) { - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - } - - if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; - if (this._radioStationMeta) { - const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; - const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; - this._radioStationMeta.textContent = selected - ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' - : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); - } - if (this._radioStationTags) { - const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; - this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; - } - if (this._radioStationHomepage) { - const homepage = selected?.homepage || ''; - this._radioStationHomepage.hidden = !homepage; - if (homepage) this._radioStationHomepage.href = homepage; - else this._radioStationHomepage.removeAttribute('href'); - } - - if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; - if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; - if (this._radioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._radioPlayBtn.disabled = !interactive || !hasStations; - this._radioPlayBtn.classList.toggle('active', activePlayback); - this._radioPlayBtn.textContent = action.toUpperCase(); - this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - } - if (this._contextRadioMiniPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; - this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); - this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._contextRadioMiniPlayBtn.title = action; - } - if (this._cockpitRadioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; - this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); - this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._cockpitRadioPlayBtn.title = action; - } - if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; - if (this._radioVolume) this._radioVolume.disabled = !interactive; - if (this._radioVolume && document.activeElement !== this._radioVolume) { - this._radioVolume.value = String(Math.round(state.volume * 100)); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { - this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); - } - if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; - if (this._contextRadioMiniVolumeValue) { - this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { - this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); - } - if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; - if (this._cockpitRadioVolumeValue) { - this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniStation) { - this._contextRadioMiniStation.textContent = uncertain - ? 'RADIO STATE UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); - } - if (this._cockpitRadioStation) { - this._cockpitRadioStation.textContent = uncertain - ? 'UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); - } - if (this._radioPlaybackState) { - const catalogSuffix = state.degraded - ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') - : (state.stale ? ' · stale directory' : ''); - const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; - const messages = { - stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', - loading: 'Connecting directly to broadcaster…', - buffering: 'Buffering broadcaster stream…', - playing: `Playing ${selected?.name || 'station'}`, - paused: `Paused ${selected?.name || 'station'}`, - error: state.audioError || 'Broadcaster stream unavailable', - }; - const voiceSuffix = state.voiceDucked - ? ' · muted during voice interaction' - : (state.voiceRestoring ? ' · restoring volume after voice' : ''); - const tuningSuffix = state.tuningAwaitingStationId - ? (state.audioState === 'error' - ? ' · static indicates no broadcaster audio' - : ' · tuning static until broadcaster starts') - : ''; - const unavailable = state.tuningUnavailableStationId - ? 'Station unavailable after directory refresh — choose another channel' - : null; - const lifecycleMessage = transitioning - ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') - : null; - const uncertainMessage = uncertain - ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' - : null; - this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; - this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); - } - if ( - !enabled - && !transitioning - && !this._preservePanelStateDuringLayerClear - && !this._radioPanel.classList.contains('collapsed') - ) { - this.setPanelCollapsed('radio-panel', true); - } - this._scheduleRightPanelLayout(); - } - - /** - * Activates an explicit CCTV target, then releases tracking before its camera - * flight. Cockpit mode keeps tracking and suppresses only the flight. - * @param {Function} activate CCTV target activation returning its camera ID. - * @param {Function} focus CCTV camera flight receiving the activated ID. - * @returns {*} Focus operation result. - */ - _runExplicitCctvFocus(activate, focus) { - if (this._disposed) return false; - const cameraId = activate(); - if (!cameraId) return false; - return this._runExplicitNavigation('camera', () => focus(cameraId)); - } - - /** - * Wires up all CCTV panel controls: enable/disable, nearest/prev/next camera, - * camera select dropdown, focus, coverage, auto-hop, projection, - * manual calibration sliders, and save/reset buttons. - * @returns {void} - */ - _initCctvPanel() { - if (!this._cctvPanel) return; - - this._cctvEnableBtn?.addEventListener('click', async () => { - await this._toggleCctvEnabled(); - }); - - this._cctvNearestBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.focusNearest({ focus: false }), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.8), - ); - }); - - this._cctvPrevBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(-1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvNextBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvSelect?.addEventListener('change', async () => { - const cameraId = this._cctvSelect.value; - if (!cameraId) return; - if (!await this._toggleCctvEnabled(true)) return; - // Picking a camera from the dropdown flies to it. The catalog spans - // three metros, so a bare selection used to leave the view in the old - // city with a camera active thousands of km away. - this._runExplicitCctvFocus( - () => (cctvLayer.selectCamera(cameraId) ? cameraId : null), - (selectedId) => cctvLayer.focusCamera(selectedId, 2.2), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: cameraId }, { origin: 'user' }); - }); - - this._cctvFocusBtn?.addEventListener('click', async () => { - const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; - if (!selected) return; - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => selected, - (cameraId) => cctvLayer.focusCamera(cameraId, 1.9), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: selected }, { origin: 'user' }); - }); - - this._cctvCoverageBtn?.addEventListener('click', () => { - const current = this._cctvState?.coverageMode - || (this._cctvState?.showCoverage ? 'on' : 'off'); - const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; - this._dataManager?.setLayerParams('cctv', { coverageMode: next }, { origin: 'user' }); - }); - - this._cctvAutoHopBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.autoHop; - this._dataManager?.setLayerParams('cctv', { autoHop: !current }, { origin: 'user' }); - }); - - this._cctvProjectionBtn?.addEventListener('click', () => { - const current = this._cctvState?.showProjection !== false; - this._dataManager?.setLayerParams('cctv', { showProjection: !current }, { origin: 'user' }); - }); - - this._cctvAdjustBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.calibrationMode; - this._dataManager?.setLayerParams('cctv', { calibrationMode: !current }, { origin: 'user' }); - }); - - // Click-to-edit pose readout: each chip swaps to a number input; Enter or - // blur commits (converted to a calibration offset against basePose), - // Escape cancels. Delegated so re-renders never re-bind. - this._cctvCalReadout?.addEventListener('click', (event) => { - const chip = event.target.closest?.('.cctv-cal-value'); - if (!chip || chip.disabled || chip.querySelector('input')) return; - this._beginCctvCalValueEdit(chip); - }); - - this._cctvCalibSaveBtn?.addEventListener('click', () => { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, save: true }, - }, { origin: 'user' }); - this._showToast('CCTV calibration saved'); - }); - - this._cctvCalibResetBtn?.addEventListener('click', () => { - this._resetCctvCalibration(); - }); - - this._renderCctvState(null); - this._syncCctvPanelViewport(); - } - - /** - * Returns the currently active CCTV camera ID from state or the select dropdown. - * @returns {string} Camera ID, or empty string if none. - */ - _activeCctvCameraId() { - return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; - } - - /** - * Clears the preview and invalidates any in-flight preload. - * @returns {void} - */ - _clearCctvFrame() { - this._cctvFrameRequestToken += 1; - this._cctvFramePreloader = null; - if (this._cctvFrame) { - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrame.dataset.cameraId = ''; - this._cctvFrame.dataset.currentSrc = ''; - this._cctvFrame.dataset.loading = ''; - this._cctvFrame.dataset.error = ''; - } - this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); - } - - /** - * Fetches a replacement frame OFF-DOM and assigns it to the live element - * only once it has decoded. - * - * The live is never pointed at an unresolved URL. A completed - * preload is already in the HTTP cache, so assigning `src` swaps in a - * single paint — the browser's own atomic behavior. A slow or failed - * fetch never reaches the element at all, so settled pixels survive. - * - * (A two-slot crossfade was tried and reverted: with no z-index the slots - * paint in DOM order, so promotion was asymmetric and yanked the visible - * layer once per refresh — a flicker on every feed cycle. Measured against - * main, which never blanked on a successful refresh in the first place.) - * - * @param {string} src - * @param {string} cameraId - * @param {boolean} cameraChanged - * @returns {void} - */ - _queueCctvFrame(src, cameraId, cameraChanged) { - if (!this._cctvFrame || !src) return; - - if (cameraChanged) { - // A different camera gets an honest acquisition state. Never retain - // the prior camera's pixels under the newly selected metadata. - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrameWrap?.classList.remove('has-frame'); - } - - const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame') - ); - - const preloader = new Image(); - this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); - preloader.onerror = () => this._settleCctvFrame(token, src, false); - preloader.src = src; - } - - /** - * Commits a decoded frame to the live element, or records the failure - * without disturbing whatever is already on screen. - * @param {number} token - Request token; a stale one is ignored. - * @param {string} src - * @param {boolean} ok - * @returns {void} - */ - _settleCctvFrame(token, src, ok) { - if (!this._cctvFrame || token !== this._cctvFrameRequestToken) return; - this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); - - const syncBadge = () => this._syncCctvSourceBadge( - this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this._dataManager?.isEnabled('cctv') - ); - - if (!ok) { - // Leave the element untouched — a settled frame stays on screen. - this._cctvFrame.dataset.error = 'true'; - syncBadge(); - return; - } - - this._cctvFrame.dataset.error = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); - syncBadge(); - } - - /** - * Keeps the source badge truthful about the visible frame lifecycle. Health - * may already be OK while the browser is still decoding the requested image. - * @param {object|null} activeCamera - * @param {boolean} enabled - * @returns {void} - */ - _syncCctvSourceBadge(activeCamera, enabled) { - if (!this._cctvSourceBadge) return; - if (!enabled || !activeCamera) { - this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; - this._cctvSourceBadge.dataset.frameState = 'idle'; - return; - } - const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); - if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · LOADING'; - this._cctvSourceBadge.dataset.frameState = 'loading'; - return; - } - if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; - this._cctvSourceBadge.dataset.frameState = 'error'; - return; - } - const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); - const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); - this._cctvSourceBadge.textContent = `${kind} · ${status}`; - this._cctvSourceBadge.dataset.frameState = 'ready'; - } - - /** - * Resets calibration for the active CCTV camera to its server defaults. - * @returns {void} - */ - _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { - cameraId, - reset: true, - }, - }, { origin: 'user' }); - this._showToast('CCTV calibration reset'); - } - - /** - * Swaps a readout chip's text for an inline number input. Enter/blur commits - * (converted to a calibration offset patch), Escape cancels. The next state - * re-render restores the chip text either way. - * @param {HTMLButtonElement} chip - The clicked `.cctv-cal-value` element. - * @returns {void} - */ - _beginCctvCalValueEdit(chip) { - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - const activeCamera = this._cctvState?.activeCamera; - if (!field || !activeCamera?.basePose) return; - const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; - chip.textContent = `${field.label} `; - chip.appendChild(input); - input.focus(); - input.select(); - - let finished = false; - const finish = (commit) => { - if (finished) return; - finished = true; - const typed = parseFloat(input.value); - input.remove(); - if (commit && Number.isFinite(typed)) { - const cameraId = this._activeCctvCameraId(); - if (cameraId && this._dataManager) { - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, patch: field.toPatch(typed, activeCamera.basePose) }, - }, { origin: 'user' }); - return; // re-render restores the chip text from fresh state - } - } - this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); - }; - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') finish(false); - event.stopPropagation(); - }); - input.addEventListener('blur', () => finish(true)); - input.addEventListener('click', (event) => event.stopPropagation()); - } - - /** - * Synchronizes the pose readout chips, ADJUST button, and SAVE/RESET - * disabled states with the active camera. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @param {object|null} activeCamera - The active camera data object (may be null). - * @returns {void} - */ - _syncCctvCalReadout(enabled, activeCamera) { - const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` - : `${field.label} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; - } - } - - /** - * Toggles the CCTV layer enabled state. When enabling and no camera is active, - * auto-focuses on the nearest camera. - * @param {boolean} [forceState] - Explicit on/off. Omit to toggle. - * @returns {Promise} True if the layer is now in the requested state. - */ - async _toggleCctvEnabled(forceState) { - if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { - this._showToast('CCTV layer unavailable'); - return false; - } - const enabled = this._dataManager.isEnabled('cctv'); - const target = typeof forceState === 'boolean' ? forceState : !enabled; - if (target === enabled) return true; - await runCctvLayerEnableTransition({ - target, - setEnabled: (next) => this._dataManager.setEnabled('cctv', next, { origin: 'user' }), - readOwnership: () => ({ - trackedEntity: this.viewer?.trackedEntity, - cockpitActive: !!this.cockpitView?.active, - }), - shouldFocus: () => !this._cctvState?.activeCameraId, - activate: () => cctvLayer.focusNearest({ focus: false }), - fly: (cameraId) => this._runExplicitCctvFocus( - () => cameraId, - (selectedId) => cctvLayer.focusCamera(selectedId, 1.6), - ), - }); - return true; - } - - /** - * Maps a `deriveCalBadge` value (cctv.js) to its panel copy. Single source - * of truth for CAL-badge casing — used by both the quality chip and the - * meta line so the two never drift onto different label conventions. - * @param {'calibrated'|'curated'|'raw-prior'|null} badge - Badge state from cctv.js. - * @returns {string} Display label, or '--' when there is no active camera. - */ - _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } - } - - /** - * Full re-render of the CCTV panel UI from a CCTV layer state snapshot. - * Updates enable button, camera select dropdown, navigation buttons, - * coverage/auto-hop/projection toggles, quality chip, source badge, - * metadata line, frame image, calibration controls, and summary text. - * @param {object|null} state - CCTV layer UI state, or null to render empty. - * @returns {void} - */ - _renderCctvState(state) { - this._cctvState = state || null; - const cameras = state?.cameras || []; - const enabled = !!state?.enabled && !!this._dataManager?.isEnabled('cctv'); - const activeId = state?.activeCameraId || ''; - const activeCamera = state?.activeCamera || null; - - // Auto-expand the panel when the active camera CHANGES to a new non-null - // id while the layer is enabled. Covers click-on-globe, panel controls, - // and voice (selectCamera/cycleCamera/focusNearest all notify through - // this subscription). The last-seen guard keeps routine notifications - // from re-expanding a panel the user deliberately collapsed, and timed - // auto-hop transitions only expand on the first activation so the panel - // does not pop open on every hop. - const effectiveActiveId = enabled ? (activeId || null) : null; - const isFirstActivation = this._lastSeenCctvActiveId === null; - if (effectiveActiveId - && effectiveActiveId !== this._lastSeenCctvActiveId - && (!state?.autoHop || isFirstActivation)) { - this.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); - } - this._lastSeenCctvActiveId = effectiveActiveId; - - this._updateCctvSyncChip(state?.loading, enabled); - - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } - - if (this._cctvSelect) { - const shouldRebuild = this._cctvSelect.options.length !== cameras.length - || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); - if (shouldRebuild) { - this._cctvSelect.innerHTML = ''; - for (const camera of cameras) { - const option = document.createElement('option'); - option.value = camera.id; - option.textContent = `${camera.city} · ${camera.name}`; - this._cctvSelect.appendChild(option); - } - } - this._cctvSelect.disabled = !enabled || cameras.length === 0; - if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { - this._cctvSelect.value = activeId; - } else if (!activeId) { - this._cctvSelect.selectedIndex = -1; - } - } - - for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { - if (!btn) continue; - btn.disabled = !enabled || cameras.length === 0; - } - if (this._cctvFocusBtn) { - this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; - } - - if (this._cctvCoverageBtn) { - // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed - // (color-coded volumes). The click handler cycles; this renders. - const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); - this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); - this._cctvCoverageBtn.textContent = mode === 'viewshed' - ? 'VIEWSHED ON' - : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; - this._cctvCoverageBtn.disabled = !enabled; - } - - if (this._cctvAutoHopBtn) { - const autoHop = !!state?.autoHop; - this._cctvAutoHopBtn.classList.toggle('active', autoHop); - this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; - this._cctvAutoHopBtn.disabled = !enabled; - } - - if (this._cctvProjectionBtn) { - const showProjection = state?.showProjection !== false; - this._cctvProjectionBtn.classList.toggle('active', showProjection); - this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; - this._cctvProjectionBtn.disabled = !enabled; - } - - if (this._cctvQualityChip) { - // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, - // no in-world tint): three states driven by cctv.js's deriveCalBadge, - // no client-side scoring math. Casing is unified via _calBadgeLabel so - // the chip and the meta line never drift onto different conventions. - // Save-gated persistence (viewshed design §3e): unsaved live edits show - // EDITED on top of whatever the persisted badge state is — SAVE CAL - // promotes to CALIBRATED, RESET CAL clears. - const badge = activeCamera?.calBadge || null; - const dirty = !!activeCamera?.calDirty; - this._cctvQualityChip.textContent = dirty - ? 'CAL · EDITED (UNSAVED)' - : `CAL · ${this._calBadgeLabel(badge)}`; - this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); - } - - this._syncCctvCalReadout(enabled, activeCamera); - - if (this._cctvMeta) { - if (activeCamera) { - const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; - const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; - const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; - const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; - this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; - } else if (cameras.length > 0) { - this._cctvMeta.textContent = enabled - ? `${cameras.length} cameras loaded · click a camera to activate` - : `${cameras.length} cameras loaded · enable CCTV to activate`; - } else { - this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; - } - } - - if (this._cctvFrame) { - const nextSrc = enabled ? activeCamera?.frameUrl : null; - const nextCameraId = enabled ? (activeCamera?.id || '') : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; - // A same-camera refresh waits for the current image to settle. Replacing - // src every 10 seconds can cancel a slow but healthy decode forever and - // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are - // immediate so navigation never waits on the prior camera's request. - if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { - this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); - } - if (!nextSrc) { - this._clearCctvFrame(); - } - } - - this._syncCctvSourceBadge(activeCamera, enabled); - this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); - } - - /** - * Typewriter-animates CCTV summary text into the summary element. - * Skips animation if the text hasn't changed since the last call. - * Advances 3 characters per 20ms tick for a fast teletype effect. - * @param {string} text - Summary text to display. - * @returns {void} - */ - _typeCctvSummary(text) { - if (!this._cctvSummary) return; - const nextText = String(text || '').trim() || 'No summary available.'; - if (nextText === this._lastCctvSummaryText) return; - this._lastCctvSummaryText = nextText; - - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; - let idx = 0; - this._cctvSummaryTypingTimer = setInterval(() => { - idx += 3; - if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummaryTypingTimer = null; - return; - } - this._cctvSummary.textContent = nextText.slice(0, idx); - }, 20); - } - - /** - * Returns the versioned localStorage key for a panel's saved position. - * @param {string} panelId - DOM id of the panel. - * @returns {string} localStorage key. - */ - _panelStorageKey(panelId) { - return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`; - } - - /** - * Returns the versioned localStorage key for a panel's collapsed state. - * @param {string} panelId - DOM id of the panel. - * @returns {string} localStorage key. - */ - _panelCollapseStorageKey(panelId) { - return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`; - } - - /** - * Restores a panel's collapsed/expanded state from localStorage. - * Falls back to the CSS class default if no saved state exists. - * @param {string} panelId - DOM id of the panel. - * @returns {void} - */ - _restorePanelCollapsedState(panelId, { allowStored = true } = {}) { - const panelEl = document.getElementById(panelId); - if (!panelEl) return; - let collapsed = panelEl.classList.contains('collapsed'); - let stored = null; - if (allowStored) { - try { - stored = localStorage.getItem(this._panelCollapseStorageKey(panelId)); - if (stored === '1') collapsed = true; - if (stored === '0') collapsed = false; - } catch { - // storage unavailable - } - } - // DISPLAY starts COLLAPSED for a first-time visitor, then respects the - // user's persisted choice like every other panel. - // - // It used to start expanded, to advertise the HUD / DETECT / 3D toggles. - // That reason expired when those became ON by default: the rail now opens - // to offer controls for things already happening, while competing with the - // first-run mission card for the one first impression there is. A stored - // choice still wins in both directions, so anyone who opens it keeps it. - if (panelId === 'pp-toggles' && stored === null) collapsed = true; - panelEl.classList.toggle('collapsed', collapsed); - this._syncPanelCollapseButton(panelEl); - } - - /** - * Persists a panel's collapsed state ('1' or '0') to localStorage. - * @param {string} panelId - DOM id of the panel. - * @param {boolean} collapsed - Whether the panel is collapsed. - * @returns {void} - */ - _savePanelCollapsedState(panelId, collapsed) { - try { - localStorage.setItem(this._panelCollapseStorageKey(panelId), collapsed ? '1' : '0'); - } catch { - // storage unavailable - } - } - - /** - * Builds one fixed right-side rail from Display, CCTV, its parameter - * controls, and Global Context (which owns the nested Radio companion). - * The rail then measures the live HUD chrome at runtime so it can stay - * aligned and within the available vertical corridor. - * @returns {void} - */ - _initRightPanelAdaptiveLayout() { - const stack = this._rightPanelStack; - if (!stack || !this._ppToggles) return; - - this._ppToggles.style.removeProperty('top'); - this._ppToggles.style.removeProperty('right'); - this._ppToggles.style.removeProperty('bottom'); - this._ppToggles.style.removeProperty('left'); - this._ppToggles.style.removeProperty('z-index'); - this._ppToggles.classList.remove('panel-draggable', 'panel-dragging'); - this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title'); - stack.prepend(this._ppToggles); - const globalContextPanel = document.getElementById('global-context-panel'); - if (this._cctvPanel) { - this._cctvPanel.style.removeProperty('top'); - this._cctvPanel.style.removeProperty('right'); - this._cctvPanel.style.removeProperty('bottom'); - this._cctvPanel.style.removeProperty('left'); - this._cctvPanel.style.removeProperty('z-index'); - this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging'); - stack.insertBefore(this._cctvPanel, globalContextPanel); - this._syncPanelCollapseButton(this._cctvPanel); - } - if (this._sliderPanel) { - this._sliderPanel.style.removeProperty('top'); - this._sliderPanel.style.removeProperty('right'); - this._sliderPanel.style.removeProperty('bottom'); - this._sliderPanel.style.removeProperty('left'); - this._sliderPanel.style.removeProperty('max-height'); - const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group'); - if (detectionGroup) detectionGroup.after(this._sliderPanel); - else this._ppToggles.append(this._sliderPanel); - } - if (typeof ResizeObserver !== 'undefined') { - this._rightStackResizeObserver = new ResizeObserver(() => { - this._scheduleRightPanelLayout(); - }); - this._rightStackResizeObserver.observe(stack); - for (const panel of [this._ppToggles, this._cctvPanel, globalContextPanel]) { - if (panel) this._rightStackResizeObserver.observe(panel); - } - document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR).forEach((element) => { - this._rightStackResizeObserver.observe(element); - }); - } - - if (typeof MutationObserver !== 'undefined') { - this._rightStackMutationObserver = new MutationObserver(() => { - this._scheduleRightPanelLayout(); - }); - this._rightStackMutationObserver.observe(stack, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ['class', 'hidden', 'data-variant'], - }); - const hud = document.getElementById('intel-hud'); - if (hud) { - this._rightStackMutationObserver.observe(hud, { - subtree: true, - attributes: true, - attributeFilter: ['class', 'hidden', 'data-variant'], - }); - } - } - - const transitionHud = document.getElementById('intel-hud'); - if (transitionHud) { - this._rightStackHudTransitionHandler = (event) => { - if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - }; - transitionHud.addEventListener('transitionend', this._rightStackHudTransitionHandler); - } - - this._scheduleRightPanelLayout(); - } - - _scheduleRightPanelLayout({ reconsiderAutoCollapse = false } = {}) { - if (reconsiderAutoCollapse) this._rightStackReconsiderAutoCollapse = true; - if (!this._rightPanelStack || this._rightStackLayoutFrame !== null) return; - this._rightStackLayoutFrame = requestAnimationFrame(() => { - this._rightStackLayoutFrame = null; - if (this._rightStackReconsiderAutoCollapse) { - this._rightStackReconsiderAutoCollapse = false; - for (const panel of this._rightPanelStack.querySelectorAll('.layout-auto-collapsed')) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - this._syncRightPanelAdaptiveLayout(); - }); - } - - /** - * Places the right rail inside the visible HUD-safe corridor. When the - * corridor is too short, the expanded panel receives the remaining height - * with internal scrolling. Tactical HUD hides collapsed sibling launchers - * while a panel is expanded; other HUD layouts keep them visible. - * @returns {void} - */ - _syncRightPanelAdaptiveLayout() { - layoutRightPanelRail({ - stack: this._rightPanelStack, - obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR), - windowRef: window, - hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, - preferredPanelId: this._rightStackPreferredPanelId, - onCollapse: (panel) => this._syncPanelCollapseButton(panel), - onRetry: () => this._scheduleRightPanelLayout(), - leftStack: this._leftPanelStack, - displayPanel: this._ppToggles, - readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' - ? this._standardDisplayScrollTop - : (this._ppToggles?.scrollTop || 0), - }); - } - - /** - * Initializes the adaptive left accordion. The layout engine measures the - * actual HUD/chrome rectangles that intersect the left lane, then decides - * whether collapsed sibling labels can remain visible beside the expanded - * panel. No decision is keyed to a specific panel or HUD variant. - * @returns {void} - */ - _initLeftPanelAdaptiveLayout() { - const stack = this._leftPanelStack; - if (!stack) return; - - if (typeof ResizeObserver !== 'undefined') { - this._leftStackResizeObserver = new ResizeObserver(() => { - this._scheduleLeftPanelLayout(); - }); - this._leftStackResizeObserver.observe(stack); - stack.querySelectorAll(':scope > [data-panel-id]').forEach((panel) => { - this._leftStackResizeObserver.observe(panel); - const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); - if (inner) this._leftStackResizeObserver.observe(inner); - }); - document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR).forEach((element) => { - this._leftStackResizeObserver.observe(element); - }); - } - - if (typeof MutationObserver !== 'undefined') { - this._leftStackMutationObserver = new MutationObserver(() => { - this._scheduleLeftPanelLayout(); - }); - this._leftStackMutationObserver.observe(stack, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ['class'], - }); - const hud = document.getElementById('intel-hud'); - if (hud) { - this._leftStackMutationObserver.observe(hud, { - attributes: true, - attributeFilter: ['class', 'data-variant'], - }); - } - const credits = document.getElementById('cesium-credits'); - if (credits) { - this._leftStackMutationObserver.observe(credits, { - subtree: true, - childList: true, - }); - } - } - - const transitionHud = document.getElementById('intel-hud'); - if (transitionHud) { - this._leftStackHudTransitionHandler = (event) => { - if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - // The Cockpit strip hangs off the HUD's REC readout, so it has to - // remeasure on the same event: the readout keeps its rect through - // the whole fade and only stops counting once the HUD has retired. - this.cockpitView?.scheduleContextLayout(); - } - }; - transitionHud.addEventListener('transitionend', this._leftStackHudTransitionHandler); - } - - this._leftStackCockpitModeHandler = () => { - // Cockpit mode repositions the peripheral HUD and reveals its own - // bottom-left context card. Measure after those styles have committed so - // the accordion remains in the same obstacle-safe lane instead of - // jumping to a cockpit-specific top anchor. - this._scheduleLeftPanelLayout(); - requestAnimationFrame(() => this._scheduleLeftPanelLayout()); - setTimeout(() => this._scheduleLeftPanelLayout(), 300); - }; - window.addEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler); - - this._scheduleLeftPanelLayout(); - } - - /** - * Batches adaptive accordion work into one animation frame. - * @returns {void} - */ - _scheduleLeftPanelLayout({ reconsiderAutoCollapse = false } = {}) { - if (reconsiderAutoCollapse) this._leftStackReconsiderAutoCollapse = true; - if (!this._leftPanelStack || this._leftStackLayoutFrame !== null) return; - this._leftStackLayoutFrame = requestAnimationFrame(() => { - this._leftStackLayoutFrame = null; - if (this._leftStackReconsiderAutoCollapse) { - this._leftStackReconsiderAutoCollapse = false; - for (const panel of this._leftPanelStack.querySelectorAll('.layout-auto-collapsed')) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - this._syncLeftPanelAdaptiveLayout(); - }); - } - - /** - * Measures a live obstacle-free corridor for the left accordion and toggles - * focus mode only when the expanded panel plus sibling labels cannot fit. - * Safe boundaries are written as viewport-relative CSS values. - * @returns {void} - */ - _syncLeftPanelAdaptiveLayout() { - layoutLeftPanelRail({ - stack: this._leftPanelStack, - obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR), - windowRef: window, - hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, - preferredPanelId: this._leftStackPreferredPanelId, - onCollapse: (panel) => this._syncPanelCollapseButton(panel), - onRetry: () => this._scheduleLeftPanelLayout(), - collapsedHeights: this._leftStackCollapsedHeights, - onAligned: () => this._scheduleRightPanelLayout(), - }); - } - - /** - * Updates collapse button glyphs based on panel state. Right-rail panels - * use directional arrows; left-stack panels use +/- symbols. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _syncPanelCollapseButton(panelEl) { - const isRightRail = ['pp-toggles', 'cctv-panel', 'global-context-panel'].includes(panelEl?.id); - const collapsed = panelEl.classList.contains('collapsed'); - panelEl.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((btn) => { - const owner = btn.closest('[data-panel-id], #param-slider-panel'); - if (owner !== panelEl) return; - if (isRightRail) { - btn.textContent = collapsed ? '◀' : '▶'; - } else { - btn.textContent = collapsed ? '+' : '−'; - } - btn.setAttribute('aria-expanded', String(!collapsed)); - const panelName = panelEl.querySelector('.panel-title, .pp-header-label')?.textContent?.trim() || 'panel'; - const action = collapsed ? 'Expand' : 'Collapse'; - btn.title = `${action} ${panelName}`; - btn.setAttribute('aria-label', `${action} ${panelName}`); - if (panelEl.id === 'radio-panel') { - const action = collapsed ? 'Expand' : 'Collapse'; - btn.title = `${action} Radio`; - btn.setAttribute('aria-label', `${action} Radio section`); - } - }); - const dockToggle = panelEl.querySelector(`[data-dock-toggle-target="${panelEl.id}"]`); - if (dockToggle) { - const panelName = panelEl.querySelector('.panel-title, .location-toolbar-label')?.textContent?.trim() || 'panel'; - const action = collapsed ? 'Expand' : 'Collapse'; - dockToggle.setAttribute('aria-expanded', String(!collapsed)); - dockToggle.setAttribute('aria-label', `${action} ${panelName}`); - dockToggle.title = `${action} ${panelName}`; - } - if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) { - this._contextRadioDetailsBtn.setAttribute('aria-expanded', String(!collapsed)); - } - if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') { - this._syncContextRadioLauncherState(); - } - } - - /** - * Converts a panel from left-positioned to right-anchored so it expands - * leftward on resize. Used for the right-rail parameter panel. - * @param {HTMLElement} panelEl - The panel to re-anchor. - * @returns {void} - */ - _pinPanelToRight(panelEl) { - if (!panelEl) return; - const rect = panelEl.getBoundingClientRect(); - const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right)); - panelEl.style.right = `${rightOffset}px`; - panelEl.style.left = 'auto'; - } - - /** - * Restores a panel's top/left position from localStorage. - * Right-rail panels are additionally pinned to the right edge. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _restorePanelPosition(panelId, panelEl) { - try { - const raw = localStorage.getItem(this._panelStorageKey(panelId)); - if (!raw) return; - const pos = JSON.parse(raw); - if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; - // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at - // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. - const { left, top } = this._clampToViewport(Math.round(pos.left), Math.round(pos.top), panelEl); - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - } catch { - // ignore malformed saved panel position - } - } - - /** - * Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag - * clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe. - * @param {number} left - desired left (px) - * @param {number} top - desired top (px) - * @param {HTMLElement} panelEl - the panel element - * @returns {{left:number, top:number}} - */ - _clampToViewport(left, top, panelEl) { - const rect = panelEl.getBoundingClientRect(); - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - return { - left: Math.max(6, Math.min(maxLeft, left)), - top: Math.max(6, Math.min(maxTop, top)), - }; - } - - /** - * Persists a panel's current bounding-rect position to localStorage. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @returns {void} - */ - _savePanelPosition(panelId, panelEl) { - const rect = panelEl.getBoundingClientRect(); - try { - localStorage.setItem(this._panelStorageKey(panelId), JSON.stringify({ - left: Math.round(rect.left), - top: Math.round(rect.top), - })); - } catch { - // storage unavailable - } - } - - /** - * Makes a panel draggable via its handle element. Implements: - * - Z-order promotion: each pointerdown increments the global z-counter - * so the clicked panel floats above siblings. - * - Viewport clamping: drag moves are clamped to a 6px inset from all edges. - * - Right-rail pinning: pp-toggles panel is re-anchored right after drag. - * - CCTV viewport sync: cctv-panel recalculates scroll height after drag. - * @param {string} panelId - DOM id of the panel. - * @param {HTMLElement} panelEl - The panel DOM element. - * @param {HTMLElement} handleEl - The drag handle element within the panel. - * @returns {void} - */ - /** - * Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX]. - * Renormalizes all promoted panels when the band is exhausted so panels can - * never climb above the voice pill (150), toasts (200), or clean-view exit (300). - * @param {HTMLElement} panelEl - Panel to bring to front. - * @returns {void} - */ - _promotePanelZ(panelEl) { - this._panelZCounter += 1; - if (this._panelZCounter > PANEL_Z_MAX) { - const promoted = [...document.querySelectorAll('.panel-draggable')] - .filter((el) => el.style.zIndex) - .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex)); - let z = PANEL_Z_BASE + 1; - for (const el of promoted) { - el.style.zIndex = String(z); - z += 1; - } - this._panelZCounter = z; - } - panelEl.style.zIndex = String(this._panelZCounter); - } - - _makePanelDraggable(panelId, panelEl, handleEl) { - // Z-order promotion: bring clicked panel to front of the stacking context - panelEl.addEventListener('pointerdown', () => { - this._promotePanelZ(panelEl); - }); - - handleEl.addEventListener('pointerdown', (event) => { - if (event.button !== 0) return; - if (event.target.closest('.panel-collapse-btn')) return; - if (event.target.closest('input, select, option, button:not(.panel-collapse-btn)')) return; - - event.preventDefault(); - const rect = panelEl.getBoundingClientRect(); - const startX = event.clientX; - const startY = event.clientY; - const offsetX = startX - rect.left; - const offsetY = startY - rect.top; - - panelEl.style.left = `${rect.left}px`; - panelEl.style.top = `${rect.top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; - panelEl.classList.add('panel-dragging'); - this._promotePanelZ(panelEl); - - const onMove = (moveEvent) => { - const nextLeftRaw = moveEvent.clientX - offsetX; - const nextTopRaw = moveEvent.clientY - offsetY; - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); - const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); - panelEl.style.left = `${nextLeft}px`; - panelEl.style.top = `${nextTop}px`; - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - const onUp = () => { - panelEl.classList.remove('panel-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - this._savePanelPosition(panelId, panelEl); - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); - }); - } - - _buildSharePanelState() { - const specs = []; - for (const spec of SHARE_PANEL_STATE_SPECS) { - const panelEl = document.getElementById(spec.id); - if (!panelEl) continue; - // Responsive auto-collapse is presentation only; the recipient should - // restore the user's explicit expanded preference at its own viewport. - const collapsed = panelEl.classList.contains('layout-auto-collapsed') - ? false - : panelEl.classList.contains('collapsed'); - const entry = { id: spec.id, collapsed }; - if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); - specs.push(entry); - } - return specs.length ? { specs } : null; - } - - _restorePanelState(panelState) { - if (!panelState || !Array.isArray(panelState.specs)) return; - const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec])); - for (const spec of SHARE_PANEL_STATE_SPECS) { - const state = specsById.get(spec.id); - if (!state || typeof state.collapsed !== 'boolean') continue; - if (spec.pinnable && typeof state.pinned === 'boolean') { - this._setCommandDockPanelPinState(spec.id, state.pinned, { - restore: true, - persist: false, - syncShare: false, - }); - } - const nextCollapsed = state.pinned && spec.pinnable ? false : state.collapsed; - this.setPanelCollapsed(spec.id, nextCollapsed, { - restore: true, - persist: false, - syncShare: false, - }); - } - this.shareLinkManager?.onPanelStateChange?.(); - } - - /** - * Programmatically collapses or expands a panel, persists the state, - * and triggers layout recalculation for dependent panels. - * @param {string} panelId - DOM id of the panel. - * @param {boolean} collapsed - Whether to collapse the panel. - * @param {object} [options] Disclosure ownership options. - * @param {boolean} [options.explicit=false] Whether a direct user action owns the panel lane. - * @returns {void} - */ - setPanelCollapsed(panelId, collapsed, { - explicit = false, - restore = false, - persist = true, - syncShare = true, - } = {}) { - if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.(); - const panelEl = document.getElementById(panelId); - if (!panelEl) return; - if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); - const nextCollapsed = Boolean(collapsed); - const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed'); - const leftOwnerPanel = this._leftPanelStack?.contains(panelEl) ? panelEl : null; - const rightOwnerPanel = panelId === 'radio-panel' - ? document.getElementById('global-context-panel') - : (this._rightPanelStack?.contains(panelEl) ? panelEl : null); - const priorLeftOwner = this._leftStackPreferredPanelId; - const priorRightOwner = this._rightStackPreferredPanelId; - if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { - this._leftStackPreferredPanelId = leftOwnerPanel.id; - } else if (explicit && !restore && nextCollapsed && leftOwnerPanel?.id === this._leftStackPreferredPanelId) { - this._leftStackPreferredPanelId = null; - } - if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { - this._rightStackPreferredPanelId = rightOwnerPanel.id; - } else if ( - explicit - && !restore - && nextCollapsed - && rightOwnerPanel?.id === this._rightStackPreferredPanelId - ) { - this._rightStackPreferredPanelId = null; - } - if (panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed) { - this._syncPanelCollapseButton(panelEl); - if (priorLeftOwner !== this._leftStackPreferredPanelId) { - this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); - } - if (priorRightOwner !== this._rightStackPreferredPanelId) { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - return; - } - panelEl.classList.remove('layout-auto-collapsed'); - if (!nextCollapsed && this.cockpitView?.active && panelId === 'data-panel') { - this._cockpitContextCollapsedForDataPanel = !this.cockpitView.contextCollapsed; - if (this._cockpitContextCollapsedForDataPanel) { - this.cockpitView.setContextCollapsed(true); - } - } - if (!nextCollapsed && panelId === 'global-context-panel' - && this._contextRadioDock?.classList.contains('disclosure-open')) { - this._setRadioDisclosure?.(false); - } - if (!nextCollapsed && panelId === 'radio-panel' - && document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.setPanelCollapsed('global-context-panel', false, { restore, persist, syncShare }); - } - if (!nextCollapsed && !restore && panelId === 'location-bar') { - const otherPanel = document.getElementById('control-panel'); - if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { - this.setPanelCollapsed('control-panel', true, { restore, persist, syncShare }); - } - } else if (!nextCollapsed && !restore && panelId === 'control-panel') { - const otherPanel = document.getElementById('location-bar'); - if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { - this.setPanelCollapsed('location-bar', true, { restore, persist, syncShare }); - } - } - panelEl.classList.toggle('collapsed', nextCollapsed); - if (nextCollapsed && this.cockpitView?.active && panelId === 'data-panel' - && this._cockpitContextCollapsedForDataPanel) { - this._cockpitContextCollapsedForDataPanel = false; - this.cockpitView.setContextCollapsed(false); - } - this._syncPanelCollapseButton(panelEl); - if (persist !== false) this._savePanelCollapsedState(panelId, nextCollapsed); - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (this._rightPanelStack?.contains(panelEl)) { - this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - requestAnimationFrame(() => this._updateCommandDockTrayStack()); - this._scheduleLeftPanelLayout({ - reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true, - }); - if (syncShare) this.shareLinkManager?.onPanelStateChange?.(); - } - - /** - * Toggles "clean view" mode which hides all UI panels via a CSS body class. - * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle. - * @returns {void} - */ - toggleCleanView(forceEnabled) { - const shouldEnable = typeof forceEnabled === 'boolean' - ? forceEnabled - : !document.body.classList.contains('ui-clean-view'); - document.body.classList.toggle('ui-clean-view', shouldEnable); - if (this._cleanViewBtn) { - this._cleanViewBtn.classList.toggle('active', shouldEnable); - } - this._scheduleLeftPanelLayout(); - } - - // ── Public control facade ────────────────────────────────────────────── - // Deliberate API for voice tools and scripting. Every setter keeps the DOM - // sliders, share-link state, and scene snapshots in sync, and returns - // { ok, ...resultingState } so callers confirm only what actually happened. - - /** - * Sets HUD visibility mode. 'auto' restores style-driven show/hide. - * @param {'on'|'off'|'auto'} mode - Visibility mode. - * @returns {{ok: boolean, visible?: boolean, layout?: string, error?: string}} - */ - setHudVisible(mode) { - const normalized = String(mode ?? '').toLowerCase(); - if (!['on', 'off', 'auto'].includes(normalized)) { - return { ok: false, error: `Unknown HUD visibility mode: ${mode}` }; - } - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.hud.setMode(normalized); - this._updateHudButtonState(); - this._syncShareState(); - return { ok: true, visible: !!this.hud.visible, mode: normalized, layout: this.hud.getVariant() }; - } - - /** - * Switches the HUD layout variant. - * @param {'tactical'|'operator'|'minimal'} variantName - Layout variant. - * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}} - */ - setHudLayout(variantName) { - const variant = String(variantName ?? '').toLowerCase(); - if (!['tactical', 'operator', 'minimal'].includes(variant)) { - return { ok: false, error: `Unknown HUD layout: ${variantName}` }; - } - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(variant); - return { ok: true, layout: this.hud.getVariant(), visible: !!this.hud.visible }; - } - - /** - * Reads current detection overlay state (engine mode + UI density percent). - * @returns {{detectionMode: string, densityPct: number|null, allocationStrategy:string, fadePct:number, outsideOpacityPct:number}} - */ - getDetectionState() { - const pct = this._detectionDensitySlider - ? parseInt(this._detectionDensitySlider.value, 10) - : null; - return { - detectionMode: getDetectionMode(), - densityPct: pct, - allocationStrategy: getDetectionTuning().allocationStrategy, - fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), - }; - } - - /** Read-only overlay diagnostics used by browser QA and regression harnesses. */ - getDetectionDiagnostics() { - return readDetectionDiagnostics(); - } - - /** - * Controls the detection overlay: on/off, mode, and density percent. - * Density writes the slider AND the engine so share links and scene - * snapshots stay truthful. - * @param {object} [options] - * @param {boolean} [options.enabled] - false forces OFF; true restores the current density profile. - * @param {'sparse'|'balanced'|'dense'|'panoptic'} [options.mode] - Profile (legacy aliases accepted). - * @param {number} [options.densityPct] - 0-100 density percent. - * @param {'elastic'|'weighted'} [options.allocationStrategy] - Layer-capacity policy. - * @param {number} [options.fadePct] - Fade distance as 0-40% of the keyhole radius. - * @param {number} [options.outsideOpacityPct] - Opacity beyond the fade distance, 0-100%. - * @returns {{ok: boolean, detectionMode?: string, densityPct?: number|null, error?: string}} - */ - setDetection({ enabled, mode, densityPct, allocationStrategy, fadePct, outsideOpacityPct } = {}) { - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid detection enabled value: ${enabled}`, ...this.getDetectionState() }; - } - let requestedProfile = null; - if (typeof mode === 'string' && mode.trim()) { - requestedProfile = normalizeProfile(mode); - if (!requestedProfile) { - return { ok: false, error: `Unknown detection mode: ${mode}`, ...this.getDetectionState() }; - } - } - let requestedDensity = null; - if (densityPct != null) { - if (!Number.isFinite(Number(densityPct))) { - return { ok: false, error: `Invalid density: ${densityPct}`, ...this.getDetectionState() }; - } - requestedDensity = canonicalizeDensity(Number(densityPct)); - } - if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity != null - && profileForDensity(requestedDensity) !== requestedProfile) { - return { - ok: false, - error: `Detection mode ${requestedProfile} conflicts with density ${requestedDensity}%`, - ...this.getDetectionState(), - }; - } - let requestedAllocation = null; - if (allocationStrategy != null) { - requestedAllocation = String(allocationStrategy).trim().toUpperCase(); - if (!ALLOCATION_STRATEGIES.includes(requestedAllocation)) { - return { ok: false, error: `Unknown allocation strategy: ${allocationStrategy}`, ...this.getDetectionState() }; - } - } - if (fadePct != null) { - if (!Number.isFinite(Number(fadePct))) { - return { ok: false, error: `Invalid fade distance: ${fadePct}`, ...this.getDetectionState() }; - } - } - if (outsideOpacityPct != null) { - if (!Number.isFinite(Number(outsideOpacityPct))) { - return { ok: false, error: `Invalid outside opacity: ${outsideOpacityPct}`, ...this.getDetectionState() }; - } - } - const hasExplicitVisualChange = typeof enabled === 'boolean' - || requestedProfile !== null - || requestedDensity !== null - || requestedAllocation !== null - || fadePct != null - || outsideOpacityPct != null; - if (hasExplicitVisualChange) { - // Voice/scripted detection control counts as an explicit user choice, so - // neither style presets nor a still-pending shared visual restore can - // overwrite it afterward. - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._detectionUserOverridden = true; - } - if (requestedAllocation) { - this._setDetectionAllocation(requestedAllocation, { syncShare: false }); - } - if (fadePct != null && this._detectionFadeSlider) { - this._detectionFadeSlider.value = String(Math.max(0, Math.min(40, Math.round(Number(fadePct))))); - } - if (outsideOpacityPct != null && this._detectionOpacitySlider) { - this._detectionOpacitySlider.value = String(Math.max(0, Math.min(100, Math.round(Number(outsideOpacityPct))))); - } - if (fadePct != null || outsideOpacityPct != null) this._applyDetectionFadeFromUi(); - - if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity == null) { - requestedDensity = defaultDensityForProfile(requestedProfile); - } - if (requestedDensity != null && this._detectionDensitySlider) { - this._detectionDensitySlider.value = String(requestedDensity); - this._applyDetectionDensityFromUi(); - } - - if (enabled === false || requestedProfile === 'OFF') { - setDetectionModeByLabel('OFF'); - } else if (requestedProfile) { - setDetectionModeByLabel(requestedProfile); - } else if (enabled === true && getDetectionMode() === 'OFF') { - setDetectionModeByLabel(profileForDensity( - requestedDensity ?? this._detectionDensitySlider?.value ?? 50, - )); - } - this._syncDetectionUiFromEngine(); - this._syncShareState(); - return { ok: true, ...this.getDetectionState() }; - } - - /** - * Switches the basemap stack and reports whether the switch landed. - * @param {string} stackId - One of mapStackController.getStacks() ids. - * @returns {Promise<{ok: boolean, activeStack?: string, error?: string|null, available?: string[]}>} - */ - async setMapStack(stackId) { - if (!this.mapStackController) { - return { ok: false, error: 'Map stack controller unavailable' }; - } - const stacks = this.mapStackController.getStacks(); - const target = stacks.find((stack) => stack.id === stackId); - if (!target) { - return { ok: false, error: `Unknown map stack: ${stackId}`, available: stacks.map((s) => s.id) }; - } - if (!target.available) { - return { ok: false, error: `${target.label} requires a Cesium ion token`, activeStack: this.mapStackController.getActiveId() }; - } - await this._setMapStack(stackId); - const state = this.mapStackController.getState(); - const landed = state.activeId === stackId; - return { - ok: landed, - activeStack: state.activeId, - error: landed ? null : (state.lastError || 'Map stack did not switch'), - }; - } - - /** - * Controls bloom post-processing. Intensity is the UI percent (0-200). - * @param {object} [options] - * @param {boolean} [options.enabled] - * @param {number} [options.intensityPct] - 0-200. - * @returns {{ok: boolean, bloom: {enabled: boolean, intensityPct: number|null}}} - */ - setBloom({ enabled, intensityPct } = {}) { - const current = () => ({ - enabled: !!this.bloomEnabled, - intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, - }); - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid bloom enabled value: ${enabled}`, bloom: current() }; - } - if (intensityPct !== undefined - && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { - return { ok: false, error: `Invalid bloom intensity: ${intensityPct}`, bloom: current() }; - } - const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; - if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); - if (intensityPct !== undefined) { - this._setBloomIntensity(Math.round(Math.max(0, Math.min(200, intensityPct)))); - } - if (enabled !== undefined) this._setBloomEnabled(enabled); - return { - ok: true, - bloom: current(), - }; - } - - /** - * Controls sharpen post-processing. Intensity is the UI percent (0-100). - * @param {object} [options] - * @param {boolean} [options.enabled] - * @param {number} [options.intensityPct] - 0-100. - * @returns {{ok: boolean, sharpen: {enabled: boolean, intensityPct: number|null}}} - */ - setSharpen({ enabled, intensityPct } = {}) { - const current = () => ({ - enabled: !!this.sharpenEnabled, - intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, - }); - if (enabled !== undefined && typeof enabled !== 'boolean') { - return { ok: false, error: `Invalid sharpen enabled value: ${enabled}`, sharpen: current() }; - } - if (intensityPct !== undefined - && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { - return { ok: false, error: `Invalid sharpen intensity: ${intensityPct}`, sharpen: current() }; - } - const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; - if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); - if (intensityPct !== undefined) { - const pct = Math.round(Math.max(0, Math.min(100, intensityPct))); - if (this._sharpenSlider) this._sharpenSlider.value = String(pct); - if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; - this._applySharpenIntensity(pct / 100); - this._syncShareState(); - } - if (enabled !== undefined) this._setSharpenEnabled(enabled); - return { - ok: true, - sharpen: current(), - }; - } - - /** Whether the full-globe celestial overlay is enabled by user preference. */ - get celestialRingEnabled() { - return !!this.celestialRing?.enabled; - } - - /** - * Controls the celestial ring. The Display button uses `focus=true` when the - * ring is disabled or unavailable at the current zoom, turning the control - * into a reveal action instead of requiring a separate globe-navigation step. - * @param {boolean} enabled - * @param {object} [options] - * @param {boolean} [options.syncShare=true] - * @param {boolean} [options.focus=false] - * @returns {{ok:boolean, celestialRing:{enabled:boolean,visible:boolean}, cameraFocused:boolean, error?:string}} - */ - setCelestialRingEnabled(enabled, { syncShare = true, focus = false } = {}) { - const styleSupported = isCelestialRingStyleSupported(this.activeStyle); - const current = () => ({ - enabled: this.celestialRingEnabled, - visible: !!this.celestialRing?.visible, - }); - if (typeof enabled !== 'boolean') { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: `Invalid celestial ring enabled value: ${enabled}`, - }; - } - if (typeof syncShare !== 'boolean' || typeof focus !== 'boolean') { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: 'Celestial ring options must be boolean', - }; - } - if (!styleSupported && enabled) { - return { - ok: false, - celestialRing: current(), - cameraFocused: false, - error: 'Celestial ring is available only in Normal style', - }; - } - if (syncShare) this.shareLinkManager?.claimRestoreLane?.('visual'); - const nextEnabled = styleSupported && enabled; - this.celestialRing?.setEnabled(nextEnabled); - this._celestialBtn?.classList.toggle('active', nextEnabled); - this._celestialBtn?.setAttribute('aria-pressed', String(nextEnabled)); - if (this._celestialBtn) { - this._celestialBtn.disabled = !styleSupported; - this._celestialBtn.setAttribute('aria-disabled', String(!styleSupported)); - this._celestialBtn.title = styleSupported - ? 'Celestial ring — reveal the full globe' - : 'Celestial ring — available in Normal style'; - } - let cameraFocused = false; - if (nextEnabled && focus) { - cameraFocused = !!this.celestialRing?.focusFullGlobe(); - } - if (syncShare) this._syncShareState(); - return { - ok: styleSupported || !enabled, - celestialRing: current(), - cameraFocused, - }; - } - - /** - * Starts or stops orbiting the active POI. - * @param {boolean} [enabled] - Omit to toggle. - * @returns {{ok: boolean, orbiting: boolean, error?: string}} - */ - setOrbit(enabled) { - const active = !!this.orbitController?.active; - if (typeof enabled === 'boolean' && enabled === active) { - return { ok: true, orbiting: active }; - } - if (enabled === false) { - this._stopOrbit(); - return { ok: true, orbiting: false }; - } - if (!this._currentTarget) { - return { ok: false, orbiting: false, error: 'No active landmark to orbit — fly to a landmark first' }; - } - this._toggleOrbit(); - return { ok: true, orbiting: !!this.orbitController?.active }; - } - - /** - * Enables/disables clean view (hides all UI chrome). - * @param {boolean} [enabled] - Omit to toggle. - * @returns {{ok: boolean, cleanView: boolean}} - */ - setCleanView(enabled) { - this.toggleCleanView(enabled); - return { ok: true, cleanView: document.body.classList.contains('ui-clean-view') }; - } - - /** - * Reads global context mode state for voice/state-sync consumers. - * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}} - */ - getContextModeState() { - return { - mode: this._contextMode || null, - active: Boolean(this._contextMode), - changing: Boolean(this._contextModeChanging), - entering: this._contextModeEntering || null, - canContact: !this._contextMode || this._contextMode === 'flights', - canMission: !this._contextMode || this._contextMode === 'space-missions', - snapshotCaptured: Boolean(this._contextSessionSnapshot), - }; - } - - /** - * Sets global context mode (Contacts / Space Missions / off) for voice. - * @param {'contacts'|'space-missions'|'off'|null} mode - Requested context target. - * @param {object} [options] - * @param {string|Symbol|null} [options.notificationToken] - * @param {AbortSignal|null} [options.signal] - * @param {Function|null} [options.isCurrent] - * @param {boolean} [options.claimVisualAuthority] Whether this request is a - * genuine operator/voice Context intent that should take the visual restore - * lane. Cockpit choreography calls this facade INTERNALLY for its own - * enter/rollback steps; those transitions are not a Context request by the - * operator and must stay inert, so they pass `false`. - * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>} - */ - async setContextMode(mode, { - notificationToken = null, - signal = null, - isCurrent = null, - claimVisualAuthority = true, - } = {}) { - const requestIsCurrent = () => !signal?.aborted - && (typeof isCurrent !== 'function' || isCurrent()); - const cancellationResult = () => ({ - ok: false, - action: 'set_context_mode', - cancelled: true, - error: 'Context request was superseded by a newer voice turn', - ...this.getContextModeState(), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }); - if (!requestIsCurrent()) return cancellationResult(); - try { - if (!mode || mode === 'off') { - // Validated explicit transition — Context owns detection, so take the - // visual lane before a delayed shared restore can reclaim it. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const result = await this._selectContextMode(null, { notificationToken, signal }); - if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); - const state = this.getContextModeState(); - return { - ok: result === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(result === true ? {} : { error: 'Context mode transition did not complete' }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } - const canonical = mode === 'contacts' ? 'flights' : mode; - if (!['flights', 'space-missions'].includes(canonical)) { - return { - ok: false, - action: 'set_context_mode', - error: `Unknown context mode: ${mode}`, - mode: this._contextMode, - ...this.getContextModeState(), - }; - } - const priorMode = this._contextMode; - // Claimed only after the mode enum validates above, so a rejected request - // takes no authority and leaves the shared visual state eligible. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); - if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); - const state = this.getContextModeState(); - // A cross-mode switch tears the prior mode down before it commits, so a - // cancelled or failed switch rests on Context OFF. Say that plainly: - // reporting a bare "did not complete" while the operator's Context is - // gone is the dishonesty this whole path was fixed for. The state fields - // below carry the same verdict, so text and state cannot disagree. - const crossModeSwitchLost = transitioned !== true - && Boolean(priorMode) && priorMode !== canonical && !state.mode; - return { - ok: transitioned === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(transitioned === true ? {} : { - // Named in the operator's vocabulary, not the internal id: this - // string is read by the voice model, which takes 'contacts'. - error: crossModeSwitchLost - ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` - : 'Context mode transition did not complete', - ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), - }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } catch (error) { - if (!requestIsCurrent()) { - return { - ...cancellationResult(), - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - }; - } - return { - ok: false, - action: 'set_context_mode', - error: error?.message || 'Context mode transition failed', - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - ...this.getContextModeState(), - }; - } - } - - /** - * Returns cockpit status for voice/state sync and navigation operations. - * @returns {{active:boolean, entryAllowed:boolean, visionMode:string, subject:{id:string,layerId:string}|null, navigation:{canPrevious:boolean,canNext:boolean,canFocus:boolean}|null, awareness?: object}|null} - */ - getCockpitState() { - const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); - const info = this.cockpitView?.readAircraftInfo?.(); - const active = Boolean(this.cockpitView?.active); - const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); - // "Could Cockpit be ENTERED right now" — so it is false while already - // inside, unconditionally. Cockpit takes the entity off - // `viewer.trackedEntity` on entry and NEXT puts one back, which made this - // flip true/false between calls while `active` stayed true; readers - // (including the voice model) read that as a broken half-entered state. - const entryAllowed = !active && Boolean( - gateOpen - && info - && this.viewer?.trackedEntity?.position, - ); - return { - active, - entryAllowed, - // Why entry is unavailable, so a refusal can be explained rather than - // guessed at. - entryBlockedReason: entryAllowed || active - ? null - : (!gateOpen - ? (this._contextModeChanging ? 'contacts-starting' : 'contacts-inactive') - : 'no-tracked-aircraft'), - visionMode: this.cockpitView?.visionMode || null, - subject: info ? { - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, - } : null, - navigation: snapshot ? { - canPrevious: Boolean(snapshot.navigation?.canPrevious), - canNext: Boolean(snapshot.navigation?.canNext), - canFocus: Boolean(snapshot.navigation?.canFocus), - } : null, - awareness: snapshot ? { - radiusM: Number.isFinite(snapshot.radiusM) ? snapshot.radiusM : null, - subject: snapshot.subject ? { - id: snapshot.subject.id || null, - layerId: snapshot.subject.layerId || null, - } : null, - cohorts: Array.isArray(snapshot.cohorts) - ? snapshot.cohorts.map((cohort) => ({ - id: cohort?.id || null, - source: cohort?.source || null, - count: Number.isFinite(cohort?.count) ? cohort.count : null, - relationship: cohort?.relationship || null, - reason: cohort?.reason || null, - coverage: cohort?.coverage || null, - })) - : [], - navigation: snapshot.navigation ? { - canPrevious: Boolean(snapshot.navigation.canPrevious), - canNext: Boolean(snapshot.navigation.canNext), - canFocus: Boolean(snapshot.navigation.canFocus), - } : null, - } : null, - activeTracked: this.cockpitView?.active ? Boolean(this.cockpitView?.trackedEntity) : false, - activeMapView: !this.cockpitView?.active && entryAllowed, - }; - } - - /** - * Point Cockpit entry at a requested contact layer before it enters. - * - * Reuses the filtered Context navigation NEXT already uses, so "cockpit in - * that military helicopter" lands on the same contact "next military - * helicopter" would. Cockpit flies aircraft only; vessel and installation - * layers are refused by name rather than silently ignored. - * @param {object} options Retarget request. - * @param {string} options.targetLayer Requested contact layer. - * @param {string|null} options.aircraftClass Optional class filter. - * @param {{layerId: string}|null} options.currentTarget Current tracker. - * @param {{layerId: string}|null} options.selectedTarget Pending selection. - * @returns {{ok: boolean, retargeted?: boolean, error?: string}} Outcome. - */ - _retargetCockpitEntryLayer({ targetLayer, aircraftClass, currentTarget, selectedTarget }) { - if (!['flights', 'military'].includes(targetLayer)) { - return { - ok: false, - error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`, - }; - } - const activeLayer = selectedTarget?.layerId || currentTarget?.layerId || null; - const alreadyOnLayer = activeLayer === targetLayer; - if (alreadyOnLayer && !aircraftClass) return { ok: true, retargeted: false }; - const moved = militaryAwarenessLayer?.navigateNext - ? !!militaryAwarenessLayer.navigateNext({ - targetLayer, - aircraftClass, - origin: 'voice', - }) - : false; - if (moved) return { ok: true, retargeted: true }; - // A filter that matched nothing still enters, as long as the layer is - // already right — the operator asked for that layer and is on it. - if (alreadyOnLayer) return { ok: true, retargeted: false }; - const label = targetLayer === 'military' ? 'military' : 'civilian'; - const filtered = aircraftClass ? `${aircraftClass} ` : ''; - return { - ok: false, - error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`, - }; - } - - /** - * Controls cockpit entry/exit and context navigation. - * @param {'enter'|'exit'|'next'|'previous'|'status'} action - Cockpit action. - * @param {object} [options] - * @param {string|Symbol|null} [options.notificationToken] - * @param {'flights'|'military'|'ais-live-vessels'|'military-installations'|null} [options.targetLayer] - * @param {string|null} [options.aircraftClass] - * @param {{layerId:'flights'|'military',id:string}|null} [options.selectedTarget] - * @param {{layerId:'flights'|'military',id:string}|null} [options.rollbackTarget] - * @returns {{ok:boolean, action:string, error?:string, state?:object}} - */ - controlCockpit(action, { - notificationToken = null, - targetLayer = null, - aircraftClass = null, - selectedTarget = null, - rollbackTarget = undefined, - } = {}) { - const normalized = String(action || '').toLowerCase(); - if (!this.cockpitView) { - return { - ok: false, - action: 'control_cockpit', - error: 'Cockpit controller unavailable', - state: this.getCockpitState(), - }; - } - if (normalized === 'status') { - return { - ok: true, - action: 'control_cockpit', - state: this.getCockpitState(), - notificationToken: notificationToken || null, - }; - } - if (normalized === 'enter') { - // Entry is gated exactly as the manual entry chip is. Attempting it while - // the gate is shut produced the half-entered look the operator reported - // (a plane anchored under the camera with no cockpit around it), so - // refuse with the reason instead of trying. - if (!this.cockpitView.isEntryAllowed?.()) { - return { - ok: false, - action: 'control_cockpit', - error: this._contextModeChanging - ? 'Contacts is still starting up — try Cockpit again in a moment' - : 'Contacts must be active to enter Cockpit — say "open contacts" first', - state: this.getCockpitState(), - }; - } - let currentTarget = this.getAircraftTrackingTarget(); - const layerForTarget = (target) => target?.layerId === 'military' - ? militaryFlightsLayer - : target?.layerId === 'flights' ? flightsLayer : null; - // A requested layer retargets BEFORE entry, through the same filtered - // navigation NEXT uses. Ignoring it entered on whatever was already - // tracked and reported success, so "cockpit in that military helicopter" - // silently put the operator in an airliner. - if (targetLayer) { - const requested = this._retargetCockpitEntryLayer({ - targetLayer, - aircraftClass, - currentTarget, - selectedTarget, - }); - if (!requested.ok) { - return { - ok: false, - action: 'control_cockpit', - error: requested.error, - state: this.getCockpitState(), - }; - } - if (requested.retargeted) { - // The retarget is now the authority; a selection sampled before it - // would drag entry back to the wrong layer. - selectedTarget = null; - rollbackTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget; - currentTarget = this.getAircraftTrackingTarget(); - } - } - const selectedLayer = selectedTarget?.layerId === 'military' - ? militaryFlightsLayer - : selectedTarget?.layerId === 'flights' ? flightsLayer : null; - const entry = enterCockpitWithTracking({ - cockpitView: this.cockpitView, - selectedLayer, - selectedTarget, - currentLayer: layerForTarget(currentTarget), - rollbackLayer: layerForTarget( - rollbackTarget === undefined ? currentTarget : rollbackTarget, - ), - rollbackTarget, - selectionOrigin: 'voice', - }); - return { - ok: entry.entered, - action: 'control_cockpit', - state: this.getCockpitState(), - error: entry.error, - }; - } - if (normalized === 'exit') { - const exited = !!this.cockpitView.exit(); - return { - ok: exited, - action: 'control_cockpit', - state: this.getCockpitState(), - error: exited ? null : 'Cockpit was already inactive', - }; - } - if (normalized === 'next' || normalized === 'previous') { - const changed = this.cockpitView.navigateContext( - normalized === 'next' ? 1 : -1, - { - targetLayer, - aircraftClass, - origin: 'voice', - }, - ); - return { - ok: changed, - action: 'control_cockpit', - state: this.getCockpitState(), - error: changed ? null : 'No further context target was available', - }; - } - return { - ok: false, - action: 'control_cockpit', - error: `Unknown cockpit action: ${action}`, - state: this.getCockpitState(), - }; - } - - /** - * Full control-state snapshot — single source for voice read-back so the - * agent confirms from the same state it acted on. - * @returns {object} Current style/stack/HUD/detection/post-processing state. - */ - getControlState() { - return { - style: this.activeStyle || 'normal', - mapStack: this.mapStackController?.getActiveId?.() || null, - hud: { visible: !!this.hud?.visible, layout: this.hud?.getVariant?.() || null }, - detection: this.getDetectionState(), - bloom: { - enabled: !!this.bloomEnabled, - intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, - }, - sharpen: { - enabled: !!this.sharpenEnabled, - intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, - }, - celestialRing: { - enabled: this.celestialRingEnabled, - visible: !!this.celestialRing?.visible, - }, - orbiting: !!this.orbitController?.active, - recording: !!this._recordingMode, - cleanView: document.body.classList.contains('ui-clean-view'), - }; - } - - /** - * Captures the current camera position and orientation as a serializable object. - * @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null} - */ - getCameraState() { - const carto = this.viewer.camera.positionCartographic; - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - alt: carto.height, - heading: Cesium.Math.toDegrees(this.viewer.camera.heading), - pitch: Cesium.Math.toDegrees(this.viewer.camera.pitch), - roll: Cesium.Math.toDegrees(this.viewer.camera.roll), - }; - } - - /** - * Flies the camera to a previously captured camera state using cubic ease-in-out. - * @param {{lat: number, lon: number, alt: number, heading?: number, pitch?: number, roll?: number}} cameraState - * @param {number} [duration=2.8] - Flight duration in seconds. - * @returns {void} - */ - applyCameraState(cameraState, duration = 2.8) { - if (!cameraState) return; - this.viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - cameraState.lon, - cameraState.lat, - cameraState.alt - ), - orientation: { - heading: Cesium.Math.toRadians(cameraState.heading || 0), - pitch: Cesium.Math.toRadians(cameraState.pitch || -35), - roll: Cesium.Math.toRadians(cameraState.roll || 0), - }, - duration: Math.max(0.2, duration || 0), - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); - } - - /** - * Snapshots the full visual state (active style, bloom, sharpen, HUD, detection, - * per-style shader uniform values) for serialization or scene recipe capture. - * @returns {object} Serializable visual state object. - */ - getVisualState() { - const styleParams = {}; - for (const [styleName, stage] of Object.entries(this.stages)) { - const shader = STYLES[styleName]; - if (!shader?.uniforms) continue; - styleParams[styleName] = {}; - for (const uniformName of Object.keys(shader.uniforms)) { - styleParams[styleName][uniformName] = stage.uniforms[uniformName]; - } - } - - return { - style: this.activeStyle, - bloom: { - enabled: this.bloomEnabled, - intensity: this._getBloomIntensity(), - version: BLOOM_SCALE_VERSION, - }, - sharpen: { - enabled: this.sharpenEnabled, - intensity: parseInt(this._sharpenSlider?.value || '49', 10), - }, - hud: { - visible: this.hud.visible, - variant: this.hud.getVariant(), - }, - detection: { - mode: getDetectionMode(), - density: parseInt(this._detectionDensitySlider?.value || '50', 10), - allocation: getDetectionTuning().allocationStrategy, - fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), - outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), - }, - scope: { - enabled: isScopeMaskEnabled(), - featherPct: Math.round(getScopeMaskFeather() * 100), - }, - mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', - styleParams, - }; - } - - /** - * Restores a full visual state snapshot, applying style, bloom, sharpen, - * HUD, detection, and per-style shader uniforms. Used by scene recipes - * and share-link restore. Async so the map-stack switch resolves before - * the share state is synced; callers may fire-and-forget. - * @param {object} [state={}] - Visual state object (as returned by getVisualState). - * @param {object} [options] - * @param {(() => boolean)|null} [options.isCurrent] Caller liveness predicate. - * The map-stack switch is this method's ONLY suspension point, and the - * shader-uniform writes come after it — so a caller superseded while that - * switch is in flight would otherwise resume and commit the look of a - * state the operator has already moved past. Scene playback reproduced - * exactly that: a stale shot's uniforms landing on top of the live run. - * Omit it and the method behaves as it always has. - * @returns {Promise