From fe1aa3b6f64efedb66fb6143ec2b479d58fb02c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 20:35:38 -0700 Subject: [PATCH] fix: clarify nearby ALPR counts and help locate cameras --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/alprCameras.test.mjs | 42 ++++++++++++++++++++++++++ src/data/manager.test.mjs | 34 +++++++++++++++++++++ src/layers/alpr/index.js | 31 ++++++++++++++++++++ src/layers/alpr/presentation.js | 52 +++++++++++++++++++++++++++++++++ src/ui/layerPanel.js | 25 +++++++--------- src/ui/styles/layers.css | 8 +++++ 8 files changed, 186 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 502fbd351..83f90ce1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## ALPR camera locations +- Label the loaded camera count as nearby, show a purple-dot legend, and add + SHOW NEAREST to frame and select a loaded camera when none are on screen. + - Keep nearby camera markers and selection stable during rotation, use bounded ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 10ae0e1cc..d6b753e17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,6 +9,11 @@ attribution; rendering does not parse Overpass payloads. The standalone registration supplies selection, picking, terrain and render services. Selected cards and Data attribution identify OpenStreetMap. Layer state and the existing voice layer tools include `alpr-cameras`. +The row count explicitly says **nearby**: loaded records can be outside the +screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when +selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is +disabled with no loaded cameras or while following a tracked entity. This action +chooses from the current source snapshot. Coverage follows a bounded neighborhood around the screen-center ground point, with a radius of twice the camera-to-ground range (at least 1 km), rather than extending to the horizon. Sky, dateline-crossing and wider-than-city views do not diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 0970f1920..d758f7060 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1082,3 +1082,45 @@ test('ground-centered orbits ignore horizon rectangles while sky, distant and da h.restore(); } }); + +test('nearby count and discovery control frame a real loaded camera without fetching or stealing tracking', async () => { + const h = cameraHarness(); + const flights = []; + h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -97.7431, + 30.2672, + 600, + ); + h.viewer.camera.flyToBoundingSphere = (sphere, options) => + flights.push({ sphere, options }); + try { + assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby'); + const controls = alprCamerasLayer.getRowControls(); + assert.equal(controls.legend[0].label, 'Purple dots'); + assert.equal(controls.chips[0].onClick(), true); + assert.equal(flights.length, 1); + assert.equal(getSelectedEntityContext().id, 'alpr:42'); + const center = Cesium.Cartographic.fromCartesian(flights[0].sphere.center); + assert.ok( + Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9, + ); + assert.equal(flights[0].options.offset.range, 800); + assert.equal(h.requests.length, 1); + h.viewer.trackedEntity = {}; + assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true); + assert.equal( + controls.chips[0].onClick(), + false, + 'a stale button cannot steal the follow camera', + ); + h.viewer.trackedEntity = undefined; + alprCamerasLayer.disable(); + assert.equal(alprCamerasLayer.getStats().countLabel, ''); + assert.equal(controls.chips[0].onClick(), false); + assert.equal(flights.length, 1); + } finally { + h.restore(); + } +}); diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index e2f084d62..80a1a8459 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3302,3 +3302,37 @@ test('panel remount releases old listeners and destruction revokes retained cont else globalThis.document = originalDocument; } }); + +test('row action chips use live disabled state and descriptive counts without writing parameters', async () => { + const originalDocument = globalThis.document; + globalThis.document = { createElement: makeControlElement }; + const mgr = new DataLayerManager({}); + const layer = makeRowControlLayer(); + let calls = 0, blocked = false; + layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' }); + layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] }); + layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); }; + mgr.register(layer.module); + const container = makeControlElement(); + try { + mgr.buildTogglePanel(container); + await mgr.setEnabled('satellites', true); + const row = container.querySelector('[data-layer-id="satellites"]'); + assert.equal(row.querySelector('.data-count').textContent, '3 nearby'); + const controls = row.querySelector('.data-toggle-controls'); + const chip = collectByClass(controls, 'data-toggle-chip')[0]; + controls.listeners.click({ target: chip }); + assert.equal(calls, 1); + blocked = true; + controls.listeners.click({ target: chip }); + assert.equal(calls, 1, 'live disabled descriptor wins before row repaint'); + blocked = false; + await mgr.setEnabled('satellites', false); + controls.listeners.click({ target: chip }); + assert.equal(calls, 1, 'disabled layer cannot navigate'); + } finally { + await mgr.destroyAll(); + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 005ecc6cc..74a20ffcf 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -6,6 +6,7 @@ import { MAX_RENDERED, QUERY_SNAP_DEGREES, QUERY_REUSE_MS, + ALPR_COLOR, } from './policy.js'; import { snapAlprBox, @@ -76,6 +77,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { presentOnMapCredit, renderRecords, selectRecord, + focusNearest, clearSelection, updateSelectedAnchor, installInteraction, @@ -301,9 +303,38 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.saturated = false; state.viewer = null; }, + getRowControls() { + const count = state.dataSource?.entities.values.length || 0; + return { + chips: [ + { + id: 'find-camera', + label: 'SHOW NEAREST', + title: state.viewer?.trackedEntity + ? 'Stop following the current object before navigating to a camera' + : 'Move to the nearest loaded camera and show its details', + disabled: + !state.enabled || !count || Boolean(state.viewer?.trackedEntity), + onClick: focusNearest, + }, + ], + legend: [ + { + label: 'Purple dots', + color: ALPR_COLOR, + count, + blurb: + 'Nearby mapped cameras may be outside the screen. Dots stay 8 pixels wide as you zoom; click one for details.', + }, + ], + }; + }, getStats() { return { count: state.dataSource?.entities.values.length || 0, + countLabel: state.enabled + ? `${state.dataSource?.entities.values.length || 0} nearby` + : '', lastUpdate: state.lastUpdate, stale: state.stale, saturated: state.saturated, diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 8e335e1ae..89b6c6735 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -245,6 +245,57 @@ export function createAlprPresentation({ state, services, source }) { if (visible.length) presentOnMapCredit(); } + function focusNearest() { + const camera = state.viewer?.camera; + if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity) + return false; + let nearest = null, + distance = Infinity; + for (const entity of state.dataSource.entities.values) { + const position = entity.position.getValue(Cesium.JulianDate.now()); + const candidate = Cesium.Cartesian3.distanceSquared( + camera.positionWC, + position, + ); + if (candidate < distance) { + nearest = entity; + distance = candidate; + } + } + if (!nearest) return false; + const record = state.recordById.get(nearest.id); + const location = Cesium.Cartographic.fromDegrees( + record.longitude, + record.latitude, + ); + let height; + if (state.viewer.scene.sampleHeightSupported) { + try { + height = state.viewer.scene.sampleHeight(location, [nearest]); + } catch { + /* tiles may still be streaming */ + } + } + if (!Number.isFinite(height) || height < -500 || height > 10000) + height = cachedGroundFloor(record.latitude, record.longitude); + const center = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + Number.isFinite(height) ? height : 0, + ); + if (!selectRecord(nearest.id)) return false; + camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { + duration: 1.2, + offset: new Cesium.HeadingPitchRange( + camera.heading || 0, + -Math.PI / 4, + 800, + ), + }); + governorRequestRender('alpr-focus'); + return true; + } + function selectRecord(id) { const entity = state.dataSource?.entities.getById(id); if (!entity || !state.recordById.has(id)) return false; @@ -343,6 +394,7 @@ export function createAlprPresentation({ state, services, source }) { presentOnMapCredit, renderRecords, selectRecord, + focusNearest, clearSelection, updateSelectedAnchor, installInteraction, diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 6bd94bf9f..fafcb5d67 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -136,9 +136,7 @@ export class LayerPanel { const count = document.createElement('span'); count.className = 'data-count'; - count.textContent = layer.stats.count - ? this._formatCount(layer.stats.count) - : '—'; + count.textContent = this._layerCountText(layer.stats); const toggle = document.createElement('button'); toggle.type = 'button'; @@ -202,7 +200,9 @@ export class LayerPanel { const chip = this._rowControlsFor(layer.id)?.chips?.find( (entry) => entry.id === button.dataset.chipId, ); - if (chip?.params) + if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; + if (typeof chip.onClick === 'function') chip.onClick(); + else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); row.appendChild(controls); @@ -213,13 +213,12 @@ export class LayerPanel { } } - /** - * 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. - */ + /** Qualify a loaded count when it does not mean items currently on screen. */ + _layerCountText(stats) { + if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) + return stats.countLabel; + return stats.count ? this._formatCount(stats.count) : '—'; + } /** * Render a layer's row chips and color legend, and keep the whole block @@ -307,9 +306,7 @@ export class LayerPanel { const count = row.querySelector('.data-count'); if (count) { - count.textContent = layer.stats.count - ? this._formatCount(layer.stats.count) - : '—'; + count.textContent = this._layerCountText(layer.stats); } const meta = row.querySelector('.data-toggle-meta'); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 87153ebd5..4127334e5 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1273,3 +1273,11 @@ #global-context-panel #cockpit-entry kbd { padding: 2px 4px; } + +/* Qualified nearby counts must also fit beside the wider loading chip. */ +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top { + flex-wrap: wrap; +} +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right { + margin-left: auto; +}