Merge branch 'main' into fix/e-context-heading-north

This commit is contained in:
Sameh Khamis committed 2026-11-01 20:53:42 -08:00
commit 37d12ad731
756 files changed
+69344 -21422

No files matched your search

+6 -3
View File
@@ -215,9 +215,12 @@ export function formatMeasure(session) {
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
const whole = Math.round(m2);
const ha = (m2 / 1e4).toFixed(1);
if (whole < 1e4) return `${whole} m²`;
if (Number(ha) < 100) return `${ha} ha`;
return `${(m2 / 1e6).toFixed(2)} km²`;
}
return '';
}
+40
View File
@@ -345,3 +345,43 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
const side = Math.sqrt(m2) / 111320;
const area = createDrawSession('area');
for (const [lon, lat] of [
[0, 0],
[side, 0],
[side, side],
[0, side],
])
addVertex(area, { lon, lat });
return area;
};
const almostHectare = square(9999.6);
const metres = ringAreaM2(almostHectare.vertices);
assert.ok(
metres >= 9999.5 && metres < 1e4,
`area should round to 10000 m², got ${metres}`,
);
assert.equal(formatMeasure(almostHectare), '1.0 ha');
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
const almostKm2 = square(999_960);
const nearKm2 = ringAreaM2(almostKm2.vertices);
assert.ok(
nearKm2 >= 999_500 && nearKm2 < 1e6,
`area should round to 100.0 ha, got ${nearKm2}`,
);
assert.equal(formatMeasure(almostKm2), '1.00 km²');
const under = square(999_400);
const underM2 = ringAreaM2(under.vertices);
assert.ok(
underM2 >= 998_500 && underM2 < 999_500,
`area should round to 99.9 ha, got ${underM2}`,
);
assert.equal(formatMeasure(under), '99.9 ha');
});
+79 -89
View File
@@ -30,7 +30,6 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -50,7 +49,7 @@ import {
removeLastVertex,
} from './drawMode.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -65,26 +64,32 @@ const PREVIEW = {
};
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* Run the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* The tool hands what the controls show (`{ active, shape, color, label,
* hint }`) to `publish`, which the view renders, and answers the controls
* through the actions it gives `connect`, which returns their disconnect.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
export function initDrawTool({
viewer,
annotations,
publish = () => {},
connect = () => () => {},
}) {
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
let label = '';
let hint = '';
let handler = null;
let lease = null;
let savedDoubleClick = null;
@@ -95,25 +100,13 @@ export function initDrawTool({ viewer, annotations }) {
// one of those must not write its outcome over the newer state.
let generation = 0;
const previewEntities = [];
// Every DOM listener this tool adds, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
// Every DOM listener this tool adds (the session's keydown), so teardown is
// one loop rather than a list that can fall out of step with the bindings.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -124,9 +117,18 @@ export function initDrawTool({ viewer, annotations }) {
() => null,
);
const present = () => publish({ active, shape, color, label, hint });
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
const setHint = (text) => {
if (hint) hint.textContent = text;
hintText = text;
hint = text();
present();
};
const offLocale = onLocaleChange(() => {
if (!destroyed) setHint(hintText);
});
// ---- preview ---------------------------------------------------------
const vertexPositions = () =>
@@ -161,7 +163,7 @@ export function initDrawTool({ viewer, annotations }) {
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
setHint(drawHint(null));
setHint(() => drawHint(null));
viewer.scene.requestRender();
return;
}
@@ -187,7 +189,7 @@ export function initDrawTool({ viewer, annotations }) {
}),
);
}
setHint(drawHint(session));
setHint(() => drawHint(session));
viewer.scene.requestRender();
};
@@ -210,14 +212,14 @@ export function initDrawTool({ viewer, annotations }) {
// A click that changed nothing still deserves an answer when the reason is
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
setHint(() =>
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint(t('That point is off the globe — click on the world.'));
setHint(() => t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -235,17 +237,18 @@ export function initDrawTool({ viewer, annotations }) {
if (reason !== 'ok') {
// 'too-few' is just "keep clicking" and the hint already says so;
// a degenerate or off-globe shape needs to be told why it was refused.
if (reason !== 'too-few') setHint(drawHint(session));
if (reason !== 'too-few') setHint(() => drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
const spec = finishSpec(session, { label, color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
label = '';
present();
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -253,11 +256,12 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
if (result?.drawn === 0)
setHint(() => t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(
setHint(() =>
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
@@ -279,13 +283,15 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint(t('Board cleared.'));
if (!destroyed && !active) setHint(() => t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
// The label field is this tool's own: typing there still drives drawing.
const isLabelField = (target) => target?.id === 'draw-label-input';
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
if (!t || isLabelField(t)) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -298,7 +304,7 @@ export function initDrawTool({ viewer, annotations }) {
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (!t || isLabelField(t)) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -328,7 +334,7 @@ export function initDrawTool({ viewer, annotations }) {
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (event.key === 'Backspace' && !isLabelField(event.target)) {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -345,7 +351,7 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(
setHint(() =>
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
@@ -354,10 +360,7 @@ export function initDrawTool({ viewer, annotations }) {
}
}
active = next;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
present();
document.body.classList.toggle('gev-drawing', active);
if (active) {
session = createDrawSession(shape);
@@ -419,8 +422,7 @@ export function initDrawTool({ viewer, annotations }) {
);
savedDoubleClick = null;
}
// Drop just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
// Drop the keydown listener bound alongside this handler.
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
@@ -429,37 +431,32 @@ export function initDrawTool({ viewer, annotations }) {
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
present();
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = COLORS.includes(value) ? value : 'primary';
present();
syncPreview();
},
setDrawLabel: (text) => {
label = String(text ?? '');
present();
},
clearDraw: clearAll,
});
setHint(drawHint(null));
setHint(() => drawHint(null));
const api = {
get active() {
@@ -472,12 +469,7 @@ export function initDrawTool({ viewer, annotations }) {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
},
setShape: chooseShape,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
@@ -526,7 +518,8 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
offLocale();
disconnect();
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -546,11 +539,8 @@ export function initDrawTool({ viewer, annotations }) {
}
});
document.body.classList.remove('gev-drawing');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
hint = '';
present();
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching;
},
+23 -12
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
@@ -62,9 +62,9 @@ test('the engine resolves manual geometry before any name resolution', () => {
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/display-controls.html');
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
// the markup is not in index.html any more.
const html = read('src/ui/views/panels/DisplayPanel.vue');
const ids = [
'draw-toggle',
'draw-mode-row',
@@ -84,15 +84,25 @@ test('the Draw control markup carries the ids the tool binds to', () => {
for (const shape of ['area', 'line', 'pin']) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
new RegExp(`'data-shape': '${shape}'`),
`shape button ${shape} missing`,
);
}
for (const id of ids) {
// The rows and the hint render what the tool publishes; the controls
// call the actions the tool connects to the display store.
for (const action of [
"act('toggleDraw')",
"act('setDrawShape', $event)",
"act('setDrawLabel', $event.target.value)",
"act('setDrawColor', $event)",
"act('clearDraw')",
]) {
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
new RegExp(`\\b${name}: `),
`drawTool must answer ${name}`,
);
}
});
@@ -102,10 +112,11 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
const rules = shim
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
+118 -61
View File
@@ -6,7 +6,6 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import { describeUnsavedWork } from '../i18n/index.js';
import {
isPointerFree,
pointerOwner,
@@ -47,7 +46,6 @@ class FakeElement {
this.attributes = new Map();
this.textContent = '';
this.value = '';
this.children = [];
this.listeners = [];
}
setAttribute(name, value) {
@@ -65,16 +63,6 @@ class FakeElement {
);
if (at >= 0) this.listeners.splice(at, 1);
}
querySelectorAll(selector) {
if (selector === '.pp-mode-btn[data-shape]')
return this.children.filter((child) => child.dataset.shape);
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
return [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
click() {
return this.emit('click');
}
@@ -108,18 +96,11 @@ function fakeDom() {
byId.set(id, element);
return element;
};
// The control whose presence means the page has Draw, a focused button
// whose Enter is its own, and the tool's label field.
make('draw-toggle', 'BUTTON');
const modeRow = make('draw-mode-row');
for (const shape of ['area', 'line', 'pin']) {
const button = new FakeElement('BUTTON');
button.dataset.shape = shape;
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
make('draw-label-input', 'INPUT');
const body = new FakeElement('BODY');
const documentDouble = {
@@ -218,14 +199,29 @@ function harness() {
const previousWindow = globalThis.window;
globalThis.document = documentDouble;
globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations });
const shown = { view: null };
// The controls' actions, as the display store holds them.
const controls = { actions: null };
const tool = initDrawTool({
viewer,
annotations,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
return () => (controls.actions = null);
},
});
return {
tool,
shown,
viewer,
annotations,
byId,
documentDouble,
element: (id) => byId.get(id),
controls,
/** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args),
restore() {
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
@@ -241,38 +237,15 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
// First in the file: tools left by later tests would answer the same registry.
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
const h = harness();
try {
let marks = [];
h.annotations.list = () => marks;
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
marks = [{ id: 'b', ttlMs: null, expiring: false }];
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
marks = [];
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
marks = [{ id: 'c', ttlMs: null, expiring: false }];
await h.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
} finally {
h.restore();
}
});
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
assert.equal(isPointerFree(), true);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
assert.equal(h.tool.active, true);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
assert.equal(h.tool.active, false);
} finally {
@@ -284,18 +257,18 @@ test('Draw refuses to start when something else holds the pointer', async () =>
const h = harness();
try {
const other = claimPointer('directions');
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
// Named by its tool, not by the internal owner id.
assert.equal(
h.element('draw-hint').textContent,
h.shown.view.hint,
'Directions is using the pointer — close it first.',
);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
} finally {
h.restore();
@@ -305,13 +278,13 @@ test('Draw refuses to start when something else holds the pointer', async () =>
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
const first = harness();
try {
first.element('draw-toggle').emit('click');
first.act('toggleDraw');
assert.equal(pointerOwner(), 'draw');
// The replacement takes over before the old one finishes tearing down.
first.tool.setActive(false);
const second = harness();
try {
second.element('draw-toggle').emit('click');
second.act('toggleDraw');
assert.equal(pointerOwner(), 'draw');
await first.tool.destroy();
assert.equal(
@@ -336,7 +309,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
undefined,
@@ -347,7 +320,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
undefined,
);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
@@ -365,7 +338,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
@@ -395,7 +368,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b
test('the live preview hangs at the height the click landed on', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.setShape('line');
h.tool.addVertex(-97.75, 30.26, 187.5);
h.tool.addVertex(-97.74, 30.26, 212.25);
@@ -449,7 +422,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
try {
const stockClick = () => 'viewer picks and selects';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
@@ -463,7 +436,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.element('draw-toggle').listeners.length, 0);
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
@@ -481,15 +454,99 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
test('Clear wipes the board and the shape in progress', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
assert.equal(h.tool.session.vertices.length, 2);
h.element('draw-clear').emit('click');
h.act('clearDraw');
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
} finally {
h.restore();
}
});
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.act('setDrawShape', 'line');
h.act('setDrawColor', 'amber');
h.act('setDrawLabel', 'Route A');
assert.deepEqual(
{ ...h.shown.view, hint: undefined },
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
'the view shows what the controls chose',
);
h.act('setDrawColor', 'purple');
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
h.act('setDrawColor', 'cyan');
h.tool.addVertex(-97.75, 30.26);
// Choosing a shape again starts the open one over.
h.act('setDrawShape', 'line');
assert.equal(h.tool.session.vertices.length, 0);
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
const spec = h.annotations.calls.annotate[0].specs[0];
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
assert.equal(spec.color, 'cyan');
assert.equal(spec.label, 'Route A');
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
} finally {
h.restore();
}
});
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
const h = harness();
try {
const label = h.element('draw-label-input');
const elsewhere = new FakeElement('INPUT', 'location-search');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
assert.equal(h.tool.active, true, 'and leaves Draw on');
h.act('setDrawShape', 'line');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
} finally {
h.restore();
}
});
test('a page without the Draw controls gets no draw tool', () => {
resetPointerOwnership();
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
let connected = false;
const tool = initDrawTool({
viewer: fakeViewer(),
annotations: fakeAnnotations(),
connect: () => {
connected = true;
return () => {};
},
});
assert.equal(tool, null);
assert.equal(connected, false);
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
});
+7 -3
View File
@@ -16,11 +16,15 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
* label-less) so every caption shares one consistent style.
*
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
* one route change that spans both sub-renderers.
* one route change that spans both sub-renderers. `screen` passes options to the
* screen renderer (its store, in tests).
*/
export function createHybridAnnotationRenderer(viewer) {
export function createHybridAnnotationRenderer(
viewer,
{ screen: screenOptions } = {},
) {
const world = createWorldAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
+41 -172
View File
@@ -1,143 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
const store = useScreenAnnotationsStore(createPinia());
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
return { store, renderer };
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
/** The screen marks the board shows, fading ones included. */
function annotationGroups(store) {
return store.board?.marks ?? [];
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
const childrenWithClass = (mark, name) =>
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
function cameraLookingAt(lon, lat) {
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
@@ -184,7 +65,6 @@ function cameraLookingAt(lon, lat) {
/** Install the browser globals both renderers touch, restored after the test. */
function installBrowserGlobals(t) {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
const originalBrowserConstructors = new Map([
@@ -193,13 +73,10 @@ function installBrowserGlobals(t) {
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
@@ -244,9 +121,6 @@ function fakeViewer(lon, lat) {
};
}
function annotationGroups(svg) {
return svg.children.filter((child) => child.classList.contains('gev-anno'));
}
function naiveRingCentroid(ring) {
const totals = ring.reduce(
@@ -257,7 +131,6 @@ function naiveRingCentroid(ring) {
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
@@ -265,12 +138,9 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
for (const [name, original] of originalBrowserConstructors) {
@@ -305,7 +175,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
},
},
};
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const anno = {
id: 'anno-hybrid-fb3',
type: 'area',
@@ -319,10 +189,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
const originalCallout = before.group.querySelector('.gev-anno-callout');
const originalDot = before.group.querySelector('.gev-anno-dot');
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
const [before] = annotationGroups(store);
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
assert.equal(dataSources.length, 1);
assert.equal(dataSources[0].entities.values.length, 0);
@@ -339,18 +207,22 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
anno.footprintKind = 'area';
renderer.update(anno);
const after = findAnnotationGroup(globalThis.document);
const [after, ...others] = annotationGroups(store);
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
);
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
assert.equal(others.length, 0);
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.deepEqual(
after.children.map((child) => child.key),
['dot', 'leader', 'callout'],
'the dot, leader and callout are retained',
);
const dot = after.children.find((child) => child.key === 'dot');
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
@@ -370,7 +242,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-add',
@@ -386,20 +258,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// The world drape lands first; the screen caption then fails the way a lost
// context does, AFTER the world geometry is already on the board.
const { svg } = findAnnotationGroup(globalThis.document);
const appendChild = svg.appendChild.bind(svg);
const publish = store.publish;
let failNextInsert = true;
svg.appendChild = (child) => {
store.publish = (board) => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('screen insert failed');
}
return appendChild(child);
return publish(board);
};
assert.throws(() => renderer.add(anno), /screen insert failed/);
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
// The engine's rollback path — it must reach the partial state by id.
renderer.remove(anno);
@@ -409,7 +280,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// an orphan nothing owns.
renderer.add(anno);
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
renderer.remove(anno);
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
@@ -418,7 +289,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-release-throw',
@@ -433,35 +304,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
};
renderer.add(anno);
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(dataSources[0].entities.values.length, 2);
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
// The world route releases fine, then the screen release fails part-way
// (its fade-out timer throws) — so its SVG group is still in the document.
// (its fade-out timer throws) — so its mark is still on the board.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
// Dropping the routing entry here would make that orphan permanently
// unreachable. It must still be addressable, so a retry finishes the job.
globalThis.window.setTimeout = workingSetTimeout;
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
// Fully released now — a third call is inert.
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0);
assert.equal(annotationGroups(store).length, 0);
renderer.destroy();
});
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-world-add',
@@ -493,9 +363,8 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
assert.equal(entities.values.length, 0,
'the rollback must see the entities that landed before the throw');
const { svg } = findAnnotationGroup(globalThis.document);
renderer.add(anno);
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
renderer.destroy();
});
+311 -261
View File
@@ -5,6 +5,9 @@ import {
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
import { nextTick } from 'vue';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../ui/vueRuntime.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
@@ -27,11 +30,17 @@ function outlineUnavailableLabel(label) {
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
* anything that projects off-screen / behind the camera is hidden.
*
* The board is presented to the screen annotations store, whose view renders
* the SVG overlay; each mark's parts are plain attribute records here, and the
* view hands back the elements this measures (callout text and mark groups).
*
* Screen-renderer contract (update is used by the hybrid outline-upgrade path):
* add(anno) / update(anno) / remove(anno) / sync(map) / destroy()
*/
const SVGNS = 'http://www.w3.org/2000/svg';
// Keys each mark, so a mark re-added while its predecessor fades out never
// reuses the fading one's elements.
let marks = 0;
const PALETTE = {
primary: '#8be9ff',
amber: '#ffb547',
@@ -62,18 +71,26 @@ export function createScreenAnnotationRenderer(
{
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
store = useScreenAnnotationsStore(applicationPinia()),
} = {},
) {
injectStyles();
const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer);
// The arrowhead markers the board's defs hold, in the order first used.
const arrowMarkers = new Map();
let viewBox = null;
const scene = viewer.scene;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { anno, group, parts }
const records = new Map(); // anno.id -> { key, anno, group, parts }
const leaving = new Map(); // key -> a removed mark's record while it fades out
const byKey = new Map(); // key -> record, live or fading
const timers = new Set();
const owner = {};
let published = '';
// Only the renderer whose board the store shows may change or remove it.
const shown = () => store.board?.owner === owner;
const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3();
// Per-point ground height cache. Marks must sit on the real surface, not at
@@ -151,13 +168,13 @@ export function createScreenAnnotationRenderer(
function add(anno) {
const c = color(anno);
const group = svgEl('g', { class: 'gev-anno', opacity: '0' });
const group = { opacity: '0', display: '', entered: false };
const parts = {};
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
parts.poly = svgPart('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c,
stroke: c,
@@ -166,16 +183,14 @@ export function createScreenAnnotationRenderer(
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', {
parts.path = svgPart('path', {
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
@@ -183,22 +198,20 @@ export function createScreenAnnotationRenderer(
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path);
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
parts.label = makeCallout(
anno.outlineUnavailable
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
if (parts.label) group.appendChild(parts.label.node);
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', {
parts.poly = svgPart('polyline', {
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
@@ -206,37 +219,33 @@ export function createScreenAnnotationRenderer(
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly);
parts.dots = anno.path.map(() => {
const dot = svgEl('circle', {
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
parts.dots = anno.path.map(() =>
svgPart('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: '4',
});
group.appendChild(dot);
return dot;
});
}),
);
parts.label = makeCallout(
anno.outlineUnavailable
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
if (parts.label) group.appendChild(parts.label.node);
} else {
// pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', {
parts.ringOuter = svgPart('circle', {
class: 'gev-anno-ring gev-pulse',
fill: 'none',
stroke: c,
'stroke-width': '2',
r: '34',
});
parts.ringInner = svgEl('circle', {
parts.ringInner = svgPart('circle', {
class: 'gev-anno-ring',
fill: c,
'fill-opacity': '0.12',
@@ -245,70 +254,57 @@ export function createScreenAnnotationRenderer(
r: '18',
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.ringOuter);
group.appendChild(parts.ringInner);
}
parts.dot = svgEl('circle', {
parts.dot = svgPart('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: anno.type === 'label' ? '4' : '5',
});
group.appendChild(parts.dot);
parts.leader = svgEl('line', {
parts.leader = svgPart('line', {
class: 'gev-anno-leader',
stroke: c,
'stroke-width': '1.5',
'stroke-opacity': '0.7',
});
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
if (parts.label) group.appendChild(parts.label.node);
}
// Record the mark BEFORE it touches the live document, then unwind on any
// throw from here down (projectAll reaches into Cesium and can fail on a
// lost context). Previously a throw past the DOM insert left an ORPHANED
// <g>: the engine's rollback only deletes its own map entry, so the next
// annotate of the same geometry stacked a fresh mark on top of the corpse.
records.set(anno.id, { anno, group, parts });
// Record the mark BEFORE it reaches the board, then unwind on any throw
// from here down (projectAll reaches into Cesium and can fail on a lost
// context). Previously a throw past the insert left an ORPHANED mark: the
// engine's rollback only deletes its own map entry, so the next annotate
// of the same geometry stacked a fresh mark on top of the corpse.
const rec = { key: ++marks, anno, group, parts, drawn: false };
records.set(anno.id, rec);
byKey.set(rec.key, rec);
try {
svg.appendChild(group);
// trigger draw-on / fade-in on the next frame
requestAnimationFrame(() => group.classList.add('gev-in'));
// The draw-on effect uses a fixed stroke-dasharray of 1400 (see CSS). Any
// stroke longer than 1400px (a long arrow/route on a wide window) would keep
// a permanent dash gap — the arrowhead detaches from the truncated line (H6).
// Once the dashoffset transition finishes the stroke is meant to be fully
// drawn, so drop the dasharray entirely then and the stroke reads solid at
// any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
},
{ once: true },
);
}
publish({ force: true });
// Trigger draw-on / fade-in on the next frame, once the view has the
// mark in the document. The draw-on effect uses a fixed
// stroke-dasharray of 1400 (see CSS); the view reports the end of the
// dashoffset transition, after which the stroke drops its dasharray so
// a stroke longer than 1400px reads solid (H6).
requestAnimationFrame(() => {
group.entered = true;
publish();
});
projectAll();
} catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id);
byKey.delete(rec.key);
try {
group.remove();
publish({ force: true });
} catch {
/* never inserted */
/* the board is unreachable */
}
if (records.size === 0) heightCache.clear();
throw error;
@@ -328,12 +324,18 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
rec.parts.label.text = outlineUnavailableLabel(anno.label);
// Re-measure once the view shows the new text.
const label = rec.parts.label;
publish();
void nextTick(() => {
if (rec.parts.label !== label || !sizeCallout(label)) return;
projectAll();
publish();
});
}
if (empty) {
for (const child of Array.from(rec.group.children)) child.remove();
rec.parts = {};
rec.hidden = true;
projectAll();
@@ -346,11 +348,9 @@ export function createScreenAnnotationRenderer(
// label, whose dot/leader/callout already exist; remove the two reticle rings and
// keep every remaining node (especially the group and callout) alive.
if (anno.type === 'label' && rec.parts.dot) {
rec.parts.ringOuter?.remove();
rec.parts.ringInner?.remove();
delete rec.parts.ringOuter;
delete rec.parts.ringInner;
rec.parts.dot.setAttribute('r', String(LABEL_DOT_R));
rec.parts.dot.set.r = String(LABEL_DOT_R);
projectAll();
return;
}
@@ -466,13 +466,16 @@ export function createScreenAnnotationRenderer(
}
function projectAll() {
if (!records.size) return;
if (!records.size) {
publish();
return;
}
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
const trackedRect = trackedEntityRect();
occluder.cameraPosition = scene.camera.positionWC;
const h = scene.canvas.clientHeight || scene.canvas.height;
const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
viewBox = `0 0 ${w} ${h}`;
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale(
viewer.camera.positionCartographic?.height ?? 1000,
@@ -481,10 +484,16 @@ export function createScreenAnnotationRenderer(
for (const rec of records.values()) {
const { anno, group, parts } = rec;
if (rec.hidden) {
group.style.display = 'none';
group.display = 'none';
continue;
}
group.setAttribute('opacity', String(anno.alpha ?? 1));
// The last frame's tracked fade holds until this frame's is measured.
group.opacity = String(
(anno.alpha ?? 1) *
(rec._trackedFade !== undefined && rec._trackedFade !== 1
? rec._trackedFade
: 1),
);
if (anno.type === 'area' && parts.poly) {
const pts = [];
@@ -500,12 +509,12 @@ export function createScreenAnnotationRenderer(
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
parts.poly.set.points = pts.join(' ');
group.display = '';
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
} else {
group.style.display = 'none';
group.display = 'none';
}
} else if (anno.type === 'arrow' && parts.path) {
const a = project(anno.anchor.lon, anno.anchor.lat);
@@ -520,11 +529,8 @@ export function createScreenAnnotationRenderer(
const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / len) * bow;
parts.path.setAttribute(
'd',
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
);
group.style.display = '';
parts.path.set.d = `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;
group.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
if (parts.label)
positionCallout(
@@ -534,7 +540,7 @@ export function createScreenAnnotationRenderer(
true,
);
} else {
group.style.display = 'none';
group.display = 'none';
}
} else if (anno.type === 'route' && parts.poly) {
const projected = anno.path.map((pt) => project(pt.lon, pt.lat));
@@ -543,19 +549,19 @@ export function createScreenAnnotationRenderer(
if (p) pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
}
if (pts.length >= 2) {
parts.poly.setAttribute('points', pts.join(' '));
parts.poly.set.points = pts.join(' ');
// dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => {
const p = projected[i];
if (p) {
dot.setAttribute('cx', p.x.toFixed(1));
dot.setAttribute('cy', p.y.toFixed(1));
dot.style.display = '';
dot.set.cx = p.x.toFixed(1);
dot.set.cy = p.y.toFixed(1);
dot.display = '';
} else {
dot.style.display = 'none';
dot.display = 'none';
}
});
group.style.display = '';
group.display = '';
// label at the middle waypoint
const mid =
projected.filter(Boolean)[
@@ -564,33 +570,22 @@ export function createScreenAnnotationRenderer(
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else {
group.style.display = 'none';
group.display = 'none';
}
} else {
const p = project(anno.anchor.lon, anno.anchor.lat);
if (p) {
group.style.display = '';
group.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
if (parts.dot) {
parts.dot.setAttribute('cx', p.x.toFixed(1));
parts.dot.setAttribute('cy', p.y.toFixed(1));
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
}
if (parts.ringOuter) {
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
parts.ringOuter.setAttribute(
'r',
(RING_OUTER_R * mScale).toFixed(1),
);
}
if (parts.ringInner) {
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
parts.ringInner.setAttribute(
'r',
(RING_INNER_R * mScale).toFixed(1),
);
for (const [part, radius] of [
[parts.dot, baseDotR],
[parts.ringOuter, RING_OUTER_R],
[parts.ringInner, RING_INNER_R],
]) {
if (!part) continue;
part.set.cx = p.x.toFixed(1);
part.set.cy = p.y.toFixed(1);
part.set.r = (radius * mScale).toFixed(1);
}
if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
@@ -598,18 +593,15 @@ export function createScreenAnnotationRenderer(
const ly = p.y - 34 * mScale;
positionCallout(parts.label, lx, ly, false);
if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1));
parts.leader.setAttribute(
'y2',
(ly + parts.label.height).toFixed(1),
);
parts.leader.set.x1 = p.x.toFixed(1);
parts.leader.set.y1 = p.y.toFixed(1);
parts.leader.set.x2 = lx.toFixed(1);
parts.leader.set.y2 = (ly + parts.label.height).toFixed(1);
parts.label._leader = parts.leader; // so de-collision can re-point it
}
}
} else {
group.style.display = 'none';
group.display = 'none';
}
}
}
@@ -621,7 +613,7 @@ export function createScreenAnnotationRenderer(
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
rec.group.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
@@ -629,23 +621,44 @@ export function createScreenAnnotationRenderer(
}
if (placed.length > 1) decollideCallouts(placed);
// Tracked-entity z-order — runs AFTER all geometry + callout de-collision so each
// group's bbox reflects its final on-screen extent. Any mark whose bbox INTERSECTS
// the tracked entity's screen footprint is faded toward FULLY HIDDEN (target 0, not a
// partial dim) so the tracked plane/ship reads as genuinely ON TOP. The fade is EASED
// per frame (no abrupt pop as the entity crosses a mark) and reverses smoothly when
// it moves off. Every screen mark here is point-like (pins/labels/reticles/arrows) —
// the big area/route footprints live in the world layer — so fully hiding an
// overlapped one is safe and not jarring.
publish();
// Tracked-entity z-order — measured once the view shows this frame's
// geometry (still before the browser paints it).
const fading = [...records.values()].some(
(rec) => rec._trackedFade !== undefined && rec._trackedFade !== 1,
);
if ((trackedRect || fading) && !trackedPending) {
trackedPending = true;
void nextTick(() => {
trackedPending = false;
fadeTracked(trackedRect);
});
}
}
let trackedPending = false;
// Runs AFTER all geometry + callout de-collision are in the document so each
// group's bbox reflects its final on-screen extent. Any mark whose bbox
// INTERSECTS the tracked entity's screen footprint is faded toward FULLY
// HIDDEN (target 0, not a partial dim) so the tracked plane/ship reads as
// genuinely ON TOP. The fade is EASED per frame (no abrupt pop as the entity
// crosses a mark) and reverses smoothly when it moves off. Every screen mark
// here is point-like (pins/labels/reticles/arrows) — the big area/route
// footprints live in the world layer — so fully hiding an overlapped one is
// safe and not jarring.
function fadeTracked(trackedRect) {
if (!shown()) return;
let changed = false;
for (const rec of records.values()) {
const { anno, group } = rec;
if (group.style.display === 'none') continue;
if (group.display === 'none') continue;
const cur = rec._trackedFade ?? 1;
let overlapping = false;
if (trackedRect) {
let bb = null;
try {
bb = group.getBBox();
bb = rec.element?.getBBox() ?? null;
} catch {
bb = null;
}
@@ -666,32 +679,109 @@ export function createScreenAnnotationRenderer(
const target = overlapping ? 0 : 1;
const next = cur + (target - cur) * TRACKED_FADE_EASE;
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
// Only override when faded — otherwise leave the base alpha (set above) intact.
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) {
group.setAttribute(
'opacity',
String((anno.alpha ?? 1) * rec._trackedFade),
);
// The base alpha applies unless faded. (The CSS `transition: opacity` was
// removed from .gev-anno so this per-frame JS ease is the ONLY easing —
// previously the two fought and produced an opacity oscillation.)
const opacity = String(
(anno.alpha ?? 1) * (rec._trackedFade !== 1 ? rec._trackedFade : 1),
);
if (opacity !== group.opacity) {
group.opacity = opacity;
changed = true;
}
}
if (changed) publish();
}
/** The board as its view renders it: one entry per live or fading mark. */
function boardView() {
const view = (rec, fading) => ({
key: rec.key,
className: fading
? 'gev-anno gev-out'
: rec.group.entered
? 'gev-anno gev-in'
: 'gev-anno',
opacity: rec.group.opacity,
display: rec.group.display,
drawn: rec.drawn,
children: partViews(rec.parts),
});
return {
owner,
viewBox,
markers: [...arrowMarkers].map(([key, fill]) => ({ key, fill })),
// In the order the marks were added, fading ones where they were.
marks: [
...[...records.values()].map((rec) => view(rec, false)),
...[...leaving.values()].map((rec) => rec.view),
].sort((a, b) => a.key - b.key),
};
}
/** Present the board; an unchanged one is not sent again unless forced. */
function publish({ force = false } = {}) {
if (!shown()) return;
const board = boardView();
const signature = JSON.stringify(board, (name, value) =>
name === 'owner' ? undefined : value,
);
if (!force && signature === published) return;
published = signature;
store.publish(board);
}
/** Take (or, with null, drop) an element the view rendered for a mark. */
function attach(key, kind, element) {
const rec = byKey.get(key);
if (!rec) return;
if (kind === 'group') {
rec.element = element;
// Lay the new mark out before its next-frame `gev-in`, so the draw-on
// transition starts from the undrawn stroke.
try {
element?.getBBox();
} catch {
/* not laid out */
}
} else if (kind === 'text' && rec.parts.label) {
rec.parts.label.element = element;
// Size the card to its text once the text is in the document.
if (element && sizeCallout(rec.parts.label) && records.has(rec.anno.id))
projectAll();
}
}
/** The draw-on stroke finished: it reads solid from now on. */
function drawn(key) {
const rec = byKey.get(key);
if (!rec || rec.drawn) return;
rec.drawn = true;
publish();
}
function remove(anno) {
const rec = records.get(anno.id);
if (!rec) return;
rec.group.classList.remove('gev-in');
rec.group.classList.add('gev-out');
const node = rec.group;
window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
// The fading mark keeps the look it had; it leaves the board after 360 ms.
rec.view = { ...boardView().marks.find((mark) => mark.key === rec.key) };
rec.view.className = 'gev-anno gev-out';
leaving.set(rec.key, rec);
let timer;
try {
timer = window.setTimeout(() => {
timers.delete(timer);
leaving.delete(rec.key);
byKey.delete(rec.key);
publish();
}, 360);
} catch (error) {
leaving.delete(rec.key);
throw error;
}
if (leaving.has(rec.key)) timers.add(timer);
records.delete(anno.id);
publish();
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
}
@@ -707,87 +797,104 @@ export function createScreenAnnotationRenderer(
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
for (const timer of timers) clearTimeout(timer);
timers.clear();
if (shown()) {
store.connect(null);
store.publish(null);
}
records.clear();
leaving.clear();
byKey.clear();
heightCache.clear();
}
// Claim the board; from here on only this renderer's own board changes.
store.publish(boardView());
store.connect({
attach: (from, key, kind, element) => {
if (from === owner && shown()) attach(key, kind, element);
},
drawn: (from, key) => {
if (from === owner && shown()) drawn(key);
},
});
return { add, update, remove, sync, destroy };
}
// --- SVG helpers ------------------------------------------------------------
function buildOverlay() {
const layer = document.createElement('div');
layer.className = 'gev-screen-whiteboard';
const svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('class', 'gev-screen-whiteboard-svg');
svg.setAttribute('preserveAspectRatio', 'none');
const defs = document.createElementNS(SVGNS, 'defs');
// subtle hand-drawn wobble for strokes
defs.innerHTML = `
<filter id="gev-sketch" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.014" numOctaves="2" seed="7" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>
</filter>`;
svg.appendChild(defs);
layer.appendChild(svg);
return { layer, svg, defs };
/**
* One SVG element of a mark as plain data: the attributes it starts with, the
* ones each frame sets, and whether it is shown.
*/
function svgPart(tag, attrs) {
return { tag, attrs, set: {}, display: '' };
}
/** A mark's parts in document order, as the view renders them. */
function partViews(parts) {
const views = [];
const element = (key, part) =>
views.push({
key,
tag: part.tag,
attrs: { ...part.attrs, ...part.set },
display: part.display,
});
if (parts.poly) element('poly', parts.poly);
if (parts.path) element('path', parts.path);
parts.dots?.forEach((dot, index) => element(`dot-${index}`, dot));
if (parts.ringOuter) element('ring-outer', parts.ringOuter);
if (parts.ringInner) element('ring-inner', parts.ringInner);
if (parts.dot) element('dot', parts.dot);
if (parts.leader) element('leader', parts.leader);
const label = parts.label;
if (label)
views.push({
key: 'callout',
tag: 'callout',
text: label.text,
fill: label.fill,
transform: label.transform,
// The card is sized to its text once the text has been measured.
card: label.sized
? { width: String(label.width), height: String(label.height) }
: {},
accent: label.sized
? { height: String(label.height - 8), x: '0', y: '4' }
: {},
});
return views;
}
function makeCallout(text, c) {
if (!text) return null;
const node = document.createElementNS(SVGNS, 'g');
node.setAttribute('class', 'gev-anno-callout');
const rect = document.createElementNS(SVGNS, 'rect');
rect.setAttribute('rx', '5');
rect.setAttribute('class', 'gev-anno-card');
const t = document.createElementNS(SVGNS, 'text');
t.setAttribute('class', 'gev-anno-text');
t.setAttribute('x', '9');
t.setAttribute('y', '16');
t.textContent = text;
const accent = document.createElementNS(SVGNS, 'rect');
accent.setAttribute('class', 'gev-anno-accent');
accent.setAttribute('width', '3');
accent.setAttribute('rx', '1.5');
accent.setAttribute('fill', c);
node.appendChild(rect);
node.appendChild(accent);
node.appendChild(t);
const callout = {
node,
rect,
text: t,
accent,
return {
text,
fill: c,
element: null,
transform: null,
width: 0,
height: 26,
sized: false,
};
// size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout));
return callout;
}
/** Size a callout to its rendered text; false until the text is laid out. */
function sizeCallout(callout) {
try {
const bbox = callout.text.getBBox();
const bbox = callout.element.getBBox();
const w = Math.max(24, bbox.width + 18);
const h = Math.max(22, bbox.height + 10);
callout.rect.setAttribute('width', String(w));
callout.rect.setAttribute('height', String(h));
callout.accent.setAttribute('height', String(h - 8));
callout.accent.setAttribute('x', '0');
callout.accent.setAttribute('y', '4');
callout.width = w;
callout.height = h;
callout.sized = true;
return true;
} catch {
/* not laid out yet */
return false;
}
}
@@ -796,10 +903,7 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x;
callout._x = tx;
callout._y = y;
callout.node.setAttribute(
'transform',
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
);
callout.transform = `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`;
}
/**
@@ -817,71 +921,17 @@ function decollideCallouts(callouts) {
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) {
a._y = b._y + b.height + 5;
a.node.setAttribute(
'transform',
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
);
a.transform = `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`;
if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1));
a._leader.set.x2 = a._x.toFixed(1);
a._leader.set.y2 = a._y.toFixed(1);
}
}
}
}
}
function ensureArrowMarker(defs, key, c) {
if (defs.querySelector(`#gev-arrow-${key}`)) return;
const marker = document.createElementNS(SVGNS, 'marker');
marker.setAttribute('id', `gev-arrow-${key}`);
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '8');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '7');
marker.setAttribute('markerHeight', '7');
marker.setAttribute('orient', 'auto-start-reverse');
const path = document.createElementNS(SVGNS, 'path');
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
path.setAttribute('fill', c);
marker.appendChild(path);
defs.appendChild(marker);
}
function svgEl(tag, attrs) {
const el = document.createElementNS(SVGNS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
function injectStyles() {
if (document.getElementById('gev-screen-whiteboard-styles')) return;
const style = document.createElement('style');
style.id = 'gev-screen-whiteboard-styles';
style.textContent = `
.gev-screen-whiteboard { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
.gev-screen-whiteboard-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* No CSS opacity transition here on purpose: group opacity is driven per-frame in JS
(the 260ms fade-in via computeAlpha, and the tracked-entity z-order ease). A CSS
transition fought those per-frame writes and made the tracked-fade opacity oscillate
(flicker). Fade-OUT keeps its own transition via .gev-anno.gev-out below. */
.gev-anno-ring { filter: drop-shadow(0 0 6px currentColor); }
.gev-anno-dot { filter: drop-shadow(0 0 5px rgba(255,255,255,0.6)); }
.gev-anno-area { filter: drop-shadow(0 0 5px currentColor); }
.gev-anno-arrow { filter: drop-shadow(0 0 4px currentColor); stroke-linecap: round; }
.gev-anno-leader { stroke-dasharray: 2 3; }
.gev-anno-card { fill: rgba(8,18,28,0.78); stroke: rgba(255,255,255,0.14); stroke-width: 1; }
.gev-anno-text { fill: #eaf6ff; font: 600 13px "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.02em; }
/* draw-on: outlined shapes reveal their stroke */
.gev-draw { stroke-dasharray: 1400; stroke-dashoffset: 1400; }
.gev-anno.gev-in .gev-draw { transition: stroke-dashoffset 900ms ease-out; stroke-dashoffset: 0; }
/* opacity-only fade-in: a CSS transform here would override the SVG transform
attribute used to POSITION the callout and snap it to 0,0. */
.gev-anno-callout { opacity: 0; }
.gev-anno.gev-in .gev-anno-callout { animation: gev-pop 320ms ease-out both; }
.gev-anno.gev-out { opacity: 0 !important; transition: opacity 320ms ease; }
.gev-pulse { animation: gev-ring-pulse 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes gev-ring-pulse { 0%,100% { opacity: 0.9; stroke-width: 2; } 50% { opacity: 0.35; stroke-width: 3.5; } }
@keyframes gev-pop { from { opacity: 0; } to { opacity: 1; } }
`;
document.head.appendChild(style);
/** Record the arrowhead marker for a color, unless one is already defined. */
function ensureArrowMarker(markers, key, c) {
if (!markers.has(key)) markers.set(key, c);
}
+293 -229
View File
@@ -1,164 +1,177 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
test('outline upgrade preserves the existing SVG group identity', (t) => {
const originalDocument = globalThis.document;
/** Mount the board's view; animation frames run when `frames()` is called. */
function board(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useScreenAnnotationsStore(pinia);
const app = createApp(ScreenAnnotations).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const queued = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
app.unmount();
});
const frames = () => {
for (const callback of queued.splice(0)) callback();
};
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
return { store, frames, svg, groups };
}
function cameraOver(lon, lat) {
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
// Low enough that point marks keep their full size.
return { positionWC, directionWC, positionCartographic: { height: 500 } };
}
function sceneFor(camera, extra = {}) {
return {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
...extra,
};
}
/** Project every point to the given window position for the test's length. */
function projectTo(t, at) {
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = original;
});
}
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
const { store, frames, svg, groups } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'pin',
type: 'pin',
color: 'amber',
label: 'Capitol',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
await settle();
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
assert.ok(layer);
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
const [group] = groups();
assert.equal(group.getAttribute('class'), 'gev-anno');
assert.equal(group.getAttribute('opacity'), '1');
assert.deepEqual(
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
[
['circle', 'gev-anno-ring gev-pulse'],
['circle', 'gev-anno-ring'],
['circle', 'gev-anno-dot'],
['line', 'gev-anno-leader'],
['g', 'gev-anno-callout'],
],
);
const [outer, inner, dot, leader, callout] = group.children;
assert.equal(outer.getAttribute('stroke'), '#ffb547');
assert.equal(outer.getAttribute('cx'), '200.0');
assert.equal(outer.getAttribute('r'), '34.0');
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
assert.equal(dot.getAttribute('r'), '5.0');
assert.equal(leader.getAttribute('x1'), '200.0');
assert.equal(leader.getAttribute('x2'), '218.0');
// The card is sized to its measured text, and the leader meets its bottom.
const [card, accent, text] = callout.children;
assert.equal(text.textContent, 'Capitol');
assert.equal(text.getAttribute('x'), '9');
assert.equal(card.getAttribute('width'), '58');
assert.equal(card.getAttribute('height'), '26');
assert.equal(accent.getAttribute('height'), '18');
assert.equal(accent.getAttribute('fill'), '#ffb547');
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
assert.equal(leader.getAttribute('y2'), '292.0');
frames();
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
});
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
const { store, frames, svg, groups } = board(t);
let calls = 0;
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'arrow',
type: 'arrow',
color: 'green',
alpha: 1,
anchor: { lon: 0, lat: 0 },
to: { lon: 0.01, lat: 0 },
});
await settle();
const marker = svg().querySelector('defs > marker#gev-arrow-green');
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
const path = groups()[0].querySelector('path');
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
frames();
await settle();
const ended = new Event('transitionend', { bubbles: true });
ended.propertyName = 'stroke-dashoffset';
path.dispatchEvent(ended);
await settle();
assert.equal(path.style.strokeDasharray, 'none');
});
test('outline upgrade preserves the existing SVG group identity', async (t) => {
const { store, svg, groups } = board(t);
const camera = {
positionWC: Cesium.Cartesian3.ZERO,
directionWC: Cesium.Cartesian3.ZERO,
positionCartographic: { height: 1000 },
};
const sampledAnchors = [];
const scene = {
camera,
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: true,
clampToHeight(world) {
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
]);
return world;
},
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
});
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'anno-fb3',
type: 'area',
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
ring: null,
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
await settle();
const [before] = groups();
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
const callout = before.querySelector('.gev-anno-callout');
const dot = before.querySelector('.gev-anno-dot');
const labelProxy = Object.assign(Object.create(anno), {
type: 'label',
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
anchor: { lon: -97.5, lat: 31.2, height: 0 },
});
renderer.update(labelProxy);
const after = findAnnotationGroup(globalThis.document);
await settle();
const [after] = groups();
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
renderer.destroy();
assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg());
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
test('a callout whose text changes is sized to the new text', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
// Text measures 7px a character; everything else keeps the shared box.
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
return this.tagName.toLowerCase() === 'text'
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
: original.call(this);
};
t.after(() => {
globalThis.SVGGraphicsElement.prototype.getBBox = original;
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'upgrade',
type: 'label',
color: 'primary',
label: 'Texas',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const card = () => groups()[0].querySelector('.gev-anno-card');
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
renderer.update({ ...anno, outlineUnavailable: true });
await settle();
const text = 'Texas · Detailed outline unavailable';
assert.equal(groups()[0].querySelector('text').textContent, text);
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 0, y: 0 });
const camera = cameraOver(0, 0);
const paintRectCalls = [];
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ scene: sceneFor(camera), camera, trackedEntity: null },
{
store,
activeTrackedReadoutId: () => 'installations:test',
overlayPaintRect(sourceId, entryId) {
paintRectCalls.push({ sourceId, entryId });
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
},
},
);
t.after(() => renderer.destroy());
renderer.add({
id: 'anno-overlap',
type: 'label',
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
const { group } = findAnnotationGroup(globalThis.document);
await settle();
const [group] = groups();
assert.deepEqual(paintRectCalls.at(-1), {
sourceId: 'tracked',
entryId: 'installations:test',
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
Number(group.getAttribute('opacity')) < 1,
'final annotation bbox fades against the complete painted card rectangle',
);
});
test('a removed mark fades out, then leaves the board', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'fading',
type: 'label',
color: 'primary',
label: 'Gone soon',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const [group] = groups();
renderer.remove(anno);
// A new mark for the same place gets its own group beside the fading one.
renderer.add(anno);
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
assert.equal(groups().length, 2);
assert.ok(groups()[1] !== group);
await new Promise((resolve) => setTimeout(resolve, 400));
await settle();
assert.deepEqual(groups().length, 1);
assert.equal(group.isConnected, false);
renderer.destroy();
await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
});
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
//
// add() inserts the group and records it, then does more live-document work
// (draw-on wiring, the first projection pass). A throw in that tail used to
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
// deletes its own map entry — so re-annotating the same place stacked a second
// mark over the corpse.
// add() records the mark, then does more work (presenting it, the first
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
// record nobody owned — the engine's rollback only deletes its own map entry —
// so re-annotating the same place stacked a second mark over the corpse.
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
const { store, groups } = board(t);
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
t.after(() => renderer.destroy());
const anno = {
id: 'anno-partial-screen',
type: 'pin',
@@ -303,26 +357,36 @@ test('an add that throws after inserting its group unwinds instead of orphaning
anchor: { lon: 0, lat: 0, height: 0 },
};
// The group is in the document and recorded by the time add() reaches its
// first projection pass — so a throw there is real partial state, not a
// clean bail-out.
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
// The mark is on the board by the time add() reaches its first projection
// pass — so a throw there is real partial state, not a clean bail-out.
projectTo(t, () => {
throw new Error('projection failed');
});
assert.throws(() => renderer.add(anno), /projection failed/);
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
0,
'the half-built group must not stay in the document',
);
await settle();
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
// The id is free again: a retry draws exactly one mark, and remove() still
// owns it (a leaked record would leave a second group behind).
renderer.add(anno);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
1,
'the retry must draw one mark, not stack over an orphan',
);
renderer.destroy();
await settle();
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
});
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
const options = { store, activeTrackedReadoutId: () => null };
const earlier = createScreenAnnotationRenderer(viewer, options);
const later = createScreenAnnotationRenderer(viewer, options);
t.after(() => later.destroy());
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.destroy();
await settle();
assert.equal(groups().length, 1);
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
});
+7 -1
View File
@@ -1,3 +1,4 @@
import { mountApplicationViews } from '../ui/views/index.js';
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
@@ -6,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
* Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* The page's views render first; `omitViews` names the ones this application
* leaves out (see mountApplicationViews).
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/
export function createApplication({
@@ -14,7 +17,10 @@ export function createApplication({
createControls,
createData,
createTools,
omitViews = [],
}) {
// A host's page needs its views before any phase reads the markup.
mountApplicationViews(globalThis.document, { omit: omitViews });
const factories = {
scene: createScene,
controls: createControls,
+2
View File
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
const result = spawnSync(
process.execPath,
[
// Views are single-file components; Node needs the loader hosts use.
'--import=gods-eye-view/build/register-vue',
'--input-type=module',
'-e',
`
+29
View File
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
assert.equal(dataManager.layers.size, 0);
});
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
const previous = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
delete globalThis.GEV_EMBED_INLINE;
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const releases = [];
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: { styleManager: { attachDataManager() {} } },
catalog: createLayerCatalog([], []),
defer: (release) => releases.push(release),
});
const visibility = dataManager._visibilityTarget;
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
globalThis.GEV_EMBED_INLINE = true;
assert.equal(visibility.hidden, false);
assert.equal(visibility.visibilityState, 'visible');
for (const release of releases.reverse()) await release();
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
+1
View File
@@ -126,6 +126,7 @@ export function createApplicationCatalog({
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
+39
View File
@@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
/Mapillary source/,
);
});
test('a GNSS integrity source can set the layer evidence window', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const gnssLayer = (descriptor) => {
const sources = fixtureSources([], []);
sources['gnss-integrity'] = {
getSnapshot: async () => ({}),
...descriptor,
};
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}).get('gnss-integrity');
};
const shows = (layer, minutes) => {
assert.equal(layer.provenance.window.minutes, minutes);
const { info, legend } = layer.getRowControls();
assert.match(info, new RegExp(`visited views over ${minutes} min`));
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
};
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
shows(gnssLayer({}), 30);
for (const windowMs of [
undefined,
null,
NaN,
Infinity,
0,
-60_000,
'3600000',
])
assert.equal(
gnssLayer({ windowMs }).provenance.window.minutes,
30,
`${String(windowMs)} falls back to the default`,
);
});
+4 -1
View File
@@ -7,7 +7,8 @@ import { t } from '../i18n/index.js';
/**
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one.
* whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that does not initialize Core's locale.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
@@ -18,6 +19,7 @@ export function createApplicationControls({
placeSearch,
defer,
initialShareCode = null,
localePicker = true,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -34,6 +36,7 @@ export function createApplicationControls({
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
localePicker,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
+4 -1
View File
@@ -1,6 +1,7 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
import { panelAwareVisibility } from './embed.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -13,7 +14,9 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
// A panel's host may report it hidden while it is on screen; its layers
// keep polling (see keepPanelRendering in embed.js).
visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+22
View File
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
* inline panel always reads as visible.
*/
export function panelAwareVisibility(doc = globalThis.document) {
const hidden = () =>
(doc.hidden === true || doc.visibilityState === 'hidden') &&
!isEmbeddedInline();
return {
get hidden() {
return hidden();
},
get visibilityState() {
return hidden() ? 'hidden' : 'visible';
},
addEventListener: (type, listener) => doc.addEventListener(type, listener),
removeEventListener: (type, listener) =>
doc.removeEventListener(type, listener),
};
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
+70
View File
@@ -5,7 +5,9 @@ import {
installViews,
isEmbedded,
keepPanelRendering,
panelAwareVisibility,
} from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
@@ -144,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
// A view kept from before Snow was retired still applies, on Normal.
send(parent, {
type: 'gev:view',
id: 4,
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
@@ -159,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted[3].id, 4);
assert.equal(posted[3].ok, true);
assert.ok(
calls.some(
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
@@ -166,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
'*',
'https://host.example',
'https://host.example',
'https://host.example',
]);
remove();
});
@@ -211,6 +227,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
}
});
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
const listeners = new Set();
const doc = {
hidden: true,
visibilityState: 'hidden',
addEventListener: (_type, fn) => listeners.add(fn),
removeEventListener: (_type, fn) => listeners.delete(fn),
};
const settle = async () => {
for (let i = 0; i < 3; i++) await Promise.resolve();
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
let updates = 0;
const manager = new LayerLifecycle(
{},
{ visibilityTarget: panelAwareVisibility(doc) },
);
manager.register({
id: 'flights',
name: 'flights',
icon: '',
source: 'test',
updateInterval: 1000,
async init() {},
enable() {},
disable() {},
async update() {
updates++;
await Promise.resolve();
},
getStats: () => ({ count: 0, lastUpdate: null }),
});
globalThis.GEV_EMBED_INLINE = true;
try {
await manager.setEnabled('flights', true);
await settle();
const enabled = updates;
t.mock.timers.tick(1000);
await settle();
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'the panel keeps polling');
delete globalThis.GEV_EMBED_INLINE;
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
} finally {
delete globalThis.GEV_EMBED_INLINE;
await manager.destroyAll();
}
assert.equal(listeners.size, 0);
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
+2
View File
@@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
@@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) {
focus,
worldFocus,
render,
readouts,
},
});
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { t } from '../i18n/index.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
@@ -60,6 +60,7 @@ export async function createApplicationScene({
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken
? t('Loading Google 3D Tiles...')
+17 -1
View File
@@ -2,6 +2,8 @@ import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -53,10 +55,22 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
const drawTool = initDrawTool({
viewer,
annotations,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
});
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
@@ -66,6 +80,8 @@ export function createApplicationTools({
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
+28 -1
View File
@@ -831,10 +831,37 @@ test('orbit-toggle controller stops globe motion before toggling', async (t) =>
return true;
},
},
_orbitIndicator: { classList: { toggle() {} } },
_locationControls: { showOrbit() {} },
});
assert.equal(toggled, true);
});
test('the voice orbit switch lights and clears the ORBIT indicator', async () => {
const { LocationNavigation } = await import('./ui/locationNavigation.js');
const shown = [];
let active = false;
const nav = Object.assign(Object.create(LocationNavigation.prototype), {
services: {},
_currentTarget: {},
_currentPoi: {},
orbitController: {
get active() {
return active;
},
toggle() {
active = !active;
return active;
},
stop() {
active = false;
},
},
_locationControls: { showOrbit: (value) => shown.push(value) },
});
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(false), { ok: true, orbiting: false });
assert.deepEqual(shown, [true, false]);
});
for (const owner of ['tracking', 'cockpit', 'close-up'])
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
const { createGevActionRunner } = await import('./voice/gevActions.js');
+1 -1
View File
@@ -353,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input.blur();',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
+141 -84
View File
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
ctx.restore();
}
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
let rings = 0;
/**
* Screen-space ring and celestial direction overlay for the full-globe view.
* The overlay is presented to the celestial ring store, whose view renders it
* inside the viewer's container and hands back the two canvases this paints.
*/
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
* the keyhole helpers here stay free of Vue for the packages that use
* them.
*/
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
@@ -406,6 +414,10 @@ export class CelestialRing {
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
this._sunCanvas = null;
this._sunCtx = null;
this._moonCanvas = null;
this._moonCtx = null;
this._sunInertial = new Cesium.Cartesian3();
this._moonInertial = new Cesium.Cartesian3();
@@ -415,7 +427,7 @@ export class CelestialRing {
this._screenCenter = new Cesium.Cartesian2();
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._initView(store);
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
@@ -436,48 +448,68 @@ export class CelestialRing {
this.setEnabled(this.enabled);
}
/** Construct cached effect layers and Material Symbols celestial markers. */
_buildDOM() {
this._root = document.createElement('div');
this._root.id = 'celestial-ring-overlay';
this._root.className = 'celestial-ring-overlay';
this._root.setAttribute('aria-hidden', 'true');
this._ringOutline = document.createElement('div');
this._ringOutline.className = 'celestial-ring-outline';
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
/** Present the overlay and take the canvases its view hands back. */
_initView(store) {
const owner = {};
this._store = store;
this._owner = owner;
this._view = {
id: ++rings,
owner,
container: this.viewer.container,
enabled: this.enabled,
visible: false,
globeVisible: null,
ephemerisUpdates: null,
size: null,
outline: null,
sun: null,
moon: null,
sunMarker: null,
moonMarker: null,
};
this._viewKey = '';
store.publish(this._view);
store.connect({
attach: (from, kind, canvas) => {
if (from === owner && this._shown()) this._attach(kind, canvas);
},
});
}
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
/** Whether the store still shows this ring's overlay. */
_shown() {
return this._store.ring?.owner === this._owner;
}
this._sunMarker = document.createElement('span');
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
this._ringOutline,
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker,
/** Publish a change to the overlay, skipping one that changes nothing. */
_present(patch) {
if (!this._shown()) return;
const next = { ...this._view, ...patch };
const key = JSON.stringify(next, (name, value) =>
name === 'owner' || name === 'container' ? undefined : value,
);
this.viewer.container.appendChild(this._root);
if (key === this._viewKey) return;
this._view = next;
this._viewKey = key;
this._store.publish(next);
}
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
_attach(kind, canvas) {
const ctx =
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
// A new canvas starts blank, so the next frame repaints its layer.
if (kind === 'sun') {
this._sunCanvas = canvas;
this._sunCtx = ctx;
this._sunRenderKey = '';
} else if (kind === 'moon') {
this._moonCanvas = canvas;
this._moonCtx = ctx;
this._moonRenderKey = '';
} else return;
if (canvas && this.enabled) governorRequestRender('celestial-ring');
}
/** Enable or disable the user preference for the effect. */
@@ -491,12 +523,14 @@ export class CelestialRing {
// all. Camera motion covers every later repaint; the 60 s ephemeris
// timer requests its own. (perf wave 2 fix — owner playtest finding)
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
this._root.classList.toggle('disabled', !this.enabled);
if (!this.enabled) {
this.visible = false;
this._root.classList.remove('visible');
this._clear();
}
this._present({
enabled: this.enabled,
...(!this.enabled && { visible: false }),
});
}
/** Whether the current camera already frames the complete globe inside the keyhole. */
@@ -577,7 +611,7 @@ export class CelestialRing {
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
this._present({ outline: { ...this._view.outline, hidden: true } });
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
@@ -607,10 +641,9 @@ export class CelestialRing {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._present({ size: { width, height } });
this._renderScale = dpr;
}
@@ -665,8 +698,8 @@ export class CelestialRing {
);
}
/** Position one icon-library marker along the keyhole circumference. */
_positionMarker(marker, cx, cy, radius, angle, opacity) {
/** Where one icon-library marker sits along the keyhole circumference. */
_markerPosition(cx, cy, radius, angle, opacity) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
@@ -679,20 +712,26 @@ export class CelestialRing {
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
marker.style.opacity = String(opacity);
return {
left: cx + cos * markerRadius,
top: cy + sin * markerRadius,
opacity: String(opacity),
};
}
/** Update the cached effect geometry only when camera distance or viewport changes. */
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
const outlineKey = `${cx}:${cy}:${radius}`;
if (outlineKey !== this._outlineRenderKey) {
this._ringOutline.style.display = '';
this._ringOutline.style.left = `${cx - radius}px`;
this._ringOutline.style.top = `${cy - radius}px`;
this._ringOutline.style.width = `${radius * 2}px`;
this._ringOutline.style.height = `${radius * 2}px`;
this._present({
outline: {
hidden: false,
left: cx - radius,
top: cy - radius,
width: radius * 2,
height: radius * 2,
},
});
this._outlineRenderKey = outlineKey;
}
@@ -724,7 +763,11 @@ export class CelestialRing {
/** Per-frame camera projection with GPU-composited cached effect layers. */
_draw() {
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
// A ring the store no longer shows has handed its overlay to a later one.
if (!this.enabled || !this._shown()) return;
// Without its canvases (no view rendered them) the ring still tracks
// whether the globe is framed, so it turns itself off as before.
const painted = !!(this._sunCtx && this._moonCtx);
const now = performance.now();
if (now < this._nextDrawAt) return;
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
@@ -732,7 +775,7 @@ export class CelestialRing {
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return;
this._resize(width, height);
if (painted) this._resize(width, height);
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
@@ -740,9 +783,11 @@ export class CelestialRing {
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({
visible: nextVisible,
globeVisible: String(nextVisible),
ephemerisUpdates: String(this._ephemerisUpdateCount),
});
if (!nextVisible || !disc) {
this._clear();
this._debug = { enabled: true, visible: false, disc };
@@ -751,10 +796,14 @@ export class CelestialRing {
}
if (!wasVisible) this._ephemerisDirty = true;
if (!painted) {
this._debug = { enabled: true, visible: true, disc };
return;
}
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
@@ -779,10 +828,6 @@ export class CelestialRing {
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
this._moonCanvas.style.opacity = String(this._moonOpacity);
// Markers sit inside the illuminated band rather than straddling its outer
// stroke. Near conjunction (such as a new moon), keep the true bearings but
@@ -791,22 +836,30 @@ export class CelestialRing {
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
);
this._present({
sun: {
transform: `rotate(${this._sunAngle}rad)`,
opacity: String(this._sunOpacity),
},
moon: {
transform: `rotate(${this._moonAngle}rad)`,
opacity: String(this._moonOpacity),
},
sunMarker: this._markerPosition(
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
),
moonMarker: this._markerPosition(
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
),
});
this._debug = {
enabled: true,
visible: true,
@@ -831,7 +884,7 @@ export class CelestialRing {
: null;
}
/** Detach the render hook and remove all overlay DOM. */
/** Detach the render hook and withdraw the overlay. */
destroy() {
if (this._ephemerisTimer) {
clearInterval(this._ephemerisTimer);
@@ -839,6 +892,10 @@ export class CelestialRing {
}
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
this._root?.remove();
if (this._shown()) {
this._store.connect(null);
this._store.publish(null);
}
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
test('celestial ring is available only in Normal style', () => {
assert.equal(isCelestialRingStyleSupported('normal'), true);
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
assert.equal(isCelestialRingStyleSupported(style), false);
}
});
+251
View File
@@ -0,0 +1,251 @@
// src/celestialRingView.test.mjs — the full-globe ring renders in the
// viewer's container from what the ring owner presents, and the owner paints
// the canvases the view hands back.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
import * as Cesium from 'cesium';
import { CelestialRing } from './celestialRing.js';
// happy-dom has no 2D context; a stand-in takes every drawing call.
const context = new Proxy(
{},
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
);
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
function fakeViewer(container) {
return {
container,
camera: {
rightWC: new Cesium.Cartesian3(1, 0, 0),
upWC: new Cesium.Cartesian3(0, 1, 0),
},
scene: {
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
postRender: { addEventListener: () => () => {} },
requestRender() {},
},
};
}
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const store = useCelestialRingStore(pinia);
const app = createApp(CelestialRingView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const rings = [];
const create = (options = {}) => {
const ring = new CelestialRing(fakeViewer(container), {
enabled: false,
store,
...options,
});
rings.push(ring);
return ring;
};
t.after(() => {
for (const ring of rings) ring.destroy();
app.unmount();
});
const $ = (selector) => container.querySelector(selector);
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
return { container, store, create, $, settle };
}
test('the ring renders its overlay in the viewer container', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.equal(root.hasAttribute('data-globe-visible'), false);
assert.deepEqual(
[...root.children].map((child) => [child.tagName, child.className]),
[
['DIV', 'celestial-ring-outline'],
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
],
);
assert.equal(root.children[0].style.display, 'none');
assert.equal(root.children[3].textContent, 'light_mode');
assert.equal(root.children[4].textContent, 'dark_mode');
assert.ok(ring._sunCanvas === root.children[1]);
assert.ok(ring._moonCanvas === root.children[2]);
ring.setEnabled(true);
await settle();
assert.equal(root.className, 'celestial-ring-overlay');
ring.destroy();
await settle();
assert.equal($('#celestial-ring-overlay'), null);
assert.equal(ring._sunCanvas, null);
});
test('a drawn frame places the outline, layers and markers', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
ring._resize(400, 300);
ring._renderEffectLayers(200, 150, 120, 80);
ring._present({
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
});
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker] = root.children;
assert.equal(outline.style.display, '');
assert.equal(outline.style.left, '80px');
assert.equal(outline.style.width, '240px');
assert.equal(sun.style.width, '400px');
assert.equal(sun.style.transform, 'rotate(1rad)');
assert.equal(sun.style.opacity, '0.5');
assert.equal(moon.style.height, '300px');
assert.equal(sunMarker.style.left, '300px');
assert.equal(sunMarker.style.top, '150px');
ring.setEnabled(false);
await settle();
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(outline.style.display, 'none');
});
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
const { create, store, $, settle } = fixture(t);
const earlier = create();
await settle();
const later = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.ok(later._sunCanvas === root.children[1]);
earlier.setEnabled(true);
earlier.destroy();
await settle();
assert.ok(store.ring.owner === later._owner);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
});
// A globe framed well inside the keyhole of a 400 x 300 view.
const FRAMED = {
earthCenterX: 200,
earthCenterY: 150,
earthRadius: 40,
keyholeCenterX: 200,
keyholeCenterY: 150,
keyholeRadius: 140,
};
/** Run one rendered frame, past the frame-rate throttle. */
function frame(ring, disc) {
ring._projectedEarthDisc = () => disc;
// Fixed sun and moon directions; Cesium's own model would fetch its
// orientation data.
ring._updateEphemeris = () => {
if (ring._ephemerisDirty) {
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
ring._ephemerisDirty = false;
ring._ephemerisUpdateCount += 1;
}
return true;
};
ring._nextDrawAt = 0;
ring._draw();
}
test('a rendered frame publishes the layers and markers it painted', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
frame(ring, FRAMED);
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
const debug = ring.getDebugState();
assert.equal(debug.visible, true);
assert.equal(outline.style.display, '');
// The outline is the keyhole circle, a fixed inset inside its radius.
const diameter = parseFloat(outline.style.width);
assert.ok(diameter > 0 && diameter < 280);
assert.equal(outline.style.height, outline.style.width);
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
assert.equal(sun.style.width, '400px');
assert.equal(moon.style.height, '300px');
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
assert.equal(sun.style.opacity, String(debug.sunOpacity));
assert.equal(moon.style.opacity, String(debug.moonOpacity));
assert.ok(sunMarker.style.left.endsWith('px'));
assert.ok(moonMarker.style.top.endsWith('px'));
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
});
test('a frame without the globe framed turns the ring off', async (t) => {
const { create, $, settle } = fixture(t);
let disabled = 0;
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
await settle();
frame(ring, FRAMED);
frame(ring, null);
await settle();
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
});
test('without a rendered view the ring still tracks the globe and turns off', () => {
const pinia = createPinia();
let disabled = 0;
const ring = new CelestialRing(fakeViewer({}), {
enabled: true,
store: useCelestialRingStore(pinia),
onAutoDisable: () => (disabled += 1),
});
frame(ring, FRAMED);
assert.equal(ring.visible, true);
frame(ring, null);
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
ring.destroy();
});
test('a ring the store no longer shows stops drawing', async (t) => {
const { create, settle } = fixture(t);
let disabled = 0;
const earlier = create({
enabled: true,
onAutoDisable: () => (disabled += 1),
});
await settle();
create();
await settle();
frame(earlier, null);
assert.equal(earlier.enabled, true);
assert.equal(disabled, 0);
});
+91 -84
View File
@@ -2,7 +2,7 @@ import {
readShellSource,
shellMethod,
} from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
@@ -16,14 +16,20 @@ import {
readAircraftInfo,
_adoptTrackedEntity,
} from './ui/cockpitTrackingController.js';
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
import { cycleVisionMode } from './ui/cockpitInstruments.js';
import { createVisionState } from './ui/cockpitInstrumentState.js';
import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js';
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
import {
syncSignalLayout,
setContextCollapsed,
setSignalCollapsed,
} from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
publishBriefView,
setBriefAutoRotate,
} from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
@@ -46,7 +52,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
);
const ui = readShellSource();
@@ -149,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
cycleVisionMode.toString(),
/const modes = COCKPIT_VISION_MODES;/,
);
assert.match(
setVisionMode.toString(),
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
assert.deepEqual(
COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label),
['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'],
);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(
@@ -227,14 +232,10 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
);
});
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(
keydown,
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
);
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
@@ -250,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button',
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match(
body,
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/,
/if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
);
assert.equal(
(body.match(/this\.context\.hidden = true/g) || []).length,
(body.match(/this\.showContextCard\(false\)/g) || []).length,
1,
'the panel has exactly one hide path, and it is the no-snapshot standby case',
);
@@ -268,7 +269,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
// A culled subject holds last-known content behind the CONTACT LOST cue.
assert.match(body, /readout\.contactLost/);
assert.match(body, /CONTACT LOST/);
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
assert.match(body, /view\.state = 'lost'/);
assert.match(
body,
/if \(readout\.contactLost\) \{/,
@@ -276,14 +277,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
// PREVIOUS/NEXT must be written before any early return, so the operator can
// always step off the current contact — including a lost one.
const navIndex = body.indexOf('this.contextNext.disabled');
const navIndex = body.indexOf('view.canNext =');
const lostIndex = body.indexOf('if (readout.contactLost)');
assert.ok(
navIndex >= 0 && lostIndex > navIndex,
'nav state must be written before the CONTACT LOST return',
);
assert.equal(
(body.match(/this\.contextNext\.disabled/g) || []).length,
(body.match(/view\.canNext =/g) || []).length,
1,
'one owner for the NEXT enabled state, reached by every visible path',
);
@@ -443,7 +444,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch(
cockpitRadio[0],
/context-radio-details-btn|radio-filter|radio-tuner/,
/radio-filter|radio-tuner/,
);
assert.match(
css,
@@ -475,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
);
assert.match(
radioBindings,
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
);
assert.match(
radioBindings,
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
);
assert.match(
css,
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
@@ -534,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
);
assert.match(
syncSignalLayout.toString(),
/setAttribute\('aria-hidden', String\(hiddenSibling\)\)/,
/primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/,
);
});
@@ -550,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
1,
'Parameters must have one DOM owner',
);
// The markup places Parameters below Detection; nothing re-homes it.
assert.match(
html,
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/,
);
assert.doesNotMatch(
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
/_sliderPanel/,
);
assert.match(
ui,
@@ -703,7 +701,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
);
assert.match(
callback[1],
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
"entry must remember each panel's exact collapsed state before hiding it",
);
assert.match(
@@ -764,7 +762,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
);
assert.match(
collapse,
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
);
assert.match(
collapse,
@@ -802,35 +800,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.ok(resetStart >= 0);
assert.ok(contextRelease > resetStart);
assert.ok(satelliteRelease > contextRelease);
assert.match(
readShellElements.toString(),
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
);
const controls = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
'utf8',
);
assert.match(
ui,
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(
controls,
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
'both reset controls delegate to the same supplied reset action',
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
// Both reset buttons ask the one reset owner.
const globeActions = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
'utf8',
);
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
const cockpitView = readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8',
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
assert.match(
cockpitEnter.toString(),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/,
);
assert.match(
readRadioSource(
new URL('./ui/cockpitTrackingController.js', import.meta.url),
'utf8',
),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/,
/Object\.assign\(this\.presence, \{\s*active: true,/,
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
});
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
@@ -892,21 +884,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(radioBindings, /\n cycleRadio,\n/);
const panel = fs.readFileSync(
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
'utf8',
);
const cockpit = fs.readFileSync(
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
'utf8',
);
assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/);
assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/);
assert.match(
radioBindings,
/listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/,
cockpit,
/id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/,
);
assert.match(
radioBindings,
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
);
assert.match(
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
);
assert.match(
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
cockpit,
/id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/,
);
});
@@ -944,7 +939,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
assert.match(
signalLayout,
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
/publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/,
'Cockpit owns the utility strip anchor and republishes it every layout tick',
);
assert.match(
@@ -980,7 +975,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
);
assert.match(
signalLayout,
/cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/,
/maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/,
);
assert.match(
css,
@@ -1065,15 +1060,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.doesNotMatch(ui, /\['presets',/);
assert.match(
CockpitDisplayPortal.toString(),
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
/window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
);
// The portal publishes the slots; the Display panel teleports its groups.
assert.match(
CockpitDisplayPortal.prototype.setActive.toString(),
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
/this\.publish\(nextActive \? this\.slots : null\)/,
);
assert.match(
CockpitDisplayPortal.prototype.destroy.toString(),
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
/this\.stop\(\)[\s\S]*?this\.publish\(null\)/,
);
assert.doesNotMatch(
ui,
@@ -1095,7 +1091,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
);
assert.match(
ui,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
);
assert.match(
radioBindings,
@@ -1180,7 +1176,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
const match = html.match(
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit weather toggle markup is missing');
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
@@ -1192,7 +1188,7 @@ test('cockpit summary presents the focused item as Contact', () => {
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
assert.ok(match, 'cockpit Contact summary is missing');
assert.match(match[0], /aria-label="Contact cockpit summary"/);
assert.match(match[0], /class="cockpit-context-kicker">CONTACT</);
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
assert.match(match[0], /aria-label="Contact navigation"/);
assert.match(
match[0],
@@ -1204,9 +1200,13 @@ test('cockpit summary presents the focused item as Contact', () => {
);
assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
// The toggle renders the collapse the controller presents.
assert.match(
setContextCollapsed.toString(),
/expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
fs.readFileSync(
new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
'utf8',
),
/collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
);
});
@@ -1262,7 +1262,7 @@ test('Global Context standby describes both chooser modes', () => {
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
const match = html.match(
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit briefing cycle toggle is missing');
assert.match(match[0], /aria-label="CYCLE OFF"/);
@@ -1273,15 +1273,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
);
const update = setBriefAutoRotate.toString();
assert.ok(update, 'cockpit briefing cycle state updater is missing');
assert.match(
update,
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
);
assert.match(update, /setAttribute\('aria-label', label\)/);
assert.match(update, /\.title = help;/);
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
// The presented label is the state; the help is only the tooltip.
const views = [];
const owner = {
brief: createBriefState(),
publishBrief: (view) => views.push(view),
publishBriefView,
startBriefRotation() {},
stopBriefRotation() {},
};
setBriefAutoRotate.call(owner, true);
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
assert.equal(views.at(-1).autoRotate, true);
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
setBriefAutoRotate.call(owner, false);
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
});
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
+264 -243
View File
@@ -1,187 +1,119 @@
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
// focus while they refresh and reorder, over happy-dom with the real view.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { afterEach } from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => {
globalThis.document = previousDocument;
});
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './ui/cockpitSignals.js';
import { useCockpitStore } from './ui/stores/cockpit.js';
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
function fixture() {
const document = { activeElement: null };
let focusLosses = 0;
let focusCalls = 0;
class Node {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.className = '';
this._text = '';
this.classList = {
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
/**
* A browser blurs an element it removes or moves. happy-dom only models the
* removal, so count both: any insert or removal of a node that holds focus
* is a loss, and a moved node gives its focus up as a browser's would.
*/
function trackFocus(t) {
const counts = { losses: 0, focusCalls: 0 };
const holdsFocus = (node) =>
node.isConnected && node.contains(document.activeElement);
const insertBefore = Node.prototype.insertBefore;
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
const lost = holdsFocus(node);
const result = insertBefore.call(this, node, anchor);
if (lost) {
counts.losses += 1;
document.activeElement.blur();
}
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
if (this.contains(document.activeElement)) {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove();
this._text = '';
this.append(...nodes);
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
}
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
controller.scheduleContextLayout = () => {};
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
return result;
});
const removeChild = Node.prototype.removeChild;
t.mock.method(Node.prototype, 'removeChild', function (node) {
if (holdsFocus(node)) counts.losses += 1;
return removeChild.call(this, node);
});
const focus = HTMLElement.prototype.focus;
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
assert.ok(this.isConnected, 'a focus destination must still be connected');
counts.focusCalls += 1;
return focus.apply(this, args);
});
return counts;
}
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitBriefing).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const button = () => {
const element = document.createElement('button');
element.type = 'button';
return document.body.appendChild(element);
};
const display = button();
const radio = button();
const outside = button();
const counts = trackFocus(t);
t.after(() => app.unmount());
const selected = [];
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
// The signal presenters need only this much of the controller.
const owner = {
destroyed: false,
brief: createBriefState(),
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => store.publishSignals(rows),
publishCockpitSignals,
scheduleContextLayout() {},
services: {
formatAwarenessLabel,
militaryAwarenessLayer: {
focusTarget: (...args) => selected.push(args),
},
},
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
// The controller shows the card on entering Cockpit.
document.getElementById('cockpit-signal-stream').hidden = false;
const list = document.getElementById('cockpit-signal-list');
list.addEventListener('click', (event) =>
handleSignalClick.call(owner, event),
);
const buttons = () => [...list.querySelectorAll('button')];
// Tab visits the enabled buttons no hidden ancestor removes from view.
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
const nodes = [...document.querySelectorAll('button')].filter(
(node) => !node.disabled && !node.closest('[hidden]'),
);
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
};
const pageTab = (index) =>
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
const signalTab = () => pageTab(0);
return {
controller,
document,
owner,
list,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
pageTab,
signalTab,
store,
losses: () => counts.losses,
focusCalls: () => counts.focusCalls,
};
}
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
const render = async (f, items) => {
f.owner.signalItems = items;
publishCockpitSignals.call(f.owner);
await nextTick();
};
const refresh = async (f, distanceM) => {
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
await nextTick();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
item('a'),
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
]);
const [action, status] = f.list.children;
assert.equal(action.className, 'nearby actionable');
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
const button = action.querySelector('button.cockpit-signal-target');
assert.equal(button.type, 'button');
assert.equal(button.dataset.signalLayer, 'flights');
assert.equal(button.dataset.signalId, 'a');
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
assert.equal(
button.querySelector('.cockpit-signal-target-label').textContent,
'Flight a',
);
assert.equal(
button.querySelector('.cockpit-signal-target-chevron').textContent,
'chevron_right',
);
assert.equal(
action.querySelector('div > span').textContent,
'COMMERCIAL FLIGHT · 2 KM',
);
assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button'), null);
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
const f = fixture(t);
await refresh(f);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
assert.equal(f.buttons()[1] === target, true);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
assert.match(f.list.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture();
render(f, [item('a')]);
test('signal content and accessible name update without replacing the active target', async (t) => {
const f = fixture(t);
await render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
await render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(f.buttons()[0] === target, true);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.match(f.list.textContent, /CURRENT/);
assert.equal(f.list.children[0].className, 'track actionable');
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture();
render(
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
const f = fixture(t);
await render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
['c', 'a', 'b', 'd'],
['b', 'a', 'd', 'c'],
]) {
render(
await render(
f,
order.map((id) => item(id)),
);
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
}
assert.equal(f.focusCalls(), calls);
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
pushCockpitSignal.call(
f.owner,
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
await nextTick();
assert.equal(f.list.children.length, 3);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
assert.equal(click.defaultPrevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
await render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1);
assert.equal(f.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]'), null);
// Traversal carries on forward from the footer tab.
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(document.activeElement === f.pageTab(1), true);
while (document.activeElement !== f.display) f.tab();
await render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(
f.document.activeElement,
f.display,
document.activeElement === f.display,
true,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
assert.equal(document.activeElement === f.radio, true);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
test('a changed target cannot reuse a focused action for another flight', async (t) => {
const f = fixture(t);
await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
await render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(target === removed, false);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.click({ target, preventDefault() {} });
assert.equal(document.activeElement === f.signalTab(), true);
target.click();
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
});
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
const f = fixture();
render(f, [item('a'), item('a')]);
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
assert.notEqual(first, second);
assert.equal(first === second, false);
second.focus();
render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]);
assert.equal(f.document.activeElement, second);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
await render(f, [item('a'), item('a')]);
const again = f.buttons();
assert.equal(again[0] === first && again[1] === second, true);
assert.equal(document.activeElement === second, true);
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.match(f.list.textContent, /UNKNOWN/);
assert.equal(document.activeElement === f.signalTab(), true);
f.outside.focus();
render(f, []);
assert.equal(f.controller.signalList.children.length, 0);
render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first);
assert.equal(f.document.activeElement, f.outside);
await render(f, []);
assert.equal(f.list.children.length, 0);
await render(f, [item('a')]);
assert.equal(f.buttons()[0] === first, false);
assert.equal(document.activeElement === f.outside, true);
assert.equal(f.losses(), 0);
});
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
const f = fixture(t);
const ids = ['a', 'b', 'c', 'd', 'e'];
// A fixed pseudo-random sequence, so a failure reproduces.
let seed = 7;
const random = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const shuffle = (list) => {
const next = [...list];
for (let index = next.length - 1; index > 0; index -= 1) {
const swap = Math.floor(random() * (index + 1));
[next[index], next[swap]] = [next[swap], next[index]];
}
return next;
};
await render(
f,
ids.map((id) => item(id)),
);
for (let round = 0; round < 60; round += 1) {
const target = f.buttons()[round % ids.length];
target.focus();
const order = shuffle(ids);
await render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(document.activeElement === target, true);
}
assert.equal(f.losses(), 0);
});
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
const f = fixture(t);
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
await render(f, [item('a'), item('b')]);
f.buttons()[1].focus();
await render(f, [item('a')]);
assert.equal(document.activeElement === f.pageTab(2), true);
for (const index of [0, 1, 2]) f.pageTab(index).remove();
f.buttons()[0].focus();
await render(f, []);
assert.equal(
document.activeElement === document.getElementById('cockpit-signal-toggle'),
true,
);
});
-3
View File
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'thermal',
'anime',
'noir',
'snow',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
const COCKPIT_MODE_BY_STYLE = Object.freeze({
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
+1 -2
View File
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
'thermal',
'anime',
'noir',
'snow',
]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
});
+2 -2
View File
@@ -168,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface',
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
@@ -178,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
+68 -84
View File
@@ -1,17 +1,16 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
@@ -22,75 +21,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
assert.match(syncSource, /button\.tabIndex = 0/);
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
// The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
}
});
@@ -129,20 +65,68 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
// The Radio view never disables its Enable button; it marks it busy.
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
assert.ok(enable, 'Radio Enable button is missing');
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
// The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/);
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
// The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
'',
);
});
+8 -8
View File
@@ -2,7 +2,7 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { getLocale, t } from './i18n/index.js';
import { t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
@@ -14,19 +14,19 @@ export function configureCreditKeyboardAccess(root = document) {
const overlay = lightbox.parentElement;
// Cesium writes these two texts in English once, when it builds the credit
// display, and never rewrites them; English keeps Cesium's own text.
if (getLocale() !== 'en') {
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
}
// display, and never rewrites them; English is Cesium's own text. Called
// again after a locale change, this words them again.
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
expand.setAttribute('aria-haspopup', 'dialog');
expand.setAttribute('aria-controls', lightbox.id);
expand.setAttribute('aria-expanded', 'false');
if (!expand.hasAttribute?.('aria-expanded'))
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', t('Close data attribution'));
+13
View File
@@ -221,3 +221,16 @@ for (const [locale, link, title] of [
setLocale('en');
}
});
test('setting up again after a switch back to English restores the English texts', () => {
const f = textFixture();
setLocale('ru');
try {
configureCreditKeyboardAccess(f.root);
} finally {
setLocale('en');
}
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, 'Data attribution');
assert.equal(f.title.textContent, 'Data provided by:');
});
+9 -3
View File
@@ -480,11 +480,16 @@ export function setCyberSonarEnabled(
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
/**
* Whether the map-mode sweep should alter flight presentation. `hudActive`
* is the HUD's visibility from its owner, which a caller acting on it in the
* same turn passes; otherwise the rendered `#intel-hud` class answers.
*/
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
@@ -498,7 +503,7 @@ export function isCyberSonarActive({
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
@@ -508,12 +513,13 @@ export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode')
);
}
+64 -18
View File
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import { layerFeedState } from './manager.js';
import {
presentSelectedVessel,
subscribeSelectedVessel,
} from '../hudReadouts.js';
test('open feed with vessels is healthy (null)', () => {
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
@@ -856,15 +860,6 @@ function makeTrailSpy() {
};
}
function makeClassList(...initial) {
const classes = new Set(initial);
return {
add(value) { classes.add(value); },
remove(value) { classes.delete(value); },
contains(value) { return classes.has(value); },
};
}
function makeInteractionHandler() {
return {
click: null,
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
};
const record = makeRecord();
const trail = makeTrailSpy();
presentSelectedVessel({ text: `AIS: ${record.name}` });
let vesselReadout;
const stopReadout = subscribeSelectedVessel(
(readout) => (vesselReadout = readout),
);
const hud = {
textContent: `AIS: ${record.name}`,
classList: makeClassList('active'),
get readout() {
return vesselReadout;
},
};
globalThis.window = windowTarget;
globalThis.document = {
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
getElementById: () => null,
};
_setVesselStateForTest({
records: [record],
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
reinstalledHandlers,
interactionHandlerFactory,
cleanup() {
stopReadout();
aisLiveVesselsLayer.destroy();
if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window;
@@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Clicking away is a deliberate deselect, not the vessel aging out of the
// feed. Consumers that keep a readout on screen (the Cockpit Contact
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
@@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Empty pick is a deliberate deselect — see the note above.
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
} finally {
@@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
try {
harness.keyTarget.dispatch({ key: 'Escape' });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
} finally {
harness.cleanup();
}
@@ -1719,3 +1718,50 @@ test('vessel selection passes the opaque source reference to optional history',
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+103 -53
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
import { t, msg, onLocaleChange } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
function placeCard(card, leader, anchor) {
/** Place a media card beside its map anchor, in the lane between the panels. */
export function placeCard(card, leader, anchor) {
const margin = 18;
const gap = 42;
const width = card.offsetWidth || 392;
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef
.querySelector('#scene-panel')
?.getBoundingClientRect?.();
const rightRail = documentRef
.querySelector('#right-context-rail')
?.getBoundingClientRect?.();
const commandDock = documentRef
.querySelector('#command-dock')
?.getBoundingClientRect?.();
if (leftPanel?.width > 0)
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
if (rightRail?.width > 0)
laneRight = Math.min(
laneRight,
rightRail.left - rootBounds.left - margin,
);
// The scene panel and the context rail swap sides in right-to-left
// languages: each narrows the lane from the side it sits on.
for (const selector of ['#scene-panel', '#right-context-rail']) {
const panel = documentRef
.querySelector(selector)
?.getBoundingClientRect?.();
if (!(panel?.width > 0)) continue;
const start = panel.left - rootBounds.left;
const end = panel.right - rootBounds.left;
if (start + end < rootBounds.width)
laneLeft = Math.max(laneLeft, end + margin);
else laneRight = Math.min(laneRight, start - margin);
}
if (commandDock?.height > 0)
laneBottom = Math.min(
laneBottom,
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
let record = null;
let warmRecord = null;
let generation = 0;
// Text the cards show, kept as the calls that word it so a locale change
// words it again. A node is forgotten once it has left the page.
const worded = new Map();
function forgetDetached() {
for (const node of worded.keys())
if (!node.isConnected) worded.delete(node);
}
function word(node, property, text) {
let properties = worded.get(node);
if (!properties) worded.set(node, (properties = new Map()));
properties.set(property, text);
writeWord(node, property, text());
}
function writeWord(node, property, value) {
if (property === 'textContent' || property === 'title')
node[property] = value;
else node.setAttribute(property, value);
}
const offLocale = onLocaleChange(() => {
forgetDetached();
for (const [node, properties] of worded)
for (const [property, text] of properties)
writeWord(node, property, text());
});
const leavingRecords = new Set();
const prefersReducedMotion = () =>
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
current.youtubeSession?.destroy();
current.card?.remove?.();
leavingRecords.delete(current);
forgetDetached();
}
const position = () => {
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.();
warmRecord = null;
forgetDetached();
}
function takeWarmFacebook(key, host) {
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
word(iframe, 'title', () =>
t('Preloading {provider} media', {
provider: providerLabel(source.provider),
}),
);
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = t('LOADING X POST…');
word(current.status, 'textContent', () => t('LOADING X POST…'));
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('X POST · PROVIDER CONTROLS'),
);
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
}),
);
position();
}
}
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('FACEBOOK · PROVIDER CONTROLS'),
);
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(
String(error?.message || msg('Facebook embed unavailable')),
),
}),
);
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
word(current.status, 'textContent', () =>
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
);
};
if (session.ready) session.onReady();
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
word(card, 'aria-label', () =>
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
leader.innerHTML =
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
player.className = 'bhote-embedded-callout-player ui-scroll';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
word(title, 'textContent', () =>
bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase(),
);
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
}),
);
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = t('OPEN ORIGINAL ↗');
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
word(iframe, 'title', () =>
t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
}),
);
}
position();
},
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
word(status, 'textContent', () =>
t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
}),
);
},
});
}
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
}
function destroy() {
offLocale();
worded.clear();
hide({ immediate: true });
for (const current of [...leavingRecords]) removeRecord(current);
clearWarm();
+24 -1
View File
@@ -6,6 +6,7 @@ import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
placeCard,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
assert.deepEqual(playerCalls, []);
media.destroy();
});
test('a media card stays between the panels when right-to-left swaps their sides', () => {
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
// Arabic: the scene panel sits on the right, the context rail on the left.
const panels = {
'#scene-panel': rect(1040, 1420),
'#right-context-rail': rect(20, 340),
'#command-dock': rect(500, 940, 820, 880),
};
const card = {
offsetWidth: 392,
offsetHeight: 360,
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
classList: { contains: () => false },
style: {},
};
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
const left = parseFloat(card.style.left);
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
});
+158 -201
View File
@@ -25,7 +25,9 @@ import {
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { applicationPinia } from '../ui/vueRuntime.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -449,18 +451,6 @@ function imageryEndpointText(entry) {
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
@@ -929,185 +919,70 @@ function setElementProperty(element, property, value) {
}
function setElementAttribute(element, attribute, value) {
if (!element || element.getAttribute?.(attribute) === value) return false;
element.setAttribute(attribute, value);
if (!element || element.attributes[attribute] === value) return false;
element.attributes[attribute] = value;
return true;
}
function collectPanelReferences(panel) {
/** One panel control's state, as the panel view renders it. */
function panelRecord(initial = {}) {
return {
textContent: '',
hidden: false,
disabled: false,
value: '',
...initial,
attributes: { ...initial.attributes },
};
}
/**
* The panel's controls as the layer writes them, starting from the panel's
* markup. The panel view renders what the layer publishes from them.
*/
function createPanelRecords(event) {
return Object.freeze({
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
observedStatus: panel.querySelector('.bhote-event-status.observed'),
beforeDate: panel.querySelector('[data-role="before-date"]'),
afterDate: panel.querySelector('[data-role="after-date"]'),
time: panel.querySelector('[data-role="time"]'),
caveat: panel.querySelector('[data-role="caveat"]'),
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
reportList: panel.querySelector('.bhote-event-report-list'),
playButton: panel.querySelector('[data-action="play"]'),
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
corridorButton: panel.querySelector('[data-action="corridor"]'),
previousButton: panel.querySelector('[data-action="previous-beat"]'),
nextButton: panel.querySelector('[data-action="next-beat"]'),
openSourceButton: panel.querySelector('[data-action="open-source"]'),
progressInput: panel.querySelector('[data-role="progress"]'),
splitInput: panel.querySelector('[data-role="split"]'),
splitReadout: panel.querySelector('[data-role="split-readout"]'),
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
beatIndex: panel.querySelector('[data-role="beat-index"]'),
beatTitle: panel.querySelector('[data-role="beat-title"]'),
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
root: { sceneBeat: false },
imagerySection: panelRecord(),
observedStatus: panelRecord(),
time: panelRecord({
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
}),
caveat: panelRecord({
textContent: bhoteEventText(event.reconstruction.caveat),
}),
imageryCloudNote: panelRecord(),
playButton: panelRecord({ textContent: t('▶ PLAY') }),
playSceneButton: panelRecord({
textContent: t('▶ PLAY SCENE'),
hidden: true,
}),
cinematicButton: panelRecord({
textContent: t('◉ CINEMATIC'),
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
corridorButton: panelRecord(),
previousButton: panelRecord({
attributes: { 'aria-label': t('Previous story beat') },
}),
nextButton: panelRecord({
attributes: { 'aria-label': t('Next story beat') },
}),
openSourceButton: panelRecord(),
progressInput: panelRecord({
value: '0',
attributes: { 'aria-label': t('Schematic downstream progression') },
}),
splitInput: panelRecord({ value: '50' }),
splitReadout: panelRecord({ textContent: '50 / 50' }),
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
beatIndex: panelRecord({ textContent: '01 / 06' }),
beatTitle: panelRecord({ textContent: t('CAUSE') }),
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
</div>
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
const label = document.createElement('span');
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
}
panel
.querySelector('[data-action="close"]')
.addEventListener('click', handlers.close);
panel
.querySelector('[data-action="play"]')
.addEventListener('click', handlers.play);
panel
.querySelector('[data-action="play-scene"]')
.addEventListener('click', handlers.playScene);
panel
.querySelector('[data-action="cinematic"]')
.addEventListener('click', handlers.toggleCinematic);
panel
.querySelector('[data-action="previous-beat"]')
.addEventListener('click', handlers.previousBeat);
panel
.querySelector('[data-action="next-beat"]')
.addEventListener('click', handlers.nextBeat);
panel
.querySelector('[data-action="story-replay"]')
.addEventListener('click', handlers.replayCinematic);
panel
.querySelector('[data-action="open-source"]')
.addEventListener('click', handlers.openActiveSource);
panel
.querySelector('[data-action="geolocation-map"]')
.addEventListener('click', handlers.openGeolocationMap);
panel
.querySelector('[data-action="corridor"]')
.addEventListener('click', handlers.focusCorridor);
panel
.querySelector('[data-role="split"]')
.addEventListener('input', (inputEvent) => {
handlers.setSplit(Number(inputEvent.target.value) / 100);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('input', (inputEvent) => {
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('change', (inputEvent) => {
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
final: true,
});
});
const rightRail = document.getElementById?.('right-context-rail');
rightRail?.classList.add('bhote-event-active');
(rightRail || document.body).appendChild(panel);
return { panel, refs };
}
export function createBhoteKoshiEventLayer({
overlayHost = DEFAULT_OVERLAY_HOST,
renderHost = DEFAULT_RENDER_HOST,
@@ -1119,14 +994,22 @@ export function createBhoteKoshiEventLayer({
evidenceFrameUpdater = updateEvidenceFrame,
videoFactory = DEFAULT_VIDEO_FACTORY,
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) {
let _viewer = null;
let _dataManager = null;
let _mapStackController = null;
let _event = null;
let _enabled = false;
let _panel = null;
// The panel's controls; null while the layer is off. Their state is
// published to the panel store, which the context rail renders.
let _panelRefs = null;
let _panelOwner = null;
let _panelPublished = '';
// The dates and field reports, which stay as they were when the panel
// opened, until a locale change words them again.
let _panelFixed = null;
let _offPanelLocale = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
@@ -2723,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
beforeTitle: () => imageryEndpointText(event.imagery.before),
afterTitle: () => imageryEndpointText(event.imagery.after),
ariaLabel: () => t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
t(
'A historical reference {before} percent, B post-event {after} percent',
@@ -2756,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
_lastCinematicProgress = _progress;
}
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
function publishPanel() {
if (!_panelRefs) return;
const { root, ...controls } = _panelRefs;
const refs = Object.fromEntries(
Object.entries(controls).map(([key, record]) => [
key,
{ ...record, attributes: { ...record.attributes } },
]),
);
const signature = JSON.stringify([root.sceneBeat, refs]);
if (signature === _panelPublished) return;
_panelPublished = signature;
_panelFixed ??= {
beforeDate: imageryEndpointLabel(_event.imagery.before),
afterDate: imageryEndpointLabel(_event.imagery.after),
reports: _event.fieldReports.map((report) => ({
label: bhoteEventText(report.label),
status: bhoteEventText(report.status),
})),
};
panelStore.publish({
owner: _panelOwner,
sceneBeat: root.sceneBeat,
refs,
..._panelFixed,
});
}
/**
* Word the panel again in the current locale: fresh records, written by
* the same syncs that keep them current.
*/
function relocalizePanel() {
if (!_panelRefs || !_event) return;
_panelRefs = createPanelRecords(_event);
_panelFixed = null;
writeCinematicButton();
syncPanel();
syncPresentationMode();
}
function syncCinematicButton() {
if (writeCinematicButton()) publishPanel();
}
/** Write the cinematic button's label and state; false without a panel. */
function writeCinematicButton() {
const button = _panelRefs?.cinematicButton;
if (!button) return;
if (!button) return false;
setElementProperty(
button,
'textContent',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
return true;
}
function claimCinematicCamera() {
@@ -3029,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
);
publishPanel();
if (_sceneSeekFrame != null) return;
const schedule =
globalThis.requestAnimationFrame ||
@@ -3302,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
'disabled',
!observation?.media?.sourceUrl,
);
syncCinematicButton();
writeCinematicButton();
setElementProperty(
_panelRefs.splitInput,
'value',
@@ -3314,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`${beforePercent} / ${100 - beforePercent}`,
);
publishPanel();
}
function syncPresentationMode() {
@@ -3330,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
const evidenceAvailable =
!sceneDirected ||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
if (_panelRefs?.playSceneButton)
_panelRefs.playSceneButton.hidden = !sceneDirected;
if (_panelRefs?.imagerySection)
@@ -3364,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
}
if (_panelRefs?.openSourceButton)
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
publishPanel();
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
}
@@ -3561,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
createSplitControl(event);
const panelView = createPanel(event, {
_panelRefs = createPanelRecords(event);
_panelOwner = {};
_panelPublished = '';
_panelFixed = null;
const owner = _panelOwner;
const controls = {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
origin: 'user',
@@ -3578,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
commitProgress,
setSplit,
focusCorridor,
});
_panel = panelView.panel;
_panelRefs = panelView.refs;
openReport: (index) => {
const url = event.fieldReports[index]?.url;
if (url) openExternal(url);
},
};
// Only the panel this enable published may act.
panelStore.connect(
Object.fromEntries(
Object.entries(controls).map(([name, control]) => [
name,
(...args) => (_panelOwner === owner ? control(...args) : undefined),
]),
),
);
publishPanel();
_offPanelLocale?.();
_offPanelLocale = onLocaleChange(relocalizePanel);
syncPresentationMode();
overlayHost.setVisible(
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
@@ -3673,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
_surgePoint = null;
_lastSurgePosition = null;
_lastSurgeVisible = null;
_panel?.remove();
document
.getElementById?.('right-context-rail')
?.classList.remove('bhote-event-active');
_panel = null;
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
panelStore.connect(null);
panelStore.publish(null);
}
_offPanelLocale?.();
_offPanelLocale = null;
_panelOwner = null;
_panelRefs = null;
_panelPublished = '';
_panelFixed = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
+125 -44
View File
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import {
BHOTE_KOSHI_LAYER_ID,
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
/** The event's divider as the imagery split store holds it, or undefined. */
const eventSplit = () =>
useImagerySplitStore(applicationPinia()).splits.findLast(
(split) => split.view.id === 'bhote-koshi-split-line',
);
// The panel controls the layer presents, by the selector the panel view gives each.
const PANEL_REFS = {
'[data-role="imagery-comparison"]': 'imagerySection',
'[data-role="time"]': 'time',
'[data-role="caveat"]': 'caveat',
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
'[data-role="progress"]': 'progressInput',
'[data-role="split"]': 'splitInput',
'[data-role="timeline-label"]': 'timelineLabel',
'[data-role="beat-index"]': 'beatIndex',
'[data-role="beat-title"]': 'beatTitle',
'[data-role="beat-meta"]': 'beatMeta',
'[data-action="play"]': 'playButton',
'[data-action="play-scene"]': 'playSceneButton',
'[data-action="cinematic"]': 'cinematicButton',
'[data-action="story-replay"]': 'storyReplayButton',
'[data-action="corridor"]': 'corridorButton',
'[data-action="previous-beat"]': 'previousButton',
'[data-action="next-beat"]': 'nextButton',
'[data-action="open-source"]': 'openSourceButton',
};
// What each control calls, as the panel view wires it.
const PANEL_EVENTS = {
playButton: { click: 'play' },
playSceneButton: { click: 'playScene' },
cinematicButton: { click: 'toggleCinematic' },
storyReplayButton: { click: 'replayCinematic' },
corridorButton: { click: 'focusCorridor' },
previousButton: { click: 'previousBeat' },
nextButton: { click: 'nextBeat' },
openSourceButton: { click: 'openActiveSource' },
progressInput: {
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
},
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
};
/**
* The event panel as the layer presents it to the panel store, or undefined
* while it is off. Controls read the latest presentation, and dispatching an
* event calls the connected controls as the panel view does.
*/
function eventPanel() {
const store = useBhoteKoshiPanelStore(applicationPinia());
if (!store.panel) return undefined;
const control = (key) => {
let typed = null;
const record = () => store.panel.refs[key];
return {
get textContent() { return record().textContent; },
get hidden() { return record().hidden; },
get disabled() { return record().disabled; },
get value() { return typed ?? record().value; },
set value(value) { typed = value; },
getAttribute: (name) => record().attributes[name] ?? null,
dispatch(type) {
const value = typed ?? record().value;
typed = null;
const handler = PANEL_EVENTS[key]?.[type];
return typeof handler === 'function'
? handler(store.controls, value)
: store.controls[handler]?.();
},
};
};
const named = (name) => ({ dispatch: () => store.controls[name]() });
return {
querySelector(selector) {
if (selector === '.bhote-event-report-list') {
return {
children: store.panel.reports.map((_, index) => ({
dispatch: () => store.controls.openReport(index),
})),
};
}
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
if (selector === '[data-action="close"]') return named('close');
return control(PANEL_REFS[selector]);
},
};
}
class FakeElement {
constructor(tagName = 'div') {
this.tagName = tagName;
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { body, removedProperties } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const clearedTimeouts = [];
globalThis.window = {
setTimeout: () => 71,
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.imagery.length, 2);
assert.equal(viewer._test.dataSources.length, 1);
assert.equal(body.children.length, 2);
assert.ok(eventPanel());
assert.ok(eventSplit());
activeMap = 'operator-choice';
generation += 1;
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.deepEqual(mapCalls, ['esri-imagery']);
assert.equal(viewer._test.removedImagery.length, 2);
assert.equal(viewer._test.removedDataSources.length, 1);
assert.ok(body.children.every((element) => element.removed));
assert.equal(eventPanel(), undefined);
assert.equal(eventSplit(), undefined);
assert.equal(viewer.scene.splitPosition, 0.37);
assert.deepEqual(clearedTimeouts, [71]);
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
try {
await layer.enable(viewer, { origin: 'scene' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
panel.querySelector('[data-action="play"]').dispatch('click');
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const { body } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const timers = [];
const openedUrls = [];
const cancelledFrames = [];
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.deepEqual(timers, []);
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.ok(panel);
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
assert.ok(splitLine);
assert.equal(splitLine.hidden, true);
assert.ok(eventSplit());
assert.equal(eventSplit().view.hidden, true);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
assert.match(panel.innerHTML, /data-action="open-source"/);
const fullStory = panel.querySelector('[data-action="story-replay"]');
assert.equal(fullStory.textContent, '↺ FULL STORY');
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
beatId: 'timure-cluster',
sceneControls: { imageryComparison: true },
}, { origin: 'scene' });
assert.equal(splitLine.hidden, false);
assert.equal(eventSplit().view.hidden, false);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
const splitHandle = splitLine.children.find(
(element) => element.className === 'bhote-koshi-split-handle',
);
assert.ok(splitHandle);
assert.equal(splitHandle.textContent, '↔');
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
splitHandle.dispatch('pointerdown', {
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
assert.equal(eventSplit().view.valueNow, 62);
const splitHandle = eventSplit().controls;
splitHandle.pointerdown({
button: 0,
clientX: 250,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointermove', {
splitHandle.pointermove({
clientX: 750,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointerup', { pointerId: 4 });
splitHandle.pointerup({ pointerId: 4 });
assert.equal(viewer.scene.splitPosition, 0.75);
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
splitHandle.dispatch('keydown', {
assert.equal(eventSplit().view.valueNow, 75);
splitHandle.keydown({
key: 'ArrowLeft',
shiftKey: true,
preventDefault() {},
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
split: 0.62,
sceneControls: {},
}, { origin: 'scene' });
assert.equal(splitLine.hidden, true);
assert.equal(eventSplit().view.hidden, true);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
assert.equal(viewer.scene.splitPosition, 0.62);
assert.equal(overlayVisibility.at(-1).visible, false);
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(layer.getCinematicState().active, false);
assert.equal(viewer._test.cameraLooks.length, 0);
assert.deepEqual(mapCalls, []);
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
assert.ok(eventPanel());
assert.equal(viewer.scene.splitPosition, 0.62);
for (const beatId of [
'debris-dammed-lake',
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
videos[0].dispatch('loadeddata');
assert.equal(layer.getCinematicState().mediaReady, true);
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
assert.ok(video.pauseCalls >= 1);
assert.ok(video.cancelledFrameHandles.length >= 1);
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
assert.ok(eventPanel());
await layer.disable();
} finally {
globalThis.document = originalDocument;
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
const panel = eventPanel();
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
const entries = publications[0].entries;
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
}
});
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const cinematic = panel.querySelector('[data-action="cinematic"]');
const progress = panel.querySelector('[data-role="progress"]');
cinematic.dispatch('click');
progress.value = '500';
progress.dispatch('input');
const queryCount = panel._queryCount;
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
sum + element._writeCount
), 0);
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
const published = panelStore.panel;
const cameraLookCount = viewer._test.cameraLooks.length;
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
assert.equal(
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
writeCount,
'unchanged frames do not rewrite panel state',
);
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
assert.equal(
viewer._test.cameraLooks.length,
cameraLookCount,
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
assert.equal(layer.getCinematicState().active, true);
assert.equal(layer.getPlaybackState().playing, true);
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
animationCallbacks[0](91001);
assert.equal(layer.getPlaybackState().progress, 1);
+5 -2
View File
@@ -12,7 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
import { t, msg, getLocale, isolate } from '../i18n/index.js';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -67,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
// Isolated: the card draws left to right after its "11 · " counter, so in
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
// DANDAGAUN») must keep its own right-to-left order.
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
}
const INCIDENT = Object.freeze({
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
+212
View File
@@ -0,0 +1,212 @@
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
// Koshi reconstruction panel the event layer presents and sends its clicks
// and slider moves back.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import ContextView from '../ui/views/ContextView.vue';
const record = (initial = {}) => ({
textContent: '',
hidden: false,
disabled: false,
value: '',
attributes: {},
...initial,
});
function presentation(patch = {}) {
return {
owner: {},
sceneBeat: false,
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
afterDate: { name: 'Post-event', date: '' },
reports: [
{ label: 'Report A', status: 'Verified' },
{ label: 'Report B', status: 'Unverified' },
],
refs: {
imagerySection: record(),
observedStatus: record(),
time: record({ textContent: '~0:00 / 2:00' }),
caveat: record({ textContent: 'Schematic only.' }),
imageryCloudNote: record(),
playButton: record({ textContent: '▶ PLAY' }),
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
cinematicButton: record({
textContent: '◉ CINEMATIC',
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
corridorButton: record(),
previousButton: record({
disabled: true,
attributes: { 'aria-label': 'Previous story beat' },
}),
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
openSourceButton: record(),
progressInput: record({
value: '0',
attributes: { 'aria-label': 'Schematic downstream progression' },
}),
splitInput: record({ value: '50' }),
splitReadout: record({ textContent: '50 / 50' }),
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
beatIndex: record({ textContent: '01 / 06' }),
beatTitle: record({ textContent: 'CAUSE' }),
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
},
...patch,
};
}
function fixture(t) {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(ContextView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useBhoteKoshiPanelStore(pinia);
const calls = [];
store.connect(
new Proxy(
{},
{
get:
(_, name) =>
(...args) =>
calls.push([name, ...args]),
},
),
);
const rail = document.getElementById('right-context-rail');
const $ = (selector) => rail.querySelector(selector);
return { store, calls, rail, $ };
}
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
const { store, rail, $ } = fixture(t);
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
store.publish(presentation());
await nextTick();
const panel = $('#bhote-koshi-event-panel');
assert.equal(panel.tagName, 'ASIDE');
assert.ok(rail.lastElementChild === panel);
assert.ok(rail.classList.contains('ui-scroll'));
assert.ok(rail.classList.contains('bhote-event-active'));
assert.equal(
panel.getAttribute('aria-label'),
'Bhote Koshi flood reconstruction controls',
);
assert.match(
panel.textContent,
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
);
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
assert.equal(
$('[data-role="before-date"]').textContent,
'Historical reference · 2021-05-01',
);
assert.equal(
$('[data-role="before-date"] .bhote-event-date').textContent,
'2021-05-01',
);
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
assert.equal($('[data-action="play-scene"]').hidden, true);
assert.ok($('[data-action="open-source"]'));
assert.equal($('[data-action="witness"]'), null);
assert.doesNotMatch(panel.innerHTML, /witness/i);
assert.equal($('[data-action="previous-beat"]').disabled, true);
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
assert.equal(
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
'false',
);
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
assert.equal($('[data-role="progress"]').value, '0');
assert.equal($('[data-role="split"]').value, '50');
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
assert.deepEqual(
[...$('.bhote-event-report-list').children].map(
(button) => button.textContent,
),
['Report AVerified', 'Report BUnverified'],
);
store.publish(null);
await nextTick();
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
assert.ok(rail.classList.contains('ui-scroll'));
});
test('a later presentation updates the controls in place', async (t) => {
const { store, $ } = fixture(t);
store.publish(presentation());
await nextTick();
const play = $('[data-action="play"]');
const next = presentation({ sceneBeat: true });
next.refs.playButton = record({
textContent: '▶ PLAY SHOT',
disabled: true,
attributes: { 'aria-label': 'Play Global context' },
});
next.refs.progressInput = record({ value: '250', hidden: true });
next.refs.imagerySection = record({ hidden: true });
store.publish(next);
await nextTick();
assert.ok($('[data-action="play"]') === play);
assert.equal(play.textContent, '▶ PLAY SHOT');
assert.equal(play.disabled, true);
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
assert.ok(
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
);
assert.equal($('[data-role="progress"]').value, '250');
assert.equal($('[data-role="progress"]').hidden, true);
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
});
test('buttons, sliders and reports call the connected controls', async (t) => {
const { store, calls, $ } = fixture(t);
store.publish(presentation());
await nextTick();
for (const action of [
'close',
'play',
'cinematic',
'story-replay',
'open-source',
'corridor',
'next-beat',
'geolocation-map',
])
$(`[data-action="${action}"]`).click();
$('.bhote-event-report-list').children[1].click();
const progress = $('[data-role="progress"]');
progress.value = '500';
progress.dispatchEvent(new Event('input'));
progress.dispatchEvent(new Event('change'));
const split = $('[data-role="split"]');
split.value = '25';
split.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
['close'],
['play'],
['toggleCinematic'],
['replayCinematic'],
['openActiveSource'],
['focusCorridor'],
['nextBeat'],
['openGeolocationMap'],
['openReport', 1],
['previewProgress', 0.5],
['commitProgress', 0.5, { final: true }],
['setSplit', 0.25],
]);
});
+2
View File
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
export const processGeometryBatch = layer.processGeometryBatch;
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
export const refreshCoverageStyles = layer.refreshCoverageStyles;
export const refreshHorizonCulling = layer.refreshHorizonCulling;
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
-26
View File
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
.replace(/\(\s+/g, '(');
}
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as Cesium from 'cesium';
import cctvLayer, {
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
@@ -83,16 +80,6 @@ import {
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'ui',
'cctvPresentation.js',
),
'utf8',
);
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
@@ -1302,19 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
const renderer = CCTV_PRESENTATION_SOURCE.match(
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
);
assert.ok(renderer, '_renderCctvState is missing');
assert.match(
renderer[0],
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+226
View File
@@ -0,0 +1,226 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
a22WebcamToSource,
loadA22SourcesFromOpenDataHub,
} from '../../server/providers/cctv/sources.js';
import {
A22_DEFAULT_GROUND_ELEVATION_M,
A22_IMAGE_ORIGIN,
DEFAULT_A22_CCTV_URL,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { staticFrameRefreshMs } from './cctvLod.js';
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
const item = (
patch = {},
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
) => ({
Id: `A22_${km}`,
Active: true,
Source: 'a22',
Shortname: 'ADS Sciliar',
Webcamname: { it: 'ADS Sciliar' },
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
Mapping: { a22: { km } },
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
LicenseInfo: {
ClosedData: false,
LicenseHolder: 'https://www.autobrennero.it/',
},
...patch,
});
test('an A22 webcam maps to a source on the pinned operator frame', () => {
const source = a22WebcamToSource(item());
assert.equal(source.id, 'it-a22-km68');
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
assert.equal(source.city, 'A22 Brennero');
assert.equal(source.cityId, 'a22');
assert.equal(source.provider, 'Autostrada del Brennero');
assert.equal(source.lat, 46.53591);
assert.equal(source.lon, 11.49772);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'a22-opendatahub');
assert.equal(source.code, 'ADS SCILIAR');
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
assert.equal(source.snapshotUrl, source.url);
});
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
const brenner = a22WebcamToSource(
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
);
const ponte = a22WebcamToSource(
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
);
assert.ok(brenner.groundElevationM > 1300);
assert.ok(ponte.groundElevationM < 50);
// A kilometre the table does not know falls back to the corridor prior.
const unknown = a22WebcamToSource(
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
);
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
});
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(a22WebcamToSource(item({ Active: false })), null);
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
),
null,
);
// The frame must be exactly the camera's own kilometre image.
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
),
null,
);
assert.equal(
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
null,
);
// Milan: plausible, but off the Brenner–Modena corridor.
assert.equal(
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
null,
);
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
assert.equal(a22WebcamToSource(null), null);
});
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
TotalResults: 3,
Items: [
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
item(),
item(),
item(
{ Shortname: 'Brennero' },
{ km: '1', lat: 46.99682, lon: 11.50218 },
),
],
});
});
const cameras = await loadA22SourcesFromOpenDataHub();
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
assert.deepEqual(
cameras.map((c) => c.id),
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(
async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://evil.test/' },
}),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_A22_CCTV_URL) {
return Response.json({ Items: [item()] });
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_A22_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the A22 lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
['it-a22-km68'],
);
});
});
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22'),
[],
);
});
});
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/WebCamImg/km77.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
const source = a22WebcamToSource(item());
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
});
+3
View File
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
// matches the other highway packs' 3-minute cadence.
'servei català de trànsit': 3 * 60 * 1000,
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
// tracks the minute); 3 min matches the other highway packs.
'autostrada del brennero': 3 * 60 * 1000,
});
/**
+251
View File
@@ -0,0 +1,251 @@
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
// near the screen show, a zoomed-out view keeps one camera per world-anchored
// cell, and the layer's UI state never rebuilds every camera for a listener
// that does not read the list.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import cctvLayer, {
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
refreshHorizonCulling,
} from './cctv.js';
const WIDTH = 800;
const HEIGHT = 600;
/**
* Records at the given [lon, lat] degrees near the origin, each drawn at the
* screen point `screenAt` gives it (default: the middle of the screen).
*/
function recordsAt(
lonLats,
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
) {
const screenByPosition = new Map();
const records = lonLats.map(([lon, lat], index) => {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
screenByPosition.set(position, screenAt(index));
return {
camera: {
id: `cam-${index}`,
name: `Camera ${index}`,
city: 'Test',
lat,
lon,
headingDeg: 0,
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
},
position,
billboard: { position, show: true },
coverageEntities: [],
projection: null,
};
});
return { records, screenByPosition };
}
function viewerAt(heightM, screenByPosition, shiftX = 0) {
return {
isDestroyed: () => false,
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
frustum: { fovy: Math.PI / 3 },
},
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
},
},
};
}
function shownIds(records) {
return records
.filter((record) => record.billboard.show)
.map((record) => record.camera.id)
.sort();
}
// Three cameras within ~110 m, and one ~22 km away.
const CLUSTER = [
[0, 0],
[0.001, 0],
[0.0005, 0.0005],
[0.2, 0],
];
test('zoomed out, cameras sharing a ground cell show one icon', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
assert.equal(
refreshHorizonCulling(),
true,
'the first pass changes the view',
);
const shown = shownIds(records);
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
});
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
x: 100 + index * 7,
y: 300,
}));
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
refreshHorizonCulling();
const before = shownIds(records);
const list = _viewRecordIdsForTest();
for (const shiftX of [3, 11, 20, 37]) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition, shiftX),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
}
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
assert.deepEqual(_viewRecordIdsForTest(), list);
});
test('a small zoom within one cell size keeps the same winners', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
const shownAt = (heightM) => {
_setCctvCoverageStateForTest({
viewer: viewerAt(heightM, screenByPosition),
records,
});
refreshHorizonCulling();
return shownIds(records);
};
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
// these heights fall in the same step, so the same cameras win.
assert.deepEqual(shownAt(20_500), shownAt(20_000));
});
test('a healthy camera wins its cell over an unknown one', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
// Whichever camera the id order would pick, the healthy one must win.
for (const id of ['cam-0', 'cam-1']) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
healthById: new Map([[id, { status: 'ok' }]]),
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), [id]);
}
});
test('the active camera always shows, even in a crowded cell', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
for (const active of records) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
activeCameraId: active.camera.id,
});
refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
}
});
test('close up, every camera near the screen shows and far-off ones hide', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) =>
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
});
test('a disabled layer skips the view pass', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
enabled: false,
});
assert.equal(refreshHorizonCulling(), false);
assert.deepEqual(_viewRecordIdsForTest(), []);
});
test('UI state builds the full camera list only when it is read', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
let built = 0;
for (const record of records) {
const camera = record.camera;
record.camera = new Proxy(camera, {
get(target, key) {
if (key === 'calibration') built += 1;
return target[key];
},
});
}
const first = cctvLayer.getUIState();
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
assert.deepEqual(
first.cameraIndex.map((camera) => camera.id),
['cam-0', 'cam-1'],
);
assert.equal(
cctvLayer.getUIState().cameraIndex,
first.cameraIndex,
'the index keeps its identity while the view does',
);
assert.equal(first.cameras.length, 2);
assert.ok(built > 0, 'reading `cameras` builds it');
assert.equal(first.cameras, first.cameras, 'and builds it once');
});
test('the camera picker lists the view, with the active camera first', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
activeCameraId: 'cam-1',
});
refreshHorizonCulling();
const state = cctvLayer.getUIState();
assert.deepEqual(
state.cameraIndex.map((camera) => camera.id),
['cam-1', 'cam-0', 'cam-2'],
'the off-screen active camera leads, then what is in view',
);
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
});
+7
View File
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'a22-cctv',
html:
'Motorway webcams (A22 Brennero–Modena): images © ' +
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
},
{
key: 'gbfs',
html:
+2 -2
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -19,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0);
+29
View File
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
setLocale('ja');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDurationText(39), '39秒');
assert.equal(routeDurationText(3900), '1時間5分');
assert.equal(routeDurationText(7200), '2時間');
});
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo',
],
[
'ja',
'ランプを斜め左に進んでI-95に入る',
'出口を急角度で右に出る',
'左車線を通行',
],
[
'zh-CN',
'向左前方驶入匝道,进入 I-95',
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
);
}
});
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
t.after(() => setLocale('en'));
const title = (id) =>
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
setLocale('de');
assert.equal(
title('mode-foot'),
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
);
assert.equal(
title('mode-bike'),
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
);
});
+25
View File
@@ -14,6 +14,7 @@ import {
FIRMS_OVERLAY_SOURCE_ID,
} from './firmsLabels.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
const NOW = Date.UTC(2026, 6, 17, 4, 0);
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
assert.equal(card.accent, accentForSeverity('orange'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
const title = (count) =>
buildCellCard(
{
cell: { count, maxFrp: 10, newestAcqMs: 0 },
position: { x: 0, y: 0, z: 0 },
},
NOW,
).title;
const previous = getLocale();
try {
assert.equal(title(1), '1 FIRE');
setLocale('ar');
assert.deepEqual(
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
['حريقان (2)', '3 حرائق', '14 حريقًا'],
);
setLocale('ru');
assert.equal(title(14), '14 ПОЖАРОВ');
} finally {
setLocale(previous);
}
});
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
// ground floor is warm — and the cached per-fire position must re-anchor when
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
+231 -2
View File
@@ -1,7 +1,19 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
import {
describeFetchError,
fetchFirms,
fetchIpv4,
isAddressSelectionFailure,
} from '../../server/providers/firms.js';
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
const start = config.indexOf(' async function refreshUpstream(key) {');
@@ -17,8 +29,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
// Inject only its upstream, clock and filter dependencies; keep its aggregation
// and source-status code intact, including failures while consuming records.
function createRefresh(fetchSource, filter = filterTrailing24h) {
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
}
test('FIRMS retains large sources in order and filters expired rows', async () => {
@@ -78,3 +90,220 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
assert.deepEqual(result.fires, []);
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
});
// #68: Node's network-family auto-selection gives each address a short attempt
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
// reports only `fetch failed`. This is the shape Node 24 throws for it.
function addressSelectionFailure() {
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
return new TypeError('fetch failed', { cause });
}
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
function wrappedAddressSelectionFailure() {
const inner = addressSelectionFailure().cause;
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
assert.equal(described,
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
assert.ok(!described.includes('SECRET'));
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
});
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
const retried = [];
await fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw shape(); },
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
warn() {},
});
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
}
const signal = new AbortController().signal;
const ok = new Response('csv');
const retried = [];
const warnings = [];
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
warn: (message) => warnings.push(message),
});
assert.equal(response, ok);
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
assert.ok(!warnings[0].includes('SECRET'));
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async () => { throw failure; },
warn() {},
}), (err) => err === failure, 'a failed retry reports its own error');
});
test('FIRMS does not retry anything but an address-selection failure', async () => {
const deps = (fetchImpl) => ({
fetchImpl,
fetchIpv4Impl: async () => assert.fail('must not retry'),
warn: () => assert.fail('must not warn'),
});
const serverError = new Response('down', { status: 503 });
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
for (const thrown of [
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
new TypeError('fetch failed', { cause: new AggregateError([]) }),
new TypeError('fetch failed'),
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
]) {
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
}
const controller = new AbortController();
controller.abort();
const aborted = addressSelectionFailure();
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
});
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
// same port and record which one the retry reaches; the TLS handshake then
// fails against a plain socket, which is fine — only the connection matters.
const reached = [];
const listen = (host, port) => new Promise((resolve, reject) => {
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
server.once('error', reject).listen(port, host, () => resolve(server));
});
const v4 = await listen('127.0.0.1', 0);
t.after(() => v4.close());
const { port } = v4.address();
let v6;
try {
v6 = await listen('::1', port);
} catch {
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
return;
}
t.after(() => v6.close());
const order = await dns.lookup('localhost', { all: true });
if (order[0]?.family !== 6) {
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
return;
}
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
assert.deepEqual(reached, ['127.0.0.1']);
});
// Answer the IPv4 retry with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of crashing the test runner.
function mockIpv4Status(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'text/csv' },
}),
),
);
return request;
});
return crashed;
}
for (const status of [204, 205, 304]) {
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
await assert.rejects(
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
});
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port refuses both loopback addresses; resolve
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
} catch {
t.skip('no IPv6 loopback on this host');
return;
}
const { port } = probe.address();
await new Promise((resolve) => probe.close(resolve));
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
});
// #68 only stays fixed while both upstream calls go through fetchFirms; the
// refresh harness injects fetchSource, so guard the call sites directly.
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
const calls = [];
const response = new Response('latitude,longitude\n');
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
() => [],
1024,
AbortSignal,
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
);
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'fetchFirms');
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
+15
View File
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
return _focusEvidenceNowMs;
}
/**
* True when a sprite holds no dimming state, or has settled at full emphasis
* with nothing pending. Advancing such a sprite without a screen position (a
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
* skip it.
* @param {object} sprite
* @returns {boolean}
*/
export function spriteFocusAtRest(sprite) {
const state = _spriteStates.get(sprite);
return (
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
);
}
/** Forget state when a primitive is removed from its owning collection. */
export function forgetSpriteFocus(sprite) {
if (sprite) _spriteStates.delete(sprite);
+9 -8
View File
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
* it in the active language. Numbers stay numbers in `values`: the UI formats
* them in that language's numerals.
*/
export class GeoImportError extends Error {
constructor(message, template = message, values = {}) {
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
if (over === 'links')
return new GeoImportError(
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
{ limit: MAX_KML_LINKS },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
{ limit: MAX_IMPORT_POSITIONS, features },
);
return new GeoImportError(
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
{ limit: MAX_IMPORT_POSITIONS },
);
}
@@ -1466,8 +1467,8 @@ export async function loadGeoFile(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
count: entityCount,
limit: MAX_IMPORT_ENTITIES,
},
);
}
+14 -5
View File
@@ -1,6 +1,6 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -734,18 +734,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity',
);
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
);
assert.match(
html,
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
);
assert.match(
ui,
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
/this\._models3dPressedShown = this\._models3dEnabled;/,
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
);
const panel = await readFile(
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
'utf8',
);
assert.match(
panel,
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
);
assert.match(
html,
-3
View File
@@ -28,9 +28,6 @@ export class DataLayerManager extends LayerLifecycle {
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
_syncToggleButton(button, layer) {
return this._presentation.panel._syncToggleButton(button, layer);
}
get _layerPanel() {
return this._presentation._panel;
}
+8 -824
View File
@@ -12,6 +12,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager, layerFeedState } from './manager.js';
import { presentLayerToggle } from '../ui/layerPanel.js';
import {
contextSnapshotLayerIds,
shouldCaptureContextSession,
@@ -90,68 +91,6 @@ test('adopts settled visibility without re-running lifecycle work', async () =>
assert.equal(manager.adoptLayerVisibility('flights', false, { origin: 'user' }), false);
});
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async () => {
const originalDocument = globalThis.document;
const makeElement = () => {
const element = {
children: [],
className: '',
dataset: {},
textContent: '',
disabled: false,
attributes: {},
classList: {
toggle() {},
},
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
removeEventListener() {},
setAttribute(name, value) { this.attributes[name] = String(value); },
querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) {
const id = selector.slice(16, -2);
return this.children.find((child) => child.dataset.layerId === id) || null;
}
const className = selector.startsWith('.') ? selector.slice(1) : '';
const visit = (node) => {
if (String(node.className).split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = visit(child);
if (found) return found;
}
return null;
};
return visit(this);
},
set innerHTML(value) { if (value === '') this.children = []; },
get innerHTML() { return ''; },
};
return element;
};
globalThis.document = { createElement: makeElement };
const mgr = new DataLayerManager({});
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
const coordinator = makeSlowLayer('military-awareness', { updateInterval: -1 });
coordinator.module.showInTogglePanel = false;
mgr.register(ordinary.module);
mgr.register(coordinator.module);
const container = makeElement();
try {
mgr.buildTogglePanel(container);
assert.ok(container.querySelector('[data-layer-id="flights"]'));
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
assert.equal(await mgr.setEnabled('military-awareness', true), true);
mgr._refreshTogglePanel();
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
assert.equal(mgr.getAll().find(({ id }) => id === 'military-awareness').enabled, true);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('clearSelectedLayers includes hidden coordinators and preserves newer dependency restoration', async () => {
const mgr = new DataLayerManager({});
const order = [];
@@ -1861,17 +1800,6 @@ test('layer metadata names degraded state instead of presenting an ordinary age'
test('uncertain lifecycle state overrides ordinary feed status without disabling reconciliation', () => {
const mgr = new DataLayerManager({});
const classes = new Map();
const attributes = new Map();
const button = {
classList: {
toggle(name, active) { classes.set(name, Boolean(active)); },
},
dataset: {},
disabled: false,
textContent: '',
setAttribute(name, value) { attributes.set(name, String(value)); },
};
const layer = {
name: 'Radio',
source: 'Radio Browser',
@@ -1881,14 +1809,14 @@ test('uncertain lifecycle state overrides ordinary feed status without disabling
stats: { count: 750, lastUpdate: Date.now() },
};
mgr._syncToggleButton(button, layer);
const toggle = presentLayerToggle(layer);
assert.equal(button.textContent, 'UNCERTAIN');
assert.equal(button.dataset.feedState, 'uncertain');
assert.equal(button.disabled, false, 'the lifecycle toggle remains available to reconcile authority');
assert.equal(attributes.get('aria-label'), 'Radio: UNCERTAIN');
assert.equal(classes.get('lifecycle-uncertain'), true);
assert.equal(classes.get('feed-nominal'), false);
assert.equal(toggle.text, 'UNCERTAIN');
assert.equal(toggle.feedState, 'uncertain');
assert.equal(toggle.ariaDisabled, false, 'the lifecycle toggle remains available to reconcile authority');
assert.equal(toggle.ariaLabel, 'Radio: UNCERTAIN');
assert.equal(toggle.classes['lifecycle-uncertain'], true);
assert.equal(toggle.classes['feed-nominal'], false);
assert.equal(
mgr._buildMetaText(layer),
'UNCERTAIN · Radio Browser · lifecycle state requires reconciliation',
@@ -2729,549 +2657,6 @@ test('destroy settles an explicit refresh waiting behind invalidated periodic wo
assert.equal(await destroy, true);
});
// ── Per-layer row controls (chips + color legend) ───────────────────────────
// The satellites layer is the first consumer: a DENSE catalog chip and a class
// legend rendered under its row. The manager owns the DOM and the param write;
// the layer only declares what it wants, so the chip can never disagree with
// the layer's real state.
/** DOM double rich enough for the row-controls render path. */
function makeControlElement() {
const element = {
children: [],
className: '',
dataset: {},
style: {},
attributes: {},
listeners: {},
textContent: '',
hidden: false,
disabled: false,
title: '',
type: '',
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
if (siblings) this.parent.children = siblings.filter((n) => n !== this);
// Browsers blur a node the moment it leaves the document. Modelling that
// is the whole point: it is exactly what in-place chip reconciliation
// exists to avoid, so a regression to rebuild-everything must fail here.
if (globalThis.document?.activeElement === this) globalThis.document.activeElement = null;
},
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
const className = selector.slice(1);
return String(this.className).split(/\s+/).includes(className) ? this : null;
},
querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) {
const id = selector.slice(16, -2);
return this.children.find((child) => child.dataset.layerId === id) || null;
}
const className = selector.startsWith('.') ? selector.slice(1) : '';
const visit = (node) => {
if (String(node.className).split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = visit(child);
if (found) return found;
}
return null;
};
return visit(this);
},
set innerHTML(value) { if (value === '') this.children = []; },
get innerHTML() { return ''; },
};
return element;
}
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => { releaseEnable = resolve; });
},
disable() {
calls.disable += 1;
return new Promise((resolve) => { releaseDisable = resolve; });
},
update() {},
destroy() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
});
const container = makeControlElement();
const waitFor = async (predicate, label) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
assert.fail(`timed out waiting for ${label}`);
};
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="focus-layer"]');
const button = row.querySelector('.data-toggle-btn');
button.focus();
const enablingClick = button.listeners.click();
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
await button.listeners.click();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await enablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), true);
const disablingClick = button.listeners.click();
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
await button.listeners.click();
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await disablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), false);
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
const originalDocument = globalThis.document;
const originalWarn = console.warn;
globalThis.document = { createElement: makeControlElement, activeElement: null };
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => { rejectEnable = reject; });
},
disable() { return true; },
update() {},
destroy() {},
getStats() { return { count: 0, lastUpdate: null }; },
});
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const button = container
.querySelector('[data-layer-id="focus-failure-layer"]')
.querySelector('.data-toggle-btn');
button.focus();
const failedClick = button.listeners.click();
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
await Promise.resolve();
}
assert.equal(typeof rejectEnable, 'function');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await failedClick;
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
await button.listeners.click();
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assert.equal(globalThis.document.activeElement, button);
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
/** Collect every node in a rendered subtree carrying `className`. */
function collectByClass(node, className) {
const found = [];
const visit = (current) => {
if (String(current.className).split(/\s+/).includes(className)) found.push(current);
for (const child of current.children || []) visit(child);
};
visit(node);
return found;
}
/** A layer that declares a two-state chip plus a legend, like satellites. */
function makeRowControlLayer() {
let mode = 'core';
return {
get mode() { return mode; },
module: {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() { return { count: 3, lastUpdate: Date.now() }; },
setParams(params) { if (params.catalog) mode = params.catalog; },
getParams() { return { catalog: mode }; },
getRowControls() {
const dense = mode === 'dense';
return {
chips: [{
id: 'catalog',
label: 'DENSE',
active: dense,
title: 'toggle the dense catalog',
params: { catalog: dense ? 'core' : 'dense' },
}],
legend: [
{ klass: 'nav', label: 'NAV', color: '#4fd8ff', blurb: 'GNSS', count: 2 },
{ klass: 'geo', label: 'GEO', color: '#c89bff', blurb: 'belt', count: 5 },
],
};
},
},
};
}
test('a layer that declares row controls renders its chips and color legend', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
assert.ok(controls, 'the row gained a controls block');
// Disabled layers stay quiet — no chip, no legend.
assert.equal(controls.hidden, true);
assert.equal(await mgr.setEnabled('satellites', true), true);
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false);
const chips = collectByClass(controls, 'data-toggle-chip');
assert.equal(chips.length, 1);
assert.equal(chips[0].textContent, 'DENSE');
assert.equal(chips[0].dataset.chipId, 'catalog');
assert.equal(chips[0].attributes['aria-pressed'], 'false');
assert.equal(chips[0].title, 'toggle the dense catalog');
const swatches = collectByClass(controls, 'data-toggle-legend-swatch');
assert.deepEqual(swatches.map((s) => s.style.background), ['#4fd8ff', '#c89bff'],
'each legend swatch is painted the exact class color');
const items = collectByClass(controls, 'data-toggle-legend-item');
assert.deepEqual(items.map((i) => i.title), ['GNSS', 'belt']);
assert.equal(items.length, 2);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('clicking a row chip applies the params it declared and re-renders', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
// setLayerParams refreshes the panel, so the chip already reflects the flip.
const afterOn = collectByClass(controls, 'data-toggle-chip')[0];
assert.equal(afterOn.attributes['aria-pressed'], 'true');
assert.equal(afterOn.className.includes('active'), true);
controls.listeners.click({ target: afterOn });
assert.equal(layer.mode, 'core', 'the chip toggles back rather than latching');
assert.equal(collectByClass(controls, 'data-toggle-chip')[0].attributes['aria-pressed'], 'false');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const warn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
const legendItem = collectByClass(controls, 'data-toggle-legend-item')[0];
controls.listeners.click({ target: legendItem });
assert.equal(layer.mode, 'core', 'the legend is not a control');
controls.listeners.click({ target: { closest: () => null } });
assert.equal(layer.mode, 'core');
layer.module.getRowControls = () => { throw new Error('boom'); };
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true, 'a throwing layer collapses to an empty block');
assert.ok(container.querySelector('[data-layer-id="satellites"]'), 'the row itself survives');
} finally {
console.warn = warn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('keyboard focus on a chip survives the refresh its own click triggers', async () => {
// _syncRowControls runs on EVERY panel refresh, including the one the chip's
// own click triggers. Rebuilding the button would blur it every time, so a
// keyboard user loses their place on activation.
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
chip.focus();
assert.equal(globalThis.document.activeElement, chip, 'the chip starts focused');
controls.listeners.click({ target: chip });
assert.equal(globalThis.document.activeElement, chip,
'activating the chip does not blur it');
assert.equal(collectByClass(controls, 'data-toggle-chip')[0], chip,
'the SAME button node is reused across the click-driven refresh');
mgr._refreshTogglePanel();
mgr._refreshTogglePanel();
assert.equal(globalThis.document.activeElement, chip,
'repeated refreshes never steal focus');
// Legend entries hold no focus, so they may be replaced — never duplicated.
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 2);
// ...and a chip that genuinely goes away still releases focus.
layer.module.getRowControls = () => ({ chips: [], legend: [] });
mgr._refreshTogglePanel();
assert.equal(globalThis.document.activeElement, null);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
let settled = false;
let writes = 0;
const module = {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
setParams() { writes += 1; },
getRowControls() {
return {
chips: [{
id: 'catalog',
label: settled ? 'DENSE' : 'DENSE ···',
active: settled,
busy: !settled,
disabled: !settled,
state: settled ? 'active' : 'loading',
title: 'x',
params: { catalog: 'core' },
}],
legend: [],
};
},
setRowControlsListener(fn) { module._listener = fn; },
};
mgr.register(module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
assert.equal(typeof module._listener, 'function', 'the manager installed its listener');
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
assert.equal(chip.textContent, 'DENSE ···');
assert.equal(chip.disabled, true);
assert.equal(chip.attributes['aria-busy'], 'true');
assert.equal(chip.attributes['aria-pressed'], 'false', 'busy is never reported as active');
assert.equal(chip.className.includes('chip-loading'), true);
controls.listeners.click({ target: chip });
assert.equal(writes, 0, 'a disabled chip is inert');
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
assert.equal(chip.attributes['aria-busy'], 'false');
controls.listeners.click({ target: chip });
assert.equal(writes, 1, 'the settled chip writes again');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a layer that surrenders its row controls hides the block entirely', async () => {
// Space Missions borrows the satellite layer with showPoints:false; the row
// must not advertise a legend for an empty sky or offer a chip whose write
// the dependency owner will silently revert.
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
let surrendered = false;
const layer = makeRowControlLayer();
const inner = layer.module.getRowControls;
layer.module.getRowControls = () => (surrendered ? { chips: [], legend: [] } : inner());
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
assert.equal(controls.hidden, false);
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
surrendered = true;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true);
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 0,
'the chip is removed, not merely hidden behind a style');
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 0);
surrendered = false;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false, 'the row returns when the owner releases it');
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('layer metadata shows a guidance prompt without reporting it as a fault', () => {
const mgr = new DataLayerManager({});
const text = mgr._buildMetaText({
@@ -3283,207 +2668,6 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
});
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
manager.register(layer.module);
try {
const first = makeControlElement();
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const retainedClick = oldButton.listeners.click;
const second = makeControlElement();
manager.buildTogglePanel(second);
assert.equal(oldButton.listeners.click, undefined);
await retainedClick();
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
const currentButton = second.querySelector('.data-toggle-btn');
assert.equal(typeof currentButton.listeners.click, 'function');
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(currentButton.listeners.click, undefined);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0, blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const row = container.querySelector('[data-layer-id="satellites"]');
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(calls, 1);
blocked = true;
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
+933
View File
@@ -0,0 +1,933 @@
// src/data/managerPanel.test.mjs — the manager's layer rows, rendered into a
// real DOM: toggles keep keyboard focus through transitions, row chips write
// the params they declare, and legends, info lines and lists follow the layer.
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js';
import { assertSameNode } from '../testSupport/domAssert.mjs';
/** Build a mock layer whose init/update resolve on the next microtask. */
function makeSlowLayer(id, { updateInterval = 1000 } = {}) {
const calls = { enable: 0, disable: 0, update: 0, init: 0 };
return {
calls,
module: {
id,
name: id,
icon: '',
source: 'test',
updateInterval,
async init() {
calls.init++;
await Promise.resolve();
},
enable() {
calls.enable++;
},
disable() {
calls.disable++;
},
async update() {
calls.update++;
await Promise.resolve();
},
getStats() {
return { count: 0, lastUpdate: null };
},
},
};
}
/** A layer that declares a two-state chip plus a legend, like satellites. */
function makeRowControlLayer() {
let mode = 'core';
return {
get mode() {
return mode;
},
module: {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() {
return { count: 3, lastUpdate: Date.now() };
},
setParams(params) {
if (params.catalog) mode = params.catalog;
},
getParams() {
return { catalog: mode };
},
getRowControls() {
const dense = mode === 'dense';
return {
chips: [
{
id: 'catalog',
label: 'DENSE',
active: dense,
title: 'toggle the dense catalog',
params: { catalog: dense ? 'core' : 'dense' },
},
],
legend: [
{
klass: 'nav',
label: 'NAV',
color: '#4fd8ff',
blurb: 'GNSS',
count: 2,
},
{
klass: 'geo',
label: 'GEO',
color: '#c89bff',
blurb: 'belt',
count: 5,
},
],
};
},
},
};
}
function panelHost(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.id = 'data-toggles';
t.after(() => document.body.replaceChildren());
return container;
}
const row = (container, id) =>
container.querySelector(`[data-layer-id="${id}"]`);
const chipsIn = (node) => [...node.querySelectorAll('.data-toggle-chip')];
const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitFor(predicate, label) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (predicate()) return;
await macrotask();
}
assert.fail(`timed out waiting for ${label}`);
}
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
const coordinator = makeSlowLayer('military-awareness', {
updateInterval: -1,
});
coordinator.module.showInTogglePanel = false;
mgr.register(ordinary.module);
mgr.register(coordinator.module);
try {
mgr.buildTogglePanel(container);
assert.ok(row(container, 'flights'));
assert.ok(!row(container, 'military-awareness'));
assert.equal(await mgr.setEnabled('military-awareness', true), true);
mgr._refreshTogglePanel();
assert.ok(!row(container, 'military-awareness'));
assert.equal(
mgr.getAll().find(({ id }) => id === 'military-awareness').enabled,
true,
);
} finally {
await mgr.destroyAll();
}
});
test('keyboard focus survives Data Layer enabling and disabling transitions', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => {
releaseEnable = resolve;
});
},
disable() {
calls.disable += 1;
return new Promise((resolve) => {
releaseDisable = resolve;
});
},
update() {},
destroy() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
});
try {
mgr.buildTogglePanel(container);
const button = row(container, 'focus-layer').querySelector(
'.data-toggle-btn',
);
button.focus();
button.click();
await waitFor(
() => typeof releaseEnable === 'function',
'enable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
button.click();
await macrotask();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await waitFor(
() =>
mgr.isEnabled('focus-layer') &&
button.getAttribute('aria-busy') === 'false',
'enabled',
);
assertSameNode(document.activeElement, button);
assertSameNode(
row(container, 'focus-layer').querySelector('.data-toggle-btn'),
button,
'the SAME button node is reused across the transition',
);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
button.click();
await waitFor(
() => typeof releaseDisable === 'function',
'disable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(
button.disabled,
false,
'DISABLING never uses native disabled',
);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
button.click();
await macrotask();
assert.equal(
calls.disable,
1,
'repeat activation is inert while disabling',
);
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await waitFor(
() =>
!mgr.isEnabled('focus-layer') &&
button.getAttribute('aria-busy') === 'false',
'disabled',
);
assertSameNode(document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async (t) => {
const container = panelHost(t);
const originalWarn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => {
rejectEnable = reject;
});
},
disable() {
return true;
},
update() {},
destroy() {},
getStats() {
return { count: 0, lastUpdate: null };
},
});
try {
mgr.buildTogglePanel(container);
const button = row(container, 'focus-failure-layer').querySelector(
'.data-toggle-btn',
);
button.focus();
button.click();
await waitFor(
() => typeof rejectEnable === 'function',
'enable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await waitFor(
() => button.getAttribute('aria-busy') === 'false',
'failure settlement',
);
assertSameNode(
document.activeElement,
button,
'failure settlement preserves focus',
);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
button.click();
await waitFor(() => mgr.isEnabled('focus-failure-layer'), 'retry');
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assertSameNode(document.activeElement, button);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
}
});
test('a layer that declares row controls renders its chips and color legend', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
assert.ok(controls, 'the row gained a controls block');
// Disabled layers stay quiet — no chip, no legend.
assert.equal(controls.hidden, true);
assert.equal(await mgr.setEnabled('satellites', true), true);
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false);
const chips = chipsIn(controls);
assert.equal(chips.length, 1);
assert.equal(chips[0].textContent, 'DENSE');
assert.equal(chips[0].dataset.chipId, 'catalog');
assert.equal(chips[0].getAttribute('aria-pressed'), 'false');
assert.equal(chips[0].title, 'toggle the dense catalog');
const swatches = [
...controls.querySelectorAll('.data-toggle-legend-swatch'),
];
assert.deepEqual(
swatches.map((s) => s.style.background),
['#4fd8ff', '#c89bff'],
'each legend swatch is painted the exact class color',
);
const items = [...controls.querySelectorAll('.data-toggle-legend-item')];
assert.deepEqual(
items.map((i) => i.title),
['GNSS', 'belt'],
);
assert.deepEqual(
items.map((i) => i.textContent),
['NAV 2', 'GEO 5'],
);
} finally {
await mgr.destroyAll();
}
});
test('clicking a row chip applies the params it declared and re-renders', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
chip.click();
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
// setLayerParams refreshes the panel, so the chip already reflects the flip.
assert.equal(chip.getAttribute('aria-pressed'), 'true');
assert.equal(chip.classList.contains('active'), true);
chip.click();
assert.equal(
layer.mode,
'core',
'the chip toggles back rather than latching',
);
assert.equal(chip.getAttribute('aria-pressed'), 'false');
} finally {
await mgr.destroyAll();
}
});
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async (t) => {
const container = panelHost(t);
const warn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
controls.querySelector('.data-toggle-legend-item').click();
assert.equal(layer.mode, 'core', 'the legend is not a control');
controls.click();
assert.equal(layer.mode, 'core');
layer.module.getRowControls = () => {
throw new Error('boom');
};
mgr._refreshTogglePanel();
assert.equal(
controls.hidden,
true,
'a throwing layer collapses to an empty block',
);
assert.ok(row(container, 'satellites'), 'the row itself survives');
} finally {
console.warn = warn;
await mgr.destroyAll();
}
});
test('keyboard focus on a chip survives the refresh its own click triggers', async (t) => {
// The panel repaints on EVERY refresh, including the one the chip's own
// click triggers. Rebuilding the button would blur it every time, so a
// keyboard user loses their place on activation.
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
chip.focus();
assertSameNode(document.activeElement, chip, 'the chip starts focused');
chip.click();
assertSameNode(
document.activeElement,
chip,
'activating the chip does not blur it',
);
assertSameNode(
chipsIn(controls)[0],
chip,
'the SAME button node is reused across the click-driven refresh',
);
mgr._refreshTogglePanel();
mgr._refreshTogglePanel();
assertSameNode(
document.activeElement,
chip,
'repeated refreshes never steal focus',
);
assert.equal(
controls.querySelectorAll('.data-toggle-legend-item').length,
2,
);
// ...and a chip that genuinely goes away is removed from the row.
layer.module.getRowControls = () => ({ chips: [], legend: [] });
mgr._refreshTogglePanel();
assert.equal(chip.isConnected, false);
} finally {
await mgr.destroyAll();
}
});
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
let settled = false;
let writes = 0;
const module = {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
setParams() {
writes += 1;
},
getRowControls() {
return {
chips: [
{
id: 'catalog',
label: settled ? 'DENSE' : 'DENSE ···',
active: settled,
busy: !settled,
disabled: !settled,
state: settled ? 'active' : 'loading',
title: 'x',
params: { catalog: 'core' },
},
],
legend: [],
};
},
setRowControlsListener(fn) {
module._listener = fn;
},
};
mgr.register(module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
assert.equal(
typeof module._listener,
'function',
'the manager installed its listener',
);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
assert.equal(chip.textContent, 'DENSE ···');
assert.equal(chip.disabled, true);
assert.equal(chip.getAttribute('aria-busy'), 'true');
assert.equal(
chip.getAttribute('aria-pressed'),
'false',
'busy is never reported as active',
);
assert.equal(chip.classList.contains('chip-loading'), true);
chip.click();
assert.equal(writes, 0, 'a disabled chip is inert');
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.getAttribute('aria-pressed'), 'true');
assert.equal(chip.getAttribute('aria-busy'), 'false');
chip.click();
assert.equal(writes, 1, 'the settled chip writes again');
} finally {
await mgr.destroyAll();
}
});
test('a layer that surrenders its row controls hides the block entirely', async (t) => {
// Space Missions borrows the satellite layer with showPoints:false; the row
// must not advertise a legend for an empty sky or offer a chip whose write
// the dependency owner will silently revert.
const container = panelHost(t);
const mgr = new DataLayerManager({});
let surrendered = false;
const layer = makeRowControlLayer();
const inner = layer.module.getRowControls;
layer.module.getRowControls = () =>
surrendered ? { chips: [], legend: [] } : inner();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
assert.equal(controls.hidden, false);
assert.equal(chipsIn(controls).length, 1);
surrendered = true;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true);
assert.equal(
chipsIn(controls).length,
0,
'the chip is removed, not merely hidden behind a style',
);
assert.equal(
controls.querySelectorAll('.data-toggle-legend-item').length,
0,
);
surrendered = false;
mgr._refreshTogglePanel();
assert.equal(
controls.hidden,
false,
'the row returns when the owner releases it',
);
assert.equal(chipsIn(controls).length, 1);
} finally {
await mgr.destroyAll();
}
});
test('panel remount releases old listeners and destruction revokes retained controls', async (t) => {
panelHost(t);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => {
rowListener = listener;
};
manager.register(layer.module);
try {
const first = document.body.appendChild(document.createElement('div'));
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const second = document.body.appendChild(document.createElement('div'));
manager.buildTogglePanel(second);
assert.equal(first.children.length, 0, 'the old container is emptied');
oldButton.click();
await macrotask();
assert.equal(
layer.calls.enable,
0,
'a revoked generation cannot issue actions',
);
const currentButton = second.querySelector('.data-toggle-btn');
assert.ok(currentButton);
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(second.children.length, 0, 'destruction removes the rows');
currentButton.click();
await macrotask();
assert.equal(
layer.calls.enable,
0,
'a retained control cannot act after destruction',
);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
}
});
test('a retained chip from a remounted or destroyed panel writes nothing', async (t) => {
panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
const first = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(first);
assert.equal(await mgr.setEnabled('satellites', true), true);
const oldChip = chipsIn(first)[0];
const second = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(second);
oldChip.click();
assert.equal(
layer.mode,
'core',
'a released generation cannot write params',
);
const chip = chipsIn(second)[0];
mgr._layerPanel.destroy();
chip.click();
assert.equal(layer.mode, 'core', 'a destroyed panel cannot write params');
} finally {
await mgr.destroyAll();
}
});
test('a toggle that settles after a remount repaints the new row idle', async (t) => {
panelHost(t);
const mgr = new DataLayerManager({});
let releaseEnable;
mgr.register({
id: 'remount-layer',
name: 'Remount layer',
icon: '',
source: 'Local fixture',
updateInterval: -1,
init() {},
enable() {
return new Promise((resolve) => {
releaseEnable = resolve;
});
},
disable() {},
update() {},
destroy() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
});
try {
const first = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(first);
first.querySelector('.data-toggle-btn').click();
await waitFor(
() => typeof releaseEnable === 'function',
'enable transition',
);
const second = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(second);
releaseEnable();
await waitFor(() => mgr.isEnabled('remount-layer'), 'enabled');
await macrotask();
const button = second.querySelector('.data-toggle-btn');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.textContent, 'ON');
} finally {
releaseEnable?.();
await mgr.destroyAll();
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0,
blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({
chips: [
{
id: 'find',
label: 'SHOW NEAREST',
disabled: blocked,
onClick: () => calls++,
},
],
});
layer.module.setParams = () => {
throw new Error('navigation must not write persistent params');
};
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const layerRow = row(container, 'satellites');
assert.equal(layerRow.querySelector('.data-count').textContent, '3 nearby');
const chip = chipsIn(layerRow)[0];
chip.click();
assert.equal(calls, 1);
blocked = true;
chip.click();
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
assert.equal(chip.isConnected, false, 'the off row drops its chips');
mgr._layerPanel._selectChip('satellites', 'find');
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
const container = panelHost(t);
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame =
scheduler === 'animation frame'
? (callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
}
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => {
if (listener) notify = listener;
};
manager.register(layer.module);
try {
manager.buildTogglePanel(container);
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => {
refreshes++;
refresh();
};
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
await macrotask();
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else await macrotask();
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
await macrotask();
assert.equal(
refreshes,
2,
'retained notifications after destroy are inert',
);
} finally {
await manager.destroyAll();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async (t) => {
const container = panelHost(t);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const block = container.querySelector('.data-toggle-controls');
const chip = block.children[0];
const info = block.children[2];
assert.equal(info.className, 'data-toggle-controls-info');
assert.equal(info.textContent, 'Forecast');
assert.equal(info.title, 'Source time');
chip.focus();
for (const change of [
{ label: 'High' },
{ color: '#ff0000' },
{ count: 2 },
{ blurb: 'High values' },
]) {
controls = structuredClone(controls);
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assertSameNode(block.children[2], info);
assertSameNode(document.activeElement, chip);
}
const legend = block.children[1];
assert.equal(legend.title, 'High values');
assert.equal(legend.textContent, 'High 2');
assert.equal(legend.firstElementChild.style.background, '#ff0000');
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(
[...block.children].map((node) =>
[...node.classList].filter((name) => !name.startsWith('ui-')).join(' '),
),
[
'data-toggle-chip chip-idle',
'data-toggle-chip chip-idle',
'data-toggle-legend-item',
'data-toggle-controls-info',
],
);
assertSameNode(block.children[0], chip);
assertSameNode(block.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(block.hidden, true);
assert.equal(info.hidden, true);
assert.equal(block.children.length, 1);
assertSameNode(block.children[0], info);
controls = {
info: 'Restored',
legend: [{ label: 'New', color: '#ffffff' }],
};
manager._refreshTogglePanel();
assertSameNode(block.lastElementChild, info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
}
});
+2 -3
View File
@@ -25,12 +25,11 @@ export const collectAircraftProximityWindow =
layer.collectAircraftProximityWindow;
export const _getAwarenessNavigationStateForTest =
layer._getAwarenessNavigationStateForTest;
export const _getAwarenessPanelRendersForTest =
layer._getAwarenessPanelRendersForTest;
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
export const historySubjectSnapshot = layer.historySubjectSnapshot;
export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
export const awarenessPanelControlKey = layer.awarenessPanelControlKey;
export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus;
export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus;
export const awarenessResultsAreLive = layer.awarenessResultsAreLive;
export const awarenessNeedsContinuousRender =
layer.awarenessNeedsContinuousRender;
+66 -193
View File
@@ -1,26 +1,25 @@
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import militaryAwarenessLayer, {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
awarenessClearMatchesSubject,
awarenessNeedsContinuousRender,
awarenessRefreshDecision,
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
'utf8',
);
const awarenessPanelSource = readFileSync(
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
'utf8',
);
const AWARENESS_DEPENDENCIES = [
'flights',
@@ -98,27 +101,12 @@ function installAwarenessRuntime({
const listeners = new Map();
let nextTimer = 1;
let preRenderListener = null;
const createdElements = [];
class FakeElement {
constructor() {
this.classList = { contains: () => false };
this.style = { setProperty() {} };
this._hidden = false;
// renderResults() writes `hidden = false` on EVERY call, ahead of its
// markup diff, so counting these writes observes "was the panel
// repaint path entered?" independently of whether the markup changed.
this.hiddenWrites = 0;
this.innerHTML = '';
}
get hidden() {
return this._hidden;
}
set hidden(value) {
this._hidden = value;
this.hiddenWrites += 1;
this.hidden = false;
}
setAttribute() {}
@@ -213,9 +201,7 @@ function installAwarenessRuntime({
return null;
},
createElement() {
const element = new FakeElement();
createdElements.push(element);
return element;
return new FakeElement();
},
addEventListener() {},
removeEventListener() {},
@@ -229,6 +215,10 @@ function installAwarenessRuntime({
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
const panelViews = [];
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
panelViews.push(view),
);
militaryAwarenessLayer.enable();
return {
@@ -248,15 +238,16 @@ function installAwarenessRuntime({
);
return taken;
},
/** The standalone Context panel element renderResults() paints into. */
/** The Contacts panel as the layer publishes it to the view. */
panel() {
return (
createdElements.find(
(element) => element.id === 'military-awareness-panel',
) || null
);
return {
view: panelViews.at(-1) ?? null,
publishes: panelViews.length,
renders: _getAwarenessPanelRendersForTest(),
};
},
restore() {
unsubscribePanel();
militaryAwarenessLayer.destroy();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
@@ -264,144 +255,24 @@ function installAwarenessRuntime({
};
}
function awarenessControl(dataset, { disabled = false } = {}) {
return {
dataset,
disabled,
focusCalls: 0,
closest(selector) {
if (selector === '[data-awareness-focus-continuation]') {
return this.isContinuation ? this : null;
}
return selector.includes('button') && !this.isContinuation ? this : null;
},
matches(selector) {
return (
selector === '[data-awareness-focus-continuation]' &&
this.isContinuation
);
},
focus(options) {
this.focusCalls += 1;
this.focusOptions = options;
},
};
/** The count a published Contacts panel shows for one cohort row. */
function panelCount(runtime, label) {
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
}
function awarenessPanel(controls) {
const continuation = awarenessControl({});
continuation.isContinuation = true;
return {
controls,
continuation,
contains(element) {
return controls.includes(element) || element === continuation;
},
querySelectorAll() {
return this.controls;
},
querySelector(selector) {
return selector === '[data-awareness-focus-continuation]'
? continuation
: null;
},
};
}
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
const previous = awarenessControl({ awarenessAction: 'previous' });
const focus = awarenessControl({ awarenessAction: 'focus' });
const next = awarenessControl({ awarenessAction: 'next' });
const alpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const bravo = awarenessControl({
awarenessLayer: 'military',
awarenessId: 'def456',
});
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
assert.equal(awarenessPanelControlKey(next), 'action:next');
const retainedAlpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const retained = awarenessPanel([
previous,
focus,
next,
bravo,
retainedAlpha,
]);
assert.equal(
restoreAwarenessPanelFocus(retained, alphaSnapshot),
retainedAlpha,
);
assert.equal(
retainedAlpha.focusCalls,
1,
'identity wins even when live distance reorders rows',
);
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
const forward = awarenessControl({
awarenessLayer: 'ais-live-vessels',
awarenessId: '789',
});
const departed = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(departed, alphaSnapshot),
departed.continuation,
);
assert.equal(
forward.focusCalls,
0,
'automatic paging must not transfer focus to another contact',
);
assert.equal(departed.continuation.focusCalls, 1);
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
assert.equal(
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
noRemainingRow.continuation,
);
assert.equal(
noRemainingRow.continuation.focusCalls,
1,
'the end sentinel continues Tab beyond the list',
);
const continuationSnapshot = captureAwarenessPanelFocus(
departed,
departed.continuation,
);
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
repaintedAgain.continuation,
);
assert.equal(
repaintedAgain.continuation.focusCalls,
1,
'later live repaints keep focus on the continuation point until the user tabs onward',
);
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
assert.match(
awarenessPanelSource,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
);
assert.doesNotMatch(
awarenessPanelSource,
/<span[^>]*data-awareness-focus-continuation/,
);
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
militaryAwarenessSource,
/'Open-source mapped\/observed context\./,
);
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
@@ -599,9 +470,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
'loading is not an observed empty cohort',
);
assert.match(installations?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
assert.equal(
panelCount(runtime, 'Mapped installations'),
'?',
'the operator sees ? rather than an all-clear 0 while installations load',
);
} finally {
@@ -741,15 +612,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
);
assert.match(vessels?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
'the panel prints ? for the whole settled-but-connecting window',
);
assert.doesNotMatch(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
'the panel must never print an all-clear 0 before the feed has answered once',
assert.equal(
panelCount(runtime, 'AIS vessels'),
'?',
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
);
} finally {
restoreCollections();
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
0,
'an answered empty viewport is a real observation',
);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
assert.equal(
panelCount(runtime, 'AIS vessels'),
'0',
'the operator sees the real count once the feed answers',
);
} finally {
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
id,
);
const panel = runtime.panel();
assert.ok(panel, 'the Context panel must exist');
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
const settledWrites = panel.hiddenWrites;
const settledRenders = runtime.panel().renders;
const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(
panel.hiddenWrites,
settledWrites,
runtime.panel().renders,
settledRenders,
'an unchanged label must not trigger a panel repaint',
);
assert.equal(
runtime.panel().publishes,
settledPublishes,
'an unchanged refresh publishes no new panel, results or standby',
);
// Enrichment answers. The contact has NOT moved and no source revision
// changed, so the label is the only thing making this render-worthy.
@@ -1283,17 +1155,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
'subject identity must stay the ICAO hex',
);
// The RENDERED panel is the surface the operator actually reads.
const repainted = runtime.panel();
assert.ok(
panel.hiddenWrites > settledWrites,
repainted.publishes > settledPublishes,
'a changed label must repaint the standalone Context panel',
);
assert.match(
panel.innerHTML,
repainted.view.subject,
/N123AB/,
'the rendered Context panel must show the registration without a position change',
);
assert.doesNotMatch(
panel.innerHTML,
JSON.stringify(repainted.view),
/ae1fa4/,
'the hex must be gone from the panel',
);
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
militaryAwarenessSource,
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
);
assert.match(militaryAwarenessSource, /military-awareness-subject/);
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
assert.match(awarenessPanelSource, /military-awareness-subject/);
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
assert.match(militaryAwarenessSource, /military-awareness-controls/);
assert.match(awarenessPanelSource, /military-awareness-controls/);
});
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
@@ -4229,7 +4103,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
}
});
test('a selected installation card keeps every member name and escapes source text', () => {
test('a selected installation card keeps every member name as text', () => {
const runtime = installAwarenessRuntime();
const restores = [];
try {
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
},
});
militaryAwarenessLayer.update();
const html = runtime.panel().innerHTML;
assert.match(html, /Named areas \(4\)/);
for (const name of [
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
const names = runtime.panel().view?.subjectNames;
assert.equal(names?.summary, 'Named areas (4)');
assert.deepEqual(names?.names, [
'Area one',
'Area two',
'Area three',
'&lt;Area four&gt;',
])
assert.ok(html.includes(name));
assert.ok(!html.includes('<Area four>'));
'<Area four>',
]);
} finally {
restores.reverse().forEach((restore) => restore());
runtime.restore();
+7 -6
View File
@@ -1,6 +1,6 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
/**
* Summarize sorted nearby results without promoting missing/stale feeds to a
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
* claim in this MVP. `reason` is English data; the awareness panel shows it
* in the UI language.
*/
export function summarizeAwarenessCohort(
items,
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed unavailable'),
reason: msg('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed stale'),
reason: msg('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
? msg('observed or mapped nearby context')
: msg('no observed or mapped objects in current feeds'),
};
}
+20
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import {
AWARENESS_RELATIONSHIP,
findByDoublingRadius,
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
});
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const reasons = [
summarizeAwarenessCohort([], { available: false }).reason,
summarizeAwarenessCohort([], { stale: true }).reason,
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
summarizeAwarenessCohort([]).reason,
];
assert.deepEqual(reasons, [
'feed unavailable',
'feed stale',
'observed or mapped nearby context',
'no observed or mapped objects in current feeds',
]);
// The awareness panel shows a reason through translateKnown().
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
});
test('navigation prioritizes nearby targets from the selected cohort', () => {
const cohorts = [
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
-4
View File
@@ -39,10 +39,6 @@ export const missionRosterEntries = layer.missionRosterEntries;
export const missionHoverPreviewRange = layer.missionHoverPreviewRange;
export const createMissionRosterPreviewOwnership =
layer.createMissionRosterPreviewOwnership;
export const bindMissionRosterItemKeyboardPreview =
layer.bindMissionRosterItemKeyboardPreview;
export const captureMissionRosterFocus = layer.captureMissionRosterFocus;
export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus;
export const resolveMissionRosterPreviewLaunch =
layer.resolveMissionRosterPreviewLaunch;
export const formatMissionEventTime = layer.formatMissionEventTime;
-83
View File
@@ -7,8 +7,6 @@ import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
approximateOrbitPath,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
buildMissionPaths,
cameraHeadingForPath,
compactLaunchSiteName,
@@ -46,7 +44,6 @@ import rocketLaunchesLayer, {
replayStartAfterPause,
replayVehicleScreenRotation,
resolveMissionRosterPreviewLaunch,
restoreMissionRosterFocus,
releaseAircraftTracking,
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
@@ -514,42 +511,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
]);
});
test('rendered mission row focus and blur drive the preview ownership controller', () => {
const listeners = new Map();
const calls = [];
const button = {
addEventListener(type, listener) {
listeners.set(type, listener);
},
};
const nextButton = {
dataset: { missionRosterIndex: '4' },
closest: () => nextButton,
};
const roster = { contains: (candidate) => candidate === nextButton };
const ownership = {
pointerEnter: (index) => calls.push(['pointer-enter', index]),
pointerLeave: () => calls.push(['pointer-leave']),
focus: (index) => calls.push(['focus', index]),
blur: (index) => calls.push(['blur', index]),
};
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
listeners.get('mouseenter')();
listeners.get('mouseleave')();
listeners.get('focus')();
listeners.get('blur')({ relatedTarget: nextButton });
listeners.get('blur')({ relatedTarget: null });
assert.deepEqual(calls, [
['pointer-enter', 2],
['pointer-leave'],
['focus', 2],
['blur', 4],
['blur', null],
]);
});
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
@@ -568,50 +529,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
]);
});
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
const focused = {
dataset: { missionRosterId: 'mission-b' },
closest: () => focused,
};
let restored = false;
const replacement = {
dataset: { missionRosterId: 'mission-b' },
focus: ({ preventScroll }) => { restored = preventScroll; },
};
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [
{ dataset: { missionRosterId: 'mission-c' } },
replacement,
{ dataset: { missionRosterId: 'mission-a' } },
],
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.deepEqual(snapshot, { launchId: 'mission-b' });
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
assert.equal(restored, true);
});
test('mission roster refresh continues after the list when the focused mission departed', () => {
const focused = {
dataset: { missionRosterId: 'departed' },
closest: () => focused,
};
let continuationFocused = false;
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
};
const continuation = {
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
assert.equal(continuationFocused, true);
});
test('pending mission preview resolves the current refreshed record by identity', () => {
const stale = { id: 'mission-a', name: 'Stale' };
const current = { id: 'mission-a', name: 'Current' };
+6 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/**
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one.
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
*
* Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
const res = await fetchImpl(url, signal ? { signal } : {});
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.retryable = res.status === 429 || res.status >= 500;
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
throw error;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5),
);
+30
View File
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
assert.equal(results[0].id, 'recovered');
});
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
let clock = 3_000_000;
let attempts = 0;
const sleeps = [];
await assert.rejects(
fetchTerrainChunkWithRetry([[1, 1]], {
now: () => clock,
random: () => 0.5,
makeSignal: () => undefined,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
fetchImpl: async () => {
attempts += 1;
return {
ok: false,
status: 429,
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
},
};
},
}),
(error) => error.status === 429 && error.retryAfter === '30',
);
assert.equal(attempts, 1);
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
});
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
let clock = 2_000_000;
let attempts = 0;
+9
View File
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache: () => {},
// The layer reads stale cached tiles through this; a source without it
// answers with fresh segments only.
fetchFlowDetail: async (...args) => ({
segments: await sourceSlot.source.fetchFlowForBounds(...args),
stale: false,
oldestTileAt: null,
}),
},
);
export const configureTrafficSource = sourceSlot.configure;
/** The source the traffic layer reads, as configured. */
export const trafficSource = sourceSlot.source;
const layer = createApplicationTraffic({
source: sourceSlot.source,
});
+105 -19
View File
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
test('a superseded flow fetch is not an outage', () => {
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
assert.equal(
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
null,
);
assert.equal(deriveTrafficFlowError(null), null);
assert.equal(deriveTrafficFlowError(undefined), null);
});
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
test('flow failures map onto short, specific reasons', () => {
const reason = (message) => deriveTrafficFlowError(new Error(message));
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
assert.equal(
reason('flow tile 12/1/1: HTTP 429'),
'TomTom daily budget reached',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 502'),
'TomTom upstream unreachable',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 504'),
'TomTom upstream unreachable',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 418'),
'TomTom flow error (HTTP 418)',
);
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
});
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
trafficFeedPresentation({ liveMode: false, fetching: false }),
trafficFeedPresentation({ liveMode: false, fetching: true }),
trafficFeedPresentation({ statusUnavailable: true }),
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
trafficFeedPresentation({
liveMode: true,
fetching: true,
flowError: 'TomTom flow unavailable',
}),
].map((feed) => feed.loadingLabel);
for (const label of labels) {
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
assert.ok(
label.startsWith('SIMULATED'),
`fallback label must lead with the mode: ${label}`,
);
}
});
test('simulating because the status probe failed reads differently from keyless by design', () => {
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
assert.equal(probeDown.mode, 'sim');
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
assert.equal(
probeDown.loadingLabel,
'SIMULATED — traffic service unreachable',
);
});
test('a healthy keyed layer reports live flow with its real coverage', () => {
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
assert.deepEqual(idle, {
mode: 'live',
error: null,
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
loadingLabel:
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
});
assert.equal(
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
fetching: true,
flowError: 'TomTom daily budget reached',
});
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
assert.deepEqual(
busy,
down,
'the degraded state reads the same whether or not a load is in flight',
);
});
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
assert.equal(
mgr._buildMetaText({
source: 'OpenStreetMap',
stats: stats(trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
})),
stats: stats(
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
}),
),
}),
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
);
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
const settled = { count: 4200, lastUpdate: Date.now() };
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: false }),
}),
'fallback',
);
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true }),
}),
'nominal',
);
assert.equal(
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
...trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
}),
'degraded',
);
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
const stats = trafficLayer.getStats();
assert.equal(stats.mode, 'sim');
assert.equal(stats.error, null);
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
assert.ok(
!LIVE_CLAIM.test(stats.loadingLabel),
`boot label implies live data: ${stats.loadingLabel}`,
);
assert.equal(layerFeedState(stats), 'fallback');
});
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
});
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
const { trafficSource, configureTrafficSource } =
await import('./traffic.js');
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
const base = {
requestRoads: async () => ({}),
getStatus: async () => ({}),
fetchFlowForBounds: async () => ['segment'],
};
let release = configureTrafficSource({
...base,
fetchFlowDetail: async () => ({
segments: ['segment'],
stale: true,
oldestTileAt: 1000,
}),
});
try {
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
segments: ['segment'],
stale: true,
oldestTileAt: 1000,
});
} finally {
release();
}
// A source without fetchFlowDetail answers with fresh segments.
release = configureTrafficSource(base);
try {
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
segments: ['segment'],
stale: false,
oldestTileAt: null,
});
} finally {
release();
}
});
+1 -1
View File
@@ -20,7 +20,7 @@
* jam keeps a size bump so queues read as fat beads.
* - `crt` (retro): saturated primaries that survive 10-level posterization
* plus an all-bucket size boost to out-shout the pixel grid.
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
* the shipped palette and sizing apply untouched.
*
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
+1 -1
View File
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const BUCKETS = ['free', 'slow', 'jam'];
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
+1 -1
View File
@@ -18,7 +18,7 @@
* black sprite with a light ring is what a hot object looks like there.
* - `crt` (retro): saturated per-mode colours that survive posterisation,
* a size boost to out-shout the pixel grid, and a thinner halo.
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
* - `normal` (normal/anime/unknown): every lookup returns the shipped
* value — mode colour, shipped size, hairline edge only.
*
* Brackets are a different matter. The detection canvas composites ABOVE the
-1
View File
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
for (const plain of [
'normal',
'anime',
'snow',
'minecraft',
'',
null,
+7 -1
View File
@@ -124,7 +124,13 @@ class PlaybackClock {
subscribe(listener) {
if (this._destroyed || typeof listener !== 'function') return () => {};
this._sceneClockListeners.add(listener);
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
if (this._sceneClockSnapshot) {
try {
listener({ ...this._sceneClockSnapshot });
} catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
return () => this._sceneClockListeners.delete(listener);
}
+29
View File
@@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a
});
assert.equal(f.timers.size, 0);
});
test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => {
const f = fixture();
f.clock.publish(scene, shot, 1);
let calls = 0;
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const unsubscribe = f.clock.subscribe(() => {
calls++;
throw new Error('listener failure');
});
assert.equal(typeof unsubscribe, 'function');
assert.equal(calls, 1);
assert.equal(warnings.length, 1);
assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:');
f.clock.publish(scene, shot, 2);
assert.equal(calls, 2);
assert.equal(warnings.length, 2);
unsubscribe();
f.clock.publish(scene, shot, 3);
assert.equal(calls, 2);
assert.equal(warnings.length, 2);
f.clock.destroy();
} finally {
console.warn = origWarn;
}
});
+9
View File
@@ -135,6 +135,15 @@ test('document byte, nesting, collection, finite-number and string bounds are en
);
});
test('a shot saved with the retired Snow style migrates to Normal', () => {
const project = fixture();
project.scenes[0].shots[0].visual = { style: 'snow' };
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.scenes[0].shots[0].visual.style, 'normal');
});
test('captured scope and extended detection edits survive migration', () => {
const project = fixture();
project.scenes[0].shots[0].visual = {
@@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
assert.equal(calls, 2);
});
test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => {
let executed = 0;
let throwOnBusy = true;
let throwOnEnd = true;
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const session = createInteractionSession({
execute: () => {
executed++;
return true;
},
changed: (state) => {
if (throwOnBusy && state.busy) throw new Error('start failed');
if (throwOnEnd && !state.busy && state.selected === 'a') {
throw new Error('end failed');
}
},
});
session.activate([{ id: 'a' }]);
const result = await session.dispatch('a');
assert.equal(result, true);
assert.equal(executed, 1);
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: 'a',
count: 1,
});
assert.equal(warnings.length, 2);
assert.equal(
warnings[0][0],
'[Scenes] Interaction session observer failed:',
);
assert.equal(
warnings[1][0],
'[Scenes] Interaction session observer failed:',
);
throwOnBusy = false;
throwOnEnd = false;
assert.equal(await session.dispatch('a'), true);
assert.equal(executed, 2);
session.clear();
assert.deepEqual(session.getState(), {
active: false,
busy: false,
selected: null,
count: 0,
});
} finally {
console.warn = origWarn;
}
});
test('an observer that always throws cannot stop activate or clear', () => {
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const session = createInteractionSession({
execute: () => true,
changed: () => {
throw new Error('observer failed');
},
});
session.activate([{ id: 'a' }, { id: 'b' }]);
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: null,
count: 2,
});
session.clear();
assert.deepEqual(session.getState(), {
active: false,
busy: false,
selected: null,
count: 0,
});
assert.equal(warnings.length, 3);
} finally {
console.warn = origWarn;
}
});
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const shot = { id: 'shot', dataPackIds: ['pack'] };
@@ -188,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
_claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'),
});
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
});
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+11 -4
View File
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
selected = null;
let actions = new Map();
const state = () => ({ active, busy, selected, count: actions.size });
function notify() {
try {
changed(state());
} catch (error) {
console.warn('[Scenes] Interaction session observer failed:', error);
}
}
function clear() {
active = false;
busy = false;
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
actions.clear();
controller?.abort();
controller = null;
changed(state());
notify();
}
return {
clear,
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
clear();
actions = new Map(items.map((item) => [item.id, item]));
active = !!actions.size;
changed(state());
notify();
},
async dispatch(id) {
const item = actions.get(id);
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
controller = current;
busy = true;
selected = id;
changed(state());
notify();
let abort;
try {
const cancelled = new Promise((resolve) => {
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
if (controller === current) {
busy = false;
controller = null;
changed(state());
notify();
}
}
},
+20 -2
View File
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
* releaseScene(scene, token?) leaves a previously loaded scene before playback
* (false refuses that initial handoff). Between scenes and during final cleanup
* it receives no cancelled token, so resource release can finish after Stop.
* The first error wins when the final release also fails.
* An empty/already-cancelled queue acquires and releases no resources.
*
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
@@ -53,6 +54,7 @@ export async function playSceneQueue(
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
let activeScene = null;
let completedShots = 0;
let runFailed = false;
try {
if (!queue.length || cancelled()) {
return {
@@ -68,8 +70,9 @@ export async function playSceneQueue(
for (let index = 0; index < queue.length && !cancelled(); index++) {
const { scene, shot } = queue[index];
if (activeScene && activeScene.id !== scene.id) {
await adapter.releaseScene(activeScene);
const toRelease = activeScene;
activeScene = null;
await adapter.releaseScene(toRelease);
if (cancelled()) break;
}
activeScene = scene;
@@ -82,8 +85,23 @@ export async function playSceneQueue(
}
if (!cancelled()) await adapter.complete?.();
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
} catch (error) {
runFailed = true;
throw error;
} finally {
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
if (releaseOnFinish && activeScene) {
const toRelease = activeScene;
activeScene = null;
try {
await adapter.releaseScene(toRelease);
} catch (error) {
if (runFailed) {
console.warn('[Scenes] Scene release failed:', error);
} else {
throw error;
}
}
}
}
}
+65
View File
@@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
(error) => error === failure,
);
});
test('a phase failure is preserved when final scene release also throws', async () => {
const phaseError = new Error('travel failed');
const releaseError = new Error('release failed');
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const f = fixture({
travel: async () => {
throw phaseError;
},
releaseScene: async () => {
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(error) => error === phaseError,
);
assert.equal(warnings.length, 1);
assert.equal(warnings[0][0], '[Scenes] Scene release failed:');
assert.equal(warnings[0][1], releaseError);
} finally {
console.warn = origWarn;
}
});
test('a failing inter-scene release releases the active scene only once', async () => {
const releaseError = new Error('release failed');
const released = [];
const f = fixture({
releaseScene: async (scene) => {
released.push(scene.id);
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
(error) => error === releaseError,
);
assert.deepEqual(released, ['a']);
});
test('a falsy phase failure is still preserved over a release failure', async () => {
const releaseError = new Error('release failed');
const origWarn = console.warn;
console.warn = () => {};
try {
const f = fixture({
travel: async () => {
throw undefined;
},
releaseScene: async () => {
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(error) => error === undefined,
);
} finally {
console.warn = origWarn;
}
});
+32
View File
@@ -0,0 +1,32 @@
// The first-run launcher's owner-chosen copy, shared by the launcher and the
// welcome view that renders it.
import { msg, t } from './i18n/index.js';
/**
* Owner-selectable name for the fires/quakes mission. Flip this ONE constant to
* re-label the tile; the alternates are pre-written so the choice is a taste
* call at review time, not an edit.
* @type {'ENVIRONMENTAL'|'EARTH_WATCH'|'ACTIVE_EVENTS'}
*/
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: msg('EARTH WATCH'),
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
});
/**
* @param {string} [choice]
* @returns {{title: string}} The label set the constant above selects.
*/
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
const key =
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
return { title: t(key) };
}
/** The status line between missions. */
export const FIRST_RUN_TIP = msg(
'Tip: the GEV MIC button in the dock lets you talk to the map.',
);
+99 -99
View File
@@ -23,36 +23,24 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import { t, msg } from './i18n/index.js';
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js';
import { FIRST_RUN_TIP, environmentalLabel } from './firstRunCopy.js';
import { useFirstRunStore } from './ui/stores/firstRun.js';
import { applicationPinia } from './ui/vueRuntime.js';
export {
ENVIRONMENTAL_LABEL_CHOICE,
FIRST_RUN_TIP,
environmentalLabel,
} from './firstRunCopy.js';
/** Launchers already wired, so a second call is a no-op. */
const initializedLaunchers = new WeakSet();
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
/**
* Owner-selectable name for the fires/quakes mission. Flip this ONE constant to
* re-label the tile; the alternates are pre-written so the choice is a taste
* call at review time, not an edit.
* @type {'ENVIRONMENTAL'|'EARTH_WATCH'|'ACTIVE_EVENTS'}
*/
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: msg('EARTH WATCH'),
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
});
/**
* @param {string} [choice]
* @returns {{title: string}} The label set the constant above selects.
*/
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
const key =
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
return { title: t(key) };
}
/*
* MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST
* ─────────────────────────────────────────────────────────────────────────────
@@ -328,6 +316,7 @@ export function exclusiveSurfaceActive(documentRef = globalThis.document) {
* @param {Storage} [input.storage]
* @param {Storage} [input.sessionStorageRef]
* @param {Location} [input.location]
* @param {object} [input.store] The welcome view's `gev-first-run` store.
* @returns {null|{dismiss: Function}}
*/
export function initFirstRunExperience({
@@ -337,10 +326,42 @@ export function initFirstRunExperience({
storage,
sessionStorageRef,
location = globalThis.location,
store = useFirstRunStore(applicationPinia()),
} = {}) {
// The welcome view renders the card; it is read here for its geometry, the
// keyboard and focus.
const root = documentRef?.getElementById?.('first-run-launcher');
if (!root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true';
if (!root || initializedLaunchers.has(root)) return null;
initializedLaunchers.add(root);
const view = {
removed: false,
shown: false,
visible: false,
closing: false,
state: null,
busy: null,
status: t(FIRST_RUN_TIP),
// The tile name is owner-switchable from one constant, so paint it from
// the module rather than trusting the markup to match.
environmentalTitle: environmentalLabel().title,
environmentalSub: t(
'Live earthquakes and active fires, from USGS and NASA',
),
description: t(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
),
scrollable: null,
};
let signature = '';
const present = (patch) => {
Object.assign(view, patch);
// A resize that changes nothing re-renders nothing.
const next = JSON.stringify(view);
if (next === signature) return;
signature = next;
store.publish({ ...view });
};
if (
!shouldShowFirstRun({
@@ -350,35 +371,14 @@ export function initFirstRunExperience({
location,
})
) {
root.remove();
present({ removed: true });
return null;
}
present({});
// The tile name is owner-switchable from one constant, so paint it from the
// module rather than trusting the markup to match. The description and the
// tile subcopy are data-i18n-runtime in welcome.html and translated here.
const environmentalTitle = root.querySelector(
'[data-first-run-environmental-title]',
);
if (environmentalTitle)
environmentalTitle.textContent = environmentalLabel().title;
const environmentalSub = root.querySelector(
'[data-first-run-environmental-title] + small',
);
if (environmentalSub)
environmentalSub.textContent = t(
'Live earthquakes and active fires, from USGS and NASA',
);
const description = root.querySelector('#first-run-description');
if (description)
description.textContent = t(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
);
const status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]');
const buttons = [...root.querySelectorAll('[data-first-run-choice]')];
const defaultStatus = status?.textContent || '';
const defaultStatus = view.status;
// An error or refused save stays on the status line until the next choice.
let stickyStatus = false;
let busy = false;
let closing = false;
@@ -424,7 +424,7 @@ export function initFirstRunExperience({
*/
const isTopmost = () =>
root.isConnected &&
root.classList.contains('visible') &&
view.visible &&
root.getClientRects().length > 0 &&
!coveredByOverlay();
@@ -432,11 +432,13 @@ export function initFirstRunExperience({
if (closing) return;
closing = true;
rememberFirstRunSessionDismissed(sessionStorageRef);
root.classList.remove('visible');
root.setAttribute('aria-hidden', 'true');
present({ visible: false, closing: true });
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
const remove = () => root.remove();
const remove = () => {
store.connect(null);
present({ removed: true });
};
root.addEventListener('transitionend', remove, { once: true });
// `transitionend` never fires under prefers-reduced-motion (no transition),
// so a bounded fallback is what actually removes the node there.
@@ -448,26 +450,20 @@ export function initFirstRunExperience({
const setBusy = (next, choice = '') => {
busy = next;
root.dataset.state = next ? 'loading' : 'ready';
root.setAttribute('aria-busy', String(next));
// aria-disabled, not `disabled`: disabling the focused button drops focus to
// <body> mid-flight and strands a keyboard visitor outside the launcher.
for (const button of buttons)
button.setAttribute('aria-disabled', String(next));
if (!status) return;
// `busy` is also aria-disabled on the choices, not `disabled`: disabling
// the focused button drops focus to <body> mid-flight and strands a
// keyboard visitor outside the launcher.
const patch = { state: next ? 'loading' : 'ready', busy: String(next) };
if (next)
status.textContent = t(
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
);
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
patch.status = t(FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'));
else if (!stickyStatus) patch.status = defaultStatus;
present(patch);
};
const onChoice = async (event) => {
const onChoice = async (choice) => {
if (busy || closing) return;
const choice = event.currentTarget?.dataset?.firstRunChoice;
if (!FIRST_RUN_MISSIONS[choice]) return;
if (status) delete status.dataset.sticky;
stickyStatus = false;
setBusy(true, choice);
let outcome = null;
try {
@@ -506,30 +502,31 @@ export function initFirstRunExperience({
: outcome?.result?.failedLayerIds;
const detail =
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) {
status.dataset.sticky = 'true';
status.textContent = t(
stickyStatus = true;
present({
status: t(
'Could not open that mission{detail}. Retry or explore manually.',
{ detail },
);
}
),
});
setBusy(false);
};
const onSuppressChange = (event) => {
const box = event.currentTarget;
const wanted = Boolean(box?.checked);
if (setFirstRunSuppressed(wanted, storage)) return;
/** @returns {boolean} Whether the tick may stay as the visitor set it. */
const onSuppressChange = (wanted) => {
if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;
// The write is best-effort; the TICK is not. A box left checked after a
// refused write tells the visitor "never again" about a launcher that is
// already guaranteed to come back next session. Put the box back where the
// truth is, and say why rather than leaving a control that undoes itself.
if (box) box.checked = !wanted;
if (!status) return;
status.dataset.sticky = 'true';
status.textContent = t(
'This browser is blocking storage, so that could not be saved.',
);
// already guaranteed to come back next session. The view puts the box back
// where the truth is; say why rather than leaving a control that undoes
// itself.
stickyStatus = true;
present({
status: t(
'This browser is blocking storage, so that could not be saved.',
),
});
return false;
};
const keyboard = createSurfaceKeyboard({
@@ -542,12 +539,12 @@ export function initFirstRunExperience({
fallbackFocus: () => documentRef.body,
});
for (const button of buttons) button.addEventListener('click', onChoice);
// The same dismissal as ESC, for screens without a keyboard.
root
.querySelector('[data-first-run-dismiss]')
?.addEventListener('click', () => dismiss());
suppressBox?.addEventListener('change', onSuppressChange);
store.connect({
choose: (choice) => void onChoice(choice),
// The same dismissal as ESC, for screens without a keyboard.
dismiss: () => dismiss(),
suppress: onSuppressChange,
});
// Capture phase: the app binds its own global hotkeys (including bare letters
// that cycle detection and styles), and the launcher owns the keyboard first.
keyboard.activate();
@@ -559,19 +556,21 @@ export function initFirstRunExperience({
const syncScrollAffordance = () => {
if (!choiceList) return;
const overflows = choiceList.scrollHeight > choiceList.clientHeight + 1;
choiceList.dataset.scrollable = String(overflows);
present({ scrollable: String(overflows) });
};
let revealed = false;
const reveal = () => {
if (revealed || closing) return;
revealed = true;
root.hidden = false;
present({ shown: true });
globalThis.requestAnimationFrame?.(() => {
if (closing) return;
root.classList.add('visible');
present({ visible: true });
syncScrollAffordance();
buttons[0]?.focus?.({ preventScroll: true });
root
.querySelector('[data-first-run-choice]')
?.focus?.({ preventScroll: true });
});
};
@@ -659,7 +658,8 @@ export function initFirstRunExperience({
keyboard.destroy();
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
root.remove();
store.connect(null);
present({ visible: false, removed: true });
};
return { dismiss, isTopmost, destroy };
}
+43 -69
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import test from 'node:test';
@@ -374,24 +374,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
test('one ESC does one thing — a Cockpit utility disclosure stops later document listeners', () => {
const ui = fs.readFileSync(
new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
// dismissed itself off the same key. The earlier listener is the only one that
// can stop the later one — and only the immediate form does it.
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
// stopPropagation() does NOT stop later listeners on the SAME document. The
// Context Radio disclosure that once raced the launcher this way is gone, and
// Cockpit's utility disclosures never share the screen with the launcher, but
// they own the same contract: their capture handler is the only one that can
// keep a later document listener (Cockpit exit, the launcher) off the key, and
// only the immediate form does it.
const utilityEsc = ui.slice(ui.indexOf('if (!displayOpen && !radioOpen) return;'));
const claim = utilityEsc.slice(0, utilityEsc.indexOf('setCockpitDisclosure(kind, false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
assert.doesNotMatch(
claim,
/event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself',
'the plain form leaves later document listeners acting on the same key',
);
// Belt on the launcher side: a key another surface already marked is not ours,
@@ -411,11 +413,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
});
test('a refused write takes the tick back instead of promising "never again"', () => {
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The write stays best-effort; the OUTCOME is now reported, because a box left
// ticked after a refused write tells the visitor the launcher is gone for good
// while it is already guaranteed to return next session.
@@ -440,20 +437,7 @@ test('a refused write takes the tick back instead of promising "never again"', (
assert.equal(working.read(), 'suppressed');
assert.equal(setFirstRunSuppressed(false, working), true);
assert.equal(working.read(), null);
const handler = module.slice(
module.indexOf('const onSuppressChange = (event) => {'),
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
);
assert.match(
handler,
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
);
assert.match(
handler,
/box\.checked = !wanted;/,
'a refused write must revert the tick',
);
// The rendered checkbox reverting is firstRunLauncherView.test.mjs.
});
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
@@ -504,16 +488,8 @@ test('the scroll fade only appears when the list really overflows', () => {
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(
module,
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
);
assert.match(
module,
/choiceList\.dataset\.scrollable = String\(overflows\);/,
);
// ...and the CSS must be gated on that flag, never on the media query alone.
// not exist, which is worse than no affordance at all. The measured flag is
// firstRunLauncherView.test.mjs; the CSS must be gated on that flag, never on the media query alone.
assert.match(
css,
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
@@ -795,8 +771,8 @@ test('the decision table is written down where the next editor will read it', ()
// ── Markup, startup ordering, accessibility ─────────────────────────────────
test('markup, startup ordering and accessibility remain pinned', () => {
const html = expandApplicationHtml(
test('markup, startup ordering and accessibility remain pinned', async () => {
const html = await renderApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const startup = fs.readFileSync(
@@ -814,7 +790,7 @@ test('markup, startup ordering and accessibility remain pinned', () => {
html,
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
);
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
assert.match(html, /<input type="checkbox" data-first-run-suppress\s*\/?>/);
assert.match(html, /<strong data-first-run-environmental-title>/);
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
// half of what it does is the defect this replaced. Only the VISIBLE <small>
@@ -838,7 +814,7 @@ test('markup, startup ordering and accessibility remain pinned', () => {
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes(
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
'<p id="first-run-description">It feels like a forbidden cockpit' +
'—then you realize the sources are public and the data is real.</p>',
),
'the owner-authored first-run line must ship exactly as written',
@@ -913,14 +889,10 @@ test('markup, startup ordering and accessibility remain pinned', () => {
assert.match(choicesBlock, /overflow-y: auto;/);
});
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', () => {
const html = expandApplicationHtml(
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', async () => {
const html = await renderApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The whole stylesheet, so a hide rule added in any other file is caught too.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const footer = html.slice(
@@ -931,14 +903,10 @@ test('the card closes without a keyboard: the ESC hint is a button, kept on phon
footer,
/<button type="button" class="first-run-dismiss" data-first-run-dismiss>/,
);
assert.match(footer, /data-i18n>ESC to dismiss</);
assert.match(footer, /data-i18n>DISMISS</);
assert.match(footer, />ESC to dismiss</);
assert.match(footer, />DISMISS</);
// It closes the way ESC does, through the one dismiss path, so the session
// flag and focus handling are the same.
assert.match(
module,
/querySelector\('\[data-first-run-dismiss\]'\)\s*\?\.addEventListener\('click', \(\) => dismiss\(\)\)/,
);
// flag and focus handling are the same: firstRunLauncherView.test.mjs.
// Phones used to hide the hint, leaving no way out but picking a mission.
assert.doesNotMatch(css, /first-run-footer > span:last-child/);
assert.doesNotMatch(css, /\.first-run-dismiss\s*\{[^}]*display:\s*none/);
@@ -961,12 +929,9 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
'utf8',
);
// aria-disabled, never the `disabled` property: disabling a focused button
// drops the keyboard to <body> and strands the visitor outside the launcher.
assert.match(
module,
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
);
assert.doesNotMatch(module, /button\.disabled = /);
// drops the keyboard to <body> and strands the visitor outside the launcher
// (firstRunLauncherView.test.mjs renders it).
assert.doesNotMatch(module, /\.disabled = /);
// Tab is confined to the launcher, and ESC always releases it.
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
@@ -1022,14 +987,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
.parameters.properties.layout;
assert.deepEqual(hudLayout.enum, [
'tactical',
'operator',
'minimal',
'cyber',
]);
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
// Cyber deliberately adds one layout and Operator was retired; first-run
// missions still change no tools.
hudLayout.enum = ['tactical', 'operator', 'minimal'];
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
.parameters.properties.motion;
assert.deepEqual(cameraMotion.enum, [
@@ -1040,6 +1001,19 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'rotate_globe',
'stop',
]);
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
const visualStyle = legacyTools.find(
(tool) => tool.name === 'set_visual_style',
).parameters.properties.style;
assert.deepEqual(visualStyle.enum, [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
visualStyle.enum = [...visualStyle.enum, 'snow'];
const block = JSON.stringify(legacyTools);
// Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt
+195
View File
@@ -0,0 +1,195 @@
// src/firstRunLauncherView.test.mjs — the welcome view renders the mission
// launcher initFirstRunExperience presents, and its buttons and checkbox call
// the launcher's controls.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import {
FIRST_RUN_SESSION_KEY,
FIRST_RUN_STORAGE_KEY,
initFirstRunExperience,
} from './firstRunExperience.js';
import { useFirstRunStore } from './ui/stores/firstRun.js';
import WelcomeView from './ui/views/WelcomeView.vue';
function memoryStorage(key, { refuse = false } = {}) {
const values = new Map();
return {
getItem: (name) => values.get(name) ?? null,
setItem: (name, next) => {
if (refuse) throw new Error('blocked');
values.set(name, next);
},
removeItem: (name) => {
if (refuse) throw new Error('blocked');
values.delete(name);
},
read: () => values.get(key) ?? null,
};
}
/** The welcome view and an initialized launcher, revealed and painted. */
async function launch(t, { styleManager = {}, refuse = false } = {}) {
document.body.replaceChildren();
// Where focus was before the launcher opened.
const opener = document.body.appendChild(document.createElement('button'));
opener.focus();
const frames = [];
const previousFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
const pinia = createPinia();
const store = useFirstRunStore(pinia);
const app = createApp(WelcomeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY, { refuse });
const sessionStorageRef = memoryStorage(FIRST_RUN_SESSION_KEY);
const launcher = initFirstRunExperience({
styleManager,
dataManager: { setEnabled: async () => true },
documentRef: document,
storage,
sessionStorageRef,
location: { search: '' },
store,
});
t.after(() => {
launcher?.destroy();
app.unmount();
globalThis.requestAnimationFrame = previousFrame;
});
await nextTick();
const root = document.getElementById('first-run-launcher');
// The list overflows its box until a test resizes it.
const list = root.querySelector('.first-run-choices');
const box = { scrollHeight: 400, clientHeight: 200 };
Object.defineProperty(list, 'scrollHeight', { get: () => box.scrollHeight });
Object.defineProperty(list, 'clientHeight', { get: () => box.clientHeight });
for (const frame of frames.splice(0)) frame();
await nextTick();
return { launcher, root, list, box, opener, storage, sessionStorageRef };
}
test('the card is hidden until initialized, then revealed with its copy', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(WelcomeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const hidden = document.getElementById('first-run-launcher');
assert.equal(hidden.hidden, true);
assert.equal(hidden.className, '');
app.unmount();
const { root, list } = await launch(t);
assert.equal(root.hidden, false);
assert.equal(root.classList.contains('visible'), true);
assert.equal(root.hasAttribute('aria-hidden'), false);
assert.equal(list.dataset.scrollable, 'true');
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'Tip: the GEV MIC button in the dock lets you talk to the map.',
);
assert.equal(
root.querySelector('[data-first-run-environmental-title]').textContent,
'ENVIRONMENTAL',
);
assert.ok(
document.activeElement === root.querySelector('[data-first-run-choice]'),
'the first mission has focus',
);
});
test('a busy mission marks the choices aria-disabled, never disabled', async (t) => {
let finish;
const { root } = await launch(t, {
styleManager: {
setContextMode: () => new Promise((resolve) => (finish = resolve)),
},
});
const contacts = root.querySelector('[data-first-run-choice="contacts"]');
contacts.focus();
contacts.click();
await nextTick();
assert.equal(root.dataset.state, 'loading');
assert.equal(root.getAttribute('aria-busy'), 'true');
for (const button of root.querySelectorAll('[data-first-run-choice]')) {
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.disabled, false);
}
assert.ok(document.activeElement === contacts, 'focus stays on the choice');
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'Starting live contacts…',
);
// A failed mission says so and stays on the card.
finish({ ok: false });
await new Promise((resolve) => setTimeout(resolve));
await nextTick();
assert.equal(root.dataset.state, 'ready');
assert.equal(contacts.getAttribute('aria-disabled'), 'false');
assert.match(
root.querySelector('[data-first-run-status]').textContent,
/^Could not open that mission/,
);
});
test('a refused write takes the tick back and says why', async (t) => {
const { root, storage } = await launch(t, { refuse: true });
const box = root.querySelector('[data-first-run-suppress]');
box.click();
await nextTick();
assert.equal(box.checked, false, 'the tick is taken back');
assert.equal(storage.read(), null);
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'This browser is blocking storage, so that could not be saved.',
);
});
test('a saved tick stays ticked', async (t) => {
const { root, storage } = await launch(t);
const box = root.querySelector('[data-first-run-suppress]');
box.click();
await nextTick();
assert.equal(box.checked, true);
assert.equal(storage.read(), 'suppressed');
});
test('the DISMISS button closes the card the way ESC does', async (t) => {
const { root, sessionStorageRef } = await launch(t);
root.querySelector('[data-first-run-dismiss]').click();
await nextTick();
assert.equal(root.classList.contains('visible'), false);
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session');
root.dispatchEvent(new Event('transitionend'));
await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null);
});
test('the scroll fade clears when the tiles fit after a resize', async (t) => {
const { list, box } = await launch(t);
assert.equal(list.dataset.scrollable, 'true');
box.scrollHeight = 200;
window.dispatchEvent(new Event('resize'));
await nextTick();
assert.equal(list.dataset.scrollable, 'false');
});
test('ESC closes the card, returns focus, and removes it without a transition', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { root, opener, sessionStorageRef } = await launch(t);
assert.ok(document.activeElement !== opener, 'the launcher took focus');
document.activeElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await nextTick();
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.notEqual(sessionStorageRef.read(), null);
assert.ok(document.activeElement === opener, 'focus returns where it was');
// Reduced motion: no transitionend, so the bounded fallback removes it.
t.mock.timers.tick(400);
await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null);
});
+169 -151
View File
@@ -9,7 +9,7 @@ import { applicationServices } from './services/application.js';
* real-time at configurable cadences.
*
* The HUD auto-activates when a military-style shader (NVG, FLIR, CRT) is
* selected and supports three layout variants: tactical, operator, minimal.
* selected and supports the layout variants: tactical, minimal, cyber.
*
* Color theming is driven by the active shader mode via CSS custom properties.
*/
@@ -37,8 +37,10 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -75,7 +77,6 @@ const MODE_LABELS = Object.freeze({
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
/** Region ids from _regionLabel() and the name shown for each. */
@@ -179,8 +180,10 @@ export class IntelHUD {
summaryPolicy = {},
basemapContext = {},
summaryService = applicationServices.summary,
publish = () => {},
} = {},
) {
this._publish = publish;
this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext;
@@ -189,7 +192,9 @@ export class IntelHUD {
this._visible = false;
this._autoMode = true; // auto show/hide based on style
this._currentStyle = 'normal';
this._el = null;
/** The presented HUD; see {@link IntelHUD#_initView}. */
this._view = null;
this._viewSignature = '';
this._variant = DEFAULT_HUD_LAYOUT;
this._recBlinkState = true;
this._updateInterval = null;
@@ -202,7 +207,13 @@ export class IntelHUD {
this._dataManagerUnsubscribe = null;
this._summaryDirty = true;
this._summaryRequest = null;
this._summaryUnconfigured = false;
// The last place lookup and the view it answered for (see
// getBasemapLabelContext), reused until the view changes enough.
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -262,85 +273,83 @@ export class IntelHUD {
this._orbitNum = 47000 + Math.floor(Math.random() * 1000);
this._passNum = 100 + Math.floor(Math.random() * 200);
this._buildDOM();
this._initView();
this._vesselUnsubscribe = subscribeSelectedVessel((readout) =>
this._present({
vessel: readout
? { active: true, text: readout.text }
: { active: false, text: t('AIS: --') },
}),
);
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
this._offLocale = onLocaleChange(() => this._relocalize());
this._startTimers();
}
/**
* Construct the HUD DOM structure inside the existing `#intel-hud` element.
* Populates corner brackets, classification banners, sensor readouts,
* edge metadata strips, and the bottom summary bar.
* Show the HUD's own words in the new locale. The deterministic summary
* is composed again; the AI reply does not depend on the locale, so the
* next tick shows the one already received rather than asking again.
*/
_buildDOM() {
this._el = document.getElementById('intel-hud');
if (!this._el) return;
_relocalize() {
this._present({
mode: MODE_LABELS[this._currentStyle]
? t(MODE_LABELS[this._currentStyle])
: this._currentStyle.toUpperCase(),
summaryLabel: t('SUMMARY'),
rec: t('REC'),
...(this._view.vessel.active
? {}
: { vessel: { active: false, text: t('AIS: --') } }),
});
if (this._visible && this._latestMetrics) this._updateCameraData();
this._setSummaryText(this._composeSummary(), false);
}
/**
* Present the HUD's first view: corner brackets, classification banners,
* sensor readouts, edge metadata strips and the bottom summary bar, which
* the Intel HUD view renders in `#intel-hud`.
*/
_initView() {
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
this._view = {
active: false,
variant: this._variant,
colors: null,
classification: 'TOP SECRET // SI-TK // NOFORN',
system: `${this._missionId} ${this._sensorId}`,
mode: t(MODE_LABELS.normal),
summaryLabel: t('SUMMARY'),
summary: t('Awaiting telemetry...'),
rec: t('REC'),
recShown: null,
timestamp: '2026-01-01 00:00:00Z',
orbital: `ORB: ${this._orbitNum} PASS: DESC-${this._passNum}`,
mgrs: 'MGRS: ---',
latlon: `--°--'--"N ---°--'--"W`,
gsd: 'GSD: --m NIIRS: --',
alt: 'ALT: --m SUN: --° EL',
vessel: { active: false, text: t('AIS: --') },
coll: 'COLL: --:--:--Z',
ona: 'ONA: --°',
sensor: ['BAND: PAN', 'BITS: 11', 'LVL: 1A'],
bottom: 'LAT: -- LON: -- MGRS: ---',
};
this._present({});
}
<div class="hud-top-bar">
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div>
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
<div class="hud-summary-wrap">
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div>
<div class="hud-bracket">┐</div>
</div>
<div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div>
<div class="hud-content">
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div>
</div>
<div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right">
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div>
<div class="hud-bracket">┘</div>
</div>
<div class="hud-edge hud-left-edge">
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div>
<div class="hud-edge hud-right-edge">
<div class="hud-readout"><div>BAND: PAN</div></div>
<div class="hud-readout"><div>BITS: 11</div></div>
<div class="hud-readout"><div>LVL: 1A</div></div>
</div>
<div class="hud-bottom-bar">
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
</div>
`;
this._el.dataset.variant = this._variant;
/**
* Merge `patch` into the view and publish it when anything changed.
* @param {object} patch
*/
_present(patch) {
Object.assign(this._view, patch);
const signature = JSON.stringify(this._view);
if (signature === this._viewSignature) return;
this._viewSignature = signature;
this._publish(Object.freeze({ ...this._view }));
}
/**
@@ -351,21 +360,20 @@ export class IntelHUD {
_startTimers() {
// Timestamp — every second
this._timestampInterval = setInterval(() => {
const el = document.getElementById('hud-timestamp');
if (el) el.textContent = this._formatUTC();
this._present({ timestamp: this._formatUTC() });
}, 1000);
// REC blink — every 800ms
this._recBlinkInterval = setInterval(() => {
this._recBlinkState = !this._recBlinkState;
const dot = document.getElementById('hud-rec-dot');
if (dot)
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
this._present({ recShown: this._recBlinkState });
}, 800);
// Camera-derived data — 4 updates/second (250ms)
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
// page, except an inline panel, whose host may report it hidden while it
// is on screen (see keepPanelRendering in app/embed.js).
this._updateInterval = setInterval(() => {
if (!this._visible) return;
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
this._updateCameraData();
}, 250);
@@ -447,23 +455,16 @@ export class IntelHUD {
// Format: 18SUJ23370716 → 18S UJ 2337 0716
const formatted = this._formatMGRS(mgrsStr);
mgrsLabel = formatted;
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = `MGRS: ${formatted}`;
} catch {
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = 'MGRS: ---';
/* the readout keeps its dashes */
}
// i18n-ignore: sensor codes
const readouts = { mgrs: `MGRS: ${mgrsLabel}` };
// Lat/Lon DMS
const llEl = document.getElementById('hud-latlon');
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
const bottomEl = document.getElementById('hud-bottom-line');
if (bottomEl) {
// i18n-ignore: sensor codes
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
}
readouts.latlon = `${latDMS} ${lonDMS}`;
// i18n-ignore: sensor codes
readouts.bottom = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
// GSD (Ground Sample Distance): approximate resolution in meters per pixel
// derived from camera altitude. NIIRS (National Imagery Interpretability
@@ -475,42 +476,35 @@ export class IntelHUD {
0,
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
);
const gsdEl = document.getElementById('hud-gsd');
if (gsdEl)
// i18n-ignore: sensor codes
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// i18n-ignore: sensor codes
readouts.gsd = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
// ellipsoidal camera height, which reads far below zero wherever the geoid
// sits under the ellipsoid: a cockpit parked on the SFO deck (N ≈ -32 m)
// showed "ALT: -15m", and JFK "ALT: -18m". Subtracting N restores the
// number a viewer expects without touching the camera or any render path.
const altEl = document.getElementById('hud-alt');
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl)
// i18n-ignore: sensor codes
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// i18n-ignore: sensor codes
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
const collEl = document.getElementById('hud-coll');
if (collEl) {
const now = new Date();
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor codes
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
}
const now = new Date();
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor codes
readouts.coll = `COLL: ${h}:${m}:${s}Z`;
// Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down),
// so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon.
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona');
// i18n-ignore: sensor codes
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
readouts.ona = `ONA: ${ona.toFixed(1)}°`;
this._present(readouts);
// `altM` stays the raw ellipsoidal camera height the sensor model reads
// (GSD/NIIRS, view band). `altMslM` is the ADDITIVE display datum — the
@@ -731,8 +725,7 @@ export class IntelHUD {
const m = this._latestMetrics;
if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode');
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
const modeLabel = this._view.mode || t(MODE_LABELS.normal);
const region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
@@ -794,20 +787,18 @@ export class IntelHUD {
* @param {string} text - Full summary string to type out.
*/
_typeSummary(text) {
const el = document.getElementById('hud-summary');
if (!el) return;
clearInterval(this._summaryTypingInterval);
let index = 0;
el.textContent = '';
this._present({ summary: '' });
this._summaryTypingInterval = setInterval(() => {
index += 2;
if (index >= text.length) {
el.textContent = text;
this._present({ summary: text });
clearInterval(this._summaryTypingInterval);
this._summaryTypingInterval = null;
return;
}
el.textContent = text.slice(0, index);
this._present({ summary: text.slice(0, index) });
}, 24);
}
@@ -826,6 +817,15 @@ export class IntelHUD {
if (this.summaryPolicy.canRequest?.() === false) return;
const revision = this._summaryRevision;
// The place lookups below are paid Google requests made only to feed the
// AI summary, so a server without one must not be asked to make them.
if (!(await this._summaryAvailable())) {
if (revision !== this._summaryRevision) return;
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
// owns the returned promise — a rejection escaping from here lands as an
// unhandled rejection in the console. Building the context walks the live
@@ -848,6 +848,9 @@ export class IntelHUD {
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
return;
}
if (this._summaryRequest) return;
@@ -855,6 +858,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -870,18 +874,16 @@ export class IntelHUD {
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
this._summaryUnconfigured = true;
this._setSummaryText(fallbackText, animate);
return;
}
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
this._setSummaryText(
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
? data.summary
: fallbackText,
animate,
);
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -911,16 +913,34 @@ export class IntelHUD {
}
// A plain paint replaces any reply still typing.
this._stopSummaryTyping();
const el = document.getElementById('hud-summary');
if (el) el.textContent = text;
this._present({ summary: text });
}
/**
* False once the summary service reports no AI summary: by its capability
* probe, when it has one, or by a deliberate unconfigured reply. Unknown
* stays available, as before.
*/
async _summaryAvailable() {
if (this._summaryUnconfigured) return false;
try {
if ((await this.summaryService?.configured?.()) === false)
this._summaryUnconfigured = true;
} catch {
/* an unanswered probe leaves the summary as it was */
}
return !this._summaryUnconfigured;
}
async _summaryContext() {
const labels = await getBasemapLabelContext(
this.viewer,
this.placeSearch,
this.basemapContext,
);
const revision = this._summaryRevision;
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
...this.basemapContext,
previous: this._placeLabels,
});
// A slower build for a view the camera has left must not replace the
// newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers =
this._dataManager
?.getAll?.()
@@ -952,20 +972,18 @@ export class IntelHUD {
onStyleChange(styleName) {
this._currentStyle = styleName;
// Update mode label
const modeEl = document.getElementById('hud-mode');
if (modeEl) {
modeEl.textContent = MODE_LABELS[styleName]
? t(MODE_LABELS[styleName])
: styleName.toUpperCase();
}
// Update color scheme
// Mode label and color scheme
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
if (this._el) {
this._el.style.setProperty('--hud-color', colors.main);
this._el.style.setProperty('--hud-glow', colors.glow);
this._el.style.setProperty('--hud-border', colors.border);
}
this._present({
mode: MODE_LABELS[styleName]
? t(MODE_LABELS[styleName])
: styleName.toUpperCase(),
colors: {
'--hud-color': colors.main,
'--hud-glow': colors.glow,
'--hud-border': colors.border,
},
});
// Auto show/hide
if (this._autoMode) {
@@ -980,7 +998,7 @@ export class IntelHUD {
/** Make the HUD visible and immediately refresh all readouts. */
show() {
this._visible = true;
if (this._el) this._el.classList.add('active');
this._present({ active: true });
this._updateCameraData(); // immediate update
this._markSummaryDirty();
void this._updateSummary(false, true);
@@ -989,7 +1007,7 @@ export class IntelHUD {
/** Hide the HUD overlay. */
hide() {
this._visible = false;
if (this._el) this._el.classList.remove('active');
this._present({ active: false });
}
/** Toggle HUD visibility and disable auto-mode (user override). */
@@ -1024,13 +1042,11 @@ export class IntelHUD {
* Switch the HUD layout variant. Falls back to `'tactical'` if the
* name is unrecognized. Cyber also applies a coordinated skin to the full
* application shell; leaving Cyber restores the standard shell tokens.
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
* @param {string} variantName - One of `'tactical'`, `'minimal'`, `'cyber'`.
*/
setVariant(variantName) {
this._variant = normalizeHudLayout(variantName);
if (this._el) {
this._el.dataset.variant = this._variant;
}
this._present({ variant: this._variant });
applyHudUiTheme(document.documentElement, this._variant);
}
@@ -1084,6 +1100,8 @@ export class IntelHUD {
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
this._dataManagerUnsubscribe?.();
this._vesselUnsubscribe?.();
this._summaryRequest?.abort();
this._offLocale();
}
}
+6 -5
View File
@@ -1,9 +1,9 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium.
@@ -24,10 +24,11 @@ test('HUD sliders and location search have descriptive explicit names', () => {
test('the first-run checkbox keeps its native visible label', () => {
// The span may carry a data-i18n extraction attribute; the native wrapping
// label contract is what this guards.
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress\s*\/?>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/);
assert.match(parameters, /label:\s*t\(metadata\.label\),/);
assert.match(parameters, /h\('span', \{ class: 'param-label' \}, row\.label\)/);
assert.match(parameters, /'aria-label': row\.label,/);
});
+35 -23
View File
@@ -21,6 +21,7 @@
// one file that can construct a real IntelHUD.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { ensureGeoidReady } from './data/geoid.js';
@@ -62,19 +63,7 @@ const SFO_ELLIPSOIDAL_M = -15;
* text sinks this test reads.
*/
function installHudEnvironment() {
const elements = new Map(
[
'hud-alt',
'hud-summary',
'hud-mgrs',
'hud-latlon',
'hud-bottom-line',
'hud-gsd',
'hud-coll',
'hud-ona',
'hud-mode',
].map((id) => [id, { textContent: '' }]),
);
const { elements, publish, view } = hudReadouts();
const previousDocument = globalThis.document;
const cameraListeners = {};
globalThis.document = {
@@ -109,6 +98,8 @@ function installHudEnvironment() {
return {
elements,
viewer,
publish,
view,
cameraListeners,
restore() {
if (previousDocument === undefined) delete globalThis.document;
@@ -213,7 +204,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer);
hud = new IntelHUD(env.viewer, { publish: env.publish });
const alt = () => env.elements.get('hud-alt').textContent;
const summary = () => env.elements.get('hud-summary').textContent;
@@ -267,7 +258,7 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
env.viewer.camera.positionCartographic.height = 100;
let hud;
try {
hud = new IntelHUD(env.viewer);
hud = new IntelHUD(env.viewer, { publish: env.publish });
hud._updateCameraData(); // cold: requests the grid, paints uncorrected
assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/);
await ensureGeoidReady();
@@ -302,7 +293,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ place });
const summary = () => env.elements.get('hud-summary').textContent;
@@ -362,7 +353,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'Yellowstone' });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
@@ -371,7 +362,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
// The move aborts the request in flight, and the abort paints nothing.
let update = hud._updateSummary(false, true);
await settle();
element.textContent = 'shown before the move';
hud._present({ summary: 'shown before the move' });
env.cameraListeners.moveStart();
assert.equal(pending[0].signal.aborted, true, 'the request is aborted');
await update;
@@ -382,7 +373,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
ignoreAbort = true;
update = hud._updateSummary(false, true);
await settle();
element.textContent = 'shown before the move';
hud._present({ summary: 'shown before the move' });
env.cameraListeners.moveStart();
pending[1].reject(new Error('network'));
await update;
@@ -393,7 +384,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
env.cameraListeners.moveStart();
throw new Error('view target unavailable');
};
element.textContent = 'shown before the move';
hud._present({ summary: 'shown before the move' });
await hud._updateSummary(false, true);
assert.equal(element.textContent, 'shown before the move');
} finally {
@@ -424,7 +415,7 @@ test('a cancelled summary retries the same context but completed answers stay re
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
@@ -481,7 +472,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' });
const element = env.elements.get('hud-summary');
@@ -544,7 +535,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._updateCameraData();
await hud._updateSummary(true);
@@ -575,3 +566,24 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
env.restore();
}
});
// ── Retired layouts ─────────────────────────────────────────────────────────
//
// Share links keep the retired Operator code, so old links still name it. The
// real HUD, not a caller, is what turns it into the default Tactical layout.
test('a retired Operator layout falls back to Tactical in the live HUD', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer, { publish: env.publish });
hud.setVariant('minimal');
assert.equal(hud.getVariant(), 'minimal');
hud.setVariant('operator');
assert.equal(hud.getVariant(), 'tactical');
assert.equal(env.view().variant, 'tactical');
} finally {
hud?.destroy();
env.restore();
}
});
+3 -14
View File
@@ -9,6 +9,7 @@
// hudAltitudeDatum.test.mjs before importing it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
@@ -56,19 +57,7 @@ test('ordinary values keep their existing format', () => {
});
test('the telemetry tick paints the carried value into both readouts', () => {
const elements = new Map(
[
'hud-alt',
'hud-summary',
'hud-mgrs',
'hud-latlon',
'hud-bottom-line',
'hud-gsd',
'hud-coll',
'hud-ona',
'hud-mode',
].map((id) => [id, { textContent: '' }]),
);
const { elements, publish } = hudReadouts();
const previousDocument = globalThis.document;
globalThis.document = {
getElementById: (id) => elements.get(id) ?? null,
@@ -91,7 +80,7 @@ test('the telemetry tick paints the carried value into both readouts', () => {
};
let hud;
try {
hud = new IntelHUD(viewer);
hud = new IntelHUD(viewer, { publish });
hud._updateCameraData();
const latlon = elements.get('hud-latlon').textContent;
const bottom = elements.get('hud-bottom-line').textContent;
+1 -6
View File
@@ -1,7 +1,2 @@
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
export const HUD_LAYOUTS = Object.freeze([
'tactical',
'operator',
'minimal',
'cyber',
]);
export const HUD_LAYOUTS = Object.freeze(['tactical', 'minimal', 'cyber']);
+8 -1
View File
@@ -8,6 +8,13 @@ import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/**
* Scripts without letter case keep a space before the unit: `13 km` in
* Chinese and Japanese, `13 كم` in Arabic, which would otherwise run the
* digits into the word.
*/
const UNCASED_UNITS = /^(?:zh|ja|ar)(?:-|$)/;
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
@@ -16,7 +23,7 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits,
});
const label = unitLabel(unit);
if (getLocale().startsWith('zh-'))
if (UNCASED_UNITS.test(getLocale()))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
+90
View File
@@ -0,0 +1,90 @@
// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in
// an inline panel, whose host may report it hidden while it is on screen.
//
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
// from, so a module hook swaps it for a stub before the import (the same
// setup as hudAltitudeDatum.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
function installHiddenPage() {
const { elements, publish } = hudReadouts();
const previousDocument = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
alt: () => elements.get('hud-alt').textContent,
viewer,
publish,
restore() {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
},
};
}
test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installHiddenPage();
let hud;
try {
hud = new IntelHUD(page.viewer, { publish: page.publish });
hud._visible = true;
t.mock.timers.tick(250);
assert.equal(
page.alt(),
'ALT: --m SUN: --° EL',
'a hidden page skips the telemetry tick',
);
globalThis.GEV_EMBED_INLINE = true;
t.mock.timers.tick(250);
assert.match(
page.alt(),
/^ALT: 500m/,
'an inline panel reported hidden keeps its readout updating',
);
} finally {
delete globalThis.GEV_EMBED_INLINE;
hud?.destroy();
page.restore();
}
});
+80
View File
@@ -0,0 +1,80 @@
import { greatCircleMeters } from './geoDistance.js';
/**
* When the HUD's passive place lookup may spend another reverse geocode or
* Nearby Places request. Every request is a paid Google call, and the HUD
* asks on its own whenever the camera settles, so a new view only earns a
* new lookup once it shows a meaningfully different area: the center has
* moved by a quarter of the visible width, or the camera's distance to the
* center has doubled or halved. The width is measured at the view center
* from that distance, so terrain under the camera and a tilted view scale it
* as they scale what is on screen.
*/
const MOVE_FRACTION = 0.25;
const RANGE_RATIO = 2;
/** Street-level views still need a small move before they count as new. */
const MIN_SPAN_M = 100;
const DEFAULT_FOV_RAD = Math.PI / 3;
/**
* Describe the view a lookup answers for.
* @param {{latitude: number, longitude: number, rangeM: number, fovRad?: number, aspectRatio?: number, tier?: string}} view
* `rangeM` is the camera's distance to the view center. `fovRad` and
* `aspectRatio` are the Cesium perspective frustum's. `tier` names which
* lookups the camera height calls for; a view in another tier is new.
* @returns {{latitude: number, longitude: number, rangeM: number, spanM: number, tier: string}}
*/
export function placeLookupView({
latitude,
longitude,
rangeM,
fovRad,
aspectRatio,
tier = '',
}) {
const fov =
Number.isFinite(fovRad) && fovRad > 0 && fovRad < Math.PI
? fovRad
: DEFAULT_FOV_RAD;
// Cesium's fov spans the wider screen dimension, so a portrait screen's
// width is narrower than it.
const aspect = Number(aspectRatio);
const horizontalFov =
Number.isFinite(aspect) && aspect > 0 && aspect < 1
? 2 * Math.atan(Math.tan(fov / 2) * aspect)
: fov;
const range = Math.max(1, Number(rangeM) || 0);
return {
latitude,
longitude,
rangeM: range,
spanM: Math.max(MIN_SPAN_M, 2 * range * Math.tan(horizontalFov / 2)),
tier,
};
}
/**
* Whether `next` shows enough new ground to look its places up again.
* A missing or unusable previous view always does.
*/
export function placeLookupViewChanged(previous, next) {
if (!previous || !next || previous.tier !== next.tier) return true;
const ratio =
Math.max(previous.rangeM, next.rangeM) /
Math.min(previous.rangeM, next.rangeM);
if (!Number.isFinite(ratio) || ratio >= RANGE_RATIO) return true;
const moved = greatCircleMeters(
{ lat: previous.latitude, lon: previous.longitude },
{ lat: next.latitude, lon: next.longitude },
);
return !(moved < MOVE_FRACTION * previous.spanM);
}
/**
* The viewport samples the HUD geocodes besides the view center: one more
* point. Sample 0 is the screen center, so it stands in for the center only
* when the center itself is not geocoded at this height.
*/
export function hudViewportSamples(samples, centerGeocoded) {
return centerGeocoded ? samples.slice(1, 2) : samples.slice(0, 2);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Readouts other owners show in the Intel HUD. The vessel layer presents the
* selected vessel here and the HUD subscribes, so neither reaches into the
* other's markup.
*/
/** @type {?{text: string}} The selected vessel's lines, or null for none. */
let selectedVessel = null;
const vesselListeners = new Set();
/**
* Show the selected vessel's readout, or clear it.
* @param {?{text: string}} readout Its lines joined by newlines, or null.
*/
export function presentSelectedVessel(readout) {
selectedVessel = readout ? Object.freeze({ text: readout.text }) : null;
for (const listener of vesselListeners) listener(selectedVessel);
}
/**
* Follow the selected vessel's readout; the current one is replayed at once.
* @param {(readout: ?{text: string}) => void} listener
* @returns {() => void} Stop following.
*/
export function subscribeSelectedVessel(listener) {
vesselListeners.add(listener);
listener(selectedVessel);
return () => vesselListeners.delete(listener);
}
+180
View File
@@ -0,0 +1,180 @@
// The HUD's place lookups are paid Google requests made only to feed the AI
// summary, so a server without one must not be asked to make them.
//
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
// from, so a module hook swaps it for a stub before the import (the same
// setup as hudPageVisibility.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
function installPage() {
const { elements, publish } = hudReadouts();
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
visibilityState: 'visible',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
globalThis.window ??= {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
viewer,
publish,
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
/** A HUD whose place lookup and summary service only count their calls. */
function createHud(t, summaryService) {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installPage();
const hud = new IntelHUD(page.viewer, {
summaryService,
publish: page.publish,
});
t.after(() => {
hud.destroy();
page.restore();
});
const calls = { context: 0 };
hud._latestMetrics = { altM: 600 };
hud._summaryContext = async () => {
calls.context += 1;
return { placeLabels: ['Austin'], streetLabels: [], nearbyPlaceLabels: [] };
};
return { hud, calls };
}
test('a server that reports no AI summary gets no place lookups', async (t) => {
const service = {
calls: 0,
configured: async () => false,
async summarize() {
this.calls += 1;
return { ok: true, status: 200, data: { summary: 'never' } };
},
};
const { hud, calls } = createHud(t, service);
await hud._updateSummary(false, true);
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.equal(calls.context, 0);
assert.equal(service.calls, 0);
});
test('an unconfigured reply stops the lookups for the rest of the page', async (t) => {
const service = {
calls: 0,
async summarize() {
this.calls += 1;
return {
ok: true,
status: 200,
data: {
configured: false,
code: 'OPENAI_NOT_CONFIGURED',
error: null,
summary: null,
},
};
},
};
const { hud, calls } = createHud(t, service);
await hud._updateSummary(false, true);
assert.equal(calls.context, 1, 'a server without the probe is asked once');
hud._markSummaryDirty();
await hud._updateSummary(false, true);
assert.equal(calls.context, 1);
assert.equal(service.calls, 1);
});
test('a configured or unknown summary keeps looking places up', async (t) => {
for (const configured of [true, null, undefined]) {
await t.test(String(configured), async (st) => {
const service = {
configured:
configured === undefined ? undefined : async () => configured,
summarize: async () => ({
ok: true,
status: 200,
data: { summary: 'Austin capitol district live' },
}),
};
const { hud, calls } = createHud(st, service);
await hud._updateSummary(false, true);
assert.equal(calls.context, 1);
});
}
});
test('an unchanged context restores its accepted reply over the fallback line', async (t) => {
const service = {
calls: 0,
configured: async () => true,
async summarize() {
this.calls += 1;
return {
ok: true,
status: 200,
data: { summary: 'Austin capitol district live' },
};
},
};
const { hud } = createHud(t, service);
const shown = () =>
globalThis.document.getElementById('hud-summary').textContent;
await hud._updateSummary(false, true);
assert.equal(shown(), 'Austin capitol district live');
// A small pan settles: the fallback line is painted and the reused labels
// give the same context, so no new request is made.
hud._setSummaryText('fallback line', false);
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.equal(service.calls, 1);
assert.equal(shown(), 'Austin capitol district live');
});
+206
View File
@@ -0,0 +1,206 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatDate,
formatNumber,
getLocale,
keepCodesWithValues,
setLocale,
t,
tn,
} from './index.js';
import {
applyDocumentLanguage,
intlLocale,
LOCALE_STORAGE_KEY,
resolveLocale,
} from './locale.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { createPresentation as createCctvPresentation } from '../layers/cctv/presentation.js';
import {
formatQuantity,
formatScaleLabel,
formatScaleRange,
} from '../ui/unitLabels.js';
test('Arabic resolves from URL, saved preference and browser language', () => {
assert.equal(
resolveLocale({
location: { search: '?lang=ar-EG' },
storage: { getItem: () => 'en' },
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: {
getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'ar' : null),
},
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: { getItem: () => null },
languages: ['ar-SA', 'en'],
}),
'ar',
);
});
test('Arabic turns the page right-to-left; other languages stay left-to-right', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ar');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'zh-CN');
assert.deepEqual(doc.documentElement, { lang: 'zh-CN', dir: 'ltr' });
});
test('Arabic formats with Western digits whatever ICU defaults to', () => {
assert.equal(intlLocale('ar'), 'ar-u-nu-latn');
assert.equal(intlLocale('ru'), 'ru');
const previous = getLocale();
try {
setLocale('ar');
assert.equal(formatNumber(1234.5), '1,234.5');
assert.equal(
formatDate(Date.UTC(2026, 7, 26), {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}),
'26 أغسطس 2026',
);
assert.doesNotMatch(formatNumber(9876543210), /[٠-٩]/);
} finally {
setLocale(previous);
}
});
/** The text without the bidi isolates t() puts around values in RTL. */
const plain = (text) => text.replace(/[\u2068\u2069]/g, '');
test('Arabic uses all six plural forms and spaced HUD units', () => {
const previous = getLocale();
try {
setLocale('ar');
assert.deepEqual(
[0, 1, 2, 3, 11, 100].map((n) =>
plain(tn(n, '{count} SOURCE', '{count} SOURCES')),
),
[
'0 مصدر',
'مصدر واحد (1)',
'مصدران (2)',
'3 مصادر',
'11 مصدرًا',
'100 مصدر',
],
);
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 كم');
// Isolated, so the number keeps its unit next to Latin words.
assert.equal(formatCockpitDistance(14000), '\u206814\u00a0كم\u2069');
assert.equal(
`Sentinel-2 · ${formatQuantity(30, 'm')}`,
'Sentinel-2 · \u206830\u00a0م\u2069',
);
assert.equal(
plain(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 })),
'القطاع 21.33ش 77.50غ',
);
assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام');
assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح');
// Each value is isolated, so a time keeps its order inside Arabic text.
assert.equal(
t('Issued: {time}', { time: '10/28 00:00 UTC' }),
'صدر: \u206810/28 00:00 UTC\u2069',
);
// A Latin code stays beside its own value: HDG 68° · FOV 44°.
assert.equal(
keepCodesWithValues(
t('{city} · HDG {heading} · FOV {fov}', {
city: 'Austin',
heading: '68°',
fov: '44°',
}),
),
'\u2068Austin\u2069 · \u2068HDG \u206868°\u2069\u2069 · \u2068FOV \u206844°\u2069\u2069',
);
// A legend's open bound keeps its plus on the written side: 300+.
assert.deepEqual(['300+', '0.1'].map(formatScaleLabel), [
'\u2068300+\u2069',
'\u20680.1\u2069',
]);
// A Latin unit after the range keeps its order: °C, not C°.
assert.equal(
formatScaleRange('-40', '50+', '°C'),
`${formatScaleLabel('-40')} — \u206850+\u2069\u00a0\u2068°C\u2069`,
);
// Counted minutes agree with the number (3–10 plural, 11+ singular).
assert.deepEqual(
[3, 15].map((n) =>
formatNumber(n, {
style: 'unit',
unit: 'minute',
unitDisplay: 'long',
}),
),
['3 دقائق', '15 دقيقة'],
);
setLocale('ru');
assert.equal(keepCodesWithValues('HDG 68°'), 'HDG 68°');
assert.equal(formatCockpitDistance(14000), '14 КМ');
} finally {
setLocale(previous);
}
});
test('Arabic keeps each CCTV summary code beside its own value', () => {
const camera = {
id: 'cam-1',
city: 'Austin',
name: 'Congress Ave',
headingDeg: 68,
fovDeg: 44,
rangeM: 120,
feedType: 'image',
};
const active = { camera };
const presentation = createCctvPresentation({
state: {
_records: [active],
_healthById: new Map(),
_showProjection: true,
_coverageMode: 'sector',
},
parts: {
selection: { getActiveRecord: () => active },
model: {
sectorAreaKm2: () => 0.01,
currentViewContext: () => 'street:1',
},
geometry: { coverageNeighborCount: () => 0 },
calibration: { deriveCalBadge: () => 'auto' },
},
});
const previous = getLocale();
try {
setLocale('ar');
const parts = presentation.buildSummaryText().split(' · ');
// Each part is its own isolate: «HDG 68° · FOV 44°» cannot form one
// left-to-right run that hands 44° to the HDG side.
for (const part of parts) assert.match(part, /^\u2068[\s\S]*\u2069$/);
assert.ok(parts.includes('\u2068FOV \u206844\u2069°\u2069'), parts.join(' | '));
setLocale('en');
assert.doesNotMatch(presentation.buildSummaryText(), /[\u2068\u2069]/);
} finally {
setLocale(previous);
}
});
+27
View File
@@ -5,6 +5,7 @@ import {
checkCatalog,
extractMessages,
findUnmarked,
markedTemplate,
readCatalog,
shippedLocales,
unmarkedMarkup,
@@ -111,6 +112,32 @@ test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('markedTemplate rejects data-i18n* attributes in a Vue template', () => {
const view = [
'<script setup>',
"const note = 'data-i18n';",
'</script>',
'<template>',
' <!-- no data-i18n here -->',
' <span data-i18n>Normal</span>',
' <b title="Explains data-i18n" data-i18n-title>{{ t(\'Go\') }}</b>',
' <i :data-i18n="true" />',
'</template>',
].join('\n');
assert.deepEqual(markedTemplate('View.vue', view), [
{ where: 'View.vue:6', text: 'data-i18n: use t() in a Vue template' },
{ where: 'View.vue:7', text: 'data-i18n-title: use t() in a Vue template' },
{ where: 'View.vue:8', text: 'data-i18n: use t() in a Vue template' },
]);
assert.deepEqual(
markedTemplate(
'View.vue',
'<template><b :title="t(\'Pin\')">x</b></template>',
),
[],
);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
+135 -28
View File
@@ -5,8 +5,10 @@ import {
applyDocumentTranslations,
formatDate,
formatNumber,
formatRelativeTime,
msg,
persistLocaleAndReload,
onLocaleChange,
persistLocale,
setLocale,
t,
tn,
@@ -33,8 +35,23 @@ function memoryStorage(initial = {}) {
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('uk'), 'uk');
assert.equal(normalizeLocale('uk-UA'), 'uk');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('de'), 'de');
assert.equal(normalizeLocale('de-AT'), 'de');
assert.equal(normalizeLocale('de_CH'), 'de');
assert.equal(normalizeLocale('fr'), 'fr');
assert.equal(normalizeLocale('fr-CA'), 'fr');
assert.equal(normalizeLocale('FR_be'), 'fr');
assert.equal(normalizeLocale('pt-BR'), 'pt');
assert.equal(normalizeLocale('pt_PT'), 'pt');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale('it-IT'), 'it');
assert.equal(normalizeLocale('it_CH'), 'it');
assert.equal(normalizeLocale('ja'), 'ja');
assert.equal(normalizeLocale('ja-JP'), 'ja');
assert.equal(normalizeLocale('JA_jp'), 'ja');
// Explicit script tags take precedence over regional defaults.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
@@ -47,6 +64,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant');
assert.equal(normalizeLocale('zh_HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-MO'), 'zh-Hant');
assert.equal(normalizeLocale('ar'), 'ar');
assert.equal(normalizeLocale('ar-EG'), 'ar');
assert.equal(normalizeLocale(' AR_sa '), 'ar');
assert.equal(normalizeLocale(undefined), null);
});
@@ -68,7 +88,7 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
resolveLocale({
location: { search: '?lang=xx' },
storage: memoryStorage(),
languages: ['de-DE', 'ru-RU'],
languages: ['ko-KR', 'ru-RU'],
}),
'ru',
);
@@ -76,7 +96,15 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
languages: ['de-DE', 'ru-RU'],
}),
'de',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['ko'],
}),
'en',
);
@@ -102,6 +130,10 @@ test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
applyDocumentLanguage(doc, 'ar-EG');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'en');
assert.deepEqual(doc.documentElement, { lang: 'en', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
@@ -142,6 +174,36 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('tn() picks the Portuguese one, many and other forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('pt');
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
});
test('tn() picks the four Ukrainian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('uk');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
@@ -164,7 +226,13 @@ test('Intl formatters follow the active locale', () => {
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад');
assert.equal(formatNumber(1234.5), '1 234,5');
// Japanese keeps a number and its unit together, as the catalog does.
setLocale('ja');
assert.equal(formatDate(date, options), '2026年1月15日');
assert.equal(formatRelativeTime(-5, 'minute'), '5分前');
assert.equal(formatRelativeTime(-3, 'day'), '3日前');
});
function fakeElement({ text = '', attributes = {} } = {}) {
@@ -198,15 +266,60 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
assert.equal(label.textContent, t('POWERED UP'));
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
assert.equal(unknown.textContent, 'No such message');
// A later pass starts from the English source, so every switch applies.
setLocale('de');
applyDocumentTranslations(doc);
assert.equal(label.textContent, t('POWERED UP'));
setLocale('en');
assert.equal(applyDocumentTranslations(doc), 2, 'English restores');
assert.equal(label.textContent, 'POWERED UP');
assert.equal(pin.attrs.get('title'), 'POWERED UP');
// Text that code wrote since is the new source; a pass leaves it alone.
label.textContent = 'Error: offline';
setLocale('ru');
applyDocumentTranslations(doc);
assert.equal(label.textContent, 'Error: offline');
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
test('onLocaleChange listeners hear each change until removed', () => {
const heard = [];
const off = onLocaleChange(() => heard.push(t('POWERED UP')));
const broken = onLocaleChange(() => {
throw new Error('broken listener');
});
const error = console.error;
console.error = () => {};
try {
setLocale('ru');
setLocale('ru');
assert.deepEqual(heard, [t('POWERED UP')], 'once, in the new locale');
off();
setLocale('en');
assert.equal(heard.length, 1);
} finally {
console.error = error;
broken();
}
});
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
const option = fakeElement({
text: 'All',
attributes: { 'data-i18n-context': 'radio tag' },
});
const doc = {
querySelectorAll: (selector) =>
selector === '[data-i18n]' ? [option] : [],
};
setLocale('pt');
applyDocumentTranslations(doc);
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
assert.notEqual(option.textContent, t('All'));
});
test('persistLocale keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = { href: 'https://gev.local/?x=1#scene=a' };
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
@@ -215,36 +328,30 @@ test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked',
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.equal(persistLocale('ru', { location, history, storage }), 'ru');
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
test('persistLocale stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage();
const calls = [];
const location = {
href: 'https://gev.local/?lang=en&x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const location = { href: 'https://gev.local/?lang=en&x=1#scene=a' };
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.equal(persistLocale('ru', { location, history, storage }), 'ru');
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1#scene=a'],
['reload'],
]);
assert.deepEqual(calls, [['replace', 'https://gev.local/?x=1#scene=a']]);
// An address without ?lang is left as it is.
calls.length = 0;
persistLocale('de', {
location: { href: 'https://gev.local/#scene=a' },
history,
storage,
});
assert.deepEqual(calls, []);
});
test('tn() shows a caller-formatted count and still picks the form by number', () => {
+166 -86
View File
@@ -13,47 +13,145 @@
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
intlLocale,
isRtlLocale,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import ar from './locales/ar.json' with { type: 'json' };
import de from './locales/de.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import fr from './locales/fr.json' with { type: 'json' };
import it from './locales/it.json' with { type: 'json' };
import ja from './locales/ja.json' with { type: 'json' };
import pt from './locales/pt.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import uk from './locales/uk.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
const CATALOGS = Object.freeze({
ar,
de,
es,
fr,
it,
ja,
pt,
ru,
uk,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
// The page's locale. The UI binds it to its vue-i18n instance's
// (src/ui/i18n.js), so a Vue render that translates re-renders when it
// changes; until then, and in portable code, it is plain state.
let pageLocale = { value: DEFAULT_LOCALE };
/**
* Keep the active locale in `locale`, a ref such as a vue-i18n composer's
* `locale`. It takes the current locale; switch with setLocale afterwards.
* @param {{value: string}} locale
*/
export function bindLocale(locale) {
locale.value = pageLocale.value;
pageLocale = locale;
bound = true;
}
let bound = false;
const localeListeners = new Set();
/**
* Call `listener` after the active locale changes, so code that shows text it
* translated itself (a status line, a count, a time) shows it again. Text a
* Vue view translates while rendering follows the locale without this.
* @param {() => void} listener
* @returns {() => void} Stops listening.
*/
export function onLocaleChange(listener) {
if (typeof listener !== 'function') return () => {};
localeListeners.add(listener);
return () => localeListeners.delete(listener);
}
/**
* Tell the listeners the locale changed. setLocale does this for an unbound
* locale; the owner of a bound ref calls it when the ref changes.
*/
export function notifyLocaleChange() {
for (const listener of [...localeListeners]) {
try {
listener();
} catch (error) {
console.error('[i18n] locale listener failed', error);
}
}
}
/** The catalog for a locale; null for English and unknown codes. */
export function getCatalog(locale) {
return CATALOGS[normalizeLocale(locale)] ?? null;
}
/** Active locale. */
let boundValue = DEFAULT_LOCALE;
let activeLocale = DEFAULT_LOCALE;
/** Active locale, normalized: a bound ref may be set to any language tag. */
export function getLocale() {
return currentLocale;
const value = pageLocale.value;
if (value !== boundValue) {
boundValue = value;
activeLocale = normalizeLocale(value) || DEFAULT_LOCALE;
}
return activeLocale;
}
/**
* Switch the active locale. Pure state change: call applyDocumentLanguage /
* applyDocumentTranslations to reflect it in the DOM.
* A value inserted into right-to-left text as one unit: `30 م` next to
* `Sentinel-2` would otherwise lend its digits to the Latin run and leave the
* unit stranded. First-strong isolates (U+2068…U+2069) in RTL locales only.
*/
export function isolate(text) {
return isRtlLocale(getLocale()) ? `\u2068${text}\u2069` : String(text);
}
const CODE_BEFORE_VALUE =
/(?<![\p{L}\p{N}])([A-Z][A-Z0-9/]+) (\u2068[^\u2068\u2069]*\u2069)/gu;
/**
* A Latin code and the isolated value after it ("HDG 68\u00b0") as one unit in
* right-to-left text. Two codes in a row (HDG 68\u00b0 \u00b7 FOV 44\u00b0) otherwise form a
* single left-to-right run and the second code ends up beside the first
* value. Applies to t() output; a no-op outside RTL locales.
*/
export function keepCodesWithValues(text) {
if (!isRtlLocale(getLocale())) return text;
return String(text).replace(CODE_BEFORE_VALUE, '\u2068$1 $2\u2069');
}
/**
* Switch the active locale. Views re-render and onLocaleChange listeners
* word their text again; the application keeps <html lang>/<html dir> and
* the page's data-i18n markup in step.
* @returns {string} The locale actually applied (normalized).
*/
export function setLocale(locale) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
if (normalized !== currentLocale) {
currentLocale = normalized;
if (normalized !== pageLocale.value) {
pageLocale.value = normalized;
formatterCache.clear();
if (!bound) notifyLocaleChange();
}
return currentLocale;
return normalized;
}
/** Development-only warning; silent under node:test and in production. */
function warnMissing(text) {
if (import.meta.env?.DEV !== true) return;
console.warn(
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
`[i18n] no ${getLocale()} translation for ${JSON.stringify(text)}`,
);
}
@@ -63,16 +161,20 @@ const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
// In a right-to-left page each value is isolated: a time, a code or an
// English name keeps its own order instead of mixing with the sentence.
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => {
if (!Object.hasOwn(values, name)) return literal;
const value = String(values[name]);
return value === '' ? value : isolate(value);
});
}
/** A catalog string with something to show; an empty one counts as missing. */
const filled = (value) => typeof value === 'string' && value.trim() !== '';
function lookup(text) {
const catalog = CATALOGS[currentLocale];
const catalog = CATALOGS[getLocale()];
if (!catalog) return undefined;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
@@ -107,7 +209,7 @@ export function t(text, values) {
*/
export function tn(count, one, other, values) {
const entry = lookup(other);
const category = pluralRules(currentLocale).select(count);
const category = pluralRules(getLocale()).select(count);
let pattern;
const forms = entry && typeof entry === 'object' ? entry : null;
if (forms && (filled(forms[category]) || filled(forms.other))) {
@@ -124,7 +226,7 @@ export function tn(count, one, other, values) {
* missing-translation warning.
*/
export function translateKnown(text) {
const catalog = CATALOGS[currentLocale];
const catalog = CATALOGS[getLocale()];
if (!catalog || typeof text !== 'string') return text;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
return filled(entry) ? entry : text;
@@ -154,10 +256,10 @@ function pluralRules(locale) {
const formatterCache = new Map();
function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
const cacheKey = `${kind}|${getLocale()}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatter = new Constructor(intlLocale(getLocale()), options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
@@ -193,7 +295,7 @@ export function formatUtcDayMonth(value) {
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
if (getLocale() === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
@@ -201,13 +303,19 @@ export function formatUtcDayMonth(value) {
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' /
* '5分前'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
const text = cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
// unit together (5分前), so the two must not differ on one screen.
return getLocale() === 'ja'
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
: text;
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
@@ -221,7 +329,9 @@ function normalizeMarkupText(text) {
* Markup opt-in: the element's own English text or attribute is the source.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
* Also data-i18n-aria-label and data-i18n-placeholder. data-i18n-context
* gives the element's text a context, as t(text, { context }) does:
* <option data-i18n data-i18n-context="radio tag">All</option>
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
@@ -230,72 +340,57 @@ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n-placeholder', 'placeholder'],
]);
// Per element, the English source a pass read and the text it wrote, so a
// later pass in another locale, English included, starts from the source
// rather than from that text. Text that code wrote since is the new source.
const documentSources = new WeakMap();
/**
* Translate every opted-in element in `doc`. Runs once at boot; switching
* locale reloads the page.
* Translate every opted-in element in `doc` into the active locale. Runs at
* boot and again after every locale change (the page's own markup, such as
* a host's, is not rendered by a view).
* @param {Document|Element} doc
* @returns {number} Count of element/attribute writes performed.
*/
export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
let applied = 0;
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${marker}]`)) {
const source = attribute
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
const read = () =>
attribute
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
let sources = documentSources.get(element);
if (!sources) documentSources.set(element, (sources = {}));
const shown = read();
const known = sources[marker];
const source = known && shown === known.written ? known.source : shown;
sources[marker] = { source, written: shown };
if (!source) continue;
const text = t(source);
if (text === source) continue;
const context = attribute
? null
: element.getAttribute?.('data-i18n-context');
const text = context ? t(source, { context }) : t(source);
if (text === shown) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
sources[marker].written = read();
applied += 1;
}
}
return applied;
}
const unsavedWorkChecks = new Set();
/**
* Name work that switching language would lose: the switch reloads the page,
* and the language switch asks first while any check reports something.
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
* @returns {() => void} Removes the check.
* Store a locale choice for the next visit and keep the address in step
* without reloading: ?lang is dropped once the choice is stored, or carries
* the choice where storage is blocked (share links drop it:
* ShareLinkManager.copyLink). The hash is kept exactly. setLocale applies
* the choice to the page.
* @returns {string} The normalized locale.
*/
export function registerUnsavedWork(check) {
if (typeof check !== 'function') return () => {};
unsavedWorkChecks.add(check);
return () => unsavedWorkChecks.delete(check);
}
/**
* What a reload would lose right now, translated, without repeats.
* @returns {string[]}
*/
export function describeUnsavedWork() {
const labels = new Set();
for (const check of unsavedWorkChecks) {
try {
const label = check();
if (typeof label === 'string' && label) labels.add(t(label));
} catch {
// A failing check must not block the switch.
}
}
return [...labels];
}
/**
* Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string, so the stored choice applies.
* Where storage is blocked, ?lang carries the choice instead (share links
* drop it: ShareLinkManager.copyLink).
* @returns {boolean} true when a reload was triggered.
*/
export function persistLocaleAndReload(
export function persistLocale(
locale,
{
location = globalThis.location,
@@ -305,28 +400,13 @@ export function persistLocaleAndReload(
) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false;
let target = location.href;
try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href);
if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href;
if (url.href !== location.href) history?.replaceState?.(null, '', url.href);
} catch {
// Keep the current href.
// Without a location or history the stored choice still applies.
}
// location.assign() to the identical URL does not navigate, so strip ?lang
// in place and force a real reload instead.
try {
history?.replaceState?.(null, '', target);
} catch {
// replaceState is best-effort; the reload below still applies.
}
if (typeof location.reload === 'function') {
location.reload();
} else {
location.assign(target);
}
return true;
return normalized;
}
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from './index.js';
import {
cockpitUnitCase,
formatCockpitDistance,
} from '../ui/cockpitPresentation.js';
test('Japanese cockpit distances keep the unit in its own case', () => {
try {
setLocale('ja');
assert.equal(formatCockpitDistance(14000), '14 km');
assert.equal(formatCockpitDistance(9500), '9.5 km');
assert.equal(cockpitUnitCase('km'), 'km');
setLocale('en');
assert.equal(formatCockpitDistance(14000), '14 KM');
assert.equal(cockpitUnitCase('km'), 'KM');
} finally {
setLocale('en');
}
});
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from './index.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
// revmain-opus-ja-i1-1: ja has no case, so HUD units keep the catalog case
// (km), as the cockpit already does and docs/translations/ja.md requires.
test('Japanese HUD units keep their case, like the cockpit', () => {
try {
setLocale('ja');
assert.equal(formatCockpitDistance(14000), '14 km');
assert.equal(hudQuantity(13, 'km'), '13 km');
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
assert.equal(
LOCALIZED_LOCALITY.near({ poi: 'Shibuya', city: 'Tokyo', distKm: 13 }),
'SHIBUYA(東京)付近 13 km',
);
} finally {
setLocale('en');
}
});
Loaded 100 of 756 files, more files were not shown because too many files have changed in this diff. Show more