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 000000000..025628e84 Binary files /dev/null and b/src/layers/alpr/assets/alpr-marker-normal.png differ diff --git a/src/layers/alpr/assets/alpr-marker-selected-brackets.png b/src/layers/alpr/assets/alpr-marker-selected-brackets.png new file mode 100644 index 000000000..8c0ce0a37 Binary files /dev/null and b/src/layers/alpr/assets/alpr-marker-selected-brackets.png differ 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 000000000..e3c3c83de Binary files /dev/null and b/src/layers/alpr/assets/alpr-marker-selected.png differ 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; }