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 <[email protected]>
This commit is contained in:
Sameh Khamisandmanjunath22466 committed 2026-09-14 17:35:24 -07:00
1 parent 687edc4da7
commit 87629ac175
21 files changed
+951 -97

No files matched your search

+25 -2
View File
@@ -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() {
+74 -12
View File
@@ -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);
+30
View File
@@ -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'] };