mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Release God's Eye View as open source
This commit is contained in:
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
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user