Merge origin/main into claude/hal-676-gev-fire-intensity-742bd1

Main moved the layer rows to Vue, so the fire confidence switch and
minimum-FRP slider now render through RowSwitch and RowSlider on the
kit's UiSegmented and UiSlider, with the same classes as before. The
fire filter strings are translated into Arabic.
This commit is contained in:
German Khokhlov committed 2026-11-02 13:01:17 +04:00
commit 02953665de
665 files changed
+44368 -19993

No files matched your search

+2 -3
View File
@@ -12,6 +12,7 @@ import {
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
import { formatDistanceText } from '../data/distanceFormat.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -1572,9 +1573,7 @@ function greatCircleM(a, b) {
}
function formatDistance(m) {
if (!Number.isFinite(m)) return null;
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return `${Math.round(m / 10) * 10} m`;
return formatDistanceText(m, { metreStep: 10 });
}
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
+15
View File
@@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
installAnimationFrameStubs(t);
const labels = [];
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
for (const metres of [994, 996, 9940, 9960]) {
await engine.annotate([
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
]);
}
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
engine.destroy();
});
+8 -8
View File
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
*/
import { greatCircleMeters } from '../geoDistance.js';
import { formatDistanceText } from '../data/distanceFormat.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
@@ -207,17 +208,16 @@ export function ringCentroid(vertices) {
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
}
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 '';
}
+48
View File
@@ -345,3 +345,51 @@ 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('a line that rounds to 10.0 km reads in whole kilometres', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
assert.equal(formatMeasure(line), '10 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',
`
+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());
+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;
}
}
+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);
});
+89 -78
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*\};/,
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(
@@ -230,7 +235,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
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, /#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\(\)/,
@@ -246,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',
);
@@ -264,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\) \{/,
@@ -272,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',
);
@@ -471,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;/,
@@ -530,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,/,
);
});
@@ -546,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,
@@ -699,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(
@@ -760,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,
@@ -798,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', () => {
@@ -888,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\._radioNextBtn, '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 \}\)"/,
);
});
@@ -940,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(
@@ -976,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,
@@ -1061,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,
@@ -1091,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,
@@ -1176,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"/);
@@ -1188,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],
@@ -1200,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'\)/,
);
});
@@ -1258,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"/);
@@ -1269,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', () => {
+7 -7
View File
@@ -1,10 +1,10 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from './data/headingDegrees.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
return ((value % 360) + 360) % 360;
}
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
export { normalizeHeading };
/**
* Three-digit heading readout, 000–359.
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
+1
View File
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
+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,
);
});
+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\)/);
+38 -87
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,30 +65,45 @@ 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._cockpitRadioToggleBtn,'));
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', '_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"/);
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="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
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');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
// 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']);
});
+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')
);
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
+38 -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();
}
@@ -1322,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
assert.deepEqual(bare.details, []);
});
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
assert.equal(
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
'CONTAINER SHIP · 14.5KT · 0°',
);
});
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, { heading: null, course: 359.7 });
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
} finally {
harness.cleanup();
}
});
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
assert.equal(unnamed.title, 'MMSI 353136000');
+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({
+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],
]);
});
-27
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,20 +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],
// Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+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);
+2
View File
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
assert.equal(routeDistanceText(9960), '10\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilometre decimals for a distance: one below 10 km, none from there on.
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
* @param {number} meters
* @returns {0|1}
*/
export function kilometreDigits(meters) {
return Math.round(meters / 100) < 100 ? 1 : 0;
}
/**
* The unit and precision a distance prints with: metres below 1 km, then
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
* except below `exactBelowM`, where they stay whole. Each cut is taken after
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
* distance that is negative or not finite. The value is already rounded to
* `digits`, so toFixed and Intl render it the same way.
*/
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
if (!Number.isFinite(meters) || meters < 0) return null;
const step = meters < exactBelowM ? 1 : metreStep;
const shownMeters = Math.round(meters / step) * step;
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
const digits = kilometreDigits(meters);
const value =
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
return { value, unit: 'km', digits };
}
/**
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {string|null}
*/
export function formatDistanceText(meters, options) {
const parts = distanceParts(meters, options);
if (!parts) return null;
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
distanceParts,
formatDistanceText,
kilometreDigits,
} from './distanceFormat.js';
test('a distance switches to kilometres where its metres round to 1,000', () => {
assert.equal(formatDistanceText(999.4), '999 m');
assert.equal(formatDistanceText(999.5), '1.0 km');
assert.equal(formatDistanceText(999.6), '1.0 km');
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
});
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
assert.equal(kilometreDigits(9949), 1);
assert.equal(kilometreDigits(9950), 0);
assert.equal(formatDistanceText(9949), '9.9 km');
assert.equal(formatDistanceText(9950), '10 km');
assert.equal(formatDistanceText(9960), '10 km');
assert.equal(formatDistanceText(99950), '100 km');
});
test('metres below exactBelowM stay whole while the rest round to the step', () => {
const options = { metreStep: 10, exactBelowM: 100 };
assert.equal(formatDistanceText(8, options), '8 m');
assert.equal(formatDistanceText(99.4, options), '99 m');
assert.equal(formatDistanceText(846, options), '850 m');
assert.deepEqual(distanceParts(846, options), {
value: 850,
unit: 'm',
digits: 0,
});
});
test('a negative or non-finite distance has no parts', () => {
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
assert.equal(distanceParts(value), null);
assert.equal(formatDistanceText(value), null);
}
assert.equal(formatDistanceText(0), '0 m');
});
test('kilometres come back rounded, so toFixed and Intl agree', () => {
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
assert.equal(formatDistanceText(1450), '1.5 km');
assert.equal(formatDistanceText(1150), '1.2 km');
});
+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);
@@ -116,6 +117,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
+6 -1
View File
@@ -3,6 +3,7 @@ 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';
@@ -258,7 +259,8 @@ test('the IPv4 retry answering an invalid reason phrase rejects instead of crash
// 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 on `localhost` refuses both addresses.
// 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 {
@@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection',
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/);
+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,
},
);
}
+19
View File
@@ -0,0 +1,19 @@
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
// Only a negative remainder takes the extra turn, so a positive bearing
// keeps its precision (0.49999999999999 must not become 360.5).
const remainder = value % 360;
const wrapped = remainder < 0 ? remainder + 360 : remainder;
return wrapped === 360 ? 0 : wrapped;
}
/**
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
* and negative or multi-turn values wrap. Null when the value is not finite,
* so each caller keeps its own placeholder.
*/
export function roundHeadingDegrees(value) {
if (!Number.isFinite(value)) return null;
return Math.round(normalizeHeading(value)) % 360;
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
assert.equal(normalizeHeading(370), 10);
assert.equal(normalizeHeading(-10), 350);
assert.equal(normalizeHeading(720), 0);
assert.equal(normalizeHeading(Number.NaN), 0);
assert.equal(normalizeHeading(Infinity), 0);
});
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
assert.equal(roundHeadingDegrees(359.4), 359);
assert.equal(roundHeadingDegrees(359.5), 0);
assert.equal(roundHeadingDegrees(359.6), 0);
assert.equal(roundHeadingDegrees(360), 0);
assert.equal(roundHeadingDegrees(0), 0);
assert.equal(roundHeadingDegrees(194.2), 194);
});
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
assert.equal(roundHeadingDegrees(-0.6), 359);
assert.equal(roundHeadingDegrees(-1), 359);
assert.equal(roundHeadingDegrees(719.6), 0);
assert.equal(roundHeadingDegrees(-360.6), 359);
});
test('roundHeadingDegrees returns null for a value that is not finite', () => {
for (const value of [
Number.NaN,
Infinity,
-Infinity,
null,
undefined,
'12',
]) {
assert.equal(roundHeadingDegrees(value), null);
}
});
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
assert.equal(normalizeHeading(-1e-15) < 360, true);
});
+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';
@@ -735,18 +735,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;
+69 -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',
@@ -76,6 +79,9 @@ test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
});
function installAwarenessRuntime({
@@ -98,27 +104,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 +204,7 @@ function installAwarenessRuntime({
return null;
},
createElement() {
const element = new FakeElement();
createdElements.push(element);
return element;
return new FakeElement();
},
addEventListener() {},
removeEventListener() {},
@@ -229,6 +218,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 +241,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 +258,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 +473,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 +615,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 +658,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 +1123,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 +1158,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 +2116,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 +4106,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 +4129,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();
+2 -4
View File
@@ -2,6 +2,7 @@
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
import { formatDistanceText } from './distanceFormat.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -46,10 +47,7 @@ export function findByDoublingRadius(
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return formatDistanceText(meters) ?? '—';
}
/**
@@ -25,6 +25,15 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid
assert.equal(formatAwarenessLabel(undefined), '—');
});
test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(formatAwarenessDistance(999.4), '999 m');
assert.equal(formatAwarenessDistance(999.6), '1.0 km');
assert.equal(formatAwarenessDistance(4600), '4.6 km');
assert.equal(formatAwarenessDistance(9949), '9.9 km');
assert.equal(formatAwarenessDistance(9950), '10 km');
assert.equal(formatAwarenessDistance(9960), '10 km');
});
test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => {
const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]);
assert.equal(result.relationship, 'NEARBY');
-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;
+12 -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,
@@ -448,6 +445,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp
assert.ok(liftoff.phaseProgress > 0);
});
test('runs the five-second tile hold before a full T-10 countdown', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
// REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead.
const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5);
assert.equal(hold.preCountdownActive, true);
assert.equal(hold.countdownActive, false);
const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5);
assert.equal(countdown.preCountdownActive, false);
assert.equal(countdown.countdownActive, true);
assert.equal(countdown.countdownSeconds, 10);
});
test('hands replay directly from ascent into orbit', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000);
@@ -514,42 +523,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 +541,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' };
+3 -5
View File
@@ -13,6 +13,8 @@
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
import { formatDistanceText } from './distanceFormat.js';
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
@@ -216,11 +218,7 @@ export function normalizeOsrmSteps(route) {
* @returns {string}
*/
export function formatRouteDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return `${Math.round(meters)} m`;
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
}
/**
+2
View File
@@ -236,6 +236,8 @@ test('distance and duration formatting', () => {
assert.equal(formatRouteDistance(8), '8 m');
assert.equal(formatRouteDistance(846), '850 m');
assert.equal(formatRouteDistance(1234), '1.2 km');
assert.equal(formatRouteDistance(996), '1.0 km');
assert.equal(formatRouteDistance(9960), '10 km');
assert.equal(formatRouteDistance(21479), '21 km');
assert.equal(formatRouteDistance(-1), '');
assert.equal(formatRouteDuration(40), '40 s');
+11
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
@@ -136,6 +139,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
const timeouts = new Map();
let timerId = 0;
let server;
const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-'));
let trafficLayer;
let viewer;
@@ -159,6 +163,12 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
};
server = await createServer({
root: fileURLToPath(new URL('../..', import.meta.url)),
// Its own optimizer cache, not node_modules/.vite in the package tree:
// a host running Core's suite refuses files inside Core.
cacheDir,
// The test loads modules over SSR; the browser dependency optimizer would
// keep writing its cache after the server closes.
optimizeDeps: { noDiscovery: true, include: [] },
configFile: false,
appType: 'custom',
logLevel: 'silent',
@@ -328,6 +338,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
console.warn = originalWarn;
performance.clearMarks();
performance.clearMeasures();
rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3 });
}
});
+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 };
}
+16 -53
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';
@@ -413,11 +413,6 @@ test('one ESC does one thing — a Cockpit utility disclosure stops later docume
});
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.
@@ -442,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', () => {
@@ -506,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/,
@@ -797,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(
@@ -816,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>
@@ -840,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',
@@ -915,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(
@@ -933,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/);
@@ -963,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),
+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);
});
+115 -129
View File
@@ -37,9 +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 = {
@@ -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;
@@ -268,79 +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-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,16 +360,13 @@ 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). Skipped in a hidden
@@ -449,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
@@ -477,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
@@ -733,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);
@@ -796,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);
}
@@ -924,8 +913,7 @@ 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 });
}
/**
@@ -984,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) {
@@ -1012,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);
@@ -1021,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). */
@@ -1060,9 +1046,7 @@ export class IntelHUD {
*/
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);
}
@@ -1116,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,/);
});
+16 -26
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);
@@ -585,13 +576,12 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer);
hud._el = { dataset: {} };
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(hud._el.dataset.variant, '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;
+7 -3
View File
@@ -9,9 +9,13 @@ import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/**
* An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language.
* Chinese and Japanese have no case and keep the catalog's `13 km`.
* 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, {
useGrouping: false,
@@ -19,7 +23,7 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits,
});
const label = unitLabel(unit);
if (/^(zh|ja)(-|$)/.test(getLocale()))
if (UNCASED_UNITS.test(getLocale()))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
+9 -6
View File
@@ -6,6 +6,7 @@
// 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';
@@ -29,10 +30,7 @@ registerHooks({
const { IntelHUD } = await import('./hud.js');
function installHiddenPage() {
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 = {
hidden: true,
@@ -54,6 +52,7 @@ function installHiddenPage() {
return {
alt: () => elements.get('hud-alt').textContent,
viewer,
publish,
restore() {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
@@ -66,11 +65,15 @@ test('the HUD readout pauses in a hidden page but keeps updating in an inline pa
const page = installHiddenPage();
let hud;
try {
hud = new IntelHUD(page.viewer);
hud = new IntelHUD(page.viewer, { publish: page.publish });
hud._visible = true;
t.mock.timers.tick(250);
assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick');
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);
+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);
}
+7 -14
View File
@@ -6,6 +6,7 @@
// 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';
@@ -30,19 +31,7 @@ registerHooks({
const { IntelHUD } = await import('./hud.js');
function installPage() {
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 previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
@@ -69,6 +58,7 @@ function installPage() {
};
return {
viewer,
publish,
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
@@ -82,7 +72,10 @@ function installPage() {
function createHud(t, summaryService) {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installPage();
const hud = new IntelHUD(page.viewer, { summaryService });
const hud = new IntelHUD(page.viewer, {
summaryService,
publish: page.publish,
});
t.after(() => {
hud.destroy();
page.restore();
+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']);
+58 -26
View File
@@ -7,7 +7,8 @@ import {
formatNumber,
formatRelativeTime,
msg,
persistLocaleAndReload,
onLocaleChange,
persistLocale,
setLocale,
t,
tn,
@@ -63,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);
});
@@ -126,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', () => {
@@ -258,6 +266,40 @@ 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('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', () => {
@@ -275,13 +317,9 @@ test('applyDocumentTranslations reads data-i18n-context for element text', () =>
assert.notEqual(option.textContent, t('All'));
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
test('persistLocale 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']),
};
const location = { href: 'https://gev.local/?x=1#scene=a' };
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
@@ -290,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', () => {
+134 -82
View File
@@ -13,9 +13,12 @@
// `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' };
@@ -28,6 +31,7 @@ import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({
ar,
de,
es,
fr,
@@ -40,37 +44,114 @@ const CATALOGS = Object.freeze({
'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)}`,
);
}
@@ -80,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;
@@ -124,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))) {
@@ -141,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;
@@ -171,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;
@@ -210,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,
);
@@ -228,7 +313,7 @@ export function formatRelativeTime(value, unit = 'second', 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 currentLocale === 'ja'
return getLocale() === 'ja'
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
: text;
}
@@ -255,75 +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 context = attribute
? null
: element.getAttribute?.('data-i18n-context');
const text = context ? t(source, { context }) : t(source);
if (text === source) continue;
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,
@@ -333,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;
}
+22 -1
View File
@@ -10,6 +10,7 @@ export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze([
'en',
'ar',
'de',
'es',
'fr',
@@ -25,6 +26,7 @@ export const LOCALES = Object.freeze([
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
ar: 'العربية',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
@@ -38,10 +40,29 @@ export const LOCALE_NAMES = Object.freeze({
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
const RTL_LOCALES = Object.freeze(['ar']);
/** Whether a shipped locale reads right to left. */
export function isRtlLocale(locale) {
return RTL_LOCALES.includes(normalizeLocale(locale));
}
/**
* Tags for Intl formatters where the locale code alone is not enough. Arabic
* keeps Western digits (0–9) like its catalog: ICU picks Arabic-Indic digits
* (٠–٩) for some Arabic regions and browsers, and a HUD that mixes both
* misreads.
*/
const INTL_TAGS = Object.freeze({ ar: 'ar-u-nu-latn' });
/** The tag to hand Intl.NumberFormat, DateTimeFormat and the like. */
export function intlLocale(locale) {
return INTL_TAGS[locale] ?? locale;
}
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'ar-EG' and 'ar-SA' → 'ar',
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
File diff suppressed because it is too large. Load diff
+30 -18
View File
@@ -153,7 +153,6 @@
"NIGHT": "NACHT",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "BRAND",
"FIRES": "BRÄNDE",
"max {value} MW": "max. {value} MW",
"new {value}": "zuletzt vor {value}",
"just now": "gerade eben",
@@ -676,8 +675,6 @@
"Cockpit vision style": "Sichtstil im Cockpit",
"Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil",
"Previous vision style": "Vorheriger Sichtstil",
"Current cockpit vision style: NORMAL. Activate for next style.": "Aktueller Cockpit-Sichtstil: NORMAL. Aktivieren für den nächsten Stil.",
"Current style: NORMAL — click for next": "Aktueller Stil: NORMAL. Klicken für den nächsten",
"CURRENT": "AKTUELL",
"Next cockpit vision style": "Nächster Cockpit-Sichtstil",
"Next vision style": "Nächster Sichtstil",
@@ -748,7 +745,6 @@
"Search any location...": "Ort suchen...",
"WEATHER": "WETTER",
"CONTEXT": "KONTEXT",
"Expand panel": "Bereich ausklappen",
"Context mode": "Kontextmodus",
"CONTACTS": "KONTAKTE",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Wechselt zwischen den nächstgelegenen Kontakten des gewählten Typs: Flugzeuge, Schiffe, Anlagen. Satelliten werden unabhängig verfolgt.",
@@ -837,13 +833,7 @@
"SAVE CAL": "KAL. SPEICHERN",
"RESET CAL": "KAL. ZURÜCKSETZEN",
"SCENE SUMMARY": "SZENEN-ÜBERSICHT",
"SCENES": "SZENEN",
"Scene recipe": "Szenenrezept",
"NEW": "NEU",
"CAPTURE SHOT": "AUFNAHME SPEICHERN",
"UPDATE SHOT": "AUFNAHME AKTUALISIEREN",
"START": "START",
"EXPORT PRESETS": "PRESETS EXPORTIEREN",
"IMPORT": "IMPORTIEREN",
"RUN LOG": "PROTOKOLL",
"POWER UP": "STARTKLAR",
@@ -881,7 +871,6 @@
"That line has no length — click somewhere further away.": "Diese Linie hat keine Länge – weiter entfernt klicken.",
" · {max}-point limit reached": " · Limit von {max} Punkten erreicht",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · Doppelklick oder Enter beendet, Rücktaste macht rückgängig, Esc bricht ab.{full}",
"drawings on the map": "Zeichnungen auf der Karte",
"That shape already has {count} points — finish it or press Backspace.": "Diese Form hat bereits {count} Punkte – abschließen oder Rücktaste drücken.",
"That point is off the globe — click on the world.": "Dieser Punkt liegt außerhalb des Globus – auf die Erde klicken.",
"That shape could not be placed.": "Diese Form konnte nicht platziert werden.",
@@ -1182,7 +1171,6 @@
"GET KEY ↗": "SCHLÜSSEL HOLEN ↗",
"{envVar} saved — paste to replace": "{envVar} gespeichert – zum Ersetzen einfügen",
"paste {envVar}": "{envVar} einfügen",
"unsaved provider keys": "ungespeicherte Anbieter-Schlüssel",
"Project keys: {status}": "Projektschlüssel: {status}",
"Cloud voice will use OPENAI_API_KEY on the next session.": "Die Cloud-Sprachsteuerung verwendet ab der nächsten Sitzung OPENAI_API_KEY.",
"Checking local ChatGPT OAuth sign-in…": "Lokale ChatGPT-OAuth-Anmeldung wird geprüft…",
@@ -1464,7 +1452,7 @@
"Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %",
"Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %",
"GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min",
"Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC",
"Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.",
@@ -1765,6 +1753,8 @@
"Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen",
"Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze",
"Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³",
"Color: {units}": "Farbe: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert",
"North America · infrared imagery": "Nordamerika · Infrarotbilder",
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
@@ -2058,9 +2048,6 @@
"Sensitivity": "Empfindlichkeit",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "ungespeicherte CCTV-Kalibrierung",
"the open cockpit": "das geöffnete Cockpit",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Ein Sprachwechsel lädt die Seite neu. Dabei geht Folgendes verloren: {items}. Trotzdem wechseln?",
"Link shared": "Link geteilt",
"Return map to straight-down view": "Karte wieder senkrecht von oben zeigen",
"Tilt map to oblique view": "Karte in Schrägansicht kippen",
@@ -2107,7 +2094,6 @@
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
"{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL",
"Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `",
"imported map files": "importierte Kartendateien",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.",
"{count} map objects": {
"one": "{count} Kartenobjekt",
@@ -2687,7 +2673,6 @@
"Activate to toggle voice": "Aktivieren, um die Sprachsteuerung umzuschalten",
"Voice active": "Sprachsteuerung aktiv",
"Voice could not be started.": "Sprachsteuerung konnte nicht gestartet werden.",
"the active voice session": "die aktive Sprachsitzung",
"{value} feet": "{value} Fuß",
"Fire · FRP {frp} MW": "Brand · FRP {frp} MW",
"The flights layer is not enabled": "Die Ebene „Live-Flüge“ ist nicht aktiviert",
@@ -2927,6 +2912,33 @@
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
"route proxy error": "Fehler des Routen-Proxys",
"scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten",
"SCENE": "SZENE",
"Scene": "Szene",
"NEW SCENE": "NEUE SZENE",
"Start a new, empty scene": "Neue, leere Szene beginnen",
"DELETE": "LÖSCHEN",
"Delete this scene and all of its shots": "Diese Szene mit allen Aufnahmen löschen",
"SHOTS": "AUFNAHMEN",
"+ CAPTURE SHOT": "+ AUFNAHME SPEICHERN",
"Add the current view, style and layers as the next shot": "Aktuelle Ansicht, Stil und Ebenen als nächste Aufnahme hinzufügen",
"Replace the selected shot with the current view": "Ausgewählte Aufnahme durch die aktuelle Ansicht ersetzen",
"Click a shot to select it, double-click to rename.": "Aufnahme anklicken zum Auswählen, Doppelklick zum Umbenennen.",
"Play from this scene through the rest of the project": "Ab dieser Szene bis zum Ende des Projekts abspielen",
"Stop playback (Esc)": "Wiedergabe stoppen (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Aufnahme nach der ausgewählten abspielen, bei Bedarf in der nächsten Szene",
"SAVE & SHARE": "SPEICHERN & TEILEN",
"Download every scene as a JSON file you can import later": "Alle Szenen als JSON-Datei zum späteren Import herunterladen",
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
"Left panels": "Linke Panels",
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS",
"fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch",
+31 -19
View File
@@ -129,8 +129,6 @@
"PREV": "ANT.",
"Previous cockpit vision style": "Estilo de visión de cabina anterior",
"Previous vision style": "Estilo de visión anterior",
"Current cockpit vision style: NORMAL. Activate for next style.": "Estilo de visión de cabina actual: NORMAL. Actívalo para pasar al siguiente estilo.",
"Current style: NORMAL — click for next": "Estilo actual: NORMAL — haz clic para el siguiente",
"NEXT": "SIG.",
"Next cockpit vision style": "Siguiente estilo de visión de cabina",
"Next vision style": "Siguiente estilo de visión",
@@ -178,7 +176,6 @@
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SOLO DATOS DISPONIBLES · NO IMPLICA TODO DESPEJADO",
"Enable cockpit weather effects": "Activar los efectos climáticos de la cabina",
"CONTEXT": "CONTEXTO",
"Expand panel": "Expandir panel",
"Context mode": "Modo de contexto",
"CONTACTS": "CONTACTOS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Recorre los contactos más cercanos del tipo que elijas — aeronaves, buques, instalaciones. Los satélites se rastrean por separado.",
@@ -665,7 +662,6 @@
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCENDIO",
"FIRES": "INCENDIOS",
"{value} conf": "confianza {value}",
"{value} ago": "hace {value}",
"max {value} MW": "máx {value} MW",
@@ -732,6 +728,8 @@
"awaiting usable AIS positions…": "esperando posiciones AIS utilizables…",
"awaiting first AIS message…": "esperando el primer mensaje AIS…",
"EST. DOWNRANGE": "DIST. ESTIMADA",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS",
"MISSION CONTROL · FIRST LAUNCH": "CENTRO DE MANDO · PRIMER INICIO",
"Choose your first view": "Elige tu primera vista",
"LIVE CONTACTS": "CONTACTOS EN VIVO",
@@ -752,14 +750,8 @@
"The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "El globo ya funciona sin claves. Cada clave de abajo activa otra fuente real — pega una y queda guardada en la configuración local de esta app, y luego el servidor se reinicia solo. Las claves del servidor se quedan en esta máquina; Google Maps y Cesium ion funcionan en el navegador y sus claves deben restringirse en la consola del proveedor. Las claves que configuraste en otro lugar se muestran, pero nunca se modifican.",
"ESC to close": "ESC para cerrar",
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
"SCENES": "ESCENAS",
"Scene recipe": "Receta de escena",
"NEW": "NUEVA",
"DEL": "BORRAR",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ACTUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREAJUSTES",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO DE EJECUCIÓN",
"Starting live contacts…": "Iniciando contactos en vivo…",
@@ -1266,6 +1258,8 @@
"Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia",
"Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales",
"Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³",
"Color: {units}": "Color: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso",
"North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible",
@@ -2164,7 +2158,7 @@
"Low-accuracy score 2–10%": "Índice de baja precisión 2–10%",
"Low-accuracy score > 10%": "Índice de baja precisión > 10%",
"GNSS integrity source unavailable": "Fuente de integridad GNSS no disponible",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min",
"Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC",
"Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.",
@@ -2475,9 +2469,6 @@
"ChatGPT sign-in timed out. Click {button} to try again.": "Se agotó el tiempo para iniciar sesión en ChatGPT. Haz clic en \"{button}\" para intentarlo de nuevo.",
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Sesión de ChatGPT iniciada. Se usará OAuth en la próxima sesión de voz en la nube.",
"OAuth check failed: {detail}": "Error al comprobar OAuth: {detail}",
"unsaved CCTV calibration": "la calibración de CCTV sin guardar",
"the open cockpit": "la cabina abierta",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiar de idioma recarga la página. Se perderá lo siguiente: {items}. ¿Cambiar de todos modos?",
"Wind upstream unavailable": "Origen de datos de viento no disponible",
"WebUSB needs desktop Chrome or Edge": "WebUSB requiere Chrome o Edge de escritorio",
"connect a receiver in Radio": "conecta un receptor en Radio",
@@ -2663,9 +2654,6 @@
"KartaView answered HTTP {status}": "KartaView respondió HTTP {status}",
"KartaView refused the request (code {code})": "KartaView rechazó la petición (código {code})",
"Stopped by voice": "Detenido por voz",
"drawings on the map": "los dibujos del mapa",
"imported map files": "los archivos importados en el mapa",
"the active voice session": "la sesión de voz activa",
"Light aircraft": "Avioneta",
"Glider": "Planeador",
"Turboprop": "Turbohélice",
@@ -2866,7 +2854,6 @@
"many": "fuentes {names} no válidas",
"other": "fuentes {names} no válidas"
},
"unsaved provider keys": "las claves de proveedores sin guardar",
"Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave",
"Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite",
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos",
@@ -3009,5 +2996,30 @@
"Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "{shown} de {total} mostrados",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas"
"route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN",
"Create and edit scenes": "Crea y edita escenas",
"SCENE": "ESCENA",
"Scene": "Escena",
"NEW SCENE": "NUEVA ESCENA",
"Start a new, empty scene": "Empieza una escena nueva y vacía",
"DELETE": "ELIMINAR",
"Delete this scene and all of its shots": "Elimina esta escena y todos sus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano",
"Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual",
"Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.",
"scene|PLAY": "REPROD.",
"Stop playback (Esc)": "Detén la reproducción (Esc)",
"SAVE & SHARE": "GUARDAR Y COMPARTIR",
"Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde",
"Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado",
"Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción",
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
"Left panels": "Paneles izquierdos",
"Right panels": "Paneles derechos"
}
+30 -18
View File
@@ -11,7 +11,6 @@
"That line has no length — click somewhere further away.": "Cette ligne n'a pas de longueur — cliquez plus loin.",
" · {max}-point limit reached": " · limite de {max} points atteinte",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · double-clic ou Enter pour terminer, Backspace annule le dernier point, Esc annule.{full}",
"drawings on the map": "les dessins sur la carte",
"That shape already has {count} points — finish it or press Backspace.": "Cette forme a déjà {count} points — terminez-la ou appuyez sur Backspace.",
"That point is off the globe — click on the world.": "Ce point est hors du globe — cliquez sur la Terre.",
"That shape could not be placed.": "Impossible de placer cette forme.",
@@ -402,7 +401,6 @@
"paste {envVar}": "collez {envVar}",
"REMOVE": "SUPPRIMER",
"Remove {title} from this app's saved keys": "Supprimer {title} des clés enregistrées de l'application",
"unsaved provider keys": "les clés de fournisseurs non enregistrées",
"Project keys: {status}": "Clés du projet : {status}",
"your app configuration": "la configuration de votre application",
"your local .env": "votre .env local",
@@ -716,7 +714,6 @@
"NIGHT": "NUIT",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "FEU",
"FIRES": "FEUX",
"max {value} MW": "max {value} MW",
"new {value}": "dernier il y a {value}",
"{value}°N": "{value}°N",
@@ -771,7 +768,7 @@
"Low-accuracy score 2–10%": "Indice de faible précision 2–10 %",
"Low-accuracy score > 10%": "Indice de faible précision > 10 %",
"GNSS integrity source unavailable": "Source d'intégrité GNSS indisponible",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur {minutes} min",
"Retrying automatically at {time} UTC": "Nouvel essai automatique à {time} UTC",
"Retrying automatically after provider cooldown": "Nouvel essai automatique après la pause du fournisseur",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Mise à jour automatique après un déplacement ou un zoom, et une fois par minute. Le flux public couvre un cercle de 250 milles nautiques autour du centre de la vue ; dézoomer n'en fait pas un instantané mondial. Les cellules vides sont hors de la couverture échantillonnée ou manquent d'observations.",
@@ -1214,6 +1211,8 @@
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
"Color: {units}": "Couleur : {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
"Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible",
@@ -1540,9 +1539,6 @@
"Sensitivity": "Sensibilité",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "la calibration CCTV non enregistrée",
"the open cockpit": "le cockpit ouvert",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Changer de langue recharge la page. Ceci sera perdu : {items}. Changer quand même ?",
"VISUAL PRESETS": "PRÉRÉGLAGES VISUELS",
"LOCATION": "LIEU",
"CONTACTS — nearest planes · vessels · sites": "CONTACTS — avions, navires et sites les plus proches",
@@ -1767,7 +1763,6 @@
"Space Missions": "Missions spatiales",
"Context": "Contexte",
"Rendered globe frames per second · toggle with `": "Images du globe rendues par seconde · basculer avec `",
"imported map files": "les fichiers importés sur la carte",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossible d'actualiser les altitudes du sol. Réessayez avec le fichier une fois la carte chargée.",
"{count} map objects": {
"one": "{count} objet cartographique",
@@ -2547,7 +2542,6 @@
"Activate to toggle voice": "Activer/désactiver la commande vocale",
"Voice active": "Voix active",
"Voice could not be started.": "Impossible de démarrer la voix.",
"the active voice session": "la session vocale active",
"{value} feet": "{value} pieds",
"Fire · FRP {frp} MW": "Feu · FRP {frp} MW",
"The flights layer is not enabled": "La couche des vols n'est pas activée",
@@ -2705,8 +2699,6 @@
"Cockpit vision style": "Style de vision du cockpit",
"Previous cockpit vision style": "Style de vision précédent du cockpit",
"Previous vision style": "Style de vision précédent",
"Current cockpit vision style: NORMAL. Activate for next style.": "Style de vision actuel du cockpit : NORMAL. Activer pour le style suivant.",
"Current style: NORMAL — click for next": "Style actuel : NORMAL — cliquer pour le suivant",
"CURRENT": "ACTUEL",
"Next cockpit vision style": "Style de vision suivant du cockpit",
"Next vision style": "Style de vision suivant",
@@ -2793,7 +2785,6 @@
"RECENT IMAGERY": "IMAGERIE RÉCENTE",
"Imagery days in the box": "Jours d'imagerie dans la zone",
"CONTEXT": "CONTEXTE",
"Expand panel": "Développer le panneau",
"Context mode": "Mode de contexte",
"CONTACTS": "CONTACTS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Parcourt les contacts les plus proches du type sélectionné — avions, navires, installations. Les satellites sont suivis séparément.",
@@ -2959,13 +2950,7 @@
"ALL": "TOUTES",
"FLAT": "CLASSIQUES",
"SINCE": "DEPUIS",
"SCENES": "SCÈNES",
"Scene recipe": "Recette de scène",
"NEW": "NOUV.",
"CAPTURE SHOT": "CAPTURER UN PLAN",
"UPDATE SHOT": "MAJ DU PLAN",
"START": "LANCER",
"EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES",
"IMPORT": "IMPORTER",
"RUN LOG": "JOURNAL",
"POWER UP": "ALLUMER",
@@ -2997,6 +2982,33 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes",
"SCENE": "SCÈNE",
"Scene": "Scène",
"NEW SCENE": "NOUVELLE SCÈNE",
"Start a new, empty scene": "Commencer une nouvelle scène vide",
"DELETE": "SUPPRIMER",
"Delete this scene and all of its shots": "Supprimer cette scène et tous ses plans",
"SHOTS": "PLANS",
"+ CAPTURE SHOT": "+ CAPTURER UN PLAN",
"Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant",
"Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle",
"Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.",
"Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet",
"Stop playback (Esc)": "Arrêter la lecture (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin",
"SAVE & SHARE": "ENREGISTRER ET PARTAGER",
"Download every scene as a JSON file you can import later": "Télécharger toutes les scènes dans un fichier JSON à importer plus tard",
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources",
"Left panels": "Panneaux de gauche",
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS",
"fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée",
+30 -18
View File
@@ -11,7 +11,6 @@
"That line has no length — click somewhere further away.": "Quella linea non ha lunghezza: fai clic più lontano.",
" · {max}-point limit reached": " · raggiunto il limite di {max} punti",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · doppio clic o Enter per finire, Backspace torna indietro, Esc annulla.{full}",
"drawings on the map": "disegni sulla mappa",
"That shape already has {count} points — finish it or press Backspace.": "Quella forma ha già {count} punti: finiscila o premi Backspace.",
"That point is off the globe — click on the world.": "Quel punto è fuori dal globo: fai clic sul mondo.",
"That shape could not be placed.": "Impossibile posizionare quella forma.",
@@ -402,7 +401,6 @@
"paste {envVar}": "incolla {envVar}",
"REMOVE": "RIMUOVI",
"Remove {title} from this app's saved keys": "Rimuovi {title} dalle chiavi salvate di questa app",
"unsaved provider keys": "chiavi provider non salvate",
"Project keys: {status}": "Chiavi del progetto: {status}",
"your app configuration": "la configurazione della tua app",
"your local .env": "il tuo .env locale",
@@ -716,7 +714,6 @@
"NIGHT": "NOTTE",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCENDIO",
"FIRES": "INCENDI",
"max {value} MW": "max {value} MW",
"new {value}": "ultimo: {value} fa",
"{value}°N": "{value}°N",
@@ -771,7 +768,7 @@
"Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%",
"Low-accuracy score > 10%": "Indice di bassa precisione > 10%",
"GNSS integrity source unavailable": "Fonte di integrità GNSS non disponibile",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi {minutes} min",
"Retrying automatically at {time} UTC": "Nuovo tentativo automatico alle {time} UTC",
"Retrying automatically after provider cooldown": "Nuovo tentativo automatico dopo la pausa del provider",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Si aggiorna automaticamente dopo uno spostamento o uno zoom e una volta al minuto. Il feed pubblico copre un cerchio di 250 miglia nautiche attorno al centro della vista; allontanare lo zoom non lo rende un'istantanea mondiale. Le celle vuote sono fuori dalla copertura campionata o hanno osservazioni insufficienti.",
@@ -1214,6 +1211,8 @@
"Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia",
"Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini",
"Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³",
"Color: {units}": "Colore: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo",
"North America · infrared imagery": "Nord America · immagini all'infrarosso",
"Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile",
@@ -1540,9 +1539,6 @@
"Sensitivity": "Sensibilità",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "calibrazione CCTV non salvata",
"the open cockpit": "il cockpit aperto",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiare lingua ricarica la pagina. Perderai: {items}. Cambiare comunque?",
"VISUAL PRESETS": "PRESET VISIVI",
"LOCATION": "POSIZIONE",
"CONTACTS — nearest planes · vessels · sites": "CONTATTI — aerei · navi · siti più vicini",
@@ -1767,7 +1763,6 @@
"Space Missions": "Missioni spaziali",
"Context": "Contesto",
"Rendered globe frames per second · toggle with `": "Fotogrammi al secondo del globo · attiva/disattiva con `",
"imported map files": "file di mappa importati",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossibile aggiornare le quote del terreno. Riprova con il file quando la mappa è caricata.",
"{count} map objects": {
"one": "{count} oggetto sulla mappa",
@@ -2547,7 +2542,6 @@
"Activate to toggle voice": "Attiva per abilitare o disabilitare la voce",
"Voice active": "Voce attiva",
"Voice could not be started.": "Impossibile avviare la voce.",
"the active voice session": "la sessione vocale attiva",
"{value} feet": "{value} piedi",
"Fire · FRP {frp} MW": "Incendio · FRP {frp} MW",
"The flights layer is not enabled": "Il livello voli non è attivo",
@@ -2705,8 +2699,6 @@
"Cockpit vision style": "Stile di visione del cockpit",
"Previous cockpit vision style": "Stile di visione precedente del cockpit",
"Previous vision style": "Stile di visione precedente",
"Current cockpit vision style: NORMAL. Activate for next style.": "Stile di visione attuale del cockpit: NORMALE. Attiva per lo stile successivo.",
"Current style: NORMAL — click for next": "Stile attuale: NORMALE. Clic per il successivo",
"CURRENT": "ATTUALE",
"Next cockpit vision style": "Stile di visione successivo del cockpit",
"Next vision style": "Stile di visione successivo",
@@ -2793,7 +2785,6 @@
"RECENT IMAGERY": "IMMAGINI RECENTI",
"Imagery days in the box": "Giorni con immagini nel riquadro",
"CONTEXT": "CONTESTO",
"Expand panel": "Espandi il pannello",
"Context mode": "Modalità contesto",
"CONTACTS": "CONTATTI",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Scorre i contatti più vicini del tipo selezionato: aerei, navi, installazioni. I satelliti vengono tracciati in modo indipendente.",
@@ -2959,13 +2950,7 @@
"ALL": "TUTTE",
"FLAT": "PIATTE",
"SINCE": "DAL",
"SCENES": "SCENE",
"Scene recipe": "Ricetta della scena",
"NEW": "NUOVA",
"CAPTURE SHOT": "CATTURA SCATTO",
"UPDATE SHOT": "AGGIORNA SCATTO",
"START": "AVVIA",
"EXPORT PRESETS": "ESPORTA PRESET",
"IMPORT": "IMPORTA",
"RUN LOG": "LOG ESECUZIONE",
"POWER UP": "ACCENDI",
@@ -2997,6 +2982,33 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene",
"SCENE": "SCENA",
"Scene": "Scena",
"NEW SCENE": "NUOVA SCENA",
"Start a new, empty scene": "Inizia una nuova scena vuota",
"DELETE": "ELIMINA",
"Delete this scene and all of its shots": "Elimina questa scena e tutti i suoi scatti",
"SHOTS": "SCATTI",
"+ CAPTURE SHOT": "+ CATTURA SCATTO",
"Add the current view, style and layers as the next shot": "Aggiungi la vista, lo stile e i livelli attuali come scatto successivo",
"Replace the selected shot with the current view": "Sostituisci lo scatto selezionato con la vista attuale",
"Click a shot to select it, double-click to rename.": "Fai clic su uno scatto per selezionarlo, doppio clic per rinominarlo.",
"Play from this scene through the rest of the project": "Riproduci da questa scena fino alla fine del progetto",
"Stop playback (Esc)": "Interrompi la riproduzione (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Riproduci lo scatto dopo quello selezionato, passando alla scena successiva se serve",
"SAVE & SHARE": "SALVA E CONDIVIDI",
"Download every scene as a JSON file you can import later": "Scarica tutte le scene in un file JSON da importare in seguito",
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse",
"Left panels": "Pannelli a sinistra",
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta",
+30 -18
View File
@@ -9,7 +9,6 @@
"That line has no length — click somewhere further away.": "線の長さがゼロです。もっと離れた場所をクリックしてください。",
" · {max}-point limit reached": " · 上限の{max}点に達しました",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · ダブルクリックまたはEnterで完了、Backspaceで元に戻す、Escでキャンセル。{full}",
"drawings on the map": "地図上の描画",
"That shape already has {count} points — finish it or press Backspace.": "この図形はすでに{count}点あります。完了するか、Backspaceを押してください。",
"That point is off the globe — click on the world.": "その点は地球の外です。地表をクリックしてください。",
"That shape could not be placed.": "図形を配置できませんでした。",
@@ -394,7 +393,6 @@
"paste {envVar}": "{envVar}を貼り付け",
"REMOVE": "削除",
"Remove {title} from this app's saved keys": "{title}をこのアプリの保存済みキーから削除",
"unsaved provider keys": "未保存のプロバイダーキー",
"Project keys: {status}": "プロジェクトのキー:{status}",
"your app configuration": "アプリの設定",
"your local .env": "ローカルの .env",
@@ -698,7 +696,6 @@
"NIGHT": "夜間",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火災",
"FIRES": "火災",
"max {value} MW": "最大 {value} MW",
"new {value}": "新規 {value}",
"{value}°N": "{value}°N",
@@ -749,7 +746,7 @@
"Low-accuracy score 2–10%": "低精度スコア 2–10%",
"Low-accuracy score > 10%": "低精度スコア > 10%",
"GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "地域サンプル · 半径250海里 · 30分以内に表示したビューのセル",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル",
"Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します",
"Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。",
@@ -1182,6 +1179,8 @@
"Contiguous US · gaps ≠ no rain": "米国本土 · 欠測 ≠ 降水なし",
"Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません",
"Color: strikes/km²/min ×10³": "色:落雷数/km²/分 ×10³",
"Color: {units}": "色:{units}",
"RADAR · {units}": "レーダー · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ",
"North America · infrared imagery": "北米 · 赤外画像",
"Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可",
@@ -1498,9 +1497,6 @@
"Sensitivity": "感度",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "未保存のCCTVキャリブレーション",
"the open cockpit": "表示中のコックピット",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "言語を切り替えるとページが再読み込みされます。次の内容は失われます:{items}。切り替えますか?",
"VISUAL PRESETS": "ビジュアルプリセット",
"LOCATION": "位置",
"CONTACTS — nearest planes · vessels · sites": "コンタクト:最寄りの航空機 · 船舶 · 施設",
@@ -1711,7 +1707,6 @@
"Space Missions": "宇宙ミッション",
"Context": "コンテキスト",
"Rendered globe frames per second · toggle with `": "地球儀の描画フレームレート(毎秒) · `キーで切り替え",
"imported map files": "インポートした地図ファイル",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "地表高を更新できませんでした。地図の読み込み後に、もう一度このファイルをお試しください。",
"{count} map objects": {
"other": "地図オブジェクト{count}個"
@@ -2417,7 +2412,6 @@
"Activate to toggle voice": "押すと音声のオン/オフを切り替え",
"Voice active": "音声 オン",
"Voice could not be started.": "音声を開始できませんでした。",
"the active voice session": "進行中の音声セッション",
"{value} feet": "{value}フィート",
"Fire · FRP {frp} MW": "火災 · FRP {frp} MW",
"The flights layer is not enabled": "フライトレイヤーが有効になっていません",
@@ -2563,8 +2557,6 @@
"Cockpit vision style": "コックピットのビジョンスタイル",
"Previous cockpit vision style": "前のコックピットビジョンスタイル",
"Previous vision style": "前のビジョンスタイル",
"Current cockpit vision style: NORMAL. Activate for next style.": "現在のコックピットビジョンスタイル:標準。押すと次のスタイルに切り替わります。",
"Current style: NORMAL — click for next": "現在のスタイル:標準。クリックで次へ",
"CURRENT": "現在",
"Next cockpit vision style": "次のコックピットビジョンスタイル",
"Next vision style": "次のビジョンスタイル",
@@ -2651,7 +2643,6 @@
"RECENT IMAGERY": "最近の衛星画像",
"Imagery days in the box": "範囲内の画像のある日",
"CONTEXT": "コンテキスト",
"Expand panel": "パネルを展開",
"Context mode": "コンテキストモード",
"CONTACTS": "コンタクト",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "選択した種類(航空機、船舶、施設)の最寄りのコンタクトを順に切り替えます。衛星は独立して追跡されます。",
@@ -2817,13 +2808,7 @@
"ALL": "すべて",
"FLAT": "平面",
"SINCE": "期間",
"SCENES": "シーン",
"Scene recipe": "シーンレシピ",
"NEW": "新規",
"CAPTURE SHOT": "ショット取得",
"UPDATE SHOT": "ショット更新",
"START": "開始",
"EXPORT PRESETS": "エクスポート",
"IMPORT": "インポート",
"RUN LOG": "実行ログ",
"POWER UP": "パワーアップ",
@@ -2857,6 +2842,33 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"scene|PLAY": "再生",
"AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集",
"SCENE": "シーン",
"Scene": "シーン",
"NEW SCENE": "新規シーン",
"Start a new, empty scene": "空の新しいシーンを作成",
"DELETE": "削除",
"Delete this scene and all of its shots": "このシーンとすべてのショットを削除",
"SHOTS": "ショット",
"+ CAPTURE SHOT": "+ ショット取得",
"Add the current view, style and layers as the next shot": "現在の視点・スタイル・レイヤーを次のショットとして追加",
"Replace the selected shot with the current view": "選択中のショットを現在の視点で置き換え",
"Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。",
"Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生",
"Stop playback (Esc)": "再生を停止(Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)",
"SAVE & SHARE": "保存と共有",
"Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード",
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
"Left panels": "左パネル",
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS",
"fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高",
+30 -18
View File
@@ -11,7 +11,6 @@
"That line has no length — click somewhere further away.": "Essa linha não tem comprimento — clique em um ponto mais distante.",
" · {max}-point limit reached": " · limite de {max} pontos atingido",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · clique duplo ou Enter para concluir, Backspace desfaz, Esc cancela.{full}",
"drawings on the map": "os desenhos no mapa",
"That shape already has {count} points — finish it or press Backspace.": "Essa forma já tem {count} pontos — conclua-a ou pressione Backspace.",
"That point is off the globe — click on the world.": "Esse ponto está fora do globo — clique sobre o mundo.",
"That shape could not be placed.": "Não foi possível colocar essa forma.",
@@ -402,7 +401,6 @@
"paste {envVar}": "cole {envVar}",
"REMOVE": "REMOVER",
"Remove {title} from this app's saved keys": "Remover {title} das chaves salvas deste app",
"unsaved provider keys": "chaves de provedores não salvas",
"Project keys: {status}": "Chaves do projeto: {status}",
"your app configuration": "sua configuração do app",
"your local .env": "seu .env local",
@@ -714,7 +712,6 @@
"NIGHT": "NOITE",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCÊNDIO",
"FIRES": "INCÊNDIOS",
"max {value} MW": "máx {value} MW",
"new {value}": "mais recente há {value}",
"{value}°N": "{value}°N",
@@ -769,7 +766,7 @@
"Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%",
"Low-accuracy score > 10%": "Índice de baixa precisão > 10%",
"GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Amostra regional · raio de 250 NM · células das vistas visitadas em 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min",
"Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC",
"Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.",
@@ -1212,6 +1209,8 @@
"Contiguous US · gaps ≠ no rain": "EUA contíguos · lacunas ≠ sem chuva",
"Americas + Pacific · not individual strikes": "Américas + Pacífico · não são raios individuais",
"Color: strikes/km²/min ×10³": "Cor: raios/km²/min ×10³",
"Color: {units}": "Cor: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso",
"North America · infrared imagery": "América do Norte · imagens infravermelhas",
"Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível",
@@ -1540,9 +1539,6 @@
"Sensitivity": "Sensibilidade",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "a calibração de CCTV não salva",
"the open cockpit": "a cabine aberta",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Mudar de idioma recarrega a página. Isto será perdido: {items}. Mudar mesmo assim?",
"VISUAL PRESETS": "PREDEF. VISUAIS",
"LOCATION": "LOCAL",
"CONTACTS — nearest planes · vessels · sites": "CONTATOS — aviões · embarcações · locais mais próximos",
@@ -1767,7 +1763,6 @@
"Space Missions": "Missões espaciais",
"Context": "Contexto",
"Rendered globe frames per second · toggle with `": "Quadros por segundo renderizados do globo · alterne com `",
"imported map files": "os arquivos importados no mapa",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Não foi possível atualizar as altitudes do terreno. Tente o arquivo de novo quando o mapa tiver carregado.",
"{count} map objects": {
"one": "{count} objeto no mapa",
@@ -2549,7 +2544,6 @@
"Activate to toggle voice": "Ative para ligar ou desligar a voz",
"Voice active": "Voz ativa",
"Voice could not be started.": "Não foi possível iniciar a voz.",
"the active voice session": "a sessão de voz ativa",
"{value} feet": "{value} pés",
"Fire · FRP {frp} MW": "Incêndio · FRP {frp} MW",
"The flights layer is not enabled": "A camada de voos não está ativada",
@@ -2707,8 +2701,6 @@
"Cockpit vision style": "Estilo de visão da cabine",
"Previous cockpit vision style": "Estilo anterior de visão da cabine",
"Previous vision style": "Estilo de visão anterior",
"Current cockpit vision style: NORMAL. Activate for next style.": "Estilo atual de visão da cabine: NORMAL. Ative para o próximo estilo.",
"Current style: NORMAL — click for next": "Estilo atual: NORMAL — clique para o próximo",
"CURRENT": "ATUAL",
"Next cockpit vision style": "Próximo estilo de visão da cabine",
"Next vision style": "Próximo estilo de visão",
@@ -2795,7 +2787,6 @@
"RECENT IMAGERY": "IMAGENS RECENTES",
"Imagery days in the box": "Dias com imagens na área",
"CONTEXT": "CONTEXTO",
"Expand panel": "Expandir painel",
"Context mode": "Modo de contexto",
"CONTACTS": "CONTATOS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Percorre os contatos mais próximos do tipo que você selecionar — aviões, embarcações, instalações. Os satélites são rastreados de forma independente.",
@@ -2961,13 +2952,7 @@
"ALL": "TODAS",
"FLAT": "PLANAS",
"SINCE": "DESDE",
"SCENES": "CENAS",
"Scene recipe": "Receita de cena",
"NEW": "NOVA",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ATUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREDEF.",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO",
"POWER UP": "ATIVAR",
@@ -2997,6 +2982,33 @@
"Don't show this again": "Não mostrar novamente",
"ESC to dismiss": "ESC para fechar",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
"scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas",
"SCENE": "CENA",
"Scene": "Cena",
"NEW SCENE": "NOVA CENA",
"Start a new, empty scene": "Começar uma nova cena vazia",
"DELETE": "EXCLUIR",
"Delete this scene and all of its shots": "Excluir esta cena e todos os seus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Adicionar a vista, o estilo e as camadas atuais como próximo plano",
"Replace the selected shot with the current view": "Substituir o plano selecionado pela vista atual",
"Click a shot to select it, double-click to rename.": "Clique em um plano para selecioná-lo e clique duas vezes para renomeá-lo.",
"Play from this scene through the rest of the project": "Reproduzir desta cena até o fim do projeto",
"Stop playback (Esc)": "Parar a reprodução (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduzir o plano após o selecionado, passando à próxima cena se necessário",
"SAVE & SHARE": "SALVAR E COMPARTILHAR",
"Download every scene as a JSON file you can import later": "Baixar todas as cenas em um arquivo JSON para importar depois",
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos",
"Left panels": "Painéis à esquerda",
"Right panels": "Painéis à direita",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
+31 -19
View File
@@ -135,7 +135,6 @@
"NIGHT": "НОЧЬ",
"▲ {frp} MW": "▲ {frp} МВт",
"FIRE": "ПОЖАР",
"FIRES": "ПОЖ.",
"max {value} MW": "макс. {value} МВт",
"new {value}": "последний {value}",
"just now": "только что",
@@ -217,6 +216,8 @@
"Pause replay": "Приостановить повтор",
"Cancel replay": "Отменить повтор",
"EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ",
"CONFIRMED": "ПОДТВЕРЖДЕНО",
"PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS",
"PLANNED · ": "ПЛАН · ",
"SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ",
"RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА",
@@ -626,8 +627,6 @@
"Cockpit display and Radio controls": "Управление экраном и радио кабины",
"Previous cockpit vision style": "Предыдущий стиль обзора кабины",
"Previous vision style": "Предыдущий стиль обзора",
"Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: ОБЫЧНЫЙ. Активируйте для следующего стиля.",
"Current style: NORMAL — click for next": "Текущий стиль: ОБЫЧНЫЙ — нажмите для следующего",
"Next cockpit vision style": "Следующий стиль обзора кабины",
"Next vision style": "Следующий стиль обзора",
"Previous briefing page": "Предыдущая страница брифинга",
@@ -709,7 +708,6 @@
"FLIR": "FLIR",
"Style": "Стиль",
"STATION SITE": "САЙТ СТАНЦИИ",
"Expand panel": "Развернуть панель",
"CONTACTS": "КОНТАКТЫ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
"SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ",
@@ -795,21 +793,15 @@
"East offset from catalog position (m) — click to type": "Смещение на восток от позиции каталога (м) — нажмите для ввода",
"SAVE CAL": "СОХРАНИТЬ КАЛИБР.",
"RESET CAL": "СБРОСИТЬ КАЛИБР.",
"NEW": "НОВАЯ",
"DEL": "УДАЛ.",
"CAPTURE SHOT": "СДЕЛАТЬ КАДР",
"UPDATE SHOT": "ОБНОВИТЬ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЭКСПОРТ ПРЕСЕТОВ",
"IMPORT": "ИМПОРТ",
"RUN LOG": "ЖУРНАЛ ЗАПУСКОВ",
"CALIBRATION": "КАЛИБРОВКА",
"Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести",
"SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ",
"CCTV camera": "Камера CCTV",
"Scene recipe": "Рецепт сцены",
"DATA LAYERS": "СЛОИ ДАННЫХ",
"SCENES": "СЦЕНЫ",
"Close key setup": "Закрыть настройку ключей",
"SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ",
"Power up the globe": "Подключите потоки данных",
@@ -1284,6 +1276,8 @@
"Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды",
"Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³",
"Color: {units}": "Цвет: {units}",
"RADAR · {units}": "РАДАР · {units}",
"60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
"North America · infrared imagery": "Северная Америка · ИК-снимки",
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
@@ -2198,7 +2192,7 @@
"Low-accuracy score 2–10%": "Доля неточных 2–10%",
"Low-accuracy score > 10%": "Доля неточных > 10%",
"GNSS integrity source unavailable": "Источник целостности GNSS недоступен",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за 30 мин",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за {minutes} мин",
"Retrying automatically at {time} UTC": "Автоматический повтор в {time} UTC",
"Retrying automatically after provider cooldown": "Автоматический повтор после паузы провайдера",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Обновляется автоматически после сдвига или масштабирования карты и раз в минуту. Публичный поток данных охватывает круг радиусом 250 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
@@ -2517,9 +2511,6 @@
"ChatGPT sign-in timed out. Click {button} to try again.": "Время на вход в ChatGPT истекло. Нажмите «{button}», чтобы попробовать ещё раз.",
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Вход в ChatGPT выполнен. В следующей облачной голосовой сессии будет использоваться OAuth.",
"OAuth check failed: {detail}": "Не удалось проверить OAuth: {detail}",
"unsaved CCTV calibration": "несохранённая калибровка CCTV",
"the open cockpit": "открытая кабина",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Смена языка перезагрузит страницу. Будет потеряно: {items}. Всё равно сменить язык?",
"Wind upstream unavailable": "Источник данных о ветре недоступен",
"WebUSB needs desktop Chrome or Edge": "Для WebUSB нужен настольный Chrome или Edge",
"connect a receiver in Radio": "подключите приёмник в разделе «Радио»",
@@ -2721,9 +2712,6 @@
"KartaView answered HTTP {status}": "KartaView ответил: HTTP {status}",
"KartaView refused the request (code {code})": "KartaView отклонил запрос (код {code})",
"Stopped by voice": "Остановлено голосовой командой",
"drawings on the map": "рисунки на карте",
"imported map files": "импортированные файлы на карте",
"the active voice session": "активная голосовая сессия",
"Light aircraft": "Лёгкий самолёт",
"Glider": "Планёр",
"Turboprop": "Турбовинтовой самолёт",
@@ -2934,7 +2922,6 @@
"many": "потоки {names} некорректны",
"other": "потоки {names} некорректны"
},
"unsaved provider keys": "несохранённые ключи провайдеров",
"Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту",
"Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику",
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре",
@@ -3079,5 +3066,30 @@
"Detection confidence": "Достоверность детекции",
"{shown} of {total} shown": "показано {shown} из {total}",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов"
"route proxy error": "ошибка прокси маршрутов",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Создание и редактирование сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВАЯ СЦЕНА",
"Start a new, empty scene": "Создать новую пустую сцену",
"DELETE": "УДАЛИТЬ",
"Delete this scene and all of its shots": "Удалить эту сцену и все её кадры",
"SHOTS": "КАДРЫ",
"+ CAPTURE SHOT": "+ СДЕЛАТЬ КАДР",
"Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром",
"Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом",
"Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.",
"scene|PLAY": "ПУСК",
"Stop playback (Esc)": "Остановить воспроизведение (Esc)",
"SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ",
"Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта",
"Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON",
"Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения",
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
"Left panels": "Левые панели",
"Right panels": "Правые панели"
}
+31 -19
View File
@@ -12,7 +12,6 @@
"That line has no length — click somewhere further away.": "Лінія не має довжини — клацніть далі.",
" · {max}-point limit reached": " · досягнуто ліміту точок: {max}",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · подвійне клацання або Enter — завершити, Backspace — скасувати точку, Esc — скасувати.{full}",
"drawings on the map": "малюнки на карті",
"That shape already has {count} points — finish it or press Backspace.": "Фігура вже має максимальну кількість точок ({count}) — завершіть її або натисніть Backspace.",
"That point is off the globe — click on the world.": "Ця точка поза глобусом — клацніть на Землі.",
"That shape could not be placed.": "Не вдалося розмістити фігуру.",
@@ -406,7 +405,6 @@
"paste {envVar}": "вставте {envVar}",
"REMOVE": "ВИДАЛИТИ",
"Remove {title} from this app's saved keys": "Видалити {title} зі збережених ключів застосунку",
"unsaved provider keys": "незбережені ключі постачальників",
"Project keys: {status}": "Ключі проєкту: {status}",
"your app configuration": "вашої конфігурації застосунку",
"your local .env": "вашого локального .env",
@@ -724,7 +722,6 @@
"NIGHT": "НІЧ",
"▲ {frp} MW": "▲ {frp} МВт",
"FIRE": "ПОЖЕЖА",
"FIRES": "ПОЖ.",
"max {value} MW": "макс. {value} МВт",
"new {value}": "останнє: {value} тому",
"{value}°N": "{value}° пн. ш.",
@@ -781,7 +778,7 @@
"Low-accuracy score 2–10%": "Показник неточності 2–10%",
"Low-accuracy score > 10%": "Показник неточності > 10%",
"GNSS integrity source unavailable": "Джерело даних про цілісність GNSS недоступне",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за 30 хв",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за {minutes} хв",
"Retrying automatically at {time} UTC": "Автоматичний повтор о {time} UTC",
"Retrying automatically after provider cooldown": "Автоматичний повтор після паузи постачальника",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Оновлюється автоматично після зсуву чи масштабування карти та раз на хвилину. Публічний потік даних охоплює коло радіусом 250 морських миль навколо центру карти; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.",
@@ -1229,6 +1226,8 @@
"Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди",
"Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³",
"Color: {units}": "Колір: {units}",
"RADAR · {units}": "РАДАР · {units}",
"60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год",
"North America · infrared imagery": "Північна Америка · ІЧ-знімки",
"Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне",
@@ -1560,9 +1559,6 @@
"Sensitivity": "Чутливість",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "незбережене калібрування CCTV",
"the open cockpit": "відкрита кабіна",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Зміна мови перезавантажить сторінку. Буде втрачено: {items}. Усе одно змінити мову?",
"VISUAL PRESETS": "ВІЗУАЛЬНІ ПРЕСЕТИ",
"LOCATION": "РОЗТАШУВАННЯ",
"CONTACTS — nearest planes · vessels · sites": "КОНТАКТИ — найближчі літаки · судна · обʼєкти",
@@ -1794,7 +1790,6 @@
"Space Missions": "Космічні місії",
"Context": "Контекст",
"Rendered globe frames per second · toggle with `": "Кадрів глобуса за секунду · перемикання клавішею `",
"imported map files": "імпортовані файли на карті",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Не вдалося оновити висоти рельєфу. Спробуйте імпортувати файл ще раз, коли карта завантажиться.",
"{count} map objects": {
"one": "{count} обʼєкт на карті",
@@ -2611,7 +2606,6 @@
"Activate to toggle voice": "Активуйте, щоб увімкнути або вимкнути голос",
"Voice active": "Голос активний",
"Voice could not be started.": "Не вдалося запустити голос.",
"the active voice session": "активний голосовий сеанс",
"{value} feet": "{value} фт",
"Fire · FRP {frp} MW": "Пожежа · FRP {frp} МВт",
"The flights layer is not enabled": "Шар рейсів не ввімкнено",
@@ -2775,8 +2769,6 @@
"Cockpit vision style": "Візуальний стиль у кабіні",
"Previous cockpit vision style": "Попередній візуальний стиль у кабіні",
"Previous vision style": "Попередній візуальний стиль",
"Current cockpit vision style: NORMAL. Activate for next style.": "Поточний візуальний стиль у кабіні: ЗВИЧАЙНИЙ. Активуйте, щоб перейти до наступного.",
"Current style: NORMAL — click for next": "Поточний стиль: ЗВИЧАЙНИЙ — клацніть для наступного",
"CURRENT": "ПОТОЧНИЙ",
"Next cockpit vision style": "Наступний візуальний стиль у кабіні",
"Next vision style": "Наступний візуальний стиль",
@@ -2863,7 +2855,6 @@
"RECENT IMAGERY": "СВІЖІ ЗНІМКИ",
"Imagery days in the box": "Дні зі знімками в області",
"CONTEXT": "КОНТЕКСТ",
"Expand panel": "Розгорнути панель",
"Context mode": "Режим контексту",
"CONTACTS": "КОНТАКТИ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.",
@@ -3029,13 +3020,7 @@
"ALL": "УСІ",
"FLAT": "ПЛОСКІ",
"SINCE": "З ДАТИ",
"SCENES": "СЦЕНИ",
"Scene recipe": "Рецепт сцени",
"NEW": "НОВА",
"CAPTURE SHOT": "ЗНЯТИ КАДР",
"UPDATE SHOT": "ОНОВИТИ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ",
"IMPORT": "ІМПОРТУВАТИ",
"RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ",
"POWER UP": "ЗАПУСК",
@@ -3079,5 +3064,32 @@
"Detection confidence": "Достовірність виявлення",
"{shown} of {total} shown": "показано {shown} з {total}",
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів"
"Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Створення й редагування сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВА СЦЕНА",
"Start a new, empty scene": "Створити нову порожню сцену",
"DELETE": "ВИДАЛИТИ",
"Delete this scene and all of its shots": "Видалити цю сцену з усіма кадрами",
"SHOTS": "КАДРИ",
"+ CAPTURE SHOT": "+ ЗНЯТИ КАДР",
"Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр",
"Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом",
"Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
"Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту",
"Stop playback (Esc)": "Зупинити відтворення (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени",
"SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ",
"Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту",
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
"Left panels": "Ліві панелі",
"Right panels": "Праві панелі",
"CONFIRMED": "ПІДТВЕРДЖЕНО",
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS"
}
+31 -19
View File
@@ -9,7 +9,6 @@
"That line has no length — click somewhere further away.": "这条线没有长度——请在更远处点击。",
" · {max}-point limit reached": " · 已达 {max} 个点上限",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 双击或按 Enter 完成,按 Backspace 撤销,按 Esc 取消。{full}",
"drawings on the map": "地图上的绘图",
"That shape already has {count} points — finish it or press Backspace.": "该形状已有 {count} 个点——请完成绘制或按 Backspace。",
"That point is off the globe — click on the world.": "该点在地球之外——请点击地球上的位置。",
"That shape could not be placed.": "该形状无法放置。",
@@ -394,7 +393,6 @@
"paste {envVar}": "粘贴 {envVar}",
"REMOVE": "移除",
"Remove {title} from this app's saved keys": "从本应用已保存的密钥中移除 {title}",
"unsaved provider keys": "未保存的服务商密钥",
"Project keys: {status}": "项目密钥:{status}",
"your app configuration": "你的应用配置",
"your local .env": "你的本地 .env",
@@ -697,7 +695,6 @@
"NIGHT": "夜间",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火点",
"FIRES": "火点",
"max {value} MW": "最大 {value} MW",
"new {value}": "最新({value}前)",
"{value}°N": "{value}°N",
@@ -726,7 +723,7 @@
"Low-accuracy score 2–10%": "低精度评分 2–10%",
"Low-accuracy score > 10%": "低精度评分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性数据源不可用",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "区域采样 · 半径 250 海里 · 30 分钟内已访问视图中的网格",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格",
"Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试",
"Retrying automatically after provider cooldown": "服务商冷却结束后自动重试",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。",
@@ -812,6 +809,8 @@
"Pause replay": "暂停回放",
"Cancel replay": "取消回放",
"EST. DOWNRANGE": "预估落点距离",
"CONFIRMED": "已确认",
"PAD / RTLS": "发射台 / RTLS",
"PLANNED · ": "计划 · ",
"SUPPLIED TRAJECTORY POINTS": "提供的轨迹点",
"RECONSTRUCTED ESTIMATE": "重建估算",
@@ -1156,6 +1155,8 @@
"Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨",
"Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击",
"Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³",
"Color: {units}": "颜色:{units}",
"RADAR · {units}": "雷达 · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时",
"North America · infrared imagery": "北美洲 · 红外影像",
"Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用",
@@ -1470,9 +1471,6 @@
"Sensitivity": "灵敏度",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "未保存的 CCTV 校准",
"the open cockpit": "当前打开的座舱",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切换语言会重新加载页面。以下内容将会丢失:{items}。仍要切换吗?",
"VISUAL PRESETS": "视觉预设",
"LOCATION": "位置",
"CONTACTS — nearest planes · vessels · sites": "目标:最近的飞机 · 船舶 · 设施",
@@ -1686,7 +1684,6 @@
"Space Missions": "太空任务",
"Context": "态势",
"Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换",
"imported map files": "已导入的地图文件",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "无法刷新地面高度。请在地图加载完成后重新尝试该文件。",
"{count} map objects": {
"other": "{count} 个地图对象"
@@ -2315,7 +2312,6 @@
"Activate to toggle voice": "点击切换语音",
"Voice active": "语音已启用",
"Voice could not be started.": "无法启动语音。",
"the active voice session": "当前语音会话",
"{value} feet": "{value} 英尺",
"The flights layer is not enabled": "航班图层未开启",
"The military layer is not enabled": "军用航班图层未开启",
@@ -2459,8 +2455,6 @@
"Cockpit vision style": "座舱视觉样式",
"Previous cockpit vision style": "上一个座舱视觉样式",
"Previous vision style": "上一个视觉样式",
"Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。",
"Current style: NORMAL — click for next": "当前样式:常规——点击切换到下一个",
"CURRENT": "当前",
"Next cockpit vision style": "下一个座舱视觉样式",
"Next vision style": "下一个视觉样式",
@@ -2547,7 +2541,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "视野内的影像天数",
"CONTEXT": "态势",
"Expand panel": "展开面板",
"Context mode": "态势模式",
"CONTACTS": "目标",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。",
@@ -2712,13 +2705,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始时间",
"SCENES": "场景",
"Scene recipe": "场景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存镜头",
"UPDATE SHOT": "更新镜头",
"START": "启动",
"EXPORT PRESETS": "导出预设",
"IMPORT": "导入",
"RUN LOG": "运行日志",
"POWER UP": "增强",
@@ -2869,5 +2856,30 @@
"Detection confidence": "检测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错"
"route proxy error": "路线代理出错",
"AUTHORING": "创作",
"Create and edit scenes": "创建和编辑场景",
"SCENE": "场景",
"Scene": "场景",
"NEW SCENE": "新建场景",
"Start a new, empty scene": "新建一个空场景",
"DELETE": "删除",
"Delete this scene and all of its shots": "删除此场景及其所有镜头",
"SHOTS": "镜头",
"+ CAPTURE SHOT": "+ 保存镜头",
"Add the current view, style and layers as the next shot": "将当前视角、样式和图层添加为下一个镜头",
"Replace the selected shot with the current view": "用当前视角替换所选镜头",
"Click a shot to select it, double-click to rename.": "单击镜头即可选中,双击可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存与分享",
"Download every scene as a JSON file you can import later": "将所有场景下载为 JSON 文件,以便日后导入",
"Load scenes from an exported JSON file": "从导出的 JSON 文件加载场景",
"Download the timing log of the last play": "下载上次播放的计时日志",
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
"Left panels": "左侧面板",
"Right panels": "右侧面板"
}
+30 -18
View File
@@ -9,7 +9,6 @@
"That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點擊。",
" · {max}-point limit reached": " · 已達 {max} 個點上限",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 雙擊或按 Enter 完成,按 Backspace 撤銷,按 Esc 取消。{full}",
"drawings on the map": "地圖上的繪圖",
"That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。",
"That point is off the globe — click on the world.": "該點在地球之外——請點擊地球上的位置。",
"That shape could not be placed.": "該形狀無法放置。",
@@ -394,7 +393,6 @@
"paste {envVar}": "粘貼 {envVar}",
"REMOVE": "移除",
"Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}",
"unsaved provider keys": "未保存的服務商密鑰",
"Project keys: {status}": "項目密鑰:{status}",
"your app configuration": "你的應用配置",
"your local .env": "你的本地 .env",
@@ -692,7 +690,6 @@
"NIGHT": "夜間",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火點",
"FIRES": "火點",
"max {value} MW": "最大 {value} MW",
"new {value}": "最新({value}前)",
"{value}°N": "{value}°N",
@@ -721,7 +718,7 @@
"Low-accuracy score 2–10%": "低精度評分 2–10%",
"Low-accuracy score > 10%": "低精度評分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性數據源不可用",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格",
"Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試",
"Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。",
@@ -807,6 +804,8 @@
"Pause replay": "暫停回放",
"Cancel replay": "取消回放",
"EST. DOWNRANGE": "預估落點距離",
"CONFIRMED": "已確認",
"PAD / RTLS": "發射臺 / RTLS",
"PLANNED · ": "計劃 · ",
"SUPPLIED TRAJECTORY POINTS": "提供的軌跡點",
"RECONSTRUCTED ESTIMATE": "重建估算",
@@ -1151,6 +1150,8 @@
"Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨",
"Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊",
"Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³",
"Color: {units}": "顏色:{units}",
"RADAR · {units}": "雷達 · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時",
"North America · infrared imagery": "北美洲 · 紅外影像",
"Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用",
@@ -1465,9 +1466,6 @@
"Sensitivity": "靈敏度",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"unsaved CCTV calibration": "未保存的 CCTV 校準",
"the open cockpit": "當前打開的座艙",
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新加載頁面。以下內容將會丟失:{items}。仍要切換嗎?",
"VISUAL PRESETS": "視覺預設",
"LOCATION": "位置",
"CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施",
@@ -1676,7 +1674,6 @@
"Space Missions": "太空任務",
"Context": "態勢",
"Rendered globe frames per second · toggle with `": "每秒渲染的地球幀數 · 按 ` 鍵切換",
"imported map files": "已導入的地圖文件",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法刷新地面高度。請在地圖加載完成後重新嘗試該文件。",
"{count} map objects": {
"other": "{count} 個地圖對象"
@@ -2303,7 +2300,6 @@
"Activate to toggle voice": "點擊切換語音",
"Voice active": "語音已啟用",
"Voice could not be started.": "無法啟動語音。",
"the active voice session": "當前語音會話",
"{value} feet": "{value} 英尺",
"The flights layer is not enabled": "航班圖層未開啟",
"The military layer is not enabled": "軍用航班圖層未開啟",
@@ -2447,8 +2443,6 @@
"Cockpit vision style": "座艙視覺樣式",
"Previous cockpit vision style": "上一個座艙視覺樣式",
"Previous vision style": "上一個視覺樣式",
"Current cockpit vision style: NORMAL. Activate for next style.": "當前座艙視覺樣式:常規。點擊可切換到下一個樣式。",
"Current style: NORMAL — click for next": "當前樣式:常規——點擊切換到下一個",
"CURRENT": "當前",
"Next cockpit vision style": "下一個座艙視覺樣式",
"Next vision style": "下一個視覺樣式",
@@ -2535,7 +2529,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "視野內的影像天數",
"CONTEXT": "態勢",
"Expand panel": "展開面板",
"Context mode": "態勢模式",
"CONTACTS": "目標",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。",
@@ -2700,13 +2693,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始時間",
"SCENES": "場景",
"Scene recipe": "場景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存鏡頭",
"UPDATE SHOT": "更新鏡頭",
"START": "啟動",
"EXPORT PRESETS": "導出預設",
"IMPORT": "導入",
"RUN LOG": "運行日誌",
"POWER UP": "增強",
@@ -2857,6 +2844,31 @@
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
"Scene": "場景",
"NEW SCENE": "新建場景",
"Start a new, empty scene": "新建一個空場景",
"DELETE": "刪除",
"Delete this scene and all of its shots": "刪除此場景及其所有鏡頭",
"SHOTS": "鏡頭",
"+ CAPTURE SHOT": "+ 保存鏡頭",
"Add the current view, style and layers as the next shot": "將當前視角、樣式和圖層添加為下一個鏡頭",
"Replace the selected shot with the current view": "用當前視角替換所選鏡頭",
"Click a shot to select it, double-click to rename.": "單擊鏡頭即可選中,雙擊可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存與分享",
"Download every scene as a JSON file you can import later": "將所有場景下載為 JSON 文件,以便日後導入",
"Load scenes from an exported JSON file": "從導出的 JSON 文件加載場景",
"Download the timing log of the last play": "下載上次播放的計時日誌",
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "標稱+",
"fire confidence filter|High": "高",
+40
View File
@@ -1,4 +1,38 @@
{
"ar": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"GEO",
"GOOGLE STREET VIEW",
"Google Street View",
"HDG",
"HDG {value}°",
"Ironbow",
"Landsat 8/9",
"NVG",
"REC",
"S2 GhostMaps",
"SQUAWK {code} · {status}",
"SRC {value}",
"Sentinel-2",
"WHOT/BHOT",
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW"
],
"de": [
"1090 MHz",
"978 MHz UAT",
@@ -93,6 +127,7 @@
"Plan",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"REGION",
@@ -185,6 +220,7 @@
"CCTV",
"CRS {value}°",
"CRT",
"Color: {units}",
"Country",
"DEST ---°",
"DEST {bearing}",
@@ -236,6 +272,7 @@
"Pop",
"Punk",
"R&B",
"RADAR · {units}",
"RADIO",
"RANGE",
"REC",
@@ -403,6 +440,7 @@
"Plan",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"Radio",
@@ -566,6 +604,7 @@
"PRECIP",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"RESET",
@@ -771,6 +810,7 @@
"Paris",
"Pop",
"Punk",
"RADAR · {units}",
"RANGE",
"REC",
"Reggae",
+5 -4
View File
@@ -89,17 +89,18 @@ test('radio option text and accessible name use the same localized parentheses',
try {
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
setLocale(locale);
let shown = null;
const host = {
_radioPanel: element(), _radioFilter: element(),
_radioPanel: element(),
store: { publish: (view) => (shown = view), publishTuner() {} },
actions: { getLifecycle: () => null, scheduleLayout() {} },
};
renderRadioState.call(host, {
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
});
const option = host._radioFilter.children[0];
assert.equal(option.textContent, `● ${expected}`);
assert.equal(option.attributes['aria-label'], expected);
// The Radio panel shows it as `● label` and names the option `label`.
assert.equal(shown.filter.options[0].label, expected);
}
} finally {
globalThis.document = originalDocument;
+249 -364
View File
@@ -1,4 +1,6 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import { useProviderSettingsStore } from './ui/stores/providerSettings.js';
import { applicationPinia } from './ui/vueRuntime.js';
import {
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
@@ -18,9 +20,13 @@ import {
* The surface self-destructs where it cannot work: a prod build (no endpoint)
* or a LAN visitor (loopback-only endpoint) fails the status fetch, and both
* the chip and the dialog are removed outright.
*
* ProviderSettingsView renders it: initKeySetup() keeps the requests, the
* field values and the dialog's keyboard, and publishes what is shown to the
* gev-provider-settings store.
*/
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
import { t, msg, tn, onLocaleChange } from './i18n/index.js';
/**
* Chip label — pure, exported for tests (keySetup.test.mjs pins the English).
@@ -217,156 +223,26 @@ export function stripKeylessBasemapFromHash(hash) {
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
function updateCloudVoiceAuthControl(auth, busy = false) {
const mode = readStoredCloudVoiceAuthMode();
const state = auth.querySelector('.key-setup-cloud-auth-state');
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
state.textContent =
mode === 'oauth'
? t('VOICE AUTH · CHATGPT OAUTH')
: t('VOICE AUTH · API KEY');
toggle.textContent =
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
toggle.title =
mode === 'oauth'
? t('Use OPENAI_API_KEY for the next cloud voice session')
: t(
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
);
// Keep the control focusable while the delegated handler blocks repeats.
toggle.setAttribute('aria-disabled', String(busy));
}
/** Build one key row. All content is our own registry text, set via textContent. */
function buildRow(documentRef, key) {
const row = documentRef.createElement('section');
row.className = 'key-setup-row';
row.dataset.keyId = key.id;
row.dataset.set = String(Boolean(key.set));
if (key.managed) row.dataset.managed = key.managed;
const external = key.managed === 'external';
const head = documentRef.createElement('div');
head.className = 'key-setup-row-head';
const led = documentRef.createElement('span');
led.className = 'key-setup-led';
led.setAttribute('aria-hidden', 'true');
const title = documentRef.createElement('strong');
title.textContent = key.title; // i18n-ignore: provider product name
const tier = documentRef.createElement('span');
tier.className = 'key-setup-tier';
tier.textContent = TIER_DOTS[key.tier] || '';
tier.title =
key.tier === 'metered'
? t('Metered — a billing-enabled account')
: key.tier === 'paid'
? t('Optional paid token — public access works without one')
: t('Free key — register, paste, done');
head.append(led, title, tier);
if (key.clientExposed) {
const exposed = documentRef.createElement('span');
exposed.className = 'key-setup-exposed';
exposed.textContent = t('browser-side');
exposed.title = t(
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
);
head.append(exposed);
}
if (external) {
// Externally supplied credentials (shell env, Keychain, a launcher) are
// facts this panel reports, never values it rewrites or deletes.
const badge = documentRef.createElement('span');
badge.className = 'key-setup-external';
badge.textContent = t('configured externally');
badge.title = t(
'Supplied by your environment, Keychain, or launcher — change it where it was set',
);
head.append(badge);
}
const get = documentRef.createElement('a');
get.className = 'key-setup-get';
get.href = key.getUrl;
get.target = '_blank';
get.rel = 'noopener noreferrer';
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
head.append(get);
const unlocks = documentRef.createElement('p');
unlocks.className = 'key-setup-unlocks';
unlocks.textContent = t(key.unlocks);
row.append(head, unlocks);
if (!external) {
const fields = documentRef.createElement('div');
fields.className = 'key-setup-fields';
for (const envVar of key.envVars) {
const input = documentRef.createElement('input');
// Passwords-style so a pasted key never shows on a shared or recorded
// screen — this app gets screen-recorded a lot.
input.type = 'password';
input.autocomplete = 'off';
input.spellcheck = false;
input.dataset.envVar = envVar;
input.setAttribute('aria-label', envVar);
input.placeholder = key.set
? t('{envVar} saved — paste to replace', { envVar })
: t('paste {envVar}', { envVar });
fields.append(input);
}
if (key.managed === 'file') {
const remove = documentRef.createElement('button');
remove.type = 'button';
remove.className = 'key-setup-remove';
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
remove.textContent = t('REMOVE');
remove.title = t("Remove {title} from this app's saved keys", {
title: key.title,
});
fields.append(remove);
}
row.append(fields);
}
if (key.id === 'openai') {
const auth = documentRef.createElement('div');
auth.className = 'key-setup-cloud-auth';
const state = documentRef.createElement('span');
state.className = 'key-setup-cloud-auth-state';
const toggle = documentRef.createElement('button');
toggle.type = 'button';
toggle.className = 'key-setup-cloud-auth-toggle';
toggle.dataset.cloudVoiceAuthToggle = 'true';
auth.append(state, toggle);
updateCloudVoiceAuthControl(auth);
row.append(auth);
}
return row;
}
/**
* Move the existing setup action with the theme, retaining its listeners:
* Cyber puts it in the top toolbar, every other theme keeps it first in its
* markup home (the bottom-right corner row, beside the language picker).
*/
export function bindKeySetupPlacement(documentRef, chip) {
const theme = documentRef?.documentElement;
const toolbar = documentRef?.getElementById?.('top-center-actions');
const Observer = documentRef?.defaultView?.MutationObserver;
const home = chip?.parentNode;
if (!theme || !toolbar || !Observer || !home) return () => {};
const sync = () => {
if (theme.dataset.uiTheme === 'cyber') {
if (chip.parentNode !== toolbar) toolbar.append(chip);
} else if (chip.parentNode !== home) {
home.prepend(chip);
}
/** One key row as the view renders it, from the status payload's own text. */
function presentRow(key) {
return {
id: key.id,
title: key.title,
set: Boolean(key.set),
managed: key.managed || null,
external: key.managed === 'external',
tierDot: TIER_DOTS[key.tier] || '',
tierTitle:
key.tier === 'metered'
? t('Metered — a billing-enabled account')
: key.tier === 'paid'
? t('Optional paid token — public access works without one')
: t('Free key — register, paste, done'),
clientExposed: Boolean(key.clientExposed),
getUrl: key.getUrl,
unlocks: t(key.unlocks),
envVars: [...(key.envVars || [])],
};
const observer = new Observer(sync);
observer.observe(theme, {
attributes: true,
attributeFilter: ['data-ui-theme'],
});
sync();
return () => observer.disconnect();
}
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
@@ -374,93 +250,79 @@ export async function initKeySetup({
documentRef = globalThis.document,
fetchImpl,
signal,
store = useProviderSettingsStore(applicationPinia()),
} = {}) {
const chip = documentRef?.getElementById?.('key-setup-chip');
const root = documentRef?.getElementById?.('key-setup');
if (!chip || !root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true';
if (!root || store.controls) return null;
const lifetime = new AbortController();
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let disposed = false;
let disposeControls = () => {};
let disposePlacement = () => {};
// A pasted key lives only in its field until Save: switching language (a
// reload) asks first while any field holds one.
const unregisterUnsavedWork = registerUnsavedWork(() =>
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
String(input.value ?? '').trim(),
)
? msg('unsaved provider keys')
: null,
);
// Pasted keys live only here until Save.
const fields = {};
const state = {
available: null,
ready: false,
chip: null,
dialog: { hidden: true, visible: false },
rows: [],
// Bumped by a save: the rows render afresh, so no field keeps a key.
generation: 0,
fields,
status: null,
// Counts messages, so the live region announces a repeated one again.
statusSeq: 0,
saving: null,
auth: { mode: readStoredCloudVoiceAuthMode(), busy: false },
};
const publish = () => {
if (store.controls !== controls) return;
store.publish({
...state,
chip: state.chip && { ...state.chip },
dialog: { ...state.dialog },
fields: { ...fields },
auth: { ...state.auth },
});
};
const destroy = () => {
if (disposed) return;
disposed = true;
delete root.dataset.ready;
unregisterUnsavedWork();
offLocale();
lifetime.abort();
signal?.removeEventListener('abort', destroy);
disposeControls();
disposePlacement();
chip.remove();
root.remove();
open = false;
keyboard.destroy();
state.available = false;
state.ready = false;
publish();
if (store.controls === controls) store.connect(null);
};
if (signal?.aborted) {
destroy();
return null;
}
signal?.addEventListener('abort', destroy, { once: true });
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let status = null;
try {
const response = await doFetch('/api/setup/status', {
cache: 'no-store',
signal: lifetime.signal,
});
if (!response.ok) throw new Error(String(response.status));
status = await response.json();
if (disposed) return null;
} catch {
// Prod build or non-loopback visitor: the surface cannot function, so it
// does not exist. (The README covers .env for headless/self-host setups.)
destroy();
return null;
}
const rowsHost = root.querySelector('[data-key-setup-rows]');
disposePlacement = bindKeySetupPlacement(documentRef, chip);
const applyButton = root.querySelector('[data-key-setup-apply]');
const closeButton = root.querySelector('[data-key-setup-close]');
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
const statusLine = root.querySelector('[data-key-setup-status]');
const defaultStatusText = statusLine?.textContent || '';
let busy = false;
let oauthBusy = false;
let open = false;
const syncCloudVoiceAuth = () => {
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
updateCloudVoiceAuthControl(auth, oauthBusy);
};
const render = (nextStatus) => {
if (disposed) return;
status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status);
chip.title = t('Project keys: {status}', {
status: keySetupChipLabel(status),
});
chip.setAttribute('aria-label', chip.title);
const label = keySetupChipLabel(status);
// Fully powered is the owner's clean screen: the chip retires. The dialog
// stays reachable this session (and via ?setup=1) to swap or verify keys.
chip.hidden = status.setCount >= status.total;
if (!rowsHost) return;
rowsHost.textContent = '';
for (const key of status.keys || [])
rowsHost.append(buildRow(documentRef, key));
syncCloudVoiceAuth();
state.chip = {
label,
title: t('Project keys: {status}', { status: label }),
hidden: status.setCount >= status.total,
};
state.rows = (status.keys || []).map(presentRow);
state.auth = { ...state.auth, mode: readStoredCloudVoiceAuthMode() };
publish();
};
// The chip and rows are translated here; show them again in a new locale.
const offLocale = onLocaleChange(() => {
if (status) render(status);
});
const visible = () =>
root.isConnected &&
root.classList.contains('visible') &&
@@ -474,32 +336,48 @@ export async function initKeySetup({
});
const openDialog = () => {
if (disposed || open) return;
if (disposed || open || !state.ready) return;
open = true;
keyboard.activate();
root.hidden = false;
state.dialog = { hidden: false, visible: false };
publish();
// The frame runs after Vue shows the dialog, so the fade starts from it.
globalThis.requestAnimationFrame?.(() => {
if (!open) return;
root.classList.add('visible');
root.querySelector('input')?.focus?.({ preventScroll: true });
state.dialog = { hidden: false, visible: true };
publish();
// The fields are already rendered; focus after Vue applies the class.
Promise.resolve().then(() => {
if (open) root.querySelector('input')?.focus?.({ preventScroll: true });
});
});
};
const hideIfClosed = () => {
if (open || disposed || state.dialog.hidden) return;
state.dialog = { hidden: true, visible: false };
publish();
};
const close = () => {
if (!open) return;
open = false;
root.classList.remove('visible');
const hide = () => {
if (!open) root.hidden = true;
};
root.addEventListener('transitionend', hide, { once: true });
globalThis.setTimeout?.(hide, 400);
if (statusLine) statusLine.textContent = defaultStatusText;
state.dialog = { hidden: false, visible: false };
state.status = null;
publish();
globalThis.setTimeout?.(hideIfClosed, 400);
keyboard.deactivate({ restoreFocus: true });
};
const say = (text) => {
if (statusLine) statusLine.textContent = text;
state.status = text;
state.statusSeq += 1;
publish();
};
const syncAuth = (busyNow = state.auth.busy) => {
state.auth = { mode: readStoredCloudVoiceAuthMode(), busy: busyNow };
publish();
};
const storeLabel = () =>
@@ -515,7 +393,7 @@ export async function initKeySetup({
(key) => key.id === 'google-maps',
)?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
state.saving = true;
say(t('Saving…'));
try {
const response = await doFetch('/api/setup/keys', {
@@ -530,8 +408,8 @@ export async function initKeySetup({
say(saveRefusalText(payload, response.status));
return;
}
for (const input of root.querySelectorAll('input[data-env-var]'))
input.value = '';
for (const envVar of Object.keys(fields)) delete fields[envVar];
state.generation += 1;
render(payload.status);
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
const strip = () => {
@@ -566,18 +444,15 @@ export async function initKeySetup({
);
} finally {
busy = false;
applyButton?.setAttribute('aria-disabled', 'false');
state.saving = false;
publish();
}
};
const onApply = async () => {
const apply = async () => {
if (disposed || busy) return;
const inputs = [...root.querySelectorAll('input[data-env-var]')];
const updates = collectKeyUpdates(
inputs.map((input) => ({
envVar: input.dataset.envVar,
value: input.value,
})),
Object.entries(fields).map(([envVar, value]) => ({ envVar, value })),
);
if (!Object.keys(updates).length) {
say(t('Paste at least one key first.'));
@@ -589,125 +464,8 @@ export async function initKeySetup({
);
};
chip.addEventListener('click', openDialog);
closeButton?.addEventListener('click', close);
applyButton?.addEventListener('click', onApply);
root.dataset.ready = 'true';
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const authButton = event.target?.closest?.(
'[data-cloud-voice-auth-toggle]',
);
if (authButton && !disposed) {
if (oauthBusy) return;
void (async () => {
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncCloudVoiceAuth();
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
return;
}
oauthBusy = true;
syncCloudVoiceAuth();
say(t('Checking local ChatGPT OAuth sign-in…'));
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in.'),
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? t('ChatGPT sign-in expired. Opening sign-in again…')
: t('Opening ChatGPT sign-in in your browser…'),
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok) {
say(
loginPayload.error ||
payload.error ||
t('Could not start ChatGPT sign-in on this machine.'),
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
t(
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
),
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
t('ChatGPT sign-in timed out. Click {button} to try again.', {
button: t('USE CHATGPT OAUTH'),
}),
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
),
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(oauthCheckFailureText(error));
} finally {
oauthBusy = false;
if (!disposed) syncCloudVoiceAuth();
}
})();
return;
}
const button = event.target?.closest?.('[data-key-setup-remove]');
if (disposed || !button || busy) return;
let envVars = [];
try {
envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
} catch {
return;
}
const remove = (envVars) => {
if (disposed || busy) return;
if (!Array.isArray(envVars) || !envVars.length) return;
// Removal is destructive and — behind a framing defense that should already
// stop it — a clickjack target. A confirm turns a single aligned click into
@@ -720,8 +478,142 @@ export async function initKeySetup({
Object.fromEntries(envVars.map((name) => [name, null])),
msg('Removed from {store}. Restarting — this page reloads itself.'),
);
});
};
const toggleAuth = async () => {
if (disposed || state.auth.busy) return;
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncAuth();
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
return;
}
syncAuth(true);
say(t('Checking local ChatGPT OAuth sign-in…'));
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in.'),
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? t('ChatGPT sign-in expired. Opening sign-in again…')
: t('Opening ChatGPT sign-in in your browser…'),
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok) {
say(
loginPayload.error ||
payload.error ||
t('Could not start ChatGPT sign-in on this machine.'),
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
t('Finish ChatGPT sign-in in the browser. Waiting for it to complete…'),
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
t('ChatGPT sign-in timed out. Click {button} to try again.', {
button: t('USE CHATGPT OAUTH'),
}),
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
),
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(oauthCheckFailureText(error));
} finally {
if (!disposed) syncAuth(false);
}
};
const controls = {
open: openDialog,
close,
apply,
remove,
toggleAuth: () => void toggleAuth(),
setField(envVar, value) {
fields[envVar] = String(value ?? '');
publish();
},
transitionEnd: hideIfClosed,
};
store.connect(controls);
publish();
if (signal?.aborted) {
destroy();
return null;
}
signal?.addEventListener('abort', destroy, { once: true });
try {
const response = await doFetch('/api/setup/status', {
cache: 'no-store',
signal: lifetime.signal,
});
if (!response.ok) throw new Error(String(response.status));
status = await response.json();
if (disposed) return null;
} catch {
// Prod build or non-loopback visitor: the surface cannot function, so it
// does not exist. (The README covers .env for headless/self-host setups.)
destroy();
return null;
}
state.available = true;
state.ready = true;
render(status);
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
@@ -736,12 +628,5 @@ export async function initKeySetup({
// An unparsable location never blocks init.
}
disposeControls = () => {
open = false;
keyboard.destroy();
chip.removeEventListener('click', openDialog);
closeButton?.removeEventListener('click', close);
applyButton?.removeEventListener('click', onApply);
};
return { open: openDialog, close, render, destroy };
}
+11 -97
View File
@@ -4,61 +4,13 @@ import {
collectKeyUpdates,
keySetupChipLabel,
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
waitForChatGptOAuth,
initKeySetup,
} from './keySetup.js';
import { describeUnsavedWork } from './i18n/index.js';
import { createPinia } from 'pinia';
import { useProviderSettingsStore } from './ui/stores/providerSettings.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = {
prepend: (node) => {
node.parentNode = home;
},
};
const chip = { parentNode: home, hidden: true };
let moves = 0;
const toolbar = {
append: (node) => {
moves++;
node.parentNode = toolbar;
},
};
const theme = { dataset: { uiTheme: 'tactical' } };
let notify,
disconnected = false;
class Observer {
constructor(callback) {
notify = callback;
}
observe(target, options) {
assert.equal(target, theme);
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
}
disconnect() {
disconnected = true;
}
}
const dispose = bindKeySetupPlacement(
{
documentElement: theme,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
},
chip,
);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
notify();
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
notify();
}
assert.equal(moves, 2, 'identical theme sync does not reparent again');
assert.equal(chip.hidden, true, 'placement cannot reveal a retired button');
dispose();
assert.equal(disconnected, true);
});
const freshStore = () => useProviderSettingsStore(createPinia());
test('the chip counts what is missing, and retires the count at zero', () => {
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
@@ -153,64 +105,26 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
});
test('aborting pending setup removes its surface and ignores a late response', async () => {
const { initKeySetup } = await import('./keySetup.js');
const removed = [];
const chip = { remove: () => removed.push('chip') };
const root = { dataset: {}, remove: () => removed.push('root') };
const store = freshStore();
const root = { dataset: {} };
let resolveResponse;
let requestSignal;
const controller = new AbortController();
const pending = initKeySetup({
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
documentRef: { getElementById: (id) => (id === 'key-setup' ? root : null) },
signal: controller.signal,
store,
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((resolve) => { resolveResponse = resolve; });
},
});
assert.equal(store.controls !== null, true);
controller.abort();
assert.equal(requestSignal.aborted, true);
assert.deepEqual(removed, ['chip', 'root']);
assert.equal(store.view.available, false);
assert.equal(store.controls, null);
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
assert.equal(await pending, null);
});
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
const node = () => ({
dataset: {},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
querySelector: () => null,
remove() {},
});
const chip = node();
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
const root = {
...node(),
querySelectorAll: (selector) =>
selector === 'input[data-env-var]' ? [input] : [],
};
const controller = new AbortController();
const setup = await initKeySetup({
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
addEventListener() {},
removeEventListener() {},
},
signal: controller.signal,
fetchImpl: async () => ({
ok: true,
json: async () => ({ keys: [], setCount: 0, total: 1 }),
}),
});
assert.ok(setup);
assert.deepEqual(describeUnsavedWork(), []);
input.value = ' ';
assert.deepEqual(describeUnsavedWork(), []);
input.value = 'AIza-pasted';
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
// The field still holds the key, so only teardown dropping the check clears it.
controller.abort();
assert.deepEqual(describeUnsavedWork(), []);
assert.equal(store.view.available, false);
});
+18
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { normalizeAlprNode } from './records.js';
import {
directionWedgePositions,
alprLabelDetails,
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
);
});
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
const direction = (tag) =>
alprLabelDetails(
normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
}),
{ label: 'Custom directory' },
).find((line) => line.startsWith('DIRECTION'));
assert.equal(direction('359.6'), 'DIRECTION 0°');
assert.equal(direction('359.4'), 'DIRECTION 359°');
assert.equal(direction('0.4'), 'DIRECTION 0°');
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
+2 -1
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
@@ -0,0 +1,185 @@
// src/layers/awareness/directionOverlay.test.mjs — the Global Context compass
// and contact bearings are presented to subscribers and rendered from a store
// inside the viewer's container.
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 { useAwarenessDirectionStore } from '../../ui/stores/awarenessDirection.js';
import AwarenessDirection from '../../ui/views/panels/AwarenessDirection.vue';
import { createState } from './state.js';
import { createRendering } from './rendering.js';
/** The rendering part over a subject with one military contact due east. */
function overlay(screenAngle) {
const state = createState({ services: {} });
const container = document.body.appendChild(document.createElement('div'));
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
Object.assign(state, {
viewer: {
container,
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
camera: {
heading: 0,
rightWC: new Cesium.Cartesian3(0, 1, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
},
},
enabled: true,
subject: { position: subject, layerId: 'military' },
results: {
cohorts: [
{
id: 'military',
summary: {
nearest: [
{
callsign: 'RCH123',
position: Cesium.Cartesian3.fromDegrees(0.05, 0, 1000),
distanceM: 5500,
track: 90,
},
],
},
},
],
},
cohortPages: new Map(),
});
const rendering = createRendering({
state,
services: {
geometry: {
getKeyholeGeometry: () => ({ centerX: 400, centerY: 300, radius: 250 }),
celestialScreenAngle:
screenAngle ||
((x, y) => ({
angle: Math.atan2(-y, x),
opacity: 1,
stable: true,
})),
},
render: { governorRequestRender() {} },
},
parts: {},
});
return { state, container, rendering };
}
test('the overlay publishes the compass and the nearest contact bearings', () => {
const { rendering, container } = overlay();
const views = [];
rendering.subscribeDirectionOverlay((view) => views.push(view));
assert.deepEqual(views, [null]);
rendering.updateDirectionOverlay();
const view = views.at(-1);
assert.ok(view.container === container);
assert.equal(view.hidden, false);
// The compass sits at 66% of the 250px keyhole: 165px.
assert.equal(view.ring.width, '330px');
assert.equal(view.ring['--compass-rotation'], '0deg');
assert.equal(view.heading.text, 'HDG 000°');
assert.deepEqual(
view.labels.map((label) => label.label),
['N', 'E', 'S', 'W'],
);
assert.equal(view.labels[0].left, '400px');
const [marker, second] = view.markers;
assert.equal(marker.hidden, false);
assert.match(marker.caption.text, /^RCH123 · .+\nBRG 090° · CRS 090°$/);
assert.equal(marker.arrow.opacity, '1');
assert.equal(second.hidden, true);
// An unchanged frame publishes nothing.
const count = views.length;
rendering.updateDirectionOverlay();
assert.equal(views.length, count);
rendering.hideDirectionOverlay();
assert.equal(views.at(-1).hidden, true);
assert.equal(views.at(-1).heading.text, 'HDG 000°', 'placement is kept');
rendering.removeDirectionOverlay();
assert.equal(views.at(-1), null);
});
test('an unstable frame keeps the last stable bearing, until the overlay is removed', () => {
const answers = [
{ angle: 1, opacity: 1, stable: true },
{ angle: 2, opacity: 0.5, stable: false },
];
const previous = [];
const { state, rendering } = overlay((x, y, last) => {
previous.push(last);
return answers.shift() ?? { angle: last ?? 0, opacity: 1, stable: true };
});
rendering.updateDirectionOverlay();
assert.equal(state.directionAngles[0], 1);
// The heading turns, so the next frame recomputes the contact.
state.viewer.camera.heading = 0.1;
rendering.updateDirectionOverlay();
assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
assert.equal(state.directionAngles[0], 1, 'and does not replace it');
rendering.removeDirectionOverlay();
rendering.updateDirectionOverlay();
assert.equal(previous[2], 0, 'a rebuilt overlay starts afresh');
});
test('the scene chrome view renders the published overlay in its container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const store = useAwarenessDirectionStore(pinia);
const app = createApp(AwarenessDirection).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const { rendering, container } = overlay();
rendering.subscribeDirectionOverlay((view) => store.publish(view));
rendering.ensureDirectionOverlay();
await nextTick();
const root = container.querySelector('#military-awareness-direction-overlay');
assert.equal(root.hidden, true);
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.deepEqual(
[...root.children].map((child) => child.className),
[
'military-awareness-compass-label cardinal-n',
'military-awareness-compass-label cardinal-e',
'military-awareness-compass-label cardinal-s',
'military-awareness-compass-label cardinal-w',
'military-awareness-compass-ring',
'military-awareness-compass-heading',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
],
);
assert.equal(root.children[0].dataset.bearing, '0');
assert.equal(root.children[0].getAttribute('style'), null);
rendering.updateDirectionOverlay();
await nextTick();
assert.equal(root.hidden, false);
const ring = root.querySelector('.military-awareness-compass-ring');
assert.equal(ring.style.getPropertyValue('--compass-rotation'), '0deg');
assert.equal(
root.querySelector('.military-awareness-compass-heading').textContent,
'HDG 000°',
);
const [marker, second] = root.querySelectorAll(
'.military-awareness-direction-marker',
);
assert.equal(marker.hidden, false);
assert.equal(
marker.querySelector('.military-awareness-direction-arrow').textContent,
'➜',
);
assert.match(
marker.querySelector('.military-awareness-direction-label').textContent,
/^RCH123 · /,
);
assert.equal(second.hidden, true);
rendering.removeDirectionOverlay();
await nextTick();
assert.equal(
container.querySelector('#military-awareness-direction-overlay'),
null,
);
});
+4 -3
View File
@@ -51,12 +51,13 @@ export function createAwarenessLayer({ services, source }) {
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest:
parts.testing._getAwarenessPanelRendersForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
subscribePanel: parts.panel.subscribePanel,
subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
+2 -16
View File
@@ -229,22 +229,8 @@ export function createLifecycle({
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
parts.panel.clearPanel();
parts.rendering.removeDirectionOverlay();
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
Loaded 100 of 665 files, more files were not shown because too many files have changed in this diff. Show more