feat: Wildlife Sightings layer from iNaturalist and Acartia

Recent community wildlife observations, as a new Wildlife group in the
layer panel.

- Server: keyless iNaturalist proxy (v2 observations with only the
  projected fields; undocumented v1 UTFGrid density tiles, decoded on the
  server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget,
  up to four in flight), in-memory caches and upstream-only cooldowns;
  Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with
  comments dropped.
- Layer: grid counts drawn as a soft view-ranked density glow; real sampled
  sightings as taxon badges at their true positions with ambient photo
  thumbnails and labels; obscured records stay privacy cells; a clicked
  sighting stays pinned through camera moves and refreshes.
- Anchored photo card per selection with the single iNaturalist link;
  attribution in the layer row and the Data attribution popover.
- Filters (CONFIRMED, taxa presets) round-trip in share links with token 9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Yehe LiuandClaude Opus 5.5 committed 2026-11-05 16:17:12 -05:00
1 parent edd6212793
commit 14f88b3e08
120 files changed
+21260 -285

No files matched your search

@@ -0,0 +1,186 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildWildlifeSelectedLabel,
buildWildlifeThumbnailEntry,
} from '../layers/wildlifeSightings/cards.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
} from './worldOverlay.js';
import {
clearWorldOverlayTextMeasureCache,
measureOverlayEntry,
paintOverlayEntry,
} from './worldOverlayDraw.js';
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
function paintingContext(glyphWidth = 6) {
const text = [];
const fills = [];
const strokes = [];
const images = [];
return {
text,
fills,
strokes,
images,
measureText: (text) => ({ width: Array.from(text).length * glyphWidth }),
save() {},
restore() {},
beginPath() {},
roundRect() {},
moveTo() {},
lineTo() {},
fillRect() {},
fill() {
fills.push(this.fillStyle);
},
stroke() {
strokes.push(this.strokeStyle);
},
fillText(value) {
text.push(value);
},
drawImage(...args) {
images.push(args);
},
};
}
test('wildlife thumbnail titles paint a word-boundary ellipsis after normalization and width fitting', () => {
for (const [commonName, glyphWidth, expected] of [
["Bullock's Oriole", 6, "BULLOCK'S…"],
['Great Blue Heron', 11, 'GREAT…'],
['Superlongunbrokentaxon', 6, 'SUPERLONGUNBRO…'],
]) {
clearWorldOverlayTextMeasureCache();
const entry = normalizeOverlayEntry('wildlife-sightings', {
...buildWildlifeThumbnailEntry(
{
stableId: 'inat:1',
commonName,
observedAt: 0,
photo: {
square:
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
licence: 'cc-by',
attribution: 'Synthetic Photographer',
},
},
0,
{ image: { width: 75, height: 75 } },
),
position: { x: 1, y: 2, z: 3 },
});
const ctx = paintingContext(glyphWidth);
entry._overlayLayout = measureOverlayEntry(ctx, entry);
paintOverlayEntry(ctx, entry, {
rect: {
x: 10,
y: 20,
w: entry._overlayLayout.w,
h: entry._overlayLayout.h,
},
});
assert.equal(ctx.text[0], expected);
assert.ok(ctx.measureText(ctx.text[0]).width <= 96);
assert.ok(entry.accessibilityLabel.includes(commonName));
}
});
test('selected thumbnails retain their image in the host selected chrome and lane, without a link line', () => {
const entry = normalizeOverlayEntry('wildlife-sightings', {
...buildWildlifeThumbnailEntry(
{
stableId: 'inat:1',
commonName: 'Synthetic bird',
observedAt: 0,
uri: 'https://www.inaturalist.org/observations/1',
photo: {
square:
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
licence: 'cc-by',
attribution: 'Synthetic Photographer',
},
},
0,
{ selected: true, image: { width: 75, height: 75 } },
),
position: { x: 1, y: 2, z: 3 },
});
const ctx = paintingContext();
entry._overlayLayout = measureOverlayEntry(ctx, entry);
paintOverlayEntry(ctx, entry, {
rect: {
x: 10,
y: 20,
w: entry._overlayLayout.w,
h: entry._overlayLayout.h,
},
});
assert.equal(entry.selected, true);
assert.equal(
paintLaneForOverlayEntry(entry),
paintLaneForOverlayEntry({ selected: true }),
);
assert.equal(entry._overlayLayout.thumbH, 72);
assert.equal(ctx.images.length, 1);
assert.ok(ctx.fills.includes(WORLD_OVERLAY_STYLE.selectedBackground));
assert.ok(ctx.strokes.includes(WORLD_OVERLAY_STYLE.selectedBorder));
assert.ok(!ctx.text.some((line) => String(line).includes('↗')));
});
test('selected wildlife names retain a bounded title with the host selected metrics', () => {
for (const origin of ['inaturalist', 'acartia']) {
clearWorldOverlayTextMeasureCache();
const row = {
stableId: `${origin}:1`,
origin,
commonName:
'Synthetic bird with a very long common name that cannot widen the globe label',
scientificName: 'Avis synthetica',
iconic: 'Aves',
photo: {
square:
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
attribution:
'Synthetic Photographer, full attribution belongs in the dialog',
licence: 'cc-by',
},
};
const entry = normalizeOverlayEntry('wildlife-sightings-selected', {
...buildWildlifeSelectedLabel(row),
position: { x: 1, y: 2, z: 3 },
});
const fonts = [];
const ctx = {
font: '',
measureText(text) {
fonts.push(this.font);
return { width: [...text].length * 6 };
},
};
const layout = measureOverlayEntry(ctx, entry);
assert.equal(entry.variant, 'label');
assert.equal(
entry.selected,
true,
'selected names use the host selected painter',
);
assert.equal(entry.protected, true);
assert.equal(entry.paintLane, 'selected');
assert.equal(
paintLaneForOverlayEntry(entry),
paintLaneForOverlayEntry({ selected: true }),
);
assert.equal(entry.pinned, false);
assert.equal(entry.interactive, false);
assert.equal(entry.placement, 'above');
assert.deepEqual(entry.details, []);
assert.equal(entry.image, null);
assert.equal(layout.h, 31, 'shared selected title and padding');
assert.ok(layout.w <= 264, `bounded name, got ${layout.w}px`);
assert.deepEqual(fonts, [WORLD_OVERLAY_STYLE.fontSelected]);
}
});
+131 -72
View File
@@ -1,3 +1,5 @@
import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
@@ -19,6 +21,7 @@ import {
measureOverlayEntry,
paintOverlayEntry,
placementVariants,
truncateOverlayTitle,
} from './worldOverlayDraw.js';
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
import { t } from '../i18n/index.js';
@@ -72,57 +75,6 @@ const PAINT_LANE_INDEX = new Map(
WORLD_OVERLAY_PAINT_LANES.map((lane, index) => [lane, index]),
);
/**
* Unified UI exclusion inventory. Selectors may match multiple elements; the
* service caches their rectangles and observes layout/visibility changes.
*
* These rectangles are a PLACEMENT policy, never a paint mask. Every element
* listed here composites ABOVE the overlay host — map chrome sits at z90–z1000
* and the cockpit HUD at z145, against this host's z5/z6 — so the browser
* already keeps overlay pixels off it and no canvas needs clipping. What the
* inventory buys is readability: a card placed under solid chrome is simply
* lost, so the solver PREFERS placements clear of it. When nothing is clear the
* entry still renders and the chrome covers it, exactly as it did before the
* host existed.
*
* The list therefore holds only chrome dense enough to swallow a card. The
* cockpit's thin translucent line art — rims, arcs, rails, tapes, toplines,
* readouts — is deliberately ABSENT under the owner's AR-HUD ruling:
* world-space overlay content renders beneath the cockpit's screen-space HUD,
* which paints over it by z-order. Those elements are also enormous (the
* altitude rim is keyhole-tall, the topline viewport-wide), and excluding them
* suppressed essentially the entire cockpit view. Only the two solid,
* backdrop-filled cockpit windows survive as exclusions.
*/
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
'#title-bar',
'#style-indicator',
'#top-center-actions',
'#traffic-sync-chip',
'#cctv-sync-chip',
'#left-panel-stack',
'#right-context-rail',
'.weather-summary',
'#pp-toggles',
'#command-dock',
'#gev-voice-control',
'#cesium-credits',
'.hud-top-left',
'.hud-top-right',
'.hud-bottom-left',
'.hud-bottom-right',
'.hud-bottom-bar',
'#space-mission-panel',
'#space-mission-panel-host',
'#military-awareness-panel',
'#bhote-koshi-event-panel',
// Cockpit: solid backdrop-filled windows only (both bounded to
// `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until
// toggled). Every other cockpit selector was removed — see the block comment.
'#cockpit-context',
'#cockpit-signal-stream',
]);
/** @typedef {{x:number,y:number,w:number,h:number}} OverlayRect */
/**
* @typedef {object} WorldOverlayEntry
@@ -143,6 +95,8 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
* @property {number} [maxVisible=Infinity] Source ceiling within a shared domain.
* @property {boolean} [moving=false] Enables bounded interval re-solves.
* @property {number} [solveIntervalMs=125]
* @property {function(Set<string>):void} [onVisibilityChange] Painted entry ids,
* reported only on membership changes. The borrowed Set is read-only.
*/
/** @type {Cesium.Viewer|null} */
@@ -470,6 +424,11 @@ export function normalizeOverlayEntry(sourceId, entry) {
collisionGroup: String(
entry.collisionGroup || defaultCollisionGroup(variant),
),
// Optional lower-priority content must fit around the actual painted
// members of another domain, including its sticky positions and exit fades.
yieldToCollisionGroup: entry.yieldToCollisionGroup
? String(entry.yieldToCollisionGroup)
: null,
// Explicit field pick rather than `{ ...entry, variant }`: the lane
// resolver reads only these four, and a full spread re-invokes every
// accessor the source put on its entry (a throwing one would abort
@@ -592,12 +551,15 @@ export function normalizeOverlayEntry(sourceId, entry) {
metadata: entry.metadata ?? null,
thumbnailWidth: entry.thumbnailWidth,
thumbnailHeight: entry.thumbnailHeight,
thumbnailFit: entry.thumbnailFit === 'contain' ? 'contain' : null,
thumbnailFixedWidth: entry.thumbnailFixedWidth === true,
thumbnailPadX: entry.thumbnailPadX,
thumbnailPadTop: entry.thumbnailPadTop,
thumbnailPadBottom: entry.thumbnailPadBottom,
thumbnailTitleGap: entry.thumbnailTitleGap,
thumbnailTitleHeight: entry.thumbnailTitleHeight,
thumbnailTitleChars: entry.thumbnailTitleChars,
thumbnailTitleWordBoundary: entry.thumbnailTitleWordBoundary === true,
thumbnailBackground: entry.thumbnailBackground,
thumbnailTitleColor: entry.thumbnailTitleColor,
thumbnailTitleFont: entry.thumbnailTitleFont,
@@ -611,13 +573,21 @@ export function normalizeOverlayEntry(sourceId, entry) {
_overlayTrackDisplayText: '',
_overlayThumbnailTitle:
variant === 'thumbnail'
? String(entry.title ?? '')
.toUpperCase()
.slice(
0,
? entry.thumbnailTitleWordBoundary === true
? truncateOverlayTitle(
String(entry.title ?? '').toUpperCase(),
Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0)) ||
undefined,
Infinity,
)
: String(entry.title ?? '')
.toUpperCase()
.slice(
0,
Math.max(
0,
Math.floor(Number(entry.thumbnailTitleChars) || 0),
) || undefined,
)
: '',
};
normalized._overlayKey = entryKey(source, id);
@@ -714,6 +684,12 @@ function normalizeSourceOptions(options = {}, previous = {}) {
DEFAULT_MOVING_SOLVE_MS,
) || 0,
),
onVisibilityChange:
options.onVisibilityChange === undefined
? previous.onVisibilityChange || null
: typeof options.onVisibilityChange === 'function'
? options.onVisibilityChange
: null,
};
}
@@ -844,6 +820,7 @@ function getOrCreateSource(sourceId, options = {}) {
cohortLists: [],
cohortCount: 0,
demandByDomain: new Map(),
visibleIds: new Set(),
options: normalizeSourceOptions(options),
};
_sources.set(id, source);
@@ -1157,18 +1134,17 @@ export function isOverlayPointVisible(
*/
function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) {
for (let i = 0; i < count; i++) {
const other = exclusions[i];
if (
rect.x < other.x + other.w &&
rect.x + rect.w > other.x &&
rect.y < other.y + other.h &&
rect.y + rect.h > other.y
)
return true;
if (overlayRectsOverlap(rect, exclusions[i])) return true;
}
return false;
}
function overlayRectsOverlap(a, b) {
return (
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
);
}
/**
* Like `overlayRectIntersectsAny`, but only against exclusions that composite
* BELOW the host — the ones a placement may never overlap at any cost.
@@ -1180,14 +1156,7 @@ function overlayRectIntersectsAnyHard(
) {
for (let i = 0; i < count; i++) {
const other = exclusions[i];
if (!other.hard) continue;
if (
rect.x < other.x + other.w &&
rect.x + rect.w > other.x &&
rect.y < other.y + other.h &&
rect.y + rect.h > other.y
)
return true;
if (other.hard && overlayRectsOverlap(rect, other)) return true;
}
return false;
}
@@ -2253,6 +2222,7 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
item.lane = paintLaneForOverlayEntry(record.entry);
item.zIndex = record.entry.zIndex;
item.key = record.key;
item.collisionHidden = false;
_paintQueue[_paintCount++] = item;
}
@@ -2454,6 +2424,7 @@ function paintCustomLane(lane) {
}
function paintEntryItem(item, keyhole, sonar) {
if (item.collisionHidden) return;
const { record, placement } = item;
const entry = record.entry;
let keyholeAlpha = 1;
@@ -2517,6 +2488,90 @@ function paintEntryItem(item, keyhole, sonar) {
_paintedBySource[entry.source]++;
}
function compareYieldingPaintItems(a, b) {
const aYields = !!a.record.entry.yieldToCollisionGroup;
const bYields = !!b.record.entry.yieldToCollisionGroup;
return (
Number(aYields) - Number(bYields) ||
b.record.entry.priority - a.record.entry.priority ||
compareStableKeys(a.key, b.key)
);
}
/** Recheck yielding content every painted frame, even between camera solves.
* The arbiter's sticky/exit placements are intentionally retained for cards;
* labels can only use the remaining rectangles. All buffers are reused. */
function declutterYieldingEntries() {
let hasYielding = false;
for (let i = 0; i < _paintCount; i++)
if (_paintQueue[i].record.entry.yieldToCollisionGroup) {
hasYielding = true;
break;
}
if (!hasYielding) return;
sortPooledRange(_paintQueue, _paintCount, compareYieldingPaintItems);
for (let i = 0; i < _paintCount; i++) {
const item = _paintQueue[i];
const entry = item.record.entry;
if (!entry.yieldToCollisionGroup) continue;
const placements = item.record.placements;
let accepted = null;
// Try the arbiter's chosen corner first, then its other valid placements.
for (let p = -1; p < placements.length; p++) {
const placement = p < 0 ? item.placement : placements[p];
if (p >= 0 && placement === item.placement) continue;
let blocked = false;
for (let j = 0; j < i; j++) {
const other = _paintQueue[j];
if (other.collisionHidden) continue;
const group = other.record.entry.collisionGroup;
if (
(group === entry.collisionGroup ||
group === entry.yieldToCollisionGroup) &&
overlayRectsOverlap(placement.rect, other.placement.rect)
) {
blocked = true;
break;
}
}
if (!blocked) {
accepted = placement;
break;
}
}
if (accepted) item.placement = accepted;
else item.collisionHidden = true;
}
}
function syncPaintVisibility(painted = true) {
for (let s = 0; s < _sourceList.length; s++) {
const source = _sourceList[s];
const callback = source.options.onVisibilityChange;
if (!callback) continue;
const count = painted ? _paintedBySource[source.id] || 0 : 0;
let changed = count !== source.visibleIds.size;
if (!changed && painted) {
for (const id of source.visibleIds) {
if (
_paintRectByKey.get(entryKey(source.id, id))?.stamp !== _frameStamp
) {
changed = true;
break;
}
}
}
if (!changed) continue;
source.visibleIds.clear();
if (painted)
for (let i = 0; i < _paintRectCount; i++) {
const rect = _paintRectPool[i];
if (rect.sourceId === source.id) source.visibleIds.add(rect.entryId);
}
callback(source.visibleIds);
}
}
function paintFrame(keyhole) {
const started = nowMs();
const sonar = isCyberSonarActive()
@@ -2531,6 +2586,7 @@ function paintFrame(keyhole) {
clearCanvas(false, true);
_paintRectCount = 0;
_hitRectCount = 0;
declutterYieldingEntries();
sortPooledRange(_paintQueue, _paintCount, comparePaintItems);
// The shared canvas is not clipped either. Cards already avoid solid chrome
// by placement, and every occluder composites above this host in the DOM, so
@@ -2553,6 +2609,7 @@ function paintFrame(keyhole) {
_diagnostics.paintRectPoolSize = _paintRectPool.length;
_diagnostics.paintMs = nowMs() - started;
syncAccessibleActions();
syncPaintVisibility();
_canvasNeedsClear =
_paintRectCount > 0 || activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0;
}
@@ -2609,6 +2666,7 @@ function drawWorldOverlay() {
_hitRectCount = 0;
syncAccessibleActions();
}
syncPaintVisibility(false);
return;
}
if (_resizeDirty) ensureCanvasSize();
@@ -2743,6 +2801,7 @@ export function destroyWorldOverlay() {
) {
delete window.__gevWorldOverlay;
}
syncPaintVisibility(false);
_sources.clear();
_sourceList.length = 0;
for (let i = 0; i < _customPaintLaneList.length; i++) {
+174
View File
@@ -12,6 +12,15 @@ import {
} from '../celestialRing.js';
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
import { combinedOverlayAlpha } from './worldOverlayDraw.js';
import {
buildWildlifeLabelEntry,
buildWildlifeThumbnailEntry,
buildWildlifeSelectedLabel,
WILDLIFE_OVERLAY_SOURCE_ID,
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
WILDLIFE_LABEL_SOURCE_ID,
WILDLIFE_LABEL_SOURCE_OPTIONS,
} from '../layers/wildlifeSightings/cards.js';
import {
AMBIENT_CARD_COLLISION_CAPACITY,
WORLD_OVERLAY_OCCLUDER_SELECTORS,
@@ -1596,6 +1605,171 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec
env.cleanup();
});
test('wildlife common names use the host ambient collision domain and never paint overlapping labels', (t) => {
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
t.after(() => env.cleanup());
initWorldOverlay(env.viewer);
const entries = [
['hawk', 'Red-tailed Hawk', position()],
['maple', 'Red Maple', position()],
['butterfly', 'Monarch', new Cesium.Cartesian3(-0.6, 0.6, 0)],
['unknown', null, position()],
].map(([stableId, commonName, anchor]) => {
const entry = buildWildlifeLabelEntry({ stableId, commonName, iconic: 'Aves' });
return entry && { ...entry, position: anchor, horizonCull: false, edgeFade: 'none' };
}).filter(Boolean);
assert.equal(entries.length, 3, 'unidentified icons never enter the overlay host');
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS);
env.postRender.raise();
env.advanceTime(200);
env.postRender.raise();
const rects = entries.map((entry) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean);
assert.ok(rects.length >= 2 && rects.length <= 3, 'the host places separated names and relocates or drops coincident names');
for (let i = 0; i < rects.length; i++) {
assert.equal(rects[i].entry.collisionGroup, 'ambient-label');
assert.equal(rects[i].entry.protected, false);
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
}
}
});
const CITY_NOW = Date.parse('2026-11-04T18:00:00Z');
function citySighting(id, overrides = {}) {
return {
stableId: id, commonName: `Synthetic city species ${id}`, iconic: 'Aves',
observedAt: CITY_NOW - 60_000, quality: 'research', favesCount: 1,
photo: {
square: 'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
licence: 'cc-by', attribution: 'Synthetic Photographer',
},
...overrides,
};
}
function cityLabel(row, x, y, width = 1000, height = 600) {
return {
...buildWildlifeLabelEntry(row, { nowMs: CITY_NOW }),
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
};
}
function cityCard(row, x, y, width = 1000, height = 600, image = { width: 75, height: 75 }) {
return {
...buildWildlifeThumbnailEntry(row, CITY_NOW, { image }),
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
maxDistance: Infinity,
};
}
function assertDisjointPaint(entries) {
const rects = entries.map(([source, entry]) => getOverlayPaintRect(source, entry.id)).filter(Boolean);
for (let i = 0; i < rects.length; i++)
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y,
`${a.entryId} overlaps ${b.entryId}`);
}
return rects;
}
test('close-range dense city labels and photo cards never overlap, including frames between camera solves', (t) => {
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
t.after(() => env.cleanup());
env.viewer.camera.positionCartographic.height = 9000;
initWorldOverlay(env.viewer);
const labels = Array.from({ length: 150 }, (_, i) =>
cityLabel(citySighting(`city:${i}`, { favesCount: 150 - i }), 65 + (i % 30) * 28, 150 + Math.floor(i / 30) * 62));
const cards = Array.from({ length: 8 }, (_, i) =>
cityCard(citySighting(`photo:${i}`), 140 + (i % 4) * 210, 180 + Math.floor(i / 4) * 240));
const all = [...labels.map((e) => [WILDLIFE_LABEL_SOURCE_ID, e]), ...cards.map((e) => [WILDLIFE_OVERLAY_SOURCE_ID, e])];
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS);
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS);
for (const ms of [0, 75, 125]) {
env.advanceTime(ms);
env.postRender.raise();
assertDisjointPaint(all);
}
assert.equal(cards.filter((e) => getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8);
const placedLabels = labels.filter((e) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, e.id));
assert.ok(placedLabels.length > 5 && placedLabels.length < labels.length, 'label count is capped by the remaining space');
// A pan/zoom reprojects sticky placements on every frame before moveEnd.
env.viewer.camera.frustum.projectionMatrix[0] = 0.7;
env.viewer.camera.frustum.projectionMatrix[5] = 0.8;
env.advanceTime(16);
env.postRender.raise();
assertDisjointPaint(all);
env.moveEnd.raise();
env.advanceTime(75);
env.postRender.raise();
assertDisjointPaint(all);
});
test('labels yield immediately to photo cards, then compete by ambientScore without disturbing the card', (t) => {
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
t.after(() => env.cleanup());
initWorldOverlay(env.viewer);
const low = cityLabel(citySighting('low'), 200, 210, 400, 350);
const high = cityLabel(citySighting('high', { favesCount: 1000 }), 200, 210, 400, 350);
const card = cityCard(citySighting('card', { quality: 'needs_id' }), 200, 210, 400, 350);
const all = [[WILDLIFE_LABEL_SOURCE_ID, low], [WILDLIFE_LABEL_SOURCE_ID, high], [WILDLIFE_OVERLAY_SOURCE_ID, card]];
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
env.postRender.raise();
env.advanceTime(200);
env.postRender.raise();
assert.equal(assertDisjointPaint(all).length, 2, 'both label slots are initially free');
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
env.postRender.raise();
for (const ms of [1, 74, 125]) {
env.advanceTime(ms);
env.postRender.raise();
assertDisjointPaint(all);
assert.ok(getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id));
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id));
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id), null);
}
const before = { ...getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id) };
const promoted = cityLabel(citySighting('low', { favesCount: 1_000_000 }), 200, 210, 400, 350);
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
env.postRender.raise();
assertDisjointPaint(all);
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id));
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id), null);
const after = getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id);
assert.deepEqual([after.x, after.y, after.w, after.h], [before.x, before.y, before.w, before.h], 'the card retains its sticky placement');
const selected = { ...buildWildlifeSelectedLabel(citySighting('selected')), position: card.position, horizonCull: false, edgeFade: 'none' };
setOverlayEntries('wildlife-sightings-selected', [selected]);
env.postRender.raise();
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
// Exit fades still occupy space; labels must wait until the card is gone.
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
for (const ms of [1, 149, 151]) {
env.advanceTime(ms);
env.postRender.raise();
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
}
});
test('photo visibility reports only painted membership changes, ignoring loading cards and idle frames', (t) => {
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
t.after(() => env.cleanup());
initWorldOverlay(env.viewer);
const changes = [];
const options = { ...WILDLIFE_OVERLAY_SOURCE_OPTIONS, onVisibilityChange: (ids) => changes.push([...ids]) };
const r = citySighting('anchor');
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options);
env.postRender.raise();
assert.deepEqual(changes, []);
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options);
env.postRender.raise();
env.advanceTime(200);
env.postRender.raise();
assert.deepEqual(changes, [['sighting:anchor']]);
for (let i = 0; i < 10; i++) env.postRender.raise();
assert.equal(changes.length, 1, 'unchanged visibility emits nothing');
setOverlaySourceVisible(WILDLIFE_OVERLAY_SOURCE_ID, false);
env.postRender.raise();
assert.deepEqual(changes, [['sighting:anchor'], []]);
});
test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregate budget', () => {
const env = installMockEnvironment({ width: 1600, height: 900, dpr: 1 });
initWorldOverlay(env.viewer);
+29
View File
@@ -0,0 +1,29 @@
// Solid chrome that card placement should avoid. It composites above the
// overlay; the host needs no paint mask. Cockpit line art is intentionally
// excluded so its large bounds cannot suppress world-space content.
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
'#title-bar',
'#style-indicator',
'#top-center-actions',
'#traffic-sync-chip',
'#cctv-sync-chip',
'#left-panel-stack',
'#right-context-rail',
'.weather-summary',
'#pp-toggles',
'#command-dock',
'#gev-voice-control',
'#cesium-credits',
'.hud-top-left',
'.hud-top-right',
'.hud-bottom-left',
'.hud-bottom-right',
'.hud-bottom-bar',
'#space-mission-panel',
'#space-mission-panel-host',
'#military-awareness-panel',
'#bhote-koshi-event-panel',
// Only the cockpit's solid windows obstruct readable cards.
'#cockpit-context',
'#cockpit-signal-stream',
]);
+103 -12
View File
@@ -312,9 +312,10 @@ function trackDisplayText(entry) {
* @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 variant =
entry?.selected && entry?.variant !== 'thumbnail'
? 'selected'
: String(entry?.variant || 'label');
const details = Array.isArray(entry?.details) ? entry.details : [];
const selected = variant === 'selected';
const tracked = variant === 'tracked';
@@ -391,7 +392,13 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
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;
out.h =
out.padY +
out.thumbH +
out.titleGap +
out.titleH +
details.length * out.lineH +
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;
@@ -912,6 +919,38 @@ export function paintCard(
return placement.rect;
}
/** Bound a title by code points, preferring a whole word before the ellipsis. */
export function truncateOverlayTitle(value, maxChars) {
const text = String(value ?? '');
if (text.length <= maxChars) return text;
const glyphs = Array.from(text);
if (glyphs.length <= maxChars) return text;
const length = Math.max(0, maxChars - 1);
const prefix = glyphs.slice(0, length).join('');
const atBoundary = /\s$/u.test(prefix) || /\s/u.test(glyphs[length] || '');
const words = atBoundary
? prefix.trimEnd()
: prefix.replace(/\s+\S*$/u, '').trimEnd();
return `${words || prefix.trimEnd()}…`;
}
function fitThumbnailText(ctx, value, font, maxWidth, wordBoundary = false) {
const text = String(value ?? '');
if (measureWorldOverlayText(ctx, text, font) <= maxWidth) return text;
const glyphs = Array.from(text);
let low = 0;
let high = glyphs.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
const candidate = `${glyphs.slice(0, mid).join('')}…`;
if (measureWorldOverlayText(ctx, candidate, font) <= maxWidth) low = mid;
else high = mid - 1;
}
return wordBoundary
? truncateOverlayTitle(text, low + 1)
: `${glyphs.slice(0, low).join('')}…`;
}
/** Paint a thumbnail card. The image slot remains source-owned. */
export function paintThumbnail(
ctx,
@@ -928,6 +967,7 @@ export function paintThumbnail(
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 details = Array.isArray(entry.details) ? entry.details : [];
const image = entry.image?.frame ?? entry.image;
ctx.save();
ctx.globalAlpha = alpha;
@@ -942,16 +982,38 @@ export function paintThumbnail(
);
drawAnchorDot(ctx, entry, placement);
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
ctx.fill();
if (entry.selected) {
drawCardChrome(ctx, entry, placement, true, false);
} else {
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);
const sourceW = Number(
image.naturalWidth || image.videoWidth || image.width,
);
const sourceH = Number(
image.naturalHeight || image.videoHeight || image.height,
);
const scale =
entry.thumbnailFit === 'contain' && sourceW > 0 && sourceH > 0
? Math.min(thumbW / sourceW, thumbH / sourceH)
: null;
const width = scale == null ? thumbW : sourceW * scale;
const height = scale == null ? thumbH : sourceH * scale;
ctx.drawImage(
image,
imageX + (thumbW - width) / 2,
imageY + (thumbH - height) / 2,
width,
height,
);
} 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.
@@ -978,12 +1040,41 @@ export function paintThumbnail(
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
if (title && titleH > 0) {
const label =
titleChars > 0
? entry.thumbnailTitleWordBoundary
? truncateOverlayTitle(title, titleChars)
: title.slice(0, titleChars)
: title;
ctx.fillText(
titleChars > 0 ? title.slice(0, titleChars) : title,
entry.thumbnailFixedWidth === true
? fitThumbnailText(
ctx,
label,
ctx.font,
thumbW,
entry.thumbnailTitleWordBoundary,
)
: label,
imageX,
imageY + thumbH + titleH - 3,
);
}
if (details.length) {
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
const lineH = Number.isFinite(layout.lineH) ? layout.lineH : 13;
const titleGap = Number.isFinite(layout.titleGap) ? layout.titleGap : 0;
for (let i = 0; i < details.length; i++) {
ctx.fillText(
entry.thumbnailFixedWidth === true
? fitThumbnailText(ctx, details[i], ctx.font, thumbW)
: details[i],
imageX,
imageY + thumbH + titleGap + titleH + (i + 1) * lineH - 3,
);
}
}
ctx.restore();
return placement.rect;
}
@@ -1125,8 +1216,6 @@ export function paintOverlayEntry(
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,
@@ -1137,6 +1226,8 @@ export function paintOverlayEntry(
contentAlpha,
);
}
if (entry.selected || entry.variant === 'selected')
return paintSelected(ctx, entry, placement, alpha);
if (entry.variant === 'card') {
return paintCard(
ctx,
+370 -104
View File
@@ -25,7 +25,11 @@ import {
placementVariants,
roundedRectPath,
} from './worldOverlayDraw.js';
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
import {
createCctvThumbnailOverlayEntry,
createFrameSlot,
} from '../data/cctvCards.js';
import { normalizeOverlayEntry } from './worldOverlay.js';
import {
CARD_PLATE_ALPHA,
DETECTION_PLATE_BAND,
@@ -35,7 +39,9 @@ import {
} 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));
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]);
}
@@ -48,28 +54,64 @@ function mockContext() {
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]); },
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]); },
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]);
},
};
}
@@ -85,14 +127,17 @@ test('every detection theme carries a callout plate inside the ambient band', ()
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,
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`);
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);
}
@@ -100,47 +145,78 @@ test('every detection theme carries a callout plate inside the ambient band', ()
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);
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');
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'));
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);
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);
@@ -167,7 +243,7 @@ test('distance scale matches the legacy infrastructure NearFarScalar curve', ()
assert.equal(distanceScale(1_000_000, null), 1);
});
test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', () => {
test("altitude scale preserves CCTV's exact smoothstep and linear waypoints", () => {
const curve = {
fullEnd: 1800,
midEnd: 6000,
@@ -185,13 +261,16 @@ test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', (
});
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: 0.5,
temporalFade: 0.8,
distanceFade: 0.5,
altitudeFade: 0.5,
keyholeEdgeFade: 0.25,
}),
0.025,
);
assert.equal(combinedOverlayAlpha({ sourceAlpha: 2, temporalFade: -1 }), 0);
});
@@ -205,7 +284,10 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
viewportHeight: 100,
gap: 10,
});
assert.deepEqual(nearTop.map((item) => item.corner), ['below', 'above', 'right', 'left']);
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);
@@ -213,14 +295,20 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
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(
placementVariants(
{
anchorX: 100,
anchorY: 80,
width: 40,
height: 20,
viewportWidth: 200,
viewportHeight: 100,
},
reused,
),
reused,
);
assert.equal(reused[0].corner, 'above');
});
@@ -236,10 +324,16 @@ test('tactical cards retain vertical-only placement and sprite-edge leaders', ()
leaderOffset: 14,
verticalOnly: true,
});
assert.deepEqual(placements.map(({ corner }) => corner), ['above', 'below']);
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);
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', () => {
@@ -254,7 +348,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
leaderOffset: 16,
verticalOnly: true,
})[0];
assert.equal(edgeClamped.rect.x, 4, 'card is horizontally clamped at the viewport edge');
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');
@@ -277,8 +375,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
// 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');
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', () => {
@@ -334,7 +435,8 @@ test('text measurement cache is font-aware and font hooks follow host lifetime',
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');
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');
@@ -355,10 +457,28 @@ test('variant measurement and all six painters remain renderer-local', () => {
thumbnailWidth: 96,
thumbnailHeight: 54,
};
const variants = ['label', 'track', 'card', 'thumbnail', 'selected', 'tracked'];
const painters = [paintLabel, paintTrack, paintCard, paintThumbnail, paintSelected, paintTracked];
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 variantEntry = {
...entry,
variant: variants[i],
selected: variants[i] === 'selected',
};
const layout = measureOverlayEntry(ctx, variantEntry, {});
variantEntry._overlayLayout = layout;
const placement = placementVariants({
@@ -369,18 +489,27 @@ test('variant measurement and all six painters remain renderer-local', () => {
viewportWidth: 400,
viewportHeight: 300,
})[0];
assert.equal(painters[i](ctx, variantEntry, placement, 0.5), placement.rect);
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(
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.frame = { width: 192, height: 144 };
frameSlot.stamp = 123;
const entry = createCctvThumbnailOverlayEntry({
id: 'cam-a',
@@ -411,7 +540,10 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
['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 === 'moveTo'),
['moveTo', 200, 184],
);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'drawImage'),
['drawImage', frameSlot.frame, 152, 105, 96, 54],
@@ -422,6 +554,69 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
);
});
test('photo thumbnails letterbox square images and visibly paint attribution and disclaimers', () => {
const ctx = mockContext();
const entry = {
...createCctvThumbnailOverlayEntry({
id: 'photo',
position: { x: 1, y: 2, z: 3 },
title: 'Synthetic bird',
frameSlot: { frame: { width: 75, height: 75 } },
}),
thumbnailHeight: 72,
thumbnailFit: 'contain',
details: ['Photo: Synthetic photographer · CC BY', 'Not a census'],
};
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
const { w, h, lineH } = entry._overlayLayout;
assert.equal(w, 104, 'canvas photos keep the compact image width');
assert.equal(h, 4 + 72 + 2 + 13 + 2 * lineH + 4);
const placement = { rect: { x: 10, y: 20, w, h } };
paintThumbnail(ctx, entry, placement);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'drawImage'),
['drawImage', entry.image.frame, 26, 24, 72, 72],
);
for (const detail of entry.details)
assert.ok(
ctx.calls.some(([name, text]) => name === 'fillText' && text === detail),
detail,
);
});
test('fixed-width photo thumbnails truncate the title and creator inside the 96px image width', () => {
const ctx = mockContext();
const entry = normalizeOverlayEntry('compact-photos', {
...createCctvThumbnailOverlayEntry({
id: 'compact-photo',
position: { x: 1, y: 2, z: 3 },
title: 'A long synthetic organism name',
frameSlot: { frame: { width: 75, height: 75 } },
}),
thumbnailHeight: 72,
thumbnailFixedWidth: true,
details: ['© A very long synthetic photographer name'],
});
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
const { w, h, lineH } = entry._overlayLayout;
assert.equal(w, 104, '96px image plus the shared 4px padding on each side');
assert.equal(h, 4 + 72 + 2 + 13 + lineH + 4, 'exactly one creator line');
const placement = { rect: { x: 10, y: 20, w, h } };
paintThumbnail(ctx, entry, placement);
const text = ctx.calls.filter(([name]) => name === 'fillText');
assert.equal(text.length, 2);
assert.equal(
text[0][1],
'A LONG SYNTHETI',
'the shared title character limit remains active',
);
assert.equal(text[1][1], '© A very long s…');
for (const [, label, x] of text) {
assert.equal(x, 14);
assert.ok(ctx.measureText(label).width <= 96, label);
}
});
test('tracked painter preserves centered multi-line readout metrics', () => {
const ctx = mockContext();
const entry = {
@@ -482,15 +677,24 @@ test('shared tactical painter preserves FIRMS card metrics and top-rule treatmen
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.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('selected elbow leader reveals from the glyph before its card fades in', () => {
const animation = {
leaderAnimationMs: 1000, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.7,
leaderAnimationMs: 1000,
leaderAnimationStartedAt: 100,
leaderDrawRatio: 0.7,
};
assert.equal(leaderRevealProgress(animation, 100), 0);
assert.ok(leaderRevealProgress(animation, 450) > 0.8);
@@ -500,20 +704,44 @@ test('selected elbow leader reveals from the glyph before its card fades in', ()
assert.equal(tacticalCardRevealAlpha(animation, 1100), 1);
const ctx = mockContext();
const entry = {
variant: 'selected', selected: true, cardStyle: 'tactical', title: 'ALPR-2516',
details: ['OSM MAPPED'], accent: '#ff6474', leaderStyle: 'elbow',
leaderAnimationMs: 1, leaderAnimationStartedAt: 0,
variant: 'selected',
selected: true,
cardStyle: 'tactical',
title: 'ALPR-2516',
details: ['OSM MAPPED'],
accent: '#ff6474',
leaderStyle: 'elbow',
leaderAnimationMs: 1,
leaderAnimationStartedAt: 0,
};
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
const placement = placementVariants({
anchorX: 240, anchorY: 180, width: entry._overlayLayout.w, height: entry._overlayLayout.h,
viewportWidth: 500, viewportHeight: 300, gap: 20, leaderOffset: 30, verticalOnly: true,
anchorX: 240,
anchorY: 180,
width: entry._overlayLayout.w,
height: entry._overlayLayout.h,
viewportWidth: 500,
viewportHeight: 300,
gap: 20,
leaderOffset: 30,
verticalOnly: true,
})[0];
paintTacticalCard(ctx, entry, placement, 1);
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 210, 180]);
assert.deepEqual(
ctx.calls.find(([name]) => name === 'moveTo'),
['moveTo', 210, 180],
);
const lines = ctx.calls.filter(([name]) => name === 'lineTo');
assert.ok(lines.length >= 2, 'leader draws horizontally from the glyph, then vertically to the card');
assert.ok(ctx.calls.some(([name, value]) => name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)'));
assert.ok(
lines.length >= 2,
'leader draws horizontally from the glyph, then vertically to the card',
);
assert.ok(
ctx.calls.some(
([name, value]) =>
name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)',
),
);
});
test('track display text is cached across measure and paint and invalidates on content change', () => {
@@ -558,16 +786,33 @@ 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); };
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,
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)',
@@ -585,20 +830,36 @@ test('a sky-backed callout feathers its PLATE and nothing else', () => {
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);
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(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),
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');
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],
@@ -622,7 +883,12 @@ test('the sky plate scale is a whisper, not a second plate', () => {
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)),
...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',
);
assert.ok(lightest * SKY_PLATE_SCALE < 0.12, 'the feathered plate must read as bare text');
});