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(); layerState._scratchViewScreen = new Cesium.Cartesian2(); layerState._scratchViewProjection = new Cesium.Matrix4(); // Counts view passes; a record shown by the latest pass carries its number. layerState._viewPassId = 0; // Records whose icons the last view pass showed (see refreshHorizonCulling): // geometry refinement, the tiles-ready completion pass and ambient cards // work over these, not the whole catalog. layerState._viewRecords = []; // Coverage ids refreshCoverageStyles last drew: part of the geometry // working set even when their icons are thinned out. layerState._coverageVisibleIds = new Set(); // View pass triggers besides camera settle: an interval while the camera // moves, and a canvas resize observer. layerState._viewMotionTimer = 0; layerState._viewResizeObserver = null; layerState._viewResizeCanvas = null; // The camera the moveStart/moveEnd listeners are attached to. layerState._viewListenerCamera = null; // A motion pass changed the shown set and the settle pass has not yet told // subscribers; the camera pose the last view pass ran at. layerState._viewUnpublished = false; layerState._viewPose = null; // { at, ms } — when the last motion pass ran and what it cost. layerState._viewMotionPass = null; // Retries the view geometry a drain left unresolved, once cells warm. layerState._viewGeometryRetryTimer = 0; // { key, count } — the active camera's coverage-overlap count, which the // summary reads on every notification. layerState._neighborCountCache = null; // { view, active, list } — the camera picker's index uiState publishes, // rebuilt only when the view pass or the active camera changes. layerState._cameraIndexCache = null; // 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; }