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, ) ) { // Browser-only feeds have a separate player; a reselect retries it // without re-running activation or touching the projection geometry. if (parts.model.isBrowserOverlayCamera(record.camera)) { layerState._activeCameraReselection += 1; parts.presentation.notifyListeners(); } return CCTV_ACTIVATION_RESULT.UNCHANGED; } layerState._activeCameraId = cameraId; layerState._autoHopSuspended = false; layerState._openingCameraProvisional = 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); // Ground under the plane's footprint (DEM, cached proxy) for cameras // without a shipped precompute for this pose — only the camera the user // is looking at ever asks. void parts.ground.resolveFootprintGround(record); record.activationDone = true; parts.rendering.refreshCoverageStyles(); // The active camera's icon always shows; the previously active one // competes for its cell again. Its coverage neighbours join the working // set and are queued for grounding. parts.rendering.refreshHorizonCulling(); parts.geometryQueue.enqueueViewGeometry(); // 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; layerState._openingCameraProvisional = false; record.activationDone = false; parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { parts.geometry.applyFrustumGeometry( previous, parts.ground.groundAltFor(previous), ); }); parts.rendering.refreshHorizonCulling(); parts.rendering.refreshCoverageStyles(); parts.geometryQueue.enqueueViewGeometry(); 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, }; }