From 87629ac175ed9a044b4f67d40459c27230f9a05d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 14 Sep 2026 17:35:24 -0700 Subject: [PATCH] Port ALPR camera visuals into the shared layer Add camera badges, gradient direction wedges and animated tactical selection cards while preserving bounded loading and layer lifecycle. Align the ALPR data-layer header and retain native pick ownership for overlay badges. Co-authored-by: manjunath22466 --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 11 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 6 + src/app/layers/alprCameras.js | 20 +- src/data/alprCameras.test.mjs | 32 +- src/data/trackedReadout.js | 12 +- src/data/trackedReadout.test.mjs | 20 ++ src/layers/alpr/assets/alpr-marker-normal.png | Bin 0 -> 13199 bytes .../assets/alpr-marker-selected-brackets.png | Bin 0 -> 4733 bytes .../alpr/assets/alpr-marker-selected.png | Bin 0 -> 10261 bytes src/layers/alpr/index.js | 9 +- src/layers/alpr/overlay.js | 298 ++++++++++++++++++ src/layers/alpr/overlay.test.mjs | 201 ++++++++++++ src/layers/alpr/policy.js | 13 +- src/layers/alpr/presentation.js | 149 ++++++--- src/layers/alpr/visuals.js | 106 +++++++ src/overlays/worldOverlay.js | 27 +- src/overlays/worldOverlayDraw.js | 86 ++++- src/overlays/worldOverlayDraw.test.mjs | 30 ++ src/ui/styles/layers.css | 19 +- 21 files changed, 951 insertions(+), 97 deletions(-) create mode 100644 src/layers/alpr/assets/alpr-marker-normal.png create mode 100644 src/layers/alpr/assets/alpr-marker-selected-brackets.png create mode 100644 src/layers/alpr/assets/alpr-marker-selected.png create mode 100644 src/layers/alpr/overlay.js create mode 100644 src/layers/alpr/overlay.test.mjs create mode 100644 src/layers/alpr/visuals.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0242199c9..af01f9c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ ## ALPR camera locations +- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets, + gradient direction wedges and animated tactical labels into the reusable ALPR + layer. Keep bounded source loading, stable entities, selection and SHOW NEAREST. +- Align the ALPR layer-row header with other layers, keeping the toggle beside + the name instead of wrapping it onto its own line. + - 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, using the available 3D-tile or globe terrain height. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ba03ad08a..cfb1f720f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -89,8 +89,15 @@ 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 +screen. Cyan camera badges use Manjunath's camera artwork; selection switches to a +larger coral badge with corner brackets and an animated tactical card. Numeric +mapped bearings draw illustrative 90 m direction wedges (not measured coverage). +The shared overlay paints gradients for at most 64 nearby cameras, promoting the +selected camera; farther cameras retain native Cesium badges and ground-clamped +wedges. Unknown bearings never acquire a wedge. Asset URLs resolve relative to +the reusable layer, and the caller supplies overlay and map-stack services. +The ALPR row uses the same header layout as other data layers. **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 and uses rendered surface height from 3D tiles or globe terrain when available. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5f27f229c..2ab545ea4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -476,6 +476,9 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/layers/alpr/visuals.js", + "src/layers/alpr/overlay.js", + "src/layers/alpr/overlay.test.mjs", "src/layers/alpr/source.js", "src/layers/alpr/source.test.mjs", "src/layers/radio/catalogModel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 202159eab..f5992b52a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -925,6 +925,8 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/layers/alpr/visuals.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/source.js" ], "external": ["cesium"] @@ -1215,6 +1217,8 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/layers/alpr/visuals.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", @@ -1665,6 +1669,8 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/layers/alpr/visuals.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index c21de9412..24bf841e8 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -2,12 +2,30 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; +import { registerWorldOverlayPaintLane } from '../../overlays/worldOverlay.js'; +import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js'; +import { refreshTrackedReadout } from '../../data/trackedReadout.js'; /** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationAlpr({ surface, source }) { const { groundFloor } = surface; return createAlprCamerasLayer({ source, - services: { render, context, picking, groundFloor }, + services: { + render, + context, + picking, + groundFloor, + overlays: { + registerPaintLane: registerWorldOverlayPaintLane, + keyholeAlpha: keyholeLabelAlphaFromGeometry, + refreshReadout: refreshTrackedReadout, + subscribeMapStack(callback) { + window.addEventListener('gev:map-stack-changed', callback); + return () => + window.removeEventListener('gev:map-stack-changed', callback); + }, + }, + }, }); } diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index ff24e3159..53d624f59 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -462,29 +462,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a assert.equal(alprCamerasLayer.name, 'ALPR Cameras'); assert.deepEqual( entities.map((entity) => entity.gevLabelModel.title), - Array(3).fill('ALPR CAMERA'), + ['ALPR-0042', 'ALPR-0043', 'ALPR-0044'], ); assert.equal( - new Set( - entities.map((entity) => - entity.point.color.getValue().toCssColorString(), - ), - ).size, + new Set(entities.map((entity) => entity.billboard.image.getValue())).size, 1, ); assert.deepEqual(entities[0].gevLabelModel.details, [ - 'Manufacturer: Flock Safety', - 'Operator: City Police', - 'Type: fixed', - 'Source: OpenStreetMap', + 'OSM MAPPED', + 'FLOCK SAFETY · CITY POLICE · FIXED', + 'PUBLIC MAP DATA', ]); assert.deepEqual(entities[1].gevLabelModel.details, [ - 'Manufacturer: Motorola Solutions', - 'Source: OpenStreetMap', + 'OSM MAPPED', + 'MOTOROLA SOLUTIONS', + 'PUBLIC MAP DATA', ]); assert.deepEqual( entities[2].gevLabelModel.details, - ['Source: OpenStreetMap'], + ['OSM MAPPED', 'PUBLIC MAP DATA'], 'unknown metadata is not guessed, but the source is always named', ); for (const entity of entities) { @@ -523,8 +519,8 @@ test('clicking the selected camera or empty map deselects, including after refre await alprCamerasLayer.update(); assert.equal(getSelectedEntityContext(), null); assert.equal( - h.source.entities.getById('alpr:42').point.pixelSize.getValue(), - 8, + h.source.entities.getById('alpr:42').billboard.width.getValue(), + 38, ); } } finally { @@ -582,8 +578,8 @@ test('disable removes contexts and highlight; cache reuse after re-enable does n await alprCamerasLayer.update(); assert.equal(getSelectedEntityContext(), null); assert.equal( - h.source.entities.getById('alpr:42').point.pixelSize.getValue(), - 8, + h.source.entities.getById('alpr:42').billboard.width.getValue(), + 38, ); } finally { h.restore(); @@ -1100,7 +1096,7 @@ test('nearby count and discovery control frame a real loaded camera without fetc 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.legend[0].label, 'Camera badges'); assert.equal(controls.chips[0].onClick(), true); assert.equal(flights.length, 1); assert.equal(getSelectedEntityContext().id, 'alpr:42'); diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 133e9fed4..40291a528 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -142,8 +142,16 @@ export function createTrackedOverlayEntry(entity) { title, details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [], accent: model.accent || WORLD_OVERLAY_STYLE.accent, - anchorRadiusPx: 10, - anchorRadiusScale: TRACKED_BILLBOARD_SCALE, + cardStyle: model.cardStyle, + selected: model.selected === true, + leaderStyle: model.leaderStyle, + leaderAnimationMs: model.leaderAnimationMs, + leaderAnimationStartedAt: model.leaderAnimationStartedAt, + leaderDrawRatio: model.leaderDrawRatio, + anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx)) + ? Math.max(0, Number(model.anchorRadiusPx)) : 10, + anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale') + ? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE, minAnchorGapPx: 16, anchorGapPaddingPx: 10, verticalOnly: true, diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index cbdaa3b7d..e068af0fe 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -121,6 +121,26 @@ test('tracked entry factory pins the production protected-lane policy', () => { assert.equal(entry.edgeFade, 'keyhole'); }); +test('selected camera readout carries tactical animation and fixed badge clearance', () => { + const entry = createTrackedOverlayEntry({ + id: 'alpr:42', gevDisplayPosition: () => ({ x: 1, y: 2, z: 3 }), + gevLabelModel: { + title: 'ALPR-0042', details: ['OSM MAPPED'], accent: '#ff6474', + cardStyle: 'tactical', selected: true, leaderStyle: 'elbow', + leaderAnimationMs: 440, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.68, + anchorRadiusPx: 30, anchorRadiusScale: null, + }, + }); + assert.equal(entry.cardStyle, 'tactical'); + assert.equal(entry.selected, true); + assert.equal(entry.leaderStyle, 'elbow'); + assert.equal(entry.leaderAnimationMs, 440); + assert.equal(entry.leaderAnimationStartedAt, 100); + assert.equal(entry.leaderDrawRatio, 0.68); + assert.equal(entry.anchorRadiusPx, 30); + assert.equal(entry.anchorRadiusScale, null); +}); + test('tracked entity publishes a protected host entry backed by the frame cache', () => { const originalWindow = globalThis.window; const fakeWindow = new EventTarget(); diff --git a/src/layers/alpr/assets/alpr-marker-normal.png b/src/layers/alpr/assets/alpr-marker-normal.png new file mode 100644 index 0000000000000000000000000000000000000000..025628e84a5514a13d50ec615326dc7e680abbb4 GIT binary patch literal 13199 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 N(9tkJRo=#g{U3`Mu9E-& literal 0 HcmV?d00001 diff --git a/src/layers/alpr/assets/alpr-marker-selected.png b/src/layers/alpr/assets/alpr-marker-selected.png new file mode 100644 index 0000000000000000000000000000000000000000..e3c3c83de45bfefbe3cd54045886671ca1cd3a77 GIT binary patch literal 10261 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 LG~}z~%tQVUHR+pu literal 0 HcmV?d00001 diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 4f74209eb..97d9a6b00 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -69,7 +69,8 @@ export function createAlprCamerasLayer({ source, services } = {}) { lastQueryBox: null, }; const { - markerColor, + initOverlay, + destroyOverlay, viewportBox, clearRendered, hideOnMapCredit, @@ -250,6 +251,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { viewer.camera.moveEnd.addEventListener(scheduleLoad); state.postRenderRemove = viewer.scene.postRender?.addEventListener(updateSelectedAnchor); + initOverlay(); installInteraction(viewer); }, enable() { @@ -281,6 +283,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { }, destroy(viewer = state.viewer) { this.disable(); + destroyOverlay(); state.moveEndRemove?.(); state.moveEndRemove = null; state.postRenderRemove?.(); @@ -320,11 +323,11 @@ export function createAlprCamerasLayer({ source, services } = {}) { ], legend: [ { - label: 'Purple dots', + label: 'Camera badges', 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.', + 'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.', }, ], }; diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js new file mode 100644 index 000000000..4ca98bd6f --- /dev/null +++ b/src/layers/alpr/overlay.js @@ -0,0 +1,298 @@ +import * as Cesium from 'cesium'; +import { + MAX_CANVAS_FRUSTUMS, + MARKER_ICON_SIZE, + SELECTED_MARKER_ICON_SIZE, +} from './policy.js'; +import { + MARKER_IMAGE, + SELECTED_IMAGE, + BRACKETS_IMAGE, + directionWedgePositions, + paintDirectionWedge, + validAlprGroundHeight, +} from './visuals.js'; + +/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */ +export function createAlprOverlay({ state, services }) { + let lane, + removeMapListener, + retryTimer, + retryUntil = 0; + let records = [], + hits = [], + images = [], + destroyed = false; + let surfaceRegime; + const requestPaint = () => { + if (!destroyed && state.enabled) lane?.requestPaint(); + }; + + function nativeVisible(entity, visible) { + if (!entity) return; + const billboard = entity.billboard; + // Keep a faint native pick target under the canvas badge so sibling layer + // handlers recognize ALPR ownership instead of treating the click as empty. + if (!visible && !entity.gevAlprNativeAppearance) { + entity.gevAlprNativeAppearance = { + position: entity.position, + heightReference: billboard.heightReference, + scaleByDistance: billboard.scaleByDistance, + color: billboard.color, + }; + entity.position = entity.gevAlprCanvasPosition; + entity.gevAlprPickPosition = entity.position; + billboard.heightReference = Cesium.HeightReference.NONE; + billboard.scaleByDistance = undefined; + billboard.color = Cesium.Color.WHITE.withAlpha(0.01); + } else if (visible && entity.gevAlprNativeAppearance) { + const saved = entity.gevAlprNativeAppearance; + if (entity.position === entity.gevAlprPickPosition) + entity.position = saved.position; + billboard.heightReference = saved.heightReference; + billboard.scaleByDistance = saved.scaleByDistance; + billboard.color = saved.color; + entity.gevAlprNativeAppearance = null; + entity.gevAlprPickPosition = null; + } + billboard.show = visible; + if (entity.polyline) entity.polyline.show = visible; + if (entity.polygon) entity.polygon.show = visible; + } + + function resetAnchors() { + for (const entity of state.dataSource.entities.values) { + entity.gevAlprCanvasPosition = null; + entity.gevAlprWedge = null; + entity.gevAlprDisplayPosition = null; + nativeVisible(entity, true); + } + state.lastAnchorSampleAt = 0; + retryUntil = Date.now() + 15000; + requestPaint(); + } + + function anchorFor(record, entity) { + if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition; + const scene = state.viewer.scene; + const location = Cesium.Cartographic.fromDegrees( + record.longitude, + record.latitude, + ); + let height; + if (scene.sampleHeightSupported) { + try { + height = scene.sampleHeight(location, [entity]); + } catch { + /* streaming tiles */ + } + } + if (!validAlprGroundHeight(height) && scene.globe.show) + height = scene.globe.getHeight?.(location); + if (!validAlprGroundHeight(height)) + height = services.groundFloor.cachedGroundFloor( + record.latitude, + record.longitude, + ); + if (!validAlprGroundHeight(height)) return null; + entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + height, + ); + entity.gevAlprWedge = directionWedgePositions(record, height); + return entity.gevAlprCanvasPosition; + } + + function paint({ ctx, width, height, keyhole, occluder }) { + hits = []; + if (!state.enabled || destroyed) return; + let unresolved = false; + const painted = []; + for (const record of records) { + const entity = state.dataSource.entities.getById(record.id); + if (!entity) continue; + const selected = record.id === state.selectedId; + const image = images[selected ? 1 : 0]; + if ( + !image?.complete || + !image.naturalWidth || + (selected && !images[2]?.naturalWidth) + ) { + nativeVisible(entity, true); + continue; + } + const anchor = anchorFor(record, entity); + if (!anchor) { + nativeVisible(entity, true); + unresolved = true; + continue; + } + nativeVisible(entity, false); + if (occluder && !occluder.isPointVisible(anchor)) continue; + const scene = state.viewer.scene; + const origin = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + anchor, + ); + if (!origin) continue; + const alpha = + services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1; + if (!(alpha > 0)) continue; + const wedge = entity.gevAlprWedge; + if (wedge) { + const left = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + wedge[1], + ); + const right = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + wedge[2], + ); + if (left && right) { + ctx.save(); + ctx.globalAlpha *= alpha; + paintDirectionWedge(ctx, origin, left, right, selected); + ctx.restore(); + } + } + if ( + origin.x < -60 || + origin.x > width + 60 || + origin.y < -60 || + origin.y > height + 60 + ) + continue; + entity.billboard.show = true; + painted.push({ record, origin, selected, image, alpha }); + if (selected) entity.gevAlprDisplayPosition = anchor; + } + // Paint all glyphs after the wedges so one camera's cone cannot wash out another. + for (const { record, origin, selected, image, alpha } of painted) { + const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.drawImage( + image, + origin.x - size / 2, + origin.y - size / 2, + size, + size, + ); + if (selected) + ctx.drawImage( + images[2], + origin.x - size / 2, + origin.y - size / 2, + size, + size, + ); + ctx.restore(); + hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 }); + } + if (unresolved && !retryTimer && Date.now() < retryUntil) { + retryTimer = setTimeout(() => { + retryTimer = null; + requestPaint(); + }, 250); + } + } + + return { + init() { + destroyed = false; + if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') + return; + lane = services.overlays.registerPaintLane('selected', paint, { + id: `alpr-${Cesium.createGuid()}`, + active: false, + }); + images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { + const image = new Image(); + image.onload = requestPaint; + image.onerror = requestPaint; + image.src = src; + return image; + }); + surfaceRegime = state.viewer.scene.globe.show; + removeMapListener = services.overlays.subscribeMapStack?.((event) => { + if (event.detail?.status !== 'ready') return; + const next = state.viewer.scene.globe.show; + if (next === surfaceRegime) return; + surfaceRegime = next; + resetAnchors(); + }); + }, + sync(visible) { + if (!lane) return; + for (const record of records) + nativeVisible(state.dataSource.entities.getById(record.id), true); + const position = state.viewer.camera.positionWC; + records = visible + .filter( + (record) => + Number.isFinite(record.directionDeg) && + record.id !== state.selectedId, + ) + .map((record) => ({ + record, + distance: position + ? Cesium.Cartesian3.distanceSquared( + position, + Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + ), + ) + : 0, + })) + .sort((a, b) => a.distance - b.distance) + .slice(0, MAX_CANVAS_FRUSTUMS) + .map((item) => item.record); + const selected = visible.find((record) => record.id === state.selectedId); + if (selected) { + if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop(); + records.push(selected); + } + hits = []; + retryUntil = Date.now() + 15000; + lane.setActive(state.enabled && records.length > 0); + requestPaint(); + }, + pick(position) { + if (!state.enabled || !position) return null; + let closest, + distance = Infinity; + for (const hit of hits) { + const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2; + if (next <= hit.radius ** 2 && next <= distance) { + closest = hit.id; + distance = next; + } + } + return closest || null; + }, + clear() { + clearTimeout(retryTimer); + retryTimer = null; + for (const record of records) + nativeVisible(state.dataSource?.entities.getById(record.id), true); + records = []; + hits = []; + lane?.setActive(false); + }, + destroy() { + this.clear(); + destroyed = true; + removeMapListener?.(); + removeMapListener = null; + lane?.unregister(); + lane = null; + for (const image of images) { + image.onload = null; + image.onerror = null; + } + images = []; + }, + }; +} diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs new file mode 100644 index 000000000..37f49e3e7 --- /dev/null +++ b/src/layers/alpr/overlay.test.mjs @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createAlprOverlay } from './overlay.js'; +import { directionWedgePositions, alprLabelDetails } from './visuals.js'; + +function harness(t, count = 2) { + const previousImage = globalThis.Image; + const project = Cesium.SceneTransforms.worldToWindowCoordinates; + globalThis.Image = class { + complete = true; + naturalWidth = 128; + }; + Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 100, y: 100 }); + const records = Array.from({ length: count }, (_, i) => ({ + id: `camera:${i}`, + latitude: 30 + i / 10000, + longitude: -97, + directionDeg: 90, + })); + const entities = new Cesium.EntityCollection(); + for (const record of records) + entities.add({ + id: record.id, + billboard: { image: 'fallback', show: true }, + polyline: { show: true }, + polygon: { show: true }, + }); + const state = { + enabled: true, + selectedId: null, + dataSource: { entities }, + viewer: { + camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97, 30, 500) }, + scene: { globe: { show: true, getHeight: () => 150 } }, + }, + }; + let paint, + mapListener, + active, + removed = 0, + paints = 0; + const overlay = createAlprOverlay({ + state, + services: { + groundFloor: { cachedGroundFloor: () => null }, + overlays: { + registerPaintLane(_lane, painter) { + paint = painter; + return { + setActive(value) { + active = value; + }, + requestPaint() { + paints++; + }, + unregister() { + removed++; + }, + }; + }, + keyholeAlpha: () => 1, + subscribeMapStack(fn) { + mapListener = fn; + return () => { + mapListener = null; + }; + }, + }, + }, + }); + overlay.init(); + t.after(() => { + overlay.destroy(); + if (previousImage === undefined) delete globalThis.Image; + else globalThis.Image = previousImage; + Cesium.SceneTransforms.worldToWindowCoordinates = project; + }); + const draw = []; + const ctx = { + globalAlpha: 1, + save() {}, + restore() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + closePath() {}, + stroke() {}, + fill() { + draw.push('wedge'); + }, + createLinearGradient: () => ({ addColorStop() {} }), + drawImage(image) { + draw.push(image.src); + }, + }; + return { + state, + records, + overlay, + entities, + draw, + paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }), + mapChanged() { + mapListener({ detail: { status: 'ready' } }); + }, + get active() { + return active; + }, + get removed() { + return removed; + }, + get paints() { + return paints; + }, + }; +} + +test('a selected camera without a bearing gets its badge and brackets, never a fabricated wedge', (t) => { + const h = harness(t, 1); + h.records[0].directionDeg = null; + h.state.selectedId = h.records[0].id; + h.overlay.sync(h.records); + h.paint(); + assert.equal(h.draw.length, 2); + assert.ok(h.draw[0].endsWith('alpr-marker-selected.png')); + assert.ok(h.draw[1].endsWith('alpr-marker-selected-brackets.png')); + assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0'); + assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true); + assert.equal( + h.entities.getById('camera:0').billboard.color.getValue().alpha, + 0.01, + ); + assert.equal(directionWedgePositions(h.records[0]), null); +}); + +test('the overlay bounds its cohort and promotes selection while leaving farther cameras native', (t) => { + const h = harness(t, 70); + h.state.selectedId = 'camera:69'; + h.overlay.sync(h.records); + h.paint(); + const hidden = h.entities.values.filter((e) => e.gevAlprNativeAppearance); + assert.equal(hidden.length, 64); + assert.ok(hidden.some((e) => e.id === 'camera:69')); + assert.equal( + h.overlay.pick({ x: 100, y: 100 }), + 'camera:69', + 'selected glyph paints and picks on top', + ); + const firstGlyph = h.draw.findIndex((value) => value !== 'wedge'); + assert.equal(firstGlyph, 64, 'all wedges paint before any glyph'); +}); + +test('surface changes invalidate cached anchors, and disable/disposal release painting and hits', (t) => { + const h = harness(t, 1); + h.overlay.sync(h.records); + h.paint(); + const entity = h.entities.getById('camera:0'); + const first = entity.gevAlprCanvasPosition; + h.paint(); + assert.equal( + entity.gevAlprCanvasPosition, + first, + 'stable anchor across frames', + ); + h.state.viewer.scene.globe.show = false; + h.mapChanged(); + assert.equal(entity.gevAlprCanvasPosition, null); + h.state.enabled = false; + h.overlay.clear(); + assert.equal(h.active, false); + assert.equal(h.overlay.pick({ x: 100, y: 100 }), null); + assert.equal(entity.billboard.show.getValue(), true); + assert.equal(entity.billboard.color, undefined); + assert.equal(entity.position, undefined); + h.overlay.destroy(); + assert.equal(h.removed, 1); +}); + +test('an unresolved surface preserves the native marker; the horizon removes overlay hits', (t) => { + const h = harness(t, 1); + h.state.viewer.scene.globe.getHeight = () => -15000; + h.overlay.sync(h.records); + h.paint(); + assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true); + assert.equal(h.draw.length, 0); + h.state.viewer.scene.globe.getHeight = () => 150; + h.paint({ isPointVisible: () => false }); + assert.equal(h.overlay.pick({ x: 100, y: 100 }), null); + assert.equal(h.draw.length, 0); +}); + +test('adapter labels never claim a custom source is public OSM data', () => { + assert.deepEqual( + alprLabelDetails( + { manufacturer: 'Vendor', operator: 'Vendor' }, + { label: 'Custom directory' }, + ), + ['Source: Custom directory', 'VENDOR'], + ); +}); diff --git a/src/layers/alpr/policy.js b/src/layers/alpr/policy.js index b945b99a2..3d4f2b52a 100644 --- a/src/layers/alpr/policy.js +++ b/src/layers/alpr/policy.js @@ -33,8 +33,9 @@ export const QUERY_LIMIT = 1500; * stayed false and `count` still reported them — a lie about coverage. */ export const MAX_RENDERED = 1500; -/** Meters — length of the facing-direction indicator line, when a camera reports one. */ -export const DIRECTION_CONE_M = 25; +/** Meters — illustrative facing wedge depth, when a camera reports a bearing. */ +export const DIRECTION_CONE_M = 90; +export const DIRECTION_CONE_HALF_ANGLE_DEG = 20; export const EARTH_MEAN_RADIUS_M = 6371008.8; @@ -47,8 +48,12 @@ export const QUERY_SNAP_DEGREES = 0.05; * for this long before asking Overpass again. */ export const QUERY_REUSE_MS = 10 * 60 * 1000; -/** One vendor-neutral color, distinct from CCTV's cyan markers. */ -export const ALPR_COLOR = '#c084fc'; +/** Vendor-neutral camera badge palette, with coral selection. */ +export const ALPR_COLOR = '#52d4ff'; +export const ALPR_SELECTED_COLOR = '#ff6474'; +export const MARKER_ICON_SIZE = 38; +export const SELECTED_MARKER_ICON_SIZE = 60; +export const MAX_CANVAS_FRUSTUMS = 64; /** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */ export const CREDIT_DISPLAY_MS = 5000; diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index f56787c79..35fcb19be 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -3,11 +3,22 @@ import { LAYER_ID, MAX_VIEWPORT_DEGREES, MAX_RENDERED, - DIRECTION_CONE_M, ALPR_COLOR, + ALPR_SELECTED_COLOR, + MARKER_ICON_SIZE, + SELECTED_MARKER_ICON_SIZE, CREDIT_DISPLAY_MS, } from './policy.js'; -import { boxContains, destinationPointDeg } from './model.js'; +import { boxContains } from './model.js'; +import { createAlprOverlay } from './overlay.js'; +import { + MARKER_IMAGE, + SELECTED_IMAGE, + alprDisplayId, + alprLabelDetails, + directionWedgePositions, + validAlprGroundHeight, +} from './visuals.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; @@ -20,6 +31,31 @@ export function createAlprPresentation({ state, services, source }) { } = services.context; const { cachedGroundFloor } = services.groundFloor; + const overlay = createAlprOverlay({ state, services }); + let visibleRecords = []; + let selectionStartedAt = 0; + + function updateAppearance(entity, selected) { + const color = Cesium.Color.fromCssColorString( + selected ? ALPR_SELECTED_COLOR : ALPR_COLOR, + ); + entity.billboard.image = selected ? SELECTED_IMAGE : MARKER_IMAGE; + entity.billboard.width = entity.billboard.height = selected + ? SELECTED_MARKER_ICON_SIZE + : MARKER_ICON_SIZE; + if (entity.polyline) { + entity.polyline.width = selected ? 3 : 1.75; + entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82); + } + if (entity.polygon) + entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2); + if (entity.gevLabelModel) { + entity.gevLabelModel.accent = color.toCssColorString(); + entity.gevLabelModel.leaderAnimationStartedAt = selected + ? selectionStartedAt + : 0; + } + } function markerColor() { return Cesium.Color.fromCssColorString(ALPR_COLOR); @@ -81,6 +117,8 @@ export function createAlprPresentation({ state, services, source }) { } function clearRendered() { + overlay.clear(); + visibleRecords = []; if (state.dataSource?.entities) state.dataSource.entities.removeAll(); removeEntityContextsForLayer(LAYER_ID); } @@ -116,6 +154,7 @@ export function createAlprPresentation({ state, services, source }) { ) .slice(0, MAX_RENDERED) : []; + visibleRecords = visible; // A refresh may retain its own selection, never reclaim one cleared or // replaced by an aircraft, another layer, or a voice action. if ( @@ -135,9 +174,7 @@ export function createAlprPresentation({ state, services, source }) { for (const record of visible) { const existing = state.dataSource.entities.getById(record.id); if (existing?.gevAlprRecord === record) { - existing.point.pixelSize = record.id === state.selectedId ? 12 : 8; - existing.point.color = - record.id === state.selectedId ? Cesium.Color.WHITE : markerColor(); + updateAppearance(existing, record.id === state.selectedId); continue; } const color = markerColor(); @@ -149,31 +186,30 @@ export function createAlprPresentation({ state, services, source }) { const entityDef = { id: record.id, position, - point: { - pixelSize: selected ? 12 : 8, - color: selected ? Cesium.Color.WHITE : color, - outlineColor: Cesium.Color.BLACK.withAlpha(0.8), - outlineWidth: 1, + billboard: { + image: selected ? SELECTED_IMAGE : MARKER_IMAGE, + width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE, + height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48), }, }; - if (Number.isFinite(record.directionDeg)) { - const tip = destinationPointDeg( - record.latitude, - record.longitude, - record.directionDeg, - DIRECTION_CONE_M, - ); + const wedge = directionWedgePositions(record); + if (wedge) { entityDef.polyline = { - positions: [ - position, - Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude), - ], - width: 2, - material: color.withAlpha(0.85), + positions: [wedge[1], position, wedge[2]], + width: selected ? 3 : 1.75, + material: color.withAlpha(0.82), clampToGround: true, }; + entityDef.polygon = { + hierarchy: wedge, + material: color.withAlpha(0.2), + height: 0, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + classificationType: Cesium.ClassificationType.BOTH, + }; } let entity = existing; // Keep the Cesium entity and its ground-clamping subscription while its @@ -184,16 +220,21 @@ export function createAlprPresentation({ state, services, source }) { if ( previous.latitude !== record.latitude || previous.longitude !== record.longitude - ) + ) { entity.position = position; + entity.gevAlprCanvasPosition = null; + entity.gevAlprDisplayPosition = null; + } if ( previous.latitude !== record.latitude || previous.longitude !== record.longitude || previous.directionDeg !== record.directionDeg - ) + ) { entity.polyline = entityDef.polyline; - entity.point.pixelSize = selected ? 12 : 8; - entity.point.color = selected ? Cesium.Color.WHITE : color; + entity.polygon = entityDef.polygon; + entity.gevAlprCanvasPosition = null; + entity.gevAlprWedge = null; + } } entity.gevAlprRecord = record; entity.gevTrackedId = record.id; @@ -203,15 +244,19 @@ export function createAlprPresentation({ state, services, source }) { entity.gevAlprDisplayPosition ??= null; entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; entity.gevLabelModel = { - title: 'ALPR CAMERA', - details: [ - record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null, - record.operator ? `Operator: ${record.operator}` : null, - record.cameraType ? `Type: ${record.cameraType}` : null, - `Source: ${source.attribution?.name || source.label || 'Camera source'}`, - ].filter(Boolean), + title: alprDisplayId(record), + details: alprLabelDetails(record, source), accent: color.toCssColorString(), + cardStyle: 'tactical', + selected: true, + leaderStyle: 'elbow', + leaderAnimationMs: 440, + leaderAnimationStartedAt: selected ? selectionStartedAt : 0, + leaderDrawRatio: 0.68, + anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2, + anchorRadiusScale: null, }; + updateAppearance(entity, selected); registerEntityContext(entity, { id: record.id, layerId: LAYER_ID, @@ -240,6 +285,8 @@ export function createAlprPresentation({ state, services, source }) { : null; if (!selectedEntity) state.selectedId = null; updateSelectedAnchor(); + overlay.sync(visible); + if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity); // Start only when mapped data is actually displayed, not while an upstream // request or a zoom-in prompt could consume the five-second introduction. if (visible.length) presentOnMapCredit(); @@ -276,14 +323,14 @@ export function createAlprPresentation({ state, services, source }) { /* tiles may still be streaming */ } } - if (!Number.isFinite(height) && state.viewer.scene.globe.show) + if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show) height = state.viewer.scene.globe.getHeight?.(location); - if (!Number.isFinite(height) || height < -500 || height > 10000) + if (!validAlprGroundHeight(height)) height = cachedGroundFloor(record.latitude, record.longitude); const center = Cesium.Cartesian3.fromDegrees( record.longitude, record.latitude, - Number.isFinite(height) ? height : 0, + validAlprGroundHeight(height) ? height : 0, ); if (!selectRecord(nearest.id)) return false; camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { @@ -303,11 +350,12 @@ export function createAlprPresentation({ state, services, source }) { if (!entity || !state.recordById.has(id)) return false; clearSelection(); state.selectedId = id; - entity.point.pixelSize = 12; - entity.point.color = Cesium.Color.WHITE; + selectionStartedAt = globalThis.performance?.now?.() ?? Date.now(); + updateAppearance(entity, true); state.lastAnchorSampleAt = 0; selectEntityContext(entity); updateSelectedAnchor(); + overlay.sync(visibleRecords); governorRequestRender('alpr-selection'); return true; } @@ -317,11 +365,10 @@ export function createAlprPresentation({ state, services, source }) { ? state.dataSource?.entities.getById(state.selectedId) : null; const record = state.recordById.get(state.selectedId); - if (entity && record) { - entity.point.pixelSize = 8; - entity.point.color = markerColor(); - } + if (entity && record) updateAppearance(entity, false); state.selectedId = null; + selectionStartedAt = 0; + overlay.sync(visibleRecords); clearSelectedEntityContextForLayer(LAYER_ID); governorRequestRender('alpr-selection'); } @@ -335,6 +382,10 @@ export function createAlprPresentation({ state, services, source }) { } const entity = state.dataSource?.entities.getById(state.selectedId); const record = state.recordById.get(state.selectedId); + if (entity?.gevAlprCanvasPosition) { + entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition; + return; + } if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000) return; state.lastAnchorSampleAt = Date.now(); @@ -351,11 +402,11 @@ export function createAlprPresentation({ state, services, source }) { /* tiles may still be streaming */ } } - if (!Number.isFinite(height) && scene.globe.show) + if (!validAlprGroundHeight(height) && scene.globe.show) height = scene.globe.getHeight?.(location); - if (!Number.isFinite(height)) + if (!validAlprGroundHeight(height)) height = cachedGroundFloor(record.latitude, record.longitude); - if (!Number.isFinite(height)) return; + if (!validAlprGroundHeight(height)) return; const next = Cesium.Cartesian3.fromDegrees( record.longitude, record.latitude, @@ -378,7 +429,9 @@ export function createAlprPresentation({ state, services, source }) { 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; + const id = + overlay.pick(click.position) || + (typeof picked?.id?.id === 'string' ? picked.id.id : null); if ( id && state.recordById.has(id) && @@ -389,6 +442,8 @@ export function createAlprPresentation({ state, services, source }) { }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } return { + initOverlay: () => overlay.init(), + destroyOverlay: () => overlay.destroy(), markerColor, viewportBox, clearRendered, diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js new file mode 100644 index 000000000..f1fc6556d --- /dev/null +++ b/src/layers/alpr/visuals.js @@ -0,0 +1,106 @@ +import * as Cesium from 'cesium'; +import { destinationPointDeg } from './model.js'; +import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js'; + +// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466). +// Module-relative assets also resolve when the layer is consumed by another app. +export const MARKER_IMAGE = new URL( + './assets/alpr-marker-normal.png', + import.meta.url, +).href; +export const SELECTED_IMAGE = new URL( + './assets/alpr-marker-selected.png', + import.meta.url, +).href; +export const BRACKETS_IMAGE = new URL( + './assets/alpr-marker-selected-brackets.png', + import.meta.url, +).href; + +/** Compact display label; selection identity always uses the full record id. */ +export function alprDisplayId(record) { + if (!Number.isSafeInteger(record.osmId)) return 'ALPR CAMERA'; + return `ALPR-${String(record.osmId).slice(-4).padStart(4, '0')}`; +} + +/** Show only supplied metadata, with the actual source named for custom adapters. */ +export function alprLabelDetails(record, source) { + const sourceName = + source.attribution?.name || source.label || 'Camera source'; + const details = [ + sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, + ]; + if (Number.isFinite(record.directionDeg)) + details.push(`DIRECTION ${Math.round(record.directionDeg)}°`); + const equipment = [ + ...new Set( + [record.manufacturer, record.operator, record.cameraType] + .map((value) => + String(value || '') + .trim() + .toUpperCase(), + ) + .filter(Boolean), + ), + ]; + if (equipment.length) details.push(equipment.join(' · ')); + if (sourceName === 'OpenStreetMap') details.push('PUBLIC MAP DATA'); + return details; +} + +/** Illustrative bearing wedge, not measured field of view or operating range. */ +export function directionWedgePositions(record, heightM = 0) { + if (!Number.isFinite(record.directionDeg)) return null; + const left = destinationPointDeg( + record.latitude, + record.longitude, + record.directionDeg - DIRECTION_CONE_HALF_ANGLE_DEG, + DIRECTION_CONE_M, + ); + const right = destinationPointDeg( + record.latitude, + record.longitude, + record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG, + DIRECTION_CONE_M, + ); + return [ + Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM), + Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM), + Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM), + ]; +} + +/** Paint the original cyan/coral gradient with a crisp V-shaped boundary. */ +export function paintDirectionWedge(ctx, origin, left, right, selected) { + const rgb = selected ? '255, 100, 116' : '82, 212, 255'; + const gradient = ctx.createLinearGradient( + origin.x, + origin.y, + (left.x + right.x) / 2, + (left.y + right.y) / 2, + ); + const alpha = selected ? [0.8, 0.48, 0.22, 0.06] : [0.34, 0.2, 0.09, 0.025]; + [0, 0.36, 0.72, 1].forEach((stop, i) => + gradient.addColorStop(stop, `rgba(${rgb}, ${alpha[i]})`), + ); + ctx.beginPath(); + ctx.moveTo(origin.x, origin.y); + ctx.lineTo(left.x, left.y); + ctx.lineTo(right.x, right.y); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(left.x, left.y); + ctx.lineTo(origin.x, origin.y); + ctx.lineTo(right.x, right.y); + ctx.strokeStyle = `rgba(${rgb}, ${selected ? 0.98 : 0.76})`; + ctx.lineWidth = selected ? 2.5 : 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); +} + +/** Reject transient tile/terrain samples outside plausible mapped ground heights. */ +export function validAlprGroundHeight(height) { + return Number.isFinite(height) && height >= -500 && height <= 10000; +} diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 473150454..9f76a45e6 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -466,6 +466,16 @@ export function normalizeOverlayEntry(sourceId, entry) { leaderOffsetPx: Number.isFinite(Number(entry.leaderOffsetPx)) ? Math.max(0, Number(entry.leaderOffsetPx)) : 0, + leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight', + leaderAnimationMs: Number.isFinite(Number(entry.leaderAnimationMs)) + ? Math.max(0, Number(entry.leaderAnimationMs)) + : 0, + leaderAnimationStartedAt: Number.isFinite(Number(entry.leaderAnimationStartedAt)) + ? Number(entry.leaderAnimationStartedAt) + : 0, + leaderDrawRatio: Number.isFinite(Number(entry.leaderDrawRatio)) + ? Math.max(0.1, Math.min(0.9, Number(entry.leaderDrawRatio))) + : 0.68, anchorRadiusPx: Number.isFinite(Number(entry.anchorRadiusPx)) ? Math.max(0, Number(entry.anchorRadiusPx)) : 0, @@ -1421,6 +1431,19 @@ function activeFadeCount(timestamp) { return count; } +function activeLeaderAnimationCount(timestamp) { + let count = 0; + for (let i = 0; i < _sourceList.length; i++) { + const source = _sourceList[i]; + if (!sourceActive(source)) continue; + for (const entry of source.entries.values()) { + if (entry.leaderAnimationMs > 0 + && timestamp - entry.leaderAnimationStartedAt < entry.leaderAnimationMs) count++; + } + } + return count; +} + function overlayHasPaintWork(timestamp = nowMs()) { if (activeCustomPaintLaneCount() > 0) return true; if (activeEntryCount() > 0) return true; @@ -2177,8 +2200,8 @@ function drawWorldOverlay() { _diagnostics.projectionMs = nowMs() - projectionStarted; solveDomains(timestamp); paintFrame(keyhole); - const fadesRemaining = activeFadeCount(timestamp); - if (fadesRemaining > 0) _viewer.scene.requestRender?.(); + const animationsRemaining = activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp); + if (animationsRemaining > 0) _viewer.scene.requestRender?.(); } function createDevFacade() { diff --git a/src/overlays/worldOverlayDraw.js b/src/overlays/worldOverlayDraw.js index fd42be94e..4c5d1338a 100644 --- a/src/overlays/worldOverlayDraw.js +++ b/src/overlays/worldOverlayDraw.js @@ -521,6 +521,74 @@ function tacticalAccentColors(entry) { return colors; } +function animationLinearProgress(entry, timestamp) { + const duration = Math.max(0, Number(entry?.leaderAnimationMs) || 0); + if (!duration) return 1; + const startedAt = Number(entry?.leaderAnimationStartedAt); + if (!Number.isFinite(startedAt)) return 1; + return Math.max(0, Math.min(1, (timestamp - startedAt) / duration)); +} + +function leaderDrawRatio(entry) { + const ratio = Number(entry?.leaderDrawRatio); + return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68; +} + +export function leaderRevealProgress(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) { + const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry)); + return 1 - Math.pow(1 - linear, 3); +} + +export function tacticalCardRevealAlpha(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) { + if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1; + const start = leaderDrawRatio(entry); + const linear = Math.max(0, Math.min(1, + (animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start))); + return linear * linear * (3 - 2 * linear); +} + +function drawTacticalLeader(ctx, entry, placement, accent, timestamp) { + ctx.strokeStyle = accent; + ctx.lineWidth = (entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + if (entry.leaderStyle !== 'elbow' + || (placement.corner !== 'above' && placement.corner !== 'below')) { + if (placement.leaderOffset === 0) { + ctx.moveTo(placement.leadFromX, placement.leadFromY); + } else if (placement.corner === 'above' || placement.corner === 'below') { + ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset); + } else { + ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY); + } + ctx.lineTo(placement.leadToX, placement.leadToY); + ctx.stroke(); + return; + } + + const { x, y, w, h } = placement.rect; + const cardX = x + Math.max(18, Math.min(w - 18, w * 0.22)); + const cardY = placement.corner === 'above' ? y + h : y; + const anchorRadius = Math.abs(placement.leaderOffset); + const anchorX = placement.leadFromX - anchorRadius; + const anchorY = placement.leadFromY; + const horizontalLength = Math.abs(anchorX - cardX); + const verticalLength = Math.abs(anchorY - cardY); + let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength); + ctx.moveTo(anchorX, anchorY); + if (remaining <= horizontalLength) { + const direction = Math.sign(cardX - anchorX) || -1; + ctx.lineTo(anchorX + direction * remaining, anchorY); + } else { + ctx.lineTo(cardX, anchorY); + remaining -= horizontalLength; + const direction = Math.sign(cardY - anchorY) || -1; + ctx.lineTo(cardX, anchorY + direction * Math.min(verticalLength, remaining)); + } + ctx.stroke(); +} + /** Paint the legacy FIRMS/vessel tactical card inside the shared host. */ export function paintTacticalCard(ctx, entry, placement, alpha = 1) { const selected = entry.selected || entry.variant === 'selected'; @@ -528,21 +596,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) { const layout = entry._overlayLayout || {}; const { x, y, w, h } = placement.rect; const accentColors = tacticalAccentColors(entry); + const animationTimestamp = globalThis.performance?.now?.() ?? Date.now(); ctx.save(); ctx.globalAlpha = alpha; - ctx.strokeStyle = accentColors.leader; - ctx.lineWidth = 1 / (placement.paintScale || 1); - ctx.beginPath(); - if (placement.leaderOffset === 0) { - ctx.moveTo(placement.leadFromX, placement.leadFromY); - } else if (placement.corner === 'above' || placement.corner === 'below') { - ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset); - } else { - ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY); - } - ctx.lineTo(placement.leadToX, placement.leadToY); - ctx.stroke(); + drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow' + ? colorWithAlpha(entry.accent, 0.95) + : accentColors.leader, animationTimestamp); + + ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp); ctx.beginPath(); roundedRectPath(ctx, x, y, w, h, 4); diff --git a/src/overlays/worldOverlayDraw.test.mjs b/src/overlays/worldOverlayDraw.test.mjs index 55ddea86d..10ec8179d 100644 --- a/src/overlays/worldOverlayDraw.test.mjs +++ b/src/overlays/worldOverlayDraw.test.mjs @@ -20,6 +20,8 @@ import { paintThumbnail, paintTrack, paintTracked, + leaderRevealProgress, + tacticalCardRevealAlpha, placementVariants, roundedRectPath, } from './worldOverlayDraw.js'; @@ -486,6 +488,34 @@ test('shared tactical painter preserves FIRMS card metrics and top-rule treatmen assert.equal(ctx.calls.at(-1)[0], 'restore'); }); +test('selected elbow leader reveals from the glyph before its card fades in', () => { + const animation = { + leaderAnimationMs: 1000, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.7, + }; + assert.equal(leaderRevealProgress(animation, 100), 0); + assert.ok(leaderRevealProgress(animation, 450) > 0.8); + assert.equal(leaderRevealProgress(animation, 800), 1); + assert.equal(tacticalCardRevealAlpha(animation, 799), 0); + assert.ok(tacticalCardRevealAlpha(animation, 950) > 0.4); + assert.equal(tacticalCardRevealAlpha(animation, 1100), 1); + const ctx = mockContext(); + const entry = { + variant: 'selected', selected: true, cardStyle: 'tactical', title: 'ALPR-2516', + details: ['OSM MAPPED'], accent: '#ff6474', leaderStyle: 'elbow', + leaderAnimationMs: 1, leaderAnimationStartedAt: 0, + }; + entry._overlayLayout = measureOverlayEntry(ctx, entry, {}); + const placement = placementVariants({ + anchorX: 240, anchorY: 180, width: entry._overlayLayout.w, height: entry._overlayLayout.h, + viewportWidth: 500, viewportHeight: 300, gap: 20, leaderOffset: 30, verticalOnly: true, + })[0]; + paintTacticalCard(ctx, entry, placement, 1); + assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 210, 180]); + const lines = ctx.calls.filter(([name]) => name === 'lineTo'); + assert.ok(lines.length >= 2, 'leader draws horizontally from the glyph, then vertically to the card'); + assert.ok(ctx.calls.some(([name, value]) => name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)')); +}); + test('track display text is cached across measure and paint and invalidates on content change', () => { const ctx = mockContext(); const entry = { variant: 'track', title: 'UAL123', details: ['450 KT'] }; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 4127334e5..ae3c8dcfa 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1274,10 +1274,17 @@ 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; +/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */ +.data-toggle-row[data-layer-id='alpr-cameras'] + .data-toggle-right:has( + .data-toggle-btn:is( + .feed-loading, + .feed-degraded, + .feed-stale, + .feed-fallback, + .feed-unavailable + ) + ) + .data-count { + display: none; }