Release God's Eye View as open source

This commit is contained in:
Bilawal Sidhu committed 2026-08-24 17:02:16 -05:00
1 parent a5aaea7e16
commit 880a672b5e
433 files changed
+219893 -23

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,362 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js';
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js';
import { vesselOverlayCohortLimit } from '../data/vesselLabels.js';
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
import { AMBIENT_CARD_COLLISION_CAPACITY } from './worldOverlay.js';
import { EARTHQUAKE_OVERLAY_COHORT_LIMIT } from '../data/earthquakes.js';
import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js';
import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js';
import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js';
import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs';
/**
* Phase-2 entry gate: a steady moving-source frame must not allocate in
* proportion to the cohort it projects. The measurement runs in a
* `--expose-gc` child (`worldOverlayAllocation.worker.mjs`) so the whole suite
* does not need the flag, and the child reports GC-bracketed heap deltas over
* ten chunks (a chunk delta is `allocated - collected`, so it can only ever
* under-report; the median is immune to a single anomalous chunk).
*
* Budgets are per FRAME and per CANDIDATE, never per painted entry: above a
* domain's `collisionCapacity` the painted count saturates while candidate
* work keeps scaling, so a per-painted-entry budget silently relaxes exactly
* where the cohort gets expensive.
*
* Two generic workloads bracket that boundary (`collisionCapacity` is 96),
* and the Phase-2 workload registers both production local source ids with
* their card entry shape and production-sized per-source cohorts:
*
* profile | entries | candidates | painted | median B/frame | B/candidate | frame budget
* ---------------------+---------+------------+---------+----------------+-------------+-------------
* generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100
* generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000
* local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000
* Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600
* Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500
* Phase 3 + tracked | 451 | 451 | 310 | 66642 | 147.8 | 86,700
* Phase 4 + CCTV | 492 | 492 | 310/312 | 78742/97696 | 160.0/198.6 | 102,400
* rocket missions | 48 | 48 | 24 | 5195 | 108.2 | 6,000
* Phase 5 pre-missions | 591 | 591 | 353 | 127815 | 216.3 | 132,000
* Phase 5 final | 639 | 639 | 353 | 133769 | 209.3 | 142,000
* submarine cables | 160 | 160 | 96 | 17015 | 106.3 | 19,000
* all-live (w/ cables) | 864 | 864 | 398 | 164711 | 190.6 | 182,000
* Phase 6 detection | 5000 | 5000 | 5000 | 524191/600729 | 104.8/120.1 | 700,000
*
* The FIRMS/placement fixes traded the all-sources-live rows up (vessels
* 24,975→67,252 and tracked 20,240→66,642 B/frame), still below the ~77–87 KB
* pre-refactor base; the vessels row's 3.2% headroom under the 154 B/candidate
* ceiling is intentional tight discipline. The budgets below
* retain the Phase-2 4,100 / 13,000 / 49,000 frame gates; the enlarged Phase-3
* rows carry about 30% headroom over their isolated medians. The shared
* 154 B/candidate ceiling is intentionally the restored Phase-2 scaling
* backstop rather than another workload-specific allowance. The vessel row
* adds the production 1600×900 grid cohort
* (112 ambient entries) and one protected selected card; its 311 painted count
* demonstrates the protected item survives while ambient cards still collide.
* The tracked row replaces that mutually exclusive selected-vessel card with
* the production tracked-entry factory and options; its larger two-detail-line
* footprint excludes one additional ambient card.
*
* The 154 B/candidate ceiling still gates every non-image workload, including
* the production-sized 48-marker rocket-mission cohort (24 ambient winners).
* CCTV is the documented image exception: each of its 41 ready thumbnail
* entries (40 shipped ambient slots plus the opt-in protected active-card path) adds a
* live source-owned frame-slot readiness read and exercises Canvas2D
* `drawImage`. V8 exhibits two stable all-chunk JIT/IC regimes for this exact
* workload: 160.0 or 198.6 B/raw candidate/frame. Extending warmup did not pin
* one regime, so the image-only candidate ceiling is 210 (5.7% headroom over
* the slower mode). The 102,400 B/frame median gate remains the primary bound
* and the shared 154 ceiling remains unchanged for every non-image row.
* The pre-missions Phase-5 row adds a third protected selected/tracked-lane
* entry, exercises the military card's full three-line presentation, and
* retains those 41 live image-slot reads. Its 216.3 B/candidate mode is gated
* at 225 (4.0% headroom); the tighter 210 exception remains on the Phase-4 image row
* and the 154 ceiling remains unchanged for all non-image workloads. The final
* row adds the 48 mission candidates. Its 225 ceiling is not a mission-label
* exception: the aggregate still performs the same 41 CCTV image-slot reads
* and `drawImage` calls that require the documented image-inclusive ceiling;
* the isolated mission row remains under 154 B/candidate. Cable references
* were the depth-tested native exception when the intermediate phase rows
* were calibrated; since the 2026-08-18 migration they publish a bounded
* 160-label host cohort gated by their own isolated row below AND folded
* into the recalibrated all-live aggregate (the phase3/phase4/phase5 rows
* deliberately keep their historical pre-cable composition).
* The all-live row adds the bounded 64 ambient Radio cluster-label
* cohort plus one protected selected-station label; Radio supplies painted
* winners in the saturated ambient-label domain while the selected lane
* remains protected. Since the 2026-08-18 recalibration the row also carries
* the 160-winner submarine-cable cohort (864 candidates total, cables winning
* painted ambient-label slots); its 182,000 frame budget and the existing 225
* B/candidate image-inclusive ceiling retain more than 10% headroom over the
* measured Node 24 median/max (164,711 / 167,313 B/frame).
*
* The Phase-6 row activates the production detection lane at Dense/100 over a
* deterministic 5,000-observation, 2,500 km scene. All observations exercise
* manual projection and batched bracket paint; the shipped global-view label
* budget keeps the rich-callout cohort bounded. Repeated clean-process probes
* expose two stable V8/GC regimes at 524,191 and 600,729 B/frame (104.8 and
* 120.1 B/observation/frame). The slower regime remains below the unchanged
* 154 ceiling with 28.2% headroom, while the 700 KB frame budget carries 16.5%
* headroom. No detection exception is taken: the larger absolute frame budget
* is the honest cost of preserving broad reticles for 5,000 live observations.
*
* History: the FIRMS migration introduced `anchor +/- leaderOffset` writes on
* every pooled placement. Those computed doubles were boxed on the shared
* frame path and inflated the three Phase-2 medians to 12,782 / 50,022 /
* ~101,000 B/frame. Hoisting a signed Smi offset and applying it in the painter
* fixed that regression. A separate saturated vertical-only queue revisit was
* then profiled and fixed before this full table was re-derived.
*/
const WORKLOADS = [
{
name: 'below collision capacity',
entries: 60,
candidates: 60,
maxBytesPerFrame: 4100,
saturated: false,
},
{
name: 'above collision capacity',
entries: 250,
candidates: 250,
maxBytesPerFrame: 13_000,
saturated: true,
},
{
name: 'with both local infrastructure sources live',
profile: 'local-infrastructure',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2,
maxBytesPerFrame: 49_000,
saturated: true,
},
{
name: 'with infrastructure and FIRMS live',
profile: 'phase3-firms',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT,
maxBytesPerFrame: 53_600,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
name: 'with infrastructure, FIRMS, and vessels live',
profile: 'phase3-vessels',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1,
maxBytesPerFrame: 87_500,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
name: 'with infrastructure, FIRMS, ambient vessels, and tracked readout live',
profile: 'phase3-tracked',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1,
maxBytesPerFrame: 86_700,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
name: 'with all Phase 3 sources and CCTV thumbnails live',
profile: 'phase4-cctv',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1,
maxBytesPerFrame: 102_400,
maxBytesPerCandidatePerFrame: 210,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
name: 'with the bounded rocket-mission ambient cohort',
profile: 'rocket-missions',
entries: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
candidates: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
maxBytesPerFrame: 6_000,
saturated: true,
},
{
name: 'with final Phase 5 host sources live (pre-cable-migration surface)',
profile: 'phase5-military',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3,
maxBytesPerFrame: 132_000,
maxBytesPerCandidatePerFrame: 225,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
name: 'with final Phase 5 sources and bounded rocket-mission markers live',
profile: 'phase5-rockets',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3
+ ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3
+ ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
// 142,000 deliberately carries ~6% headroom (vs the ~3.3% the previous
// aggregate row ran at): a chosen margin correction, not drift.
maxBytesPerFrame: 142_000,
maxBytesPerCandidatePerFrame: 225,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
// Recalibrated 2026-08-18 when the migrated submarine-cable cohort joined
// the aggregate (the row is "every shared-host source", so cable labels
// must coexist with Radio/earthquake/mission quotas here, not only in
// their isolated row; the probe shows cables winning painted slots in the
// saturated ambient-label domain). Node 24.19 measures 164,711 B/frame
// median (190.6 B/candidate) across two identical runs with cables folded
// in; 182,000 keeps the row's >10% headroom convention and the
// image-inclusive 225 ceiling stands with ~15% headroom.
name: 'with every shared-host source and bounded Radio text live',
profile: 'all-live-radio',
entries: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3
+ ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT + RADIO_OVERLAY_COHORT_LIMIT + 1
+ CABLE_REFERENCE_LABEL_WINNER_CAP,
candidates: LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT
+ vesselOverlayCohortLimit(1600, 900) + 1 + CCTV_AMBIENT_CARD_MAX + 1
+ EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3
+ ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT + RADIO_OVERLAY_COHORT_LIMIT + 1
+ CABLE_REFERENCE_LABEL_WINNER_CAP,
maxBytesPerFrame: 182_000,
maxBytesPerCandidatePerFrame: 225,
saturated: true,
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
},
{
// 2026-08-18 cable-label migration: the former native exception now
// publishes a bounded 160-winner ambient-label cohort (collision capacity
// 96, so the row saturates). Isolated row like rocket-missions, so a
// cables-only regression stays attributable; the cohort is also folded
// into the recalibrated all-live aggregate below for interaction
// coverage. Measured median 17,015 B/frame (106.3 B/candidate)
// on Node 24.19; 19,000 carries ~11.7% headroom and the shared 154
// ceiling applies unchanged.
name: 'with the bounded submarine-cable reference cohort',
profile: 'submarine-cables',
entries: CABLE_REFERENCE_LABEL_WINNER_CAP,
candidates: CABLE_REFERENCE_LABEL_WINNER_CAP,
maxBytesPerFrame: 19_000,
saturated: true,
},
{
name: 'with the Dense detection lane active over 5,000 observations',
profile: 'phase6-detection',
entries: 5_000,
candidates: 5_000,
maxBytesPerFrame: 700_000,
detectionLabelBudget: 56,
saturated: false,
},
];
/** Scale-invariant ceiling shared by every production-shape workload. */
const MAX_BYTES_PER_CANDIDATE_PER_FRAME = 154;
const WORKER_PATH = fileURLToPath(new URL('./worldOverlayAllocation.worker.mjs', import.meta.url));
const CALIBRATED_ALLOCATION_RUNTIME = isCalibratedAllocationRuntime();
function runAllocationProbe(entryCount, profile = 'generic') {
// Compile hot functions synchronously so the gate measures the calibrated
// top optimization tier even when the parent unit suite is CPU-saturated.
// With concurrent recompilation, the fixed frame warmup races TurboFan and
// this worker nondeterministically measures a lower tier instead.
const result = spawnSync(
process.execPath,
['--expose-gc', '--no-concurrent-recompilation', WORKER_PATH],
{
encoding: 'utf8',
timeout: 180_000,
env: {
...process.env,
GEV_ALLOC_ENTRIES: String(entryCount),
GEV_ALLOC_PROFILE: profile,
},
},
);
if (result.error) throw new Error(`probe failed to spawn: ${result.error.message}`);
if (result.status !== 0) {
throw new Error(`probe exited with ${result.status}: ${String(result.stderr).slice(0, 400)}`);
}
const line = String(result.stdout).trim().split('\n').filter(Boolean).at(-1);
if (!line) throw new Error('probe produced no output');
let payload;
try {
payload = JSON.parse(line);
} catch (error) {
throw new Error(`probe output was not JSON: ${error.message}`);
}
if (payload.ok !== true) throw new Error(`probe unavailable: ${payload.reason}`);
return payload;
}
for (const workload of WORKLOADS) {
test(`steady moving-source frames stay in budget ${workload.name}`, (t) => {
if (!CALIBRATED_ALLOCATION_RUNTIME) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
}
const payload = runAllocationProbe(workload.entries, workload.profile);
// Workload guards: a probe that stopped painting, stopped re-solving, or
// quietly shrank its cohort would report a flattering number for the wrong
// reason. Painted count is deliberately NOT pinned to the entry count.
assert.equal(payload.entryCount, workload.entries);
assert.equal(payload.candidateCount, workload.candidates, 'bounded cohorts changed unexpectedly');
assert.equal(payload.profile, workload.profile || 'generic');
if (workload.ambientCardCapacity != null) {
assert.equal(payload.ambientCardCapacity, workload.ambientCardCapacity);
}
if (workload.detectionLabelBudget != null) {
assert.equal(payload.detectionCollectiveLabelBudget, workload.detectionLabelBudget);
assert.equal(payload.detectionSelectedCount, workload.detectionLabelBudget);
}
assert.ok(payload.paintedCount > 0, 'probe painted nothing');
assert.ok(payload.solveCount > 0, 'probe never exercised an arbiter solve');
assert.ok(payload.measuredFrames >= 400, 'probe measured too few frames');
if (workload.saturated) {
assert.ok(
payload.paintedCount < payload.candidateCount,
'workload was supposed to exceed the domain collision capacity',
);
}
const report = `${payload.candidateCount} candidates / ${payload.paintedCount} painted`
+ `, median ${payload.medianBytesPerFrame.toFixed(0)} B/frame`
+ ` (max ${payload.maxBytesPerFrame.toFixed(0)})`
+ `, median ${payload.medianBytesPerCandidatePerFrame.toFixed(1)} B/candidate/frame`
+ ` over ${payload.measuredFrames} frames and ${payload.solveCount} solves`
+ `; chunks: ${payload.chunkBytesPerFrame.map((value) => value.toFixed(0)).join(', ')}`;
assert.ok(
payload.medianBytesPerFrame <= workload.maxBytesPerFrame,
`world-overlay steady frame exceeded ${workload.maxBytesPerFrame} B/frame: ${report}`,
);
assert.ok(
payload.medianBytesPerCandidatePerFrame
<= (workload.maxBytesPerCandidatePerFrame ?? MAX_BYTES_PER_CANDIDATE_PER_FRAME),
`world-overlay steady frame exceeded ${workload.maxBytesPerCandidatePerFrame
?? MAX_BYTES_PER_CANDIDATE_PER_FRAME} B/candidate: ${report}`,
);
});
}
File diff suppressed because it is too large. Load diff
+800
View File
@@ -0,0 +1,800 @@
/**
* @module worldOverlayDraw
* @description Pure geometry, measurement, and Canvas2D painters for the
* shared world overlay. This module owns presentation only: no layer state,
* fetching, Cesium scene queries, or source selection policy belongs here.
*/
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
// Two high-cardinality infrastructure sources share this cache; 1024 avoids
// repeated O(n) eviction scans while keeping host-lifetime retention bounded.
const TEXT_MEASURE_CACHE_LIMIT = 1024;
const _textMeasureCache = new Map();
let _textMeasureCacheSize = 0;
// Recency is a monotonic integer stamp rather than a doubly linked list: a
// cache hit happens twice per painted entry per frame, and re-linking four
// object pointers on every hit costs real write-barrier traffic. A Smi store
// costs nothing, and eviction (only above the cap) can afford a scan.
let _textMeasureClock = 0;
let _fontInvalidationInstalled = false;
let _fontInvalidationGeneration = 0;
let _observedFontSet = null;
function evictOldestTextMeasureEntry() {
let oldestFont = null;
let oldestText = null;
let oldestUsedAt = Number.POSITIVE_INFINITY;
_textMeasureCache.forEach((fontCache, font) => {
fontCache.forEach((entry, text) => {
if (entry.usedAt >= oldestUsedAt) return;
oldestUsedAt = entry.usedAt;
oldestFont = font;
oldestText = text;
});
});
if (oldestFont === null) return;
const fontCache = _textMeasureCache.get(oldestFont);
fontCache.delete(oldestText);
if (fontCache.size === 0) _textMeasureCache.delete(oldestFont);
_textMeasureCacheSize--;
}
/** Clear cached text widths after web-font availability changes. */
export function clearWorldOverlayTextMeasureCache() {
_textMeasureCache.clear();
_textMeasureCacheSize = 0;
_textMeasureClock = 0;
}
/** Install the font-loading invalidation hooks once, when the API exists. */
export function installWorldOverlayFontInvalidation() {
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return;
_fontInvalidationInstalled = true;
_observedFontSet = document.fonts;
const generation = ++_fontInvalidationGeneration;
Promise.resolve(document.fonts.ready)
.then(() => {
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache();
})
.catch(() => {});
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
}
/** Remove font hooks and cached measurements when the overlay host is destroyed. */
export function destroyWorldOverlayDraw() {
_fontInvalidationGeneration++;
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
_observedFontSet = null;
_fontInvalidationInstalled = false;
clearWorldOverlayTextMeasureCache();
}
/**
* Measure text with a font-aware cache.
* @param {CanvasRenderingContext2D} ctx
* @param {string} text
* @param {string} font
* @returns {number}
*/
export function measureWorldOverlayText(ctx, text, font) {
if (!_fontInvalidationInstalled) installWorldOverlayFontInvalidation();
const normalizedText = String(text ?? '');
const normalizedFont = String(font || WORLD_OVERLAY_STYLE.fontLabel);
let fontCache = _textMeasureCache.get(normalizedFont);
const cached = fontCache?.get(normalizedText);
if (cached) {
cached.usedAt = ++_textMeasureClock;
return cached.width;
}
ctx.font = normalizedFont;
const width = Number(ctx.measureText(normalizedText)?.width) || 0;
if (!fontCache) {
fontCache = new Map();
_textMeasureCache.set(normalizedFont, fontCache);
}
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
_textMeasureCacheSize++;
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry();
return width;
}
/** @returns {number} Current cache size, exposed for focused diagnostics/tests. */
export function getWorldOverlayTextMeasureCacheSize() {
return _textMeasureCacheSize;
}
/**
* Append a rounded rectangle to the current Canvas2D path.
* @param {CanvasRenderingContext2D|Path2D} path
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
* @param {number} radius
*/
export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.radius) {
if (typeof path.roundRect === 'function') {
path.roundRect(x, y, w, h, radius);
return;
}
const r = Math.max(0, Math.min(Number(radius) || 0, w / 2, h / 2));
path.moveTo(x + r, y);
path.arcTo(x + w, y, x + w, y + h, r);
path.arcTo(x + w, y + h, x, y + h, r);
path.arcTo(x, y + h, x, y, r);
path.arcTo(x, y, x + w, y, r);
path.closePath();
}
/**
* FIRMS-compatible distance fade: full through 70% of the far range, then a
* linear ramp to zero. A finite minimum is a hard near-range policy boundary.
* @param {number} distanceM
* @param {object} [options]
* @param {number} [options.minDistance]
* @param {number} [options.maxDistance]
* @param {number} [options.fadeStartRatio]
* @returns {number}
*/
export function distanceFade(distanceM, {
minDistance = 0,
maxDistance = Number.POSITIVE_INFINITY,
fadeStartRatio = 0.7,
} = {}) {
if (!Number.isFinite(distanceM)) return 0;
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
if (distanceM < min) return 0;
if (!Number.isFinite(maxDistance)) return 1;
const max = Math.max(min, maxDistance);
if (distanceM >= max) return 0;
const ratio = Math.max(0, Math.min(1, Number(fadeStartRatio) || 0));
const fadeStart = min + (max - min) * ratio;
if (distanceM <= fadeStart || fadeStart >= max) return 1;
return 1 - (distanceM - fadeStart) / (max - fadeStart);
}
/**
* Cesium NearFarScalar-compatible distance scale for a complete overlay card.
* Values clamp outside the curve and interpolate linearly between its stops.
* @param {number} distanceM
* @param {object|null} [curve]
* @param {number} [curve.near]
* @param {number} [curve.nearValue]
* @param {number} [curve.far]
* @param {number} [curve.farValue]
* @returns {number}
*/
export function distanceScale(distanceM, curve = null) {
if (!curve || !Number.isFinite(distanceM)) return 1;
const near = Math.max(0, Number(curve.near) || 0);
const far = Math.max(near, Number(curve.far) || near);
const nearValue = Math.max(0, Number(curve.nearValue) || 0);
const farValue = Math.max(0, Number(curve.farValue) || 0);
if (distanceM <= near || far === near) return nearValue;
if (distanceM >= far) return farValue;
const progress = (distanceM - near) / (far - near);
return nearValue + (farValue - nearValue) * progress;
}
/**
* Source-configurable camera-altitude scale for a complete overlay card.
* The first leg may use smoothstep to preserve CCTV's shipped street-to-city
* transition; the second leg is linear and clamps at the source's minimum.
* @param {number} altitudeM
* @param {object|null} [curve]
* @returns {number}
*/
export function altitudeScale(altitudeM, curve = null) {
if (!curve || !Number.isFinite(altitudeM)) return 1;
const fullEnd = Number(curve.fullEnd);
const midEnd = Number(curve.midEnd);
const end = Number(curve.end);
const midValue = Number(curve.midValue);
const endValue = Number(curve.endValue);
if (!Number.isFinite(fullEnd) || !Number.isFinite(midEnd) || !Number.isFinite(end)
|| !Number.isFinite(midValue) || !Number.isFinite(endValue)) return 1;
if (altitudeM <= fullEnd) return 1;
if (altitudeM <= midEnd) {
const span = Math.max(1, midEnd - fullEnd);
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span));
if (curve.smoothToMid === true) progress = progress * progress * (3 - 2 * progress);
return 1 + (midValue - 1) * progress;
}
if (altitudeM >= end) return endValue;
const progress = Math.max(0, Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)));
return midValue + (endValue - midValue) * progress;
}
/**
* Linear camera-altitude fade. Sources opt in with finite fade boundaries.
* @param {number} altitudeM
* @param {object} [options]
* @param {number} [options.minAltitude]
* @param {number} [options.fadeStart]
* @param {number} [options.fadeEnd]
* @returns {number}
*/
export function altitudeFade(altitudeM, {
minAltitude = Number.NEGATIVE_INFINITY,
fadeStart = Number.POSITIVE_INFINITY,
fadeEnd = Number.POSITIVE_INFINITY,
} = {}) {
if (!Number.isFinite(altitudeM)) return 1;
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
if (!Number.isFinite(fadeEnd)) return 1;
const end = fadeEnd;
const start = Number.isFinite(fadeStart) ? Math.min(fadeStart, end) : end;
if (altitudeM <= start) return 1;
if (altitudeM >= end || start === end) return 0;
return 1 - (altitudeM - start) / (end - start);
}
/** Multiply the five independent opacity channels in the binding order. */
export function combinedOverlayAlpha({
sourceAlpha = 1,
temporalFade = 1,
distanceFade: distanceAlpha = 1,
altitudeFade: altitudeAlpha = 1,
keyholeEdgeFade = 1,
} = {}) {
return clampUnit(sourceAlpha)
* clampUnit(temporalFade)
* clampUnit(distanceAlpha)
* clampUnit(altitudeAlpha)
* clampUnit(keyholeEdgeFade);
}
function clampUnit(value) {
return Number.isFinite(Number(value)) ? Math.max(0, Math.min(1, Number(value))) : 1;
}
function trackDisplayText(entry) {
const title = entry?.title || '';
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : '';
if (entry._overlayTrackDisplayTitle !== title || entry._overlayTrackDisplayDetail !== detail) {
entry._overlayTrackDisplayTitle = title;
entry._overlayTrackDisplayDetail = detail;
entry._overlayTrackDisplayText = title && detail ? `${title} · ${detail}` : title || detail;
}
return entry._overlayTrackDisplayText || '';
}
/**
* Measure the selected entry variant into a caller-owned layout object.
* @param {CanvasRenderingContext2D} ctx
* @param {object} entry
* @param {object} [out]
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
*/
export function measureOverlayEntry(ctx, entry, out = {}) {
const variant = entry?.selected ? 'selected' : String(entry?.variant || 'label');
const details = Array.isArray(entry?.details) ? entry.details : [];
const selected = variant === 'selected';
const tracked = variant === 'tracked';
const tactical = entry?.cardStyle === 'tactical';
const titleFont = tracked
? WORLD_OVERLAY_STYLE.fontTrackedTitle
: selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
let titleWidth = measureWorldOverlayText(ctx, entry?.title || '',
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont);
if (variant === 'track' && details[0]) {
titleWidth = measureWorldOverlayText(
ctx,
trackDisplayText(entry),
WORLD_OVERLAY_STYLE.fontTrack,
);
}
let detailWidth = 0;
for (let i = 0; i < details.length; i++) {
detailWidth = Math.max(
detailWidth,
measureWorldOverlayText(
ctx,
details[i],
tracked ? WORLD_OVERLAY_STYLE.fontTrackedDetail : WORLD_OVERLAY_STYLE.fontDetail,
),
);
}
out.padX = tracked ? 13 : selected ? 12 : variant === 'label' || variant === 'track' ? 6 : 9;
out.padY = tracked ? 9 : selected ? 8 : variant === 'label' || variant === 'track' ? 4 : 6;
out.titleH = tactical
? (selected ? 14 : 12)
: tracked ? 13 : selected ? 15 : variant === 'label' || variant === 'track' ? 11 : 13;
out.lineH = tracked ? 17 : selected ? 15 : 13;
out.thumbW = 0;
out.thumbH = 0;
if (variant === 'thumbnail') {
out.padX = Math.max(0, Number(entry?.thumbnailPadX) || 0);
out.padY = Math.max(0, Number(entry?.thumbnailPadTop) || 0);
out.padBottom = Math.max(0, Number(entry?.thumbnailPadBottom) || 0);
out.titleGap = Math.max(0, Number(entry?.thumbnailTitleGap) || 0);
out.titleH = Math.max(0, Number(entry?.thumbnailTitleHeight) || out.titleH);
out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96);
out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54);
out.w = out.thumbW + out.padX * 2;
out.h = out.padY + out.thumbH + out.titleGap + out.titleH + out.padBottom;
} else {
out.w = Math.ceil(Math.max(titleWidth, detailWidth)) + out.padX * 2;
out.h = out.padY * 2 + out.titleH + details.length * out.lineH;
}
out.w = Math.max(8, out.w);
out.h = Math.max(8, out.h);
return out;
}
function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderOffset = 0) {
const placement = out || {};
placement.corner = corner;
placement.rect ||= {};
placement.rect.x = Math.round(x);
placement.rect.y = Math.round(y);
placement.rect.w = w;
placement.rect.h = h;
placement.centerX = placement.rect.x + w / 2;
placement.centerY = placement.rect.y + h / 2;
placement.anchorX = anchorX;
placement.anchorY = anchorY;
// This value is computed once per entry below and copied into every pooled
// placement. Keeping the signed pixel offset a Smi avoids boxing a fresh
// `anchor +/- offset` double on every placement write.
placement.leaderOffset = signedLeaderOffset;
if (corner === 'above' || corner === 'below') {
placement.leadFromX = anchorX;
placement.leadFromY = anchorY;
// Strictly vertical, always — the shipped leader ran from the anchor's sx to
// the card edge at the same sx with no clamp. Clamping the endpoint into the
// card rect made the stub visibly diagonal whenever a viewport-edge clamp had
// pushed the card sideways off its anchor, which reads as "the card isn't
// attached to that camera".
placement.leadToX = anchorX;
placement.leadToY = corner === 'above' ? placement.rect.y + h : placement.rect.y;
} else {
placement.leadFromX = anchorX;
placement.leadFromY = anchorY;
placement.leadToX = corner === 'left' ? placement.rect.x + w : placement.rect.x;
placement.leadToY = Math.max(placement.rect.y, Math.min(anchorY, placement.rect.y + h));
}
return placement;
}
const PLACEMENT_ORDERS = Object.freeze({
above: Object.freeze(['above', 'below', 'right', 'left']),
below: Object.freeze(['below', 'above', 'right', 'left']),
right: Object.freeze(['right', 'above', 'below', 'left']),
left: Object.freeze(['left', 'above', 'below', 'right']),
});
const VERTICAL_PLACEMENT_ORDERS = Object.freeze({
above: Object.freeze(['above', 'below']),
below: Object.freeze(['below', 'above']),
});
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
return Math.max(margin, Math.min(value, Math.max(margin, viewportSize - size - margin)));
}
/**
* Build deterministic above/below/right/left placements, clamped to the live
* viewport. The caller may reuse `out` and its placement objects every frame.
* @param {object} input
* @param {number} input.anchorX
* @param {number} input.anchorY
* @param {number} input.width
* @param {number} input.height
* @param {number} input.viewportWidth
* @param {number} input.viewportHeight
* @param {number} [input.gap]
* @param {string} [input.preferred]
* @param {number} [input.leaderOffset]
* @param {boolean} [input.verticalOnly]
* @param {number} [input.viewportMargin]
* @param {Array<object>} [out]
* @returns {Array<object>}
*/
export function placementVariants({
anchorX,
anchorY,
width,
height,
viewportWidth,
viewportHeight,
gap = 12,
preferred = 'auto',
leaderOffset = 0,
verticalOnly = false,
viewportMargin = 4,
}, out = []) {
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
const order = orders[preferred] || orders[automatic];
let positiveLeaderOffset = leaderOffset;
let negativeLeaderOffset = leaderOffset;
if (leaderOffset !== 0) {
positiveLeaderOffset = Math.round(leaderOffset);
negativeLeaderOffset = -positiveLeaderOffset;
}
for (let i = 0; i < order.length; i++) {
const corner = order[i];
let x = anchorX - width / 2;
let y = anchorY - gap - height;
if (corner === 'below') y = anchorY + gap;
else if (corner === 'right') {
x = anchorX + gap;
y = anchorY - height / 2;
} else if (corner === 'left') {
x = anchorX - gap - width;
y = anchorY - height / 2;
}
out[i] = writePlacement(
out[i],
corner,
clampPlacementCoordinate(x, width, viewportWidth, viewportMargin),
clampPlacementCoordinate(y, height, viewportHeight, viewportMargin),
width,
height,
anchorX,
anchorY,
corner === 'above' || corner === 'left' ? negativeLeaderOffset : positiveLeaderOffset,
);
}
out.length = order.length;
return out;
}
function drawLeader(ctx, placement, accent) {
ctx.strokeStyle = accent || WORLD_OVERLAY_STYLE.leader;
// Leaders are screen-space strokes. Card content may be painted inside an
// altitude/distance scale transform, so counter-scale the canvas width to
// retain the shipped one-CSS-pixel leader at every card size.
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();
}
function drawCardChrome(ctx, entry, placement, selected = false) {
const { x, y, w, h } = placement.rect;
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
drawLeader(ctx, placement, accent);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h);
ctx.fillStyle = selected ? WORLD_OVERLAY_STYLE.selectedBackground : WORLD_OVERLAY_STYLE.background;
ctx.fill();
ctx.strokeStyle = selected ? WORLD_OVERLAY_STYLE.selectedBorder : WORLD_OVERLAY_STYLE.border;
ctx.lineWidth = selected ? 1.25 : 1;
ctx.stroke();
ctx.fillStyle = accent;
ctx.fillRect(x, y + 3, selected ? 3 : 2, Math.max(1, h - 6));
}
function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
const details = Array.isArray(entry.details) ? entry.details : [];
const x = placement.rect.x + (selected ? 12 : 9);
let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
ctx.textBaseline = 'top';
ctx.fillText(String(entry.title || ''), x, y);
y += selected ? 15 : 13;
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
for (let i = 0; i < details.length; i++) {
ctx.fillText(String(details[i]), x, y);
y += selected ? 15 : 13;
}
}
function colorWithAlpha(color, alpha) {
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim();
const triplet = text.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
if (triplet) return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
const hex = text.match(/^#([0-9a-f]{6})$/i);
if (hex) {
const value = Number.parseInt(hex[1], 16);
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
}
return text;
}
function tacticalAccentColors(entry) {
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
let colors = entry._overlayTacticalAccentColors;
if (!colors || colors.accent !== accent) {
colors = {
accent,
leader: colorWithAlpha(accent, 0.65),
border: colorWithAlpha(accent, 0.85),
rule: colorWithAlpha(accent, 0.95),
};
entry._overlayTacticalAccentColors = colors;
}
return colors;
}
/** 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';
const details = Array.isArray(entry.details) ? entry.details : [];
const layout = entry._overlayLayout || {};
const { x, y, w, h } = placement.rect;
const accentColors = tacticalAccentColors(entry);
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();
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, 4);
ctx.fillStyle = WORLD_OVERLAY_STYLE.background;
ctx.fill();
if (selected) {
ctx.beginPath();
roundedRectPath(ctx, x + 0.5, y + 0.5, w - 1, h - 1, 4);
ctx.strokeStyle = accentColors.border;
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.beginPath();
roundedRectPath(ctx, x, y, w, 2, 1);
ctx.fillStyle = accentColors.rule;
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
const titleBaseline = y + layout.padY + layout.titleH - 2;
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
for (let i = 0; i < details.length; i++) {
ctx.fillText(String(details[i]), x + layout.padX, titleBaseline + (i + 1) * layout.lineH);
}
ctx.restore();
return placement.rect;
}
/** Paint a compact ambient label. */
export function paintLabel(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawCardChrome(ctx, entry, placement, false);
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
ctx.textBaseline = 'top';
ctx.fillText(String(entry.title || ''), placement.rect.x + 6, placement.rect.y + 4);
ctx.restore();
return placement.rect;
}
/** Paint a compact track label with optional inline detail. */
export function paintTrack(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawCardChrome(ctx, entry, placement, false);
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = WORLD_OVERLAY_STYLE.fontTrack;
ctx.textBaseline = 'top';
ctx.fillText(
trackDisplayText(entry),
placement.rect.x + 6,
placement.rect.y + 4,
);
ctx.restore();
return placement.rect;
}
/** Paint a standard detail card. */
export function paintCard(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawCardChrome(ctx, entry, placement, false);
drawCardText(ctx, entry, placement, false);
ctx.restore();
return placement.rect;
}
/** Paint a thumbnail card. The image slot remains source-owned. */
export function paintThumbnail(ctx, entry, placement, alpha = 1) {
const layout = entry._overlayLayout || {};
const { x, y, w, h } = placement.rect;
const padX = Number.isFinite(layout.padX) ? layout.padX : 4;
const padY = Number.isFinite(layout.padY) ? layout.padY : 4;
const thumbW = layout.thumbW || Number(entry.thumbnailWidth) || 96;
const thumbH = layout.thumbH || Number(entry.thumbnailHeight) || 54;
const titleH = Number.isFinite(layout.titleH) ? layout.titleH : 13;
const image = entry.image?.frame ?? entry.image;
ctx.save();
ctx.globalAlpha = alpha;
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
ctx.fill();
const imageX = x + padX;
const imageY = y + padY;
if (image && typeof ctx.drawImage === 'function') {
try {
ctx.drawImage(image, imageX, imageY, thumbW, thumbH);
} catch {
// A source may replace/close a live image slot between frames. Chrome
// and text remain valid; the next source update supplies the next frame.
}
}
const ruleHeight = Math.max(0, Number(entry.thumbnailRuleHeight) || 0);
if (ruleHeight > 0) {
ctx.beginPath();
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight));
ctx.fillStyle = entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.fill();
}
const titleChars = Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0));
const title = entry._overlayThumbnailTitle
|| String(entry.title || '').toUpperCase();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
ctx.fillText(
titleChars > 0 ? title.slice(0, titleChars) : title,
imageX,
imageY + thumbH + titleH - 3,
);
ctx.restore();
return placement.rect;
}
/** Paint the protected selected-card treatment. */
export function paintSelected(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawCardChrome(ctx, entry, placement, true);
drawCardText(ctx, entry, placement, true);
ctx.restore();
return placement.rect;
}
/** Paint the centered protected tracked-target readout treatment. */
export function paintTracked(ctx, entry, placement, alpha = 1) {
const details = Array.isArray(entry.details) ? entry.details : [];
const layout = entry._overlayLayout || {};
const { x, y, w, h } = placement.rect;
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.save();
ctx.globalAlpha = alpha;
drawLeader(ctx, placement, accent);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, 5);
ctx.fillStyle = WORLD_OVERLAY_STYLE.background;
ctx.fill();
ctx.beginPath();
roundedRectPath(ctx, x, y, w, 2, 1);
ctx.fillStyle = accent;
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const centerX = x + w / 2;
const titleBaseline = y + layout.padY + layout.titleH - 2;
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedTitle;
ctx.fillText(String(entry.title || ''), centerX, titleBaseline);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
for (let i = 0; i < details.length; i++) {
ctx.fillText(String(details[i]), centerX, titleBaseline + (i + 1) * layout.lineH);
}
ctx.restore();
return placement.rect;
}
/**
* Paint one ambient detection callout: backing plate, tier accent bar, leader
* stub, bright primary text and a dimmer micro-field.
*
* This is the LEGIBILITY-CRITICAL painter. It runs on the shared normal-blend
* canvas rather than the screen-blended sensor surface, because `screen` can
* only lighten: a dark plate composited with `screen` over sunlit imagery
* resolves to the imagery itself (measured: rgba(2,18,26,0.66) over rgb(230)
* yields rgb(230,231,232)), which is why ambient callsigns used to dissolve
* into bright ground while the tracked card — always painted on this same
* normal-blend canvas — stayed readable in every style.
*
* Allocation-free by contract: no Path2D, array, or string is constructed here.
* It is called once per visible callout per frame.
*
* @param {CanvasRenderingContext2D} ctx Shared normal-blend canvas context.
* @param {object} callout Placement + text, supplied by the detection lane.
* @param {number} callout.x Plate left edge, CSS px.
* @param {number} callout.y Plate top edge, CSS px.
* @param {number} callout.w Plate width, CSS px.
* @param {number} callout.h Plate height, CSS px.
* @param {number} callout.primaryX Baseline-relative x of the primary text.
* @param {number} callout.microX Baseline-relative x of the micro text.
* @param {number} callout.baseline Text baseline, CSS px.
* @param {number} callout.leadFromX Leader start x.
* @param {number} callout.leadFromY Leader start y.
* @param {number} callout.leadToX Leader end x.
* @param {number} callout.leadToY Leader end y.
* @param {string} callout.plate Backing plate fill.
* @param {number} [callout.plateScale=1] Backdrop feather: 1 over ground, down
* to `SKY_PLATE_SCALE` against sky. Multiplies the PLATE only — the accent
* bar, leader and text keep the composed fades, so a feathered callout loses
* its box without losing its identity.
* @param {string} callout.accent Tier colour for the accent bar and leader.
* @param {string} callout.label Primary text fill.
* @param {string} callout.primary Primary text.
* @param {string} callout.micro Micro-field text ('' when absent).
* @param {string} callout.font Primary font.
* @param {string} callout.microFont Micro-field font.
* @param {number} alpha Composed opacity; already folded through every fade.
*/
export function paintDetectionCallout(ctx, callout, alpha = 1) {
// Scaling globalAlpha rather than rewriting the fill string keeps every
// theme's own plate hue and weight, and keeps this painter allocation-free.
ctx.globalAlpha = alpha * (callout.plateScale ?? 1);
ctx.beginPath();
roundedRectPath(ctx, callout.x, callout.y, callout.w, callout.h, 3);
ctx.fillStyle = callout.plate;
ctx.fill();
ctx.globalAlpha = alpha;
ctx.beginPath();
roundedRectPath(ctx, callout.x, callout.y + 2, 2, callout.h - 4, 1);
ctx.fillStyle = callout.accent;
ctx.fill();
ctx.globalAlpha = alpha * 0.6;
ctx.strokeStyle = callout.accent;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(callout.leadFromX, callout.leadFromY);
ctx.lineTo(callout.leadToX, callout.leadToY);
ctx.stroke();
ctx.globalAlpha = alpha;
ctx.fillStyle = callout.label;
ctx.font = callout.font;
if (callout.primary) ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
if (callout.micro) {
ctx.globalAlpha = alpha * 0.8;
ctx.font = callout.microFont;
ctx.fillText(callout.micro, callout.microX, callout.baseline);
}
}
/** Dispatch a normalized entry to its pure variant painter. */
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha);
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha);
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
return paintLabel(ctx, entry, placement, alpha);
}
+598
View File
@@ -0,0 +1,598 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
altitudeFade,
altitudeScale,
clearWorldOverlayTextMeasureCache,
combinedOverlayAlpha,
distanceFade,
distanceScale,
destroyWorldOverlayDraw,
getWorldOverlayTextMeasureCacheSize,
installWorldOverlayFontInvalidation,
measureOverlayEntry,
measureWorldOverlayText,
paintCard,
paintDetectionCallout,
paintLabel,
paintSelected,
paintTacticalCard,
paintThumbnail,
paintTrack,
paintTracked,
placementVariants,
roundedRectPath,
} from './worldOverlayDraw.js';
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
import {
CARD_PLATE_ALPHA,
DETECTION_PLATE_BAND,
DETECTION_THEME_MAP,
SKY_PLATE_SCALE,
WORLD_OVERLAY_STYLE,
} from './worldOverlayTokens.js';
function alphaOf(rgba) {
const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec(String(rgba));
assert.ok(match, `expected an rgba() colour, got ${rgba}`);
return Number(match[1]);
}
function mockContext() {
const calls = [];
let strokeStyle = '';
let lineWidth = 1;
return {
calls,
font: '',
globalAlpha: 1,
get strokeStyle() { return strokeStyle; },
set strokeStyle(value) { strokeStyle = value; calls.push(['strokeStyle', value]); },
get lineWidth() { return lineWidth; },
set lineWidth(value) { lineWidth = value; calls.push(['lineWidth', value]); },
measureCount: 0,
measureText(text) {
this.measureCount++;
return { width: String(text).length * 6 };
},
save() { calls.push(['save']); },
restore() { calls.push(['restore']); },
beginPath() { calls.push(['beginPath']); },
roundRect(...args) { calls.push(['roundRect', ...args]); },
moveTo(...args) { calls.push(['moveTo', ...args]); },
lineTo(...args) { calls.push(['lineTo', ...args]); },
arcTo(...args) { calls.push(['arcTo', ...args]); },
closePath() { calls.push(['closePath']); },
fill() { calls.push(['fill']); },
stroke() { calls.push(['stroke']); },
fillRect(...args) { calls.push(['fillRect', ...args]); },
fillText(...args) { calls.push(['fillText', ...args]); },
drawImage(...args) { calls.push(['drawImage', ...args]); },
};
}
test('the tracked card alpha constant stays bound to the card it describes', () => {
// Every ambient backing is specified as a fraction of the tracked readout
// card's plate. If the card is ever retuned, this fails first and forces the
// ambient family to be re-derived rather than silently drifting apart.
assert.equal(alphaOf(WORLD_OVERLAY_STYLE.background), CARD_PLATE_ALPHA);
});
test('every detection theme carries a callout plate inside the ambient band', () => {
for (const [name, theme] of Object.entries(DETECTION_THEME_MAP)) {
const plate = alphaOf(theme.calloutPlate);
const space = alphaOf(theme.calloutPlateSpace);
assert.ok(
plate >= DETECTION_PLATE_BAND.min * CARD_PLATE_ALPHA
&& plate <= DETECTION_PLATE_BAND.max * CARD_PLATE_ALPHA,
`${name} callout plate ${plate} outside the ambient band`,
);
// The owner's spec: satellites read over the lit Earth disc, so the space
// tier pops slightly harder — but never as heavy as the tracked card.
assert.ok(space > plate, `${name} space plate must exceed the base plate`);
assert.ok(space < CARD_PLATE_ALPHA, `${name} space plate must stay under the card`);
// The scanline wash is a separate token and must not have been repurposed.
assert.notEqual(theme.calloutPlate, theme.labelBg);
}
});
test('an ambient detection callout paints its backing plate under its text', () => {
const ctx = mockContext();
paintDetectionCallout(ctx, {
x: 20, y: 30, w: 80, h: 18,
primaryX: 27, microX: 70, baseline: 42,
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
plate: 'rgba(2, 18, 26, 0.52)',
accent: '#22e0ff',
label: 'rgba(200, 250, 255, 0.97)',
primary: 'JA23NF',
micro: 'FL017',
font: '10px JetBrains Mono, monospace',
microFont: '9px JetBrains Mono, monospace',
}, 1);
const names = ctx.calls.map(([name]) => name);
const firstFill = names.indexOf('fill');
const firstText = names.indexOf('fillText');
assert.ok(firstFill >= 0, 'the callout must fill a backing plate');
assert.ok(firstText > firstFill, 'the plate must be painted before the callsign');
// Plate geometry: a rounded rect covering the whole measured label box.
assert.deepEqual(
ctx.calls.find(([name]) => name === 'roundRect'),
['roundRect', 20, 30, 80, 18, 3],
);
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'JA23NF'));
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'FL017'));
});
test('a callout with no micro-field paints one text run and still gets a plate', () => {
const ctx = mockContext();
paintDetectionCallout(ctx, {
x: 0, y: 0, w: 40, h: 18,
primaryX: 7, microX: 30, baseline: 12,
leadFromX: 20, leadFromY: 30, leadToX: 20, leadToY: 18,
plate: 'rgba(2, 18, 26, 0.52)',
accent: '#22e0ff',
label: '#fff',
primary: 'N12345',
micro: '',
font: '10px mono',
microFont: '9px mono',
}, 0.5);
assert.ok(ctx.calls.some(([name]) => name === 'fill'));
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 1);
});
test('distance and altitude fades preserve exact boundary/ramp math', () => {
assert.equal(distanceFade(0, { maxDistance: 1000 }), 1);
assert.equal(distanceFade(700, { maxDistance: 1000 }), 1);
assert.equal(distanceFade(850, { maxDistance: 1000 }), 0.5);
assert.equal(distanceFade(1000, { maxDistance: 1000 }), 0);
assert.equal(distanceFade(50, { minDistance: 100, maxDistance: 1000 }), 0);
assert.equal(altitudeFade(6000, { fadeStart: 7500, fadeEnd: 9500 }), 1);
assert.equal(altitudeFade(8500, { fadeStart: 7500, fadeEnd: 9500 }), 0.5);
assert.equal(altitudeFade(9500, { fadeStart: 7500, fadeEnd: 9500 }), 0);
});
test('distance scale matches the legacy infrastructure NearFarScalar curve', () => {
const curve = { near: 250_000, nearValue: 1, far: 9_000_000, farValue: 0.62 };
assert.equal(distanceScale(0, curve), 1);
assert.equal(distanceScale(250_000, curve), 1);
assert.equal(distanceScale(9_000_000, curve), 0.62);
assert.equal(distanceScale(12_000_000, curve), 0.62);
assert.ok(Math.abs(distanceScale(4_625_000, curve) - 0.81) < 1e-12);
assert.equal(distanceScale(1_000_000, null), 1);
});
test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', () => {
const curve = {
fullEnd: 1800,
midEnd: 6000,
end: 9500,
midValue: 0.45,
endValue: 0.35,
smoothToMid: true,
};
assert.equal(altitudeScale(0, curve), 1);
assert.equal(altitudeScale(1800, curve), 1);
assert.ok(Math.abs(altitudeScale(6000, curve) - 0.45) < 1e-12);
assert.equal(altitudeScale(9500, curve), 0.35);
assert.ok(Math.abs(altitudeScale(3900, curve) - 0.725) < 1e-12);
assert.ok(Math.abs(altitudeScale(7750, curve) - 0.4) < 1e-12);
});
test('the five-channel alpha chain is multiplicative and clamps inputs', () => {
assert.equal(combinedOverlayAlpha({
sourceAlpha: 0.5,
temporalFade: 0.8,
distanceFade: 0.5,
altitudeFade: 0.5,
keyholeEdgeFade: 0.25,
}), 0.025);
assert.equal(combinedOverlayAlpha({ sourceAlpha: 2, temporalFade: -1 }), 0);
});
test('placement variants flip below near the top and stay viewport-clamped', () => {
const nearTop = placementVariants({
anchorX: 10,
anchorY: 5,
width: 80,
height: 30,
viewportWidth: 200,
viewportHeight: 100,
gap: 10,
});
assert.deepEqual(nearTop.map((item) => item.corner), ['below', 'above', 'right', 'left']);
for (const placement of nearTop) {
assert.ok(placement.rect.x >= 4);
assert.ok(placement.rect.y >= 4);
assert.ok(placement.rect.x + placement.rect.w <= 196);
assert.ok(placement.rect.y + placement.rect.h <= 96);
}
const reused = nearTop.slice();
assert.equal(placementVariants({
anchorX: 100,
anchorY: 80,
width: 40,
height: 20,
viewportWidth: 200,
viewportHeight: 100,
}, reused), reused);
assert.equal(reused[0].corner, 'above');
});
test('tactical cards retain vertical-only placement and sprite-edge leaders', () => {
const placements = placementVariants({
anchorX: 100,
anchorY: 80,
width: 80,
height: 30,
viewportWidth: 200,
viewportHeight: 120,
gap: 20,
leaderOffset: 14,
verticalOnly: true,
});
assert.deepEqual(placements.map(({ corner }) => corner), ['above', 'below']);
assert.equal(placements[0].leadFromY, 80);
assert.equal(placements[0].leaderOffset, -14);
assert.equal(placements[0].leadToY, placements[0].rect.y + placements[0].rect.h);
});
test('edge-clamped CCTV placements keep leaders vertical unless the anchor is outside the card', () => {
const edgeClamped = placementVariants({
anchorX: 20,
anchorY: 160,
width: 104,
height: 77,
viewportWidth: 240,
viewportHeight: 240,
gap: 22,
leaderOffset: 16,
verticalOnly: true,
})[0];
assert.equal(edgeClamped.rect.x, 4, 'card is horizontally clamped at the viewport edge');
assert.equal(edgeClamped.leadFromX, 20);
assert.equal(edgeClamped.leadToX, 20, 'leader stays strictly vertical at sx');
const offRectAnchor = placementVariants({
anchorX: 2,
anchorY: 160,
width: 104,
height: 77,
viewportWidth: 240,
viewportHeight: 240,
gap: 22,
leaderOffset: 16,
verticalOnly: true,
})[0];
// RE-SCOPED 2026-08-03. The old contract was `leadToX === 4` here: an anchor
// outside the (edge-clamped) card rect pulled the leader endpoint to the card,
// making the stub diagonal. That is branch-new — the shipped leader ran from
// the anchor's sx to the card edge at the SAME sx, unconditionally — and a
// slanted stub is exactly what reads as "this card is not attached to that
// camera". Vertical always, even when the card has been clamped sideways off
// its anchor.
assert.equal(offRectAnchor.leadFromX, 2);
assert.equal(offRectAnchor.leadToX, 2,
'an off-rect anchor keeps the leader vertical rather than slanting it to the card');
});
test('rounded rectangles use native support and retain a fallback path', () => {
const native = mockContext();
roundedRectPath(native, 1, 2, 30, 20, 4);
assert.deepEqual(native.calls[0], ['roundRect', 1, 2, 30, 20, 4]);
const fallback = mockContext();
delete fallback.roundRect;
roundedRectPath(fallback, 1, 2, 30, 20, 4);
assert.equal(fallback.calls.filter(([name]) => name === 'arcTo').length, 4);
assert.equal(fallback.calls.at(-1)[0], 'closePath');
});
test('text measurement cache is font-aware and font hooks follow host lifetime', () => {
destroyWorldOverlayDraw();
const ctx = mockContext();
let loadingDone = null;
let addCount = 0;
let removeCount = 0;
globalThis.document = {
fonts: {
ready: new Promise(() => {}),
addEventListener(name, listener) {
if (name === 'loadingdone') {
loadingDone = listener;
addCount++;
}
},
removeEventListener(name, listener) {
if (name === 'loadingdone' && listener === loadingDone) removeCount++;
},
},
};
assert.equal(measureWorldOverlayText(ctx, 'A12', '10px mono'), 18);
assert.equal(measureWorldOverlayText(ctx, 'A12', '10px mono'), 18);
assert.equal(ctx.measureCount, 1);
measureWorldOverlayText(ctx, 'A12', '12px mono');
assert.equal(ctx.measureCount, 2);
assert.equal(addCount, 1);
assert.equal(getWorldOverlayTextMeasureCacheSize(), 2);
loadingDone();
assert.equal(getWorldOverlayTextMeasureCacheSize(), 0);
destroyWorldOverlayDraw();
assert.equal(removeCount, 1);
installWorldOverlayFontInvalidation();
assert.equal(addCount, 2);
destroyWorldOverlayDraw();
delete globalThis.document;
});
test('text measurement cache caps LRU retention at 1024 entries', () => {
clearWorldOverlayTextMeasureCache();
const ctx = mockContext();
for (let i = 0; i < 1100; i++) measureWorldOverlayText(ctx, `label-${i}`, '10px mono');
assert.equal(getWorldOverlayTextMeasureCacheSize(), 1024);
const measured = ctx.measureCount;
measureWorldOverlayText(ctx, 'label-0', '10px mono');
assert.equal(ctx.measureCount, measured + 1);
measureWorldOverlayText(ctx, 'label-1099', '10px mono');
assert.equal(ctx.measureCount, measured + 1);
assert.equal(getWorldOverlayTextMeasureCacheSize(), 1024);
clearWorldOverlayTextMeasureCache();
});
test('variant measurement and all six painters remain renderer-local', () => {
const ctx = mockContext();
const entry = {
title: 'CAMERA 12',
details: ['LIVE', '1.2 KM'],
accent: '#6be8ff',
image: { width: 192, height: 108 },
thumbnailWidth: 96,
thumbnailHeight: 54,
};
const variants = ['label', 'track', 'card', 'thumbnail', 'selected', 'tracked'];
const painters = [paintLabel, paintTrack, paintCard, paintThumbnail, paintSelected, paintTracked];
for (let i = 0; i < variants.length; i++) {
const variantEntry = { ...entry, variant: variants[i], selected: variants[i] === 'selected' };
const layout = measureOverlayEntry(ctx, variantEntry, {});
variantEntry._overlayLayout = layout;
const placement = placementVariants({
anchorX: 100,
anchorY: 100,
width: layout.w,
height: layout.h,
viewportWidth: 400,
viewportHeight: 300,
})[0];
assert.equal(painters[i](ctx, variantEntry, placement, 0.5), placement.rect);
}
assert.ok(ctx.calls.some(([name]) => name === 'drawImage'));
assert.ok(ctx.calls.filter(([name]) => name === 'fillText').length >= variants.length);
const trackLayout = measureOverlayEntry(ctx, { ...entry, variant: 'track' }, {});
assert.ok(trackLayout.w >= 'CAMERA 12 · LIVE'.length * 6);
});
test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing coordinates', () => {
const ctx = mockContext();
const frameSlot = createFrameSlot();
frameSlot.frame = { width: 192, height: 108 };
frameSlot.stamp = 123;
const entry = createCctvThumbnailOverlayEntry({
id: 'cam-a',
position: { x: 1, y: 2, z: 3 },
title: 'Main & Fifth Avenue',
frameSlot,
});
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
assert.deepEqual(
{ w: entry._overlayLayout.w, h: entry._overlayLayout.h },
{ w: 104, h: 77 },
);
const placement = placementVariants({
anchorX: 200,
anchorY: 200,
width: 104,
height: 77,
viewportWidth: 500,
viewportHeight: 400,
gap: entry.gapPx,
leaderOffset: entry.leaderOffsetPx,
verticalOnly: true,
})[0];
assert.deepEqual(placement.rect, { x: 148, y: 101, w: 104, h: 77 });
paintThumbnail(ctx, entry, placement, 0.75);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'strokeStyle'),
['strokeStyle', 'rgba(107, 232, 255, 0.6)'],
'CCTV leader uses the source cyan token rather than the generic leader fallback',
);
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 184]);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'drawImage'),
['drawImage', frameSlot.frame, 152, 105, 96, 54],
);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'fillText'),
['fillText', 'MAIN & FIFTH AV', 152, 169],
);
});
test('tracked painter preserves centered multi-line readout metrics', () => {
const ctx = mockContext();
const entry = {
variant: 'tracked',
title: 'UAL123',
details: ['FL350 · 451 kts', 'SFO → JFK'],
accent: '#39d0ff',
};
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
assert.equal(entry._overlayLayout.padX, 13);
assert.equal(entry._overlayLayout.padY, 9);
assert.equal(entry._overlayLayout.lineH, 17);
const placement = placementVariants({
anchorX: 200,
anchorY: 180,
width: entry._overlayLayout.w,
height: entry._overlayLayout.h,
viewportWidth: 600,
viewportHeight: 400,
verticalOnly: true,
})[0];
paintTracked(ctx, entry, placement, 0.8);
assert.deepEqual(
ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text),
['UAL123', 'FL350 · 451 kts', 'SFO → JFK'],
);
});
test('shared tactical painter preserves FIRMS card metrics and top-rule treatment', () => {
const ctx = mockContext();
const entry = {
variant: 'selected',
selected: true,
cardStyle: 'tactical',
title: 'FIRE · 1520 MW',
details: ['high conf · 2h ago', '61.9°N 122.9°W'],
accent: '224, 82, 82',
};
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
assert.deepEqual(
{
padX: entry._overlayLayout.padX,
padY: entry._overlayLayout.padY,
titleH: entry._overlayLayout.titleH,
lineH: entry._overlayLayout.lineH,
},
{ padX: 12, padY: 8, titleH: 14, lineH: 15 },
);
const placement = placementVariants({
anchorX: 200,
anchorY: 160,
width: entry._overlayLayout.w,
height: entry._overlayLayout.h,
viewportWidth: 500,
viewportHeight: 300,
gap: 20,
leaderOffset: 14,
verticalOnly: true,
})[0];
assert.equal(paintTacticalCard(ctx, entry, placement, 0.5), placement.rect);
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 146]);
assert.equal(ctx.calls.filter(([name]) => name === 'stroke').length, 2, 'leader + selected border');
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 3);
assert.equal(ctx.calls.at(-1)[0], 'restore');
});
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'] };
const originalFilter = Array.prototype.filter;
let filterCalls = 0;
Array.prototype.filter = function countTrackDisplayFilters(...args) {
filterCalls++;
return originalFilter.apply(this, args);
};
try {
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
const placement = placementVariants({
anchorX: 100,
anchorY: 100,
width: entry._overlayLayout.w,
height: entry._overlayLayout.h,
viewportWidth: 400,
viewportHeight: 300,
})[0];
paintTrack(ctx, entry, placement);
measureOverlayEntry(ctx, entry, entry._overlayLayout);
paintTrack(ctx, entry, placement);
entry.title = 'UAL124';
entry.details[0] = '451 KT';
measureOverlayEntry(ctx, entry, entry._overlayLayout);
paintTrack(ctx, entry, placement);
} finally {
Array.prototype.filter = originalFilter;
}
assert.equal(filterCalls, 0);
assert.equal(entry._overlayTrackDisplayText, 'UAL124 · 451 KT');
assert.deepEqual(
ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text),
['UAL123 · 450 KT', 'UAL123 · 450 KT', 'UAL124 · 451 KT'],
);
});
/** Records the globalAlpha in force at each paint op, which the shared mock does not. */
function alphaProbe() {
const ctx = mockContext();
const alphas = { fill: [], text: [], stroke: [] };
const inner = { fill: ctx.fill, fillText: ctx.fillText, stroke: ctx.stroke };
ctx.fill = function fill(...args) { alphas.fill.push(this.globalAlpha); return inner.fill.apply(this, args); };
ctx.fillText = function fillText(...args) { alphas.text.push(this.globalAlpha); return inner.fillText.apply(this, args); };
ctx.stroke = function stroke(...args) { alphas.stroke.push(this.globalAlpha); return inner.stroke.apply(this, args); };
return { ctx, alphas };
}
const CALLOUT_FIXTURE = Object.freeze({
x: 20, y: 30, w: 80, h: 18,
primaryX: 27, microX: 70, baseline: 42,
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
plate: 'rgba(2, 18, 26, 0.52)',
accent: '#22e0ff',
label: 'rgba(200, 250, 255, 0.97)',
primary: 'JA23NF',
micro: 'FL017',
font: '10px JetBrains Mono, monospace',
microFont: '9px JetBrains Mono, monospace',
});
test('a sky-backed callout feathers its PLATE and nothing else', () => {
// The owner's call: against the horizon the plate reads as a dark box on an
// empty sky and the pre-plate bare text was better. Feathering must land on
// the backing only — the callsign, the tier bar and the leader are the
// callout's identity and keep the composed fades.
const ground = alphaProbe();
paintDetectionCallout(ground.ctx, { ...CALLOUT_FIXTURE, plateScale: 1 }, 0.8);
const sky = alphaProbe();
paintDetectionCallout(sky.ctx, { ...CALLOUT_FIXTURE, plateScale: SKY_PLATE_SCALE }, 0.8);
// The plate is the FIRST fill; the tier accent bar is the second.
assert.ok(Math.abs(ground.alphas.fill[0] - 0.8) < 1e-12, 'ground keeps the full plate');
assert.ok(
Math.abs(sky.alphas.fill[0] - 0.8 * SKY_PLATE_SCALE) < 1e-12,
'sky scales the plate by the token, not by some other number',
);
assert.deepEqual(
sky.alphas.fill.slice(1), ground.alphas.fill.slice(1),
'the accent bar must not inherit the plate feather',
);
assert.deepEqual(sky.alphas.text, ground.alphas.text, 'text opacity is untouched');
assert.deepEqual(sky.alphas.stroke, ground.alphas.stroke, 'the leader is untouched');
// Same fill token in both: the theme's plate hue is scaled, never swapped.
assert.equal(
sky.ctx.calls.find(([name]) => name === 'fillStyle')?.[1],
ground.ctx.calls.find(([name]) => name === 'fillStyle')?.[1],
);
});
test('a callout with no backdrop information paints at full plate', () => {
// Every caller before the backdrop pass omitted plateScale. Defaulting to a
// feather would silently strip plates off the tilted-down case the plate was
// introduced for, so the default must be the full plate.
const { ctx, alphas } = alphaProbe();
paintDetectionCallout(ctx, CALLOUT_FIXTURE, 0.5);
assert.ok(Math.abs(alphas.fill[0] - 0.5) < 1e-12);
});
test('the sky plate scale is a whisper, not a second plate', () => {
// The point of the feather is to land back near the bare-text look the owner
// preferred against sky. A scale that drifts up toward 1 quietly restores the
// boxes; a negative or >1 scale is nonsense.
assert.ok(SKY_PLATE_SCALE > 0 && SKY_PLATE_SCALE <= 0.35);
// Against the lightest shipped plate this must resolve to near-invisible.
const lightest = Math.min(
...Object.values(DETECTION_THEME_MAP).map((theme) => alphaOf(theme.calloutPlate)),
);
assert.ok(lightest * SKY_PLATE_SCALE < 0.12, 'the feathered plate must read as bare text');
});
+172
View File
@@ -0,0 +1,172 @@
/**
* @module worldOverlayTokens
* @description Visual constants shared by the world-overlay host, painters,
* and source presentation bridges. Keep source selection and data semantics in
* their owning modules; this file is the single home for cross-source canvas
* presentation values.
*/
/** Shared visual tokens used by every world-overlay source. */
export const WORLD_OVERLAY_STYLE = Object.freeze({
background: 'rgba(4, 12, 16, 0.82)',
selectedBackground: 'rgba(5, 18, 24, 0.94)',
border: 'rgba(190, 232, 242, 0.18)',
selectedBorder: 'rgba(107, 232, 255, 0.72)',
title: 'rgba(232, 240, 244, 0.96)',
detail: 'rgba(147, 161, 173, 0.92)',
leader: 'rgba(147, 213, 228, 0.58)',
accent: '#6be8ff',
fontLabel: '500 10px "JetBrains Mono", monospace',
fontTrack: '600 10px "JetBrains Mono", monospace',
fontTitle: '600 12px "JetBrains Mono", monospace',
fontDetail: '500 10.5px "JetBrains Mono", monospace',
fontSelected: '600 13px "JetBrains Mono", monospace',
fontTrackedTitle: '600 13px "JetBrains Mono", monospace',
fontTrackedDetail: '500 11px "JetBrains Mono", monospace',
radius: 4,
});
/** CCTV's field-tested thumbnail-card overrides on top of shared card chrome. */
export const CCTV_THUMBNAIL_STYLE = Object.freeze({
padding: 4,
titleHeight: 13,
titleChars: 15,
background: WORLD_OVERLAY_STYLE.background,
titleColor: 'rgba(210, 236, 244, 0.95)',
titleFont: '600 10px "JetBrains Mono", monospace',
accent: 'rgb(107, 232, 255)',
leader: 'rgba(107, 232, 255, 0.6)',
rule: 'rgba(107, 232, 255, 0.95)',
ruleHeight: 2,
radius: 4,
});
/** Detection fonts and compositor glow retained exactly from the source renderer. */
export const DETECTION_STYLE = Object.freeze({
font: '10px JetBrains Mono, monospace',
microFont: '9px JetBrains Mono, monospace',
glowPx: 3,
});
/**
* Alpha of the tracked readout card's backing plate — the legibility reference
* every other backing is measured against. Derived from
* `WORLD_OVERLAY_STYLE.background` and asserted against it in unit tests, so a
* future retune of the card cannot silently desynchronize the ambient family.
*/
export const CARD_PLATE_ALPHA = 0.82;
/**
* Ambient detection callouts carry a LIGHTER member of the card's backing
* family: enough plate to hold small mono text against sunlit imagery, not so
* much that a field of them reads as a wall of boxes. `calloutPlate` sits at
* ~58% of `CARD_PLATE_ALPHA`; `calloutPlateSpace` is the requested
* "slightly higher opacity so the text pops" for space-tier (satellite)
* contacts, which sit over the high-albedo lit Earth disc more often than
* aircraft do.
*
* These are DARK plates painted with NORMAL blending, which makes them
* self-adapting: over night terrain or space a dark plate on a dark scene is
* nearly invisible, while over bright ground it does the full darkening job.
* That is the same mechanism the tracked card uses, and the reason it survives
* every style — see `PLATE_ALPHA_BAND` in the tests.
*/
export const DETECTION_PLATE_BAND = Object.freeze({ min: 0.5, max: 0.62 });
/**
* What survives of a callout plate when SKY, not ground, is behind the label.
*
* The plate exists to hold small mono text against sunlit imagery. Against the
* horizon it has no job to do — there is nothing bright and busy to separate
* the text from — and at full strength it reads as a row of dark boxes pasted
* on an empty sky, which is the one place the pre-plate bare-text look was
* already better (field finding, 2026-08-21).
*
* So the plate is SCALED here rather than replaced: every theme keeps its own
* hue and its own relative weight, and the sky case lands at a whisper that is
* visually the old bare text while still catching a bright cloud. Which case a
* given label is in comes from `skyBackdropFactor`, blended across a band so
* contacts crossing the horizon fade instead of popping.
*/
export const SKY_PLATE_SCALE = 0.22;
/**
* Sensor-style palettes for the host-owned detection paint lane.
*
* `labelBg` is the SCANLINE wash colour and stays on the screen-blended sensor
* surface. `calloutPlate`/`calloutPlateSpace` are the callout backings and are
* painted on the shared normal-blend canvas; they are deliberately separate
* tokens so retuning label legibility can never shift the scanline wash.
* Each keeps its theme's hue so a plate reads as amber/green/thermal chrome
* rather than a neutral box dropped on top of the sensor image.
*/
export const DETECTION_THEME_MAP = Object.freeze({
retro: {
line: 'rgba(255, 176, 56, 0.88)',
label: 'rgba(255, 216, 128, 0.95)',
labelBg: 'rgba(30, 12, 0, 0.72)',
calloutPlate: 'rgba(30, 12, 0, 0.48)',
calloutPlateSpace: 'rgba(30, 12, 0, 0.56)',
glow: 'rgba(255, 176, 56, 0.45)',
dim: 'rgba(230, 190, 140, 0.62)',
cardBorder: 'rgba(255, 210, 150, 0.16)',
blend: 'screen',
filter: 'contrast(1.08) saturate(1.04)',
scanline: 0.085,
tiers: {
civil: '#ffd27a', military: '#ff8a3c', sea: '#ffc06a', space: '#ffe0a0', vehicle: '#d0a060',
veh_jam: '#ff3b30', veh_slow: '#ffb300', veh_free: '#00ff66', veh_nodata: '#c9c9c9',
},
},
surveillance: {
line: 'rgba(120, 255, 130, 0.9)',
label: 'rgba(225, 255, 210, 0.97)',
labelBg: 'rgba(6, 16, 6, 0.78)',
calloutPlate: 'rgba(6, 16, 6, 0.48)',
calloutPlateSpace: 'rgba(6, 16, 6, 0.56)',
glow: 'rgba(120, 255, 120, 0.42)',
dim: 'rgba(170, 205, 160, 0.62)',
cardBorder: 'rgba(190, 255, 190, 0.18)',
blend: 'screen',
filter: 'contrast(1.12) saturate(1.12)',
scanline: 0.09,
tiers: {
civil: '#8fe89a', military: '#ff5a47', sea: '#a6f0c0', space: '#9fe8ff', vehicle: '#ffc24a',
veh_jam: '#ff4538', veh_slow: '#ffc24a', veh_free: '#45d8ff', veh_nodata: '#dcdcdc',
},
},
thermal: {
line: 'rgba(255, 224, 170, 0.95)',
label: 'rgba(255, 236, 208, 0.98)',
labelBg: 'rgba(20, 20, 20, 0.66)',
calloutPlate: 'rgba(20, 20, 20, 0.46)',
calloutPlateSpace: 'rgba(20, 20, 20, 0.54)',
glow: 'rgba(255, 224, 170, 0.42)',
dim: 'rgba(245, 222, 196, 0.74)',
cardBorder: 'rgba(255, 255, 255, 0.15)',
blend: 'screen',
filter: 'contrast(1.1) saturate(1.08)',
scanline: 0.04,
tiers: {
civil: '#ffffff', military: '#ff7a5c', sea: '#ffd0b0', space: '#d0e0ff', vehicle: '#ffcf9f',
veh_jam: '#ff4538', veh_slow: '#ffc24a', veh_free: '#2ecc71', veh_nodata: '#d8d8d8',
},
},
_default: {
line: 'rgba(0, 244, 255, 0.9)',
label: 'rgba(200, 250, 255, 0.97)',
labelBg: 'rgba(2, 18, 26, 0.66)',
calloutPlate: 'rgba(2, 18, 26, 0.46)',
calloutPlateSpace: 'rgba(2, 18, 26, 0.54)',
glow: 'rgba(0, 244, 255, 0.4)',
dim: 'rgba(150, 200, 215, 0.66)',
cardBorder: 'rgba(255, 255, 255, 0.12)',
blend: 'screen',
filter: 'contrast(1.05) saturate(1.05)',
scanline: 0.05,
tiers: {
civil: '#22e0ff', military: '#ffb347', sea: '#3fe0c8', space: '#bda4ff', vehicle: '#8fa6b4',
veh_jam: '#e05252', veh_slow: '#f0b23e', veh_free: '#2ecc71', veh_nodata: '#c9c9c9',
},
},
});