mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge branch 'main' into fix/node26-allocation-gates
This commit is contained in:
738 files changed
+136229
-3138
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
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user