mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
merge main 07094d9 into feat/voice-outlines
This commit is contained in:
commit
c5aa0d28c3
593 files changed
+42386
-19629
No files matched your search
@@ -215,9 +215,12 @@ export function formatMeasure(session) {
|
||||
}
|
||||
if (session.shape === 'area') {
|
||||
const m2 = ringAreaM2(session.vertices);
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
|
||||
return `${Math.round(m2)} m²`;
|
||||
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
|
||||
const whole = Math.round(m2);
|
||||
const ha = (m2 / 1e4).toFixed(1);
|
||||
if (whole < 1e4) return `${whole} m²`;
|
||||
if (Number(ha) < 100) return `${ha} ha`;
|
||||
return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -345,3 +345,43 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||
assert.equal(formatMeasure(line), '1.0 km');
|
||||
assert.match(drawHint(line), /^1\.0 km · /);
|
||||
});
|
||||
|
||||
test('an area that rounds up to the next unit reads in that unit', () => {
|
||||
// A square on the equator whose shoelace area is about `m2`.
|
||||
const square = (m2) => {
|
||||
const side = Math.sqrt(m2) / 111320;
|
||||
const area = createDrawSession('area');
|
||||
for (const [lon, lat] of [
|
||||
[0, 0],
|
||||
[side, 0],
|
||||
[side, side],
|
||||
[0, side],
|
||||
])
|
||||
addVertex(area, { lon, lat });
|
||||
return area;
|
||||
};
|
||||
const almostHectare = square(9999.6);
|
||||
const metres = ringAreaM2(almostHectare.vertices);
|
||||
assert.ok(
|
||||
metres >= 9999.5 && metres < 1e4,
|
||||
`area should round to 10000 m², got ${metres}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostHectare), '1.0 ha');
|
||||
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
|
||||
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
|
||||
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
|
||||
const almostKm2 = square(999_960);
|
||||
const nearKm2 = ringAreaM2(almostKm2.vertices);
|
||||
assert.ok(
|
||||
nearKm2 >= 999_500 && nearKm2 < 1e6,
|
||||
`area should round to 100.0 ha, got ${nearKm2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostKm2), '1.00 km²');
|
||||
const under = square(999_400);
|
||||
const underM2 = ringAreaM2(under.vertices);
|
||||
assert.ok(
|
||||
underM2 >= 998_500 && underM2 < 999_500,
|
||||
`area should round to 99.9 ha, got ${underM2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(under), '99.9 ha');
|
||||
});
|
||||
+79
-89
@@ -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,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/,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -17,11 +17,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();
|
||||
|
||||
|
||||
@@ -1,144 +1,25 @@
|
||||
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';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.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);
|
||||
@@ -185,7 +66,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([
|
||||
@@ -194,13 +74,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;
|
||||
@@ -283,10 +160,6 @@ function buildingAnnotation(id, offset = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
function annotationGroups(svg) {
|
||||
return svg.children.filter((child) => child.classList.contains('gev-anno'));
|
||||
}
|
||||
|
||||
function naiveRingCentroid(ring) {
|
||||
const totals = ring.reduce(
|
||||
(sum, [lon, lat]) => ({ lon: sum.lon + lon, lat: sum.lat + lat }),
|
||||
@@ -296,7 +169,6 @@ function naiveRingCentroid(ring) {
|
||||
}
|
||||
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
@@ -306,16 +178,13 @@ 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 (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
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;
|
||||
else globalThis.window = originalWindow;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
if (original === undefined) delete globalThis[name];
|
||||
else globalThis[name] = original;
|
||||
@@ -350,7 +219,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',
|
||||
@@ -364,10 +233,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);
|
||||
|
||||
@@ -384,18 +251,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);
|
||||
@@ -471,7 +342,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',
|
||||
@@ -487,20 +358,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);
|
||||
@@ -510,7 +380,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();
|
||||
@@ -519,7 +389,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',
|
||||
@@ -534,35 +404,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',
|
||||
@@ -594,10 +463,9 @@ 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();
|
||||
});
|
||||
|
||||
@@ -823,17 +691,18 @@ test('a timed-out building upgrade keeps its existing pending screen group', asy
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
const [before] = annotationGroups(store);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const completion = renderer.update(anno);
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal((await completion).status, 'timeout');
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(after.group, before.group);
|
||||
assert.ok(after.group.querySelector('.gev-anno-ring'), 'the honest pending point is not converted');
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
assert.equal(after.key, before.key);
|
||||
assert.equal(others.length, 0);
|
||||
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
frames.emit();
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
@@ -843,11 +712,9 @@ test('a timed-out building upgrade keeps its existing pending screen group', asy
|
||||
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
const { group, svg } = findAnnotationGroup(globalThis.document);
|
||||
const dot = group.querySelector('.gev-anno-dot');
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
|
||||
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
@@ -859,11 +726,16 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
|
||||
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
|
||||
const originalSetAttribute = dot.setAttribute.bind(dot);
|
||||
// The caption conversion fails when it presents the converted mark.
|
||||
const publish = store.publish;
|
||||
const failure = new Error('caption conversion failed');
|
||||
dot.setAttribute = (name, value) => {
|
||||
if (name === 'r') throw failure;
|
||||
return originalSetAttribute(name, value);
|
||||
let armed = true;
|
||||
store.publish = (board) => {
|
||||
if (armed) {
|
||||
armed = false;
|
||||
throw failure;
|
||||
}
|
||||
return publish(board);
|
||||
};
|
||||
// Also fail screen teardown once: the routing entry must survive this second
|
||||
// failure so explicit cleanup can still reach the partial screen state.
|
||||
@@ -877,11 +749,11 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
|
||||
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'failed screen cleanup remains addressable');
|
||||
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
dot.setAttribute = originalSetAttribute;
|
||||
store.publish = publish;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.remove(anno);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -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('path', {
|
||||
parts.poly = svgPart('path', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c,
|
||||
'fill-rule': 'evenodd',
|
||||
@@ -167,16 +184,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,
|
||||
@@ -184,22 +199,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,
|
||||
@@ -207,37 +220,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',
|
||||
@@ -246,70 +255,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;
|
||||
@@ -329,12 +325,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();
|
||||
@@ -347,11 +349,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;
|
||||
}
|
||||
@@ -471,13 +471,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,
|
||||
@@ -486,10 +489,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 paths = [];
|
||||
@@ -525,12 +534,12 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
}
|
||||
if (paths.length) {
|
||||
parts.poly.setAttribute('d', paths.join(' '));
|
||||
group.style.display = '';
|
||||
parts.poly.set.d = paths.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);
|
||||
@@ -545,11 +554,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(
|
||||
@@ -559,7 +565,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));
|
||||
@@ -568,19 +574,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)[
|
||||
@@ -589,33 +595,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.
|
||||
@@ -623,18 +618,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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,32 +638,53 @@ export function createScreenAnnotationRenderer(
|
||||
if (
|
||||
cal &&
|
||||
cal.sized &&
|
||||
rec.group.style.display !== 'none' &&
|
||||
rec.group.display !== 'none' &&
|
||||
Number.isFinite(cal._x)
|
||||
) {
|
||||
placed.push(cal);
|
||||
}
|
||||
}
|
||||
if (placed.length > 1) decollideCallouts(placed);
|
||||
placeCalloutsAboveVoicePreview(placed, svg, w, h);
|
||||
placeCalloutsAboveVoicePreview(placed, w, h);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -692,32 +705,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();
|
||||
}
|
||||
@@ -733,87 +823,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,10 +929,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)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,13 +947,10 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,11 +958,13 @@ function decollideCallouts(callouts) {
|
||||
}
|
||||
|
||||
/** Keep an overlapping callout above the actual open Voice Preview, when it fits. */
|
||||
function placeCalloutsAboveVoicePreview(callouts, svg, width, height) {
|
||||
function placeCalloutsAboveVoicePreview(callouts, width, height) {
|
||||
if (!callouts.length) return;
|
||||
const panel = document.getElementById('gev-voice-panel');
|
||||
const svg = document.querySelector('.gev-screen-whiteboard-svg');
|
||||
if (
|
||||
!panel ||
|
||||
!svg ||
|
||||
panel.hidden ||
|
||||
panel.dataset.view !== 'preview' ||
|
||||
panel.checkVisibility?.({
|
||||
@@ -911,67 +1014,13 @@ function placeCalloutsAboveVoicePreview(callouts, svg, width, height) {
|
||||
continue;
|
||||
positionCallout(callout, candidate._x, candidate._y, false);
|
||||
if (callout._leader) {
|
||||
callout._leader.setAttribute('x2', candidate._x.toFixed(1));
|
||||
callout._leader.setAttribute(
|
||||
'y2',
|
||||
(candidate._y + candidate.height).toFixed(1),
|
||||
);
|
||||
callout._leader.set.x2 = candidate._x.toFixed(1);
|
||||
callout._leader.set.y2 = (candidate._y + candidate.height).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);
|
||||
}
|
||||
@@ -1,168 +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 };
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this._rect ?? { left: 0, top: 0, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -173,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',
|
||||
@@ -186,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',
|
||||
@@ -195,279 +210,144 @@ 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('Preview avoids the map callout without moving its world anchor', (t) => {
|
||||
const originals = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
project: Cesium.SceneTransforms.worldToWindowCoordinates,
|
||||
};
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
|
||||
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(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originals.project;
|
||||
for (const key of ['document', 'requestAnimationFrame']) {
|
||||
if (originals[key] === undefined) delete globalThis[key];
|
||||
else globalThis[key] = originals[key];
|
||||
}
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = original;
|
||||
});
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 800);
|
||||
const camera = {
|
||||
positionWC,
|
||||
directionWC: Cesium.Cartesian3.normalize(Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()), new Cesium.Cartesian3()),
|
||||
positionCartographic: { height: 800 },
|
||||
};
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const panel = new FakeElement('div');
|
||||
panel.id = 'gev-voice-panel';
|
||||
panel.dataset = { view: 'preview' };
|
||||
panel.hidden = true;
|
||||
panel.checkVisibility = () => true;
|
||||
// Non-unit, independent axis scales and a nonzero client offset.
|
||||
// The actual overlay uses preserveAspectRatio="none", not letterboxing.
|
||||
panel._rect = { left: 210, top: 120, width: 100, height: 120 };
|
||||
document.body.appendChild(panel);
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
|
||||
renderer.add(anno);
|
||||
const { svg, group } = findAnnotationGroup(document);
|
||||
svg._rect = { left: 10, top: 20, width: 640, height: 400 };
|
||||
const callout = group.querySelector('.gev-anno-callout');
|
||||
const leader = group.querySelector('.gev-anno-leader');
|
||||
const dot = group.querySelector('.gev-anno-dot');
|
||||
const natural = callout.getAttribute('transform');
|
||||
const anchor = structuredClone(anno.anchor);
|
||||
panel.hidden = false;
|
||||
renderer.sync();
|
||||
assert.equal(callout.getAttribute('transform'), 'translate(418.0, 146.0)');
|
||||
assert.equal(leader.getAttribute('x2'), '418.0');
|
||||
assert.equal(leader.getAttribute('y2'), '172.0');
|
||||
assert.equal(leader.getAttribute('x1'), '400.0');
|
||||
assert.equal(leader.getAttribute('y1'), '200.0');
|
||||
assert.equal(dot.getAttribute('cx'), '400.0');
|
||||
assert.equal(dot.getAttribute('cy'), '200.0');
|
||||
assert.deepEqual(anno.anchor, anchor);
|
||||
assert.equal(findAnnotationGroup(document).group, group);
|
||||
panel.hidden = true;
|
||||
renderer.sync();
|
||||
assert.equal(callout.getAttribute('transform'), natural, 'closing Preview restores natural placement');
|
||||
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
|
||||
panel.hidden = false;
|
||||
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
|
||||
panel.checkVisibility = () => variant !== 'invisible';
|
||||
panel._rect = variant === 'no-overlap'
|
||||
? { left: 510, top: 120, width: 100, height: 120 }
|
||||
: variant === 'no-room'
|
||||
? { left: 210, top: 25, width: 100, height: 180 }
|
||||
: { left: 210, top: 120, width: 100, height: 120 };
|
||||
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
|
||||
renderer.sync();
|
||||
assert.equal(callout.getAttribute('transform'), natural, variant);
|
||||
}
|
||||
});
|
||||
|
||||
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 = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
const camera = {
|
||||
positionWC,
|
||||
directionWC,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
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 },
|
||||
{
|
||||
activeTrackedReadoutId: () => "installations:test",
|
||||
store,
|
||||
activeTrackedReadoutId: () => 'installations:test',
|
||||
overlayPaintRect(sourceId, entryId) {
|
||||
paintRectCalls.push({ sourceId, entryId });
|
||||
return { x: -10, y: -10, w: 120, h: 80 };
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: "anno-overlap",
|
||||
type: "label",
|
||||
color: "primary",
|
||||
label: "OVERLAP",
|
||||
id: 'anno-overlap',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'OVERLAP',
|
||||
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",
|
||||
sourceId: 'tracked',
|
||||
entryId: 'installations:test',
|
||||
});
|
||||
assert.ok(
|
||||
Number(group.getAttribute("opacity")) < 1,
|
||||
"final annotation bbox fades against the complete painted card rectangle",
|
||||
Number(group.getAttribute('opacity')) < 1,
|
||||
'final annotation bbox fades against the complete painted card rectangle',
|
||||
);
|
||||
const outer = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.001, 0.001],
|
||||
[0, 0.001],
|
||||
[0, 0],
|
||||
];
|
||||
const hole = [
|
||||
[0.0002, 0.0002],
|
||||
[0.0004, 0.0002],
|
||||
[0.0004, 0.0004],
|
||||
[0.0002, 0.0004],
|
||||
[0.0002, 0.0002],
|
||||
];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const multipart = {
|
||||
id: "anno-multipart-screen",
|
||||
label: "Multipart",
|
||||
type: "area",
|
||||
color: "primary",
|
||||
});
|
||||
|
||||
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, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
synthesized: false,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(multipart);
|
||||
const area = globalThis.document.body.querySelector(".gev-anno-area");
|
||||
assert.equal(area.tagName, "path");
|
||||
assert.equal(area.getAttribute("fill-rule"), "evenodd");
|
||||
assert.equal(
|
||||
(area.getAttribute("d").match(/M /g) || []).length,
|
||||
3,
|
||||
"screen fill retains both parts and the hole",
|
||||
);
|
||||
|
||||
multipart.polygons = [
|
||||
[island.map(([lon, lat]) => [lon + 180, lat])],
|
||||
[outer, hole],
|
||||
];
|
||||
renderer.sync();
|
||||
assert.equal(
|
||||
(area.getAttribute("d").match(/M /g) || []).length,
|
||||
2,
|
||||
"an island behind the globe cannot hide the visible part and its hole",
|
||||
);
|
||||
assert.notEqual(area.parentNode.style.display, "none");
|
||||
assert.doesNotMatch(
|
||||
area.parentNode
|
||||
.querySelector(".gev-anno-callout")
|
||||
.getAttribute("transform"),
|
||||
/NaN/,
|
||||
);
|
||||
multipart.polygons = [[outer, hole]];
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(world).longitude,
|
||||
);
|
||||
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
|
||||
};
|
||||
renderer.sync();
|
||||
assert.equal(
|
||||
(area.getAttribute("d").match(/M /g) || []).length,
|
||||
2,
|
||||
"finite offscreen shell coordinates cannot hide a visible holed part",
|
||||
);
|
||||
assert.notEqual(area.parentNode.style.display, "none");
|
||||
multipart.polygons = [[outer, hole], [island]];
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(world).longitude,
|
||||
);
|
||||
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
|
||||
};
|
||||
renderer.sync();
|
||||
assert.equal(
|
||||
(area.getAttribute("d").match(/M /g) || []).length,
|
||||
1,
|
||||
"an unprojectable hole omits its entire part, not the independently visible island",
|
||||
);
|
||||
assert.notEqual(area.parentNode.style.display, "none");
|
||||
|
||||
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',
|
||||
@@ -477,26 +357,175 @@ 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',
|
||||
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');
|
||||
});
|
||||
|
||||
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
|
||||
const { store, svg } = board(t);
|
||||
// A fixed projection: 0.001 degrees spans 100 px.
|
||||
projectTo(t, (_scene, world) => {
|
||||
const c = Cesium.Cartographic.fromCartesian(world);
|
||||
return {
|
||||
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
|
||||
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
|
||||
};
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
renderer.destroy();
|
||||
t.after(() => renderer.destroy());
|
||||
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
|
||||
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const anno = {
|
||||
id: 'anno-multipart-screen',
|
||||
label: 'Multipart',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
synthesized: false,
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const area = () => svg().querySelector('.gev-anno-area');
|
||||
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
|
||||
assert.equal(area().tagName.toLowerCase(), 'path');
|
||||
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
|
||||
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
|
||||
|
||||
// The first part is behind the globe: the label centres on the next part.
|
||||
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
assert.doesNotMatch(
|
||||
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
|
||||
/NaN/,
|
||||
);
|
||||
|
||||
anno.polygons = [[outer, hole]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
|
||||
anno.polygons = [[outer, hole], [island]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
});
|
||||
|
||||
test('Preview avoids the map callout without moving its world anchor', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
});
|
||||
const panel = document.body.appendChild(document.createElement('section'));
|
||||
panel.id = 'gev-voice-panel';
|
||||
panel.dataset.view = 'preview';
|
||||
panel.hidden = true;
|
||||
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
|
||||
let panelVisible = true;
|
||||
panel.getBoundingClientRect = () => panelRect;
|
||||
panel.checkVisibility = () => panelVisible;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
// Non-unit, independent axis scales and a nonzero client offset: the
|
||||
// overlay uses preserveAspectRatio="none", not letterboxing.
|
||||
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
const callout = () => group.querySelector('.gev-anno-callout');
|
||||
const leader = group.querySelector('.gev-anno-leader');
|
||||
const dot = group.querySelector('.gev-anno-dot');
|
||||
const natural = callout().getAttribute('transform');
|
||||
const anchor = structuredClone(anno.anchor);
|
||||
panel.hidden = false;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
|
||||
assert.equal(leader.getAttribute('x2'), '418.0');
|
||||
assert.equal(leader.getAttribute('y2'), '172.0');
|
||||
assert.equal(leader.getAttribute('x1'), '400.0');
|
||||
assert.equal(leader.getAttribute('y1'), '200.0');
|
||||
assert.equal(dot.getAttribute('cx'), '400.0');
|
||||
assert.equal(dot.getAttribute('cy'), '200.0');
|
||||
assert.deepEqual(anno.anchor, anchor);
|
||||
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
|
||||
panel.hidden = true;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
|
||||
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
|
||||
panel.hidden = false;
|
||||
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
|
||||
panelVisible = variant !== 'invisible';
|
||||
panelRect = variant === 'no-overlap'
|
||||
? { left: 510, top: 120, width: 100, height: 120 }
|
||||
: variant === 'no-room'
|
||||
? { left: 210, top: 25, width: 100, height: 180 }
|
||||
: { left: 210, top: 120, width: 100, height: 120 };
|
||||
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, variant);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
`
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
Vendored
+4
-1
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
+264
-243
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
} from './firmsLabels.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
});
|
||||
|
||||
test('buildCellCard: the count picks the plural form of the language', () => {
|
||||
const title = (count) =>
|
||||
buildCellCard(
|
||||
{
|
||||
cell: { count, maxFrp: 10, newestAcqMs: 0 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
NOW,
|
||||
).title;
|
||||
const previous = getLocale();
|
||||
try {
|
||||
assert.equal(title(1), '1 FIRE');
|
||||
setLocale('ar');
|
||||
assert.deepEqual(
|
||||
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
|
||||
['حريقان (2)', '3 حرائق', '14 حريقًا'],
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.equal(title(14), '14 ПОЖАРОВ');
|
||||
} finally {
|
||||
setLocale(previous);
|
||||
}
|
||||
});
|
||||
|
||||
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// ground floor is warm — and the cached per-fire position must re-anchor when
|
||||
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -734,18 +734,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||
'ui.js: and believes the mode is proximity',
|
||||
);
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
|
||||
/this\._models3dPressedShown = this\._models3dEnabled;/,
|
||||
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
|
||||
);
|
||||
const panel = await readFile(
|
||||
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
|
||||
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
|
||||
@@ -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
@@ -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({});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import militaryAwarenessLayer, {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
_getAwarenessPanelRendersForTest,
|
||||
awarenessClearMatchesSubject,
|
||||
awarenessNeedsContinuousRender,
|
||||
awarenessRefreshDecision,
|
||||
awarenessRefreshIntervalMs,
|
||||
awarenessRefreshRequired,
|
||||
awarenessResultsAreLive,
|
||||
awarenessPanelControlKey,
|
||||
buildAwarenessContextSnapshot,
|
||||
canNavigateAwarenessNext,
|
||||
contactsWindowFromSnapshot,
|
||||
contextTargetFlyToAllowed,
|
||||
captureAwarenessPanelFocus,
|
||||
summarizeInstallationViewport,
|
||||
findCompatibleHistoryIndex,
|
||||
AWARENESS_QUERY_LIMIT,
|
||||
historySubjectSnapshot,
|
||||
restoreAwarenessPanelFocus,
|
||||
} from './militaryAwareness.js';
|
||||
import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const awarenessPanelSource = readFileSync(
|
||||
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const AWARENESS_DEPENDENCIES = [
|
||||
'flights',
|
||||
@@ -98,27 +101,12 @@ function installAwarenessRuntime({
|
||||
const listeners = new Map();
|
||||
let nextTimer = 1;
|
||||
let preRenderListener = null;
|
||||
const createdElements = [];
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.classList = { contains: () => false };
|
||||
this.style = { setProperty() {} };
|
||||
this._hidden = false;
|
||||
// renderResults() writes `hidden = false` on EVERY call, ahead of its
|
||||
// markup diff, so counting these writes observes "was the panel
|
||||
// repaint path entered?" independently of whether the markup changed.
|
||||
this.hiddenWrites = 0;
|
||||
this.innerHTML = '';
|
||||
}
|
||||
|
||||
get hidden() {
|
||||
return this._hidden;
|
||||
}
|
||||
|
||||
set hidden(value) {
|
||||
this._hidden = value;
|
||||
this.hiddenWrites += 1;
|
||||
this.hidden = false;
|
||||
}
|
||||
|
||||
setAttribute() {}
|
||||
@@ -213,9 +201,7 @@ function installAwarenessRuntime({
|
||||
return null;
|
||||
},
|
||||
createElement() {
|
||||
const element = new FakeElement();
|
||||
createdElements.push(element);
|
||||
return element;
|
||||
return new FakeElement();
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
@@ -229,6 +215,10 @@ function installAwarenessRuntime({
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
const panelViews = [];
|
||||
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
||||
panelViews.push(view),
|
||||
);
|
||||
militaryAwarenessLayer.enable();
|
||||
|
||||
return {
|
||||
@@ -248,15 +238,16 @@ function installAwarenessRuntime({
|
||||
);
|
||||
return taken;
|
||||
},
|
||||
/** The standalone Context panel element renderResults() paints into. */
|
||||
/** The Contacts panel as the layer publishes it to the view. */
|
||||
panel() {
|
||||
return (
|
||||
createdElements.find(
|
||||
(element) => element.id === 'military-awareness-panel',
|
||||
) || null
|
||||
);
|
||||
return {
|
||||
view: panelViews.at(-1) ?? null,
|
||||
publishes: panelViews.length,
|
||||
renders: _getAwarenessPanelRendersForTest(),
|
||||
};
|
||||
},
|
||||
restore() {
|
||||
unsubscribePanel();
|
||||
militaryAwarenessLayer.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
@@ -264,144 +255,24 @@ function installAwarenessRuntime({
|
||||
};
|
||||
}
|
||||
|
||||
function awarenessControl(dataset, { disabled = false } = {}) {
|
||||
return {
|
||||
dataset,
|
||||
disabled,
|
||||
focusCalls: 0,
|
||||
closest(selector) {
|
||||
if (selector === '[data-awareness-focus-continuation]') {
|
||||
return this.isContinuation ? this : null;
|
||||
}
|
||||
return selector.includes('button') && !this.isContinuation ? this : null;
|
||||
},
|
||||
matches(selector) {
|
||||
return (
|
||||
selector === '[data-awareness-focus-continuation]' &&
|
||||
this.isContinuation
|
||||
);
|
||||
},
|
||||
focus(options) {
|
||||
this.focusCalls += 1;
|
||||
this.focusOptions = options;
|
||||
},
|
||||
};
|
||||
/** The count a published Contacts panel shows for one cohort row. */
|
||||
function panelCount(runtime, label) {
|
||||
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
|
||||
}
|
||||
|
||||
function awarenessPanel(controls) {
|
||||
const continuation = awarenessControl({});
|
||||
continuation.isContinuation = true;
|
||||
return {
|
||||
controls,
|
||||
continuation,
|
||||
contains(element) {
|
||||
return controls.includes(element) || element === continuation;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return this.controls;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === '[data-awareness-focus-continuation]'
|
||||
? continuation
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
|
||||
const previous = awarenessControl({ awarenessAction: 'previous' });
|
||||
const focus = awarenessControl({ awarenessAction: 'focus' });
|
||||
const next = awarenessControl({ awarenessAction: 'next' });
|
||||
const alpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const bravo = awarenessControl({
|
||||
awarenessLayer: 'military',
|
||||
awarenessId: 'def456',
|
||||
});
|
||||
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
|
||||
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
|
||||
|
||||
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
|
||||
assert.equal(awarenessPanelControlKey(next), 'action:next');
|
||||
|
||||
const retainedAlpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const retained = awarenessPanel([
|
||||
previous,
|
||||
focus,
|
||||
next,
|
||||
bravo,
|
||||
retainedAlpha,
|
||||
]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(retained, alphaSnapshot),
|
||||
retainedAlpha,
|
||||
);
|
||||
assert.equal(
|
||||
retainedAlpha.focusCalls,
|
||||
1,
|
||||
'identity wins even when live distance reorders rows',
|
||||
);
|
||||
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
|
||||
|
||||
const forward = awarenessControl({
|
||||
awarenessLayer: 'ais-live-vessels',
|
||||
awarenessId: '789',
|
||||
});
|
||||
const departed = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(departed, alphaSnapshot),
|
||||
departed.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
forward.focusCalls,
|
||||
0,
|
||||
'automatic paging must not transfer focus to another contact',
|
||||
);
|
||||
assert.equal(departed.continuation.focusCalls, 1);
|
||||
|
||||
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
|
||||
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
|
||||
noRemainingRow.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
noRemainingRow.continuation.focusCalls,
|
||||
1,
|
||||
'the end sentinel continues Tab beyond the list',
|
||||
);
|
||||
|
||||
const continuationSnapshot = captureAwarenessPanelFocus(
|
||||
departed,
|
||||
departed.continuation,
|
||||
);
|
||||
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
|
||||
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
|
||||
repaintedAgain.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
repaintedAgain.continuation.focusCalls,
|
||||
1,
|
||||
'later live repaints keep focus on the continuation point until the user tabs onward',
|
||||
);
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
assert.match(
|
||||
awarenessPanelSource,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
awarenessPanelSource,
|
||||
/<span[^>]*data-awareness-focus-continuation/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
|
||||
militaryAwarenessSource,
|
||||
/'Open-source mapped\/observed context\./,
|
||||
);
|
||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
||||
});
|
||||
|
||||
test('awareness disable settles every owned dependency release before resolving', async () => {
|
||||
@@ -599,9 +470,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
'loading is not an observed empty cohort',
|
||||
);
|
||||
assert.match(installations?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'Mapped installations'),
|
||||
'?',
|
||||
'the operator sees ? rather than an all-clear 0 while installations load',
|
||||
);
|
||||
} finally {
|
||||
@@ -741,15 +612,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
|
||||
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
|
||||
);
|
||||
assert.match(vessels?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
'the panel prints ? for the whole settled-but-connecting window',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
'the panel must never print an all-clear 0 before the feed has answered once',
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'?',
|
||||
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
|
||||
);
|
||||
} finally {
|
||||
restoreCollections();
|
||||
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
|
||||
0,
|
||||
'an answered empty viewport is a real observation',
|
||||
);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'0',
|
||||
'the operator sees the real count once the feed answers',
|
||||
);
|
||||
} finally {
|
||||
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
id,
|
||||
);
|
||||
const panel = runtime.panel();
|
||||
assert.ok(panel, 'the Context panel must exist');
|
||||
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
|
||||
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
|
||||
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
|
||||
|
||||
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
||||
const settledWrites = panel.hiddenWrites;
|
||||
const settledRenders = runtime.panel().renders;
|
||||
const settledPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(
|
||||
panel.hiddenWrites,
|
||||
settledWrites,
|
||||
runtime.panel().renders,
|
||||
settledRenders,
|
||||
'an unchanged label must not trigger a panel repaint',
|
||||
);
|
||||
assert.equal(
|
||||
runtime.panel().publishes,
|
||||
settledPublishes,
|
||||
'an unchanged refresh publishes no new panel, results or standby',
|
||||
);
|
||||
|
||||
// Enrichment answers. The contact has NOT moved and no source revision
|
||||
// changed, so the label is the only thing making this render-worthy.
|
||||
@@ -1283,17 +1155,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
'subject identity must stay the ICAO hex',
|
||||
);
|
||||
// The RENDERED panel is the surface the operator actually reads.
|
||||
const repainted = runtime.panel();
|
||||
assert.ok(
|
||||
panel.hiddenWrites > settledWrites,
|
||||
repainted.publishes > settledPublishes,
|
||||
'a changed label must repaint the standalone Context panel',
|
||||
);
|
||||
assert.match(
|
||||
panel.innerHTML,
|
||||
repainted.view.subject,
|
||||
/N123AB/,
|
||||
'the rendered Context panel must show the registration without a position change',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel.innerHTML,
|
||||
JSON.stringify(repainted.view),
|
||||
/ae1fa4/,
|
||||
'the hex must be gone from the panel',
|
||||
);
|
||||
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
|
||||
militaryAwarenessSource,
|
||||
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
|
||||
);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-subject/);
|
||||
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-subject/);
|
||||
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-controls/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-controls/);
|
||||
});
|
||||
|
||||
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
|
||||
@@ -4229,7 +4103,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
}
|
||||
});
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
test('a selected installation card keeps every member name as text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
|
||||
},
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of [
|
||||
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
|
||||
const names = runtime.panel().view?.subjectNames;
|
||||
assert.equal(names?.summary, 'Named areas (4)');
|
||||
assert.deepEqual(names?.names, [
|
||||
'Area one',
|
||||
'Area two',
|
||||
'Area three',
|
||||
'<Area four>',
|
||||
])
|
||||
assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
'<Area four>',
|
||||
]);
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,8 +7,6 @@ import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest,
|
||||
approximateOrbitPath,
|
||||
bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus,
|
||||
buildMissionPaths,
|
||||
cameraHeadingForPath,
|
||||
compactLaunchSiteName,
|
||||
@@ -46,7 +44,6 @@ import rocketLaunchesLayer, {
|
||||
replayStartAfterPause,
|
||||
replayVehicleScreenRotation,
|
||||
resolveMissionRosterPreviewLaunch,
|
||||
restoreMissionRosterFocus,
|
||||
releaseAircraftTracking,
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
|
||||
@@ -514,42 +511,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
|
||||
]);
|
||||
});
|
||||
|
||||
test('rendered mission row focus and blur drive the preview ownership controller', () => {
|
||||
const listeners = new Map();
|
||||
const calls = [];
|
||||
const button = {
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
};
|
||||
const nextButton = {
|
||||
dataset: { missionRosterIndex: '4' },
|
||||
closest: () => nextButton,
|
||||
};
|
||||
const roster = { contains: (candidate) => candidate === nextButton };
|
||||
const ownership = {
|
||||
pointerEnter: (index) => calls.push(['pointer-enter', index]),
|
||||
pointerLeave: () => calls.push(['pointer-leave']),
|
||||
focus: (index) => calls.push(['focus', index]),
|
||||
blur: (index) => calls.push(['blur', index]),
|
||||
};
|
||||
|
||||
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
|
||||
listeners.get('mouseenter')();
|
||||
listeners.get('mouseleave')();
|
||||
listeners.get('focus')();
|
||||
listeners.get('blur')({ relatedTarget: nextButton });
|
||||
listeners.get('blur')({ relatedTarget: null });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['pointer-enter', 2],
|
||||
['pointer-leave'],
|
||||
['focus', 2],
|
||||
['blur', 4],
|
||||
['blur', null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
|
||||
const calls = [];
|
||||
const ownership = createMissionRosterPreviewOwnership({
|
||||
@@ -568,50 +529,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
|
||||
]);
|
||||
});
|
||||
|
||||
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let restored = false;
|
||||
const replacement = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
focus: ({ preventScroll }) => { restored = preventScroll; },
|
||||
};
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [
|
||||
{ dataset: { missionRosterId: 'mission-c' } },
|
||||
replacement,
|
||||
{ dataset: { missionRosterId: 'mission-a' } },
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.deepEqual(snapshot, { launchId: 'mission-b' });
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
|
||||
assert.equal(restored, true);
|
||||
});
|
||||
|
||||
test('mission roster refresh continues after the list when the focused mission departed', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'departed' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let continuationFocused = false;
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
|
||||
};
|
||||
const continuation = {
|
||||
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
|
||||
assert.equal(continuationFocused, true);
|
||||
});
|
||||
|
||||
test('pending mission preview resolves the current refreshed record by identity', () => {
|
||||
const stale = { id: 'mission-a', name: 'Stale' };
|
||||
const current = { id: 'mission-a', name: 'Current' };
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,/);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
@@ -875,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.",
|
||||
@@ -1176,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…",
|
||||
@@ -1458,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.",
|
||||
@@ -1759,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",
|
||||
@@ -2052,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",
|
||||
@@ -2101,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",
|
||||
@@ -2681,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",
|
||||
@@ -2943,5 +2934,9 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1260,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",
|
||||
@@ -2158,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.",
|
||||
@@ -2469,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",
|
||||
@@ -2657,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",
|
||||
@@ -2860,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",
|
||||
@@ -3013,5 +3006,7 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -3013,5 +3004,9 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -3013,5 +3004,9 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -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.": "選択した種類(航空機、船舶、施設)の最寄りのコンタクトを順に切り替えます。衛星は独立して追跡されます。",
|
||||
@@ -2873,5 +2864,9 @@
|
||||
"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またはアセットバンドルとしてダウンロード"
|
||||
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
|
||||
"Left panels": "左パネル",
|
||||
"Right panels": "右パネル",
|
||||
"CONFIRMED": "確認済み",
|
||||
"PAD / RTLS": "射場 / RTLS"
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -3013,5 +3004,9 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -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": "ПОИСК ОБЪЕКТОВ РЯДОМ",
|
||||
@@ -1278,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": "Уменьшение движения · воспроизведение истории недоступно",
|
||||
@@ -2192,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 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
|
||||
@@ -2511,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": "подключите приёмник в разделе «Радио»",
|
||||
@@ -2715,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": "Турбовинтовой самолёт",
|
||||
@@ -2928,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": "Выйдите из кабины, чтобы показать контакты в кадре",
|
||||
@@ -3083,5 +3076,7 @@
|
||||
"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 или архивом ресурсов"
|
||||
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
|
||||
"Left panels": "Левые панели",
|
||||
"Right panels": "Правые панели"
|
||||
}
|
||||
@@ -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.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.",
|
||||
@@ -3083,5 +3074,9 @@
|
||||
"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 або архівом ресурсів"
|
||||
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
|
||||
"Left panels": "Ліві панелі",
|
||||
"Right panels": "Праві панелі",
|
||||
"CONFIRMED": "ПІДТВЕРДЖЕНО",
|
||||
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS"
|
||||
}
|
||||
@@ -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.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。",
|
||||
@@ -2873,5 +2866,7 @@
|
||||
"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 或资源包"
|
||||
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
|
||||
"Left panels": "左侧面板",
|
||||
"Right panels": "右侧面板"
|
||||
}
|
||||
@@ -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.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。",
|
||||
@@ -2873,5 +2866,7 @@
|
||||
"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 或資源包"
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -184,6 +219,7 @@
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Color: {units}",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
@@ -235,6 +271,7 @@
|
||||
"Pop",
|
||||
"Punk",
|
||||
"R&B",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"RANGE",
|
||||
"REC",
|
||||
@@ -400,6 +437,7 @@
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"Radio",
|
||||
@@ -562,6 +600,7 @@
|
||||
"PRECIP",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"RESET",
|
||||
@@ -765,6 +804,7 @@
|
||||
"Paris",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Reggae",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+126
-157
@@ -2,7 +2,12 @@ import {
|
||||
formatAwarenessLabel,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
onLocaleChange,
|
||||
t,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
@@ -43,76 +48,95 @@ function presentAwarenessReason(summary = {}) {
|
||||
: translateKnown(summary.reason || '');
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* The Contacts panel as a view renders it. The layer presents a plain view
|
||||
* object (`standby`, `results` or `empty`) and publishes it to subscribers;
|
||||
* Core's AwarenessPanel component renders it and calls the layer's
|
||||
* navigation methods. Text arrives in the UI language.
|
||||
*/
|
||||
export function createPanel({ state: layerState, parts }) {
|
||||
/** Publish a fresh view unless it is the one already shown. */
|
||||
function publish(view) {
|
||||
const signature = JSON.stringify(view);
|
||||
if (layerState.panelSignature === signature) return;
|
||||
layerState.panelSignature = signature;
|
||||
layerState.panelView = view;
|
||||
for (const listener of [...layerState.panelListeners]) listener(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive every published view, starting with the current one.
|
||||
* @param {(view: object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribePanel(listener) {
|
||||
layerState.panelListeners.add(listener);
|
||||
if (layerState.panelView) listener(layerState.panelView);
|
||||
return () => layerState.panelListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The panel shows layer views from the first enable or result onwards. */
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
const existing = document.getElementById('military-awareness-panel');
|
||||
const panel = existing || document.createElement('aside');
|
||||
layerState.panelOwned = !existing;
|
||||
if (!existing) {
|
||||
panel.id = 'military-awareness-panel';
|
||||
}
|
||||
layerState.panelClickListener = (event) => {
|
||||
const action = event.target.closest('button[data-awareness-action]');
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
if (action.dataset.awarenessAction === 'previous')
|
||||
parts.history.navigateHistory(-1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'next')
|
||||
parts.history.navigateHistory(1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'focus')
|
||||
parts.focus.focusCurrentSubject({ origin: 'user' });
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest(
|
||||
'button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
parts.focus.requestFocus(
|
||||
target.dataset.awarenessLayer,
|
||||
target.dataset.awarenessId,
|
||||
false,
|
||||
{ origin: 'user' },
|
||||
);
|
||||
};
|
||||
panel.addEventListener('click', layerState.panelClickListener);
|
||||
if (!existing) document.body.appendChild(panel);
|
||||
layerState.panel = panel;
|
||||
return panel;
|
||||
layerState.panelShown = true;
|
||||
// A shown view is worded again in a new locale.
|
||||
layerState.offPanelLocale ??= onLocaleChange(() => {
|
||||
const kind = layerState.panelView?.kind;
|
||||
if (kind === 'results') renderResults();
|
||||
else if (kind === 'standby') hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
if (layerState.panel) {
|
||||
const markup = `<div class="military-awareness-standby">
|
||||
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
|
||||
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
|
||||
</div>`;
|
||||
layerState.panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
layerState.panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
}
|
||||
}
|
||||
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
|
||||
if (layerState.panelShown)
|
||||
publish({
|
||||
kind: 'standby',
|
||||
title: layerState.enabled
|
||||
? t('CONTEXT READY')
|
||||
: t('GLOBAL CONTEXT OFF'),
|
||||
text: layerState.enabled
|
||||
? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION')
|
||||
: t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'),
|
||||
});
|
||||
parts.rendering.hideDirectionOverlay();
|
||||
}
|
||||
|
||||
function rowHtml(cohort) {
|
||||
/** Clear the panel when the layer is destroyed. */
|
||||
function clearPanel() {
|
||||
if (layerState.panelShown) publish({ kind: 'empty' });
|
||||
layerState.offPanelLocale?.();
|
||||
layerState.offPanelLocale = null;
|
||||
layerState.panelShown = false;
|
||||
layerState.panelView = null;
|
||||
layerState.panelSignature = '';
|
||||
}
|
||||
|
||||
function presentRow(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = presentAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item) => {
|
||||
.map((item, index) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const distance = presentAwarenessDistance(item.distanceM);
|
||||
if (!targetId)
|
||||
return {
|
||||
key: `unavailable:${page + index}`,
|
||||
label,
|
||||
distance,
|
||||
ariaLabel: t('Unavailable'),
|
||||
};
|
||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
return {
|
||||
key: `target:${cohort.id}:${targetId}`,
|
||||
layer: cohort.id,
|
||||
targetId: String(targetId),
|
||||
label,
|
||||
distance,
|
||||
ariaLabel: t('Focus {label}', { label: accessibleLabel }),
|
||||
names: presentNames(item.memberNames),
|
||||
};
|
||||
});
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||
@@ -121,109 +145,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
pageCount > 1
|
||||
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
||||
: '';
|
||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||
</section>`;
|
||||
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
|
||||
return {
|
||||
id: cohort.id,
|
||||
relationship: summary.relationship.toLowerCase(),
|
||||
label: cohort.label,
|
||||
count: `${presentAwarenessCount(summary)}${pageLabel}`,
|
||||
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
|
||||
nearest,
|
||||
};
|
||||
}
|
||||
|
||||
function navigationControlsHtml() {
|
||||
const canPrevious = layerState.navigationIndex > 0;
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
|
||||
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
|
||||
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
|
||||
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
|
||||
</div>`;
|
||||
function presentNavigation() {
|
||||
return {
|
||||
label: t('Global Context navigation'),
|
||||
previous: {
|
||||
text: t('PREVIOUS'),
|
||||
title: t('Previous — prior visited contact in the 250 km window'),
|
||||
disabled: !(layerState.navigationIndex > 0),
|
||||
},
|
||||
focus: { text: t('FOCUS') },
|
||||
next: {
|
||||
text: t('NEXT'),
|
||||
title: t('Next — nearest unvisited contact in the 250 km window'),
|
||||
disabled: !parts.navigation.canNavigateNext(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||
|
||||
function awarenessPanelControlKey(element) {
|
||||
const control = element?.closest?.(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!control) return null;
|
||||
if (control.dataset.awarenessAction)
|
||||
return `action:${control.dataset.awarenessAction}`;
|
||||
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||
}
|
||||
|
||||
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||
|
||||
function captureAwarenessPanelFocus(
|
||||
panel,
|
||||
activeElement = document.activeElement,
|
||||
) {
|
||||
if (!panel?.contains?.(activeElement)) return null;
|
||||
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||
return { key: 'continuation' };
|
||||
}
|
||||
const key = awarenessPanelControlKey(activeElement);
|
||||
if (!key) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||
|
||||
function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||
if (!panel || !snapshot) return null;
|
||||
const continuation = panel.querySelector(
|
||||
'[data-awareness-focus-continuation]',
|
||||
);
|
||||
const controls = [
|
||||
...panel.querySelectorAll(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
),
|
||||
].filter((control) => !control.disabled);
|
||||
const retained =
|
||||
snapshot.key === 'continuation'
|
||||
? continuation
|
||||
: controls.find(
|
||||
(control) => awarenessPanelControlKey(control) === snapshot.key,
|
||||
);
|
||||
const target = retained || continuation;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
return target || null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>'"]/g,
|
||||
(char) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[char],
|
||||
);
|
||||
}
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
function presentNames(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
: '';
|
||||
? {
|
||||
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
|
||||
names: [...names].map((name) => String(name ?? '')),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
ensurePanel();
|
||||
layerState.panelRenders += 1;
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
|
||||
panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
||||
}
|
||||
publish({
|
||||
kind: 'results',
|
||||
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||
subjectNames: presentNames(subject.memberNames),
|
||||
navigation: presentNavigation(),
|
||||
rows: cohorts.map(presentRow),
|
||||
note: t(
|
||||
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function rotateAwarenessPages() {
|
||||
@@ -277,14 +250,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
||||
}
|
||||
return {
|
||||
subscribePanel,
|
||||
ensurePanel,
|
||||
hidePanel,
|
||||
rowHtml,
|
||||
navigationControlsHtml,
|
||||
awarenessPanelControlKey,
|
||||
captureAwarenessPanelFocus,
|
||||
restoreAwarenessPanelFocus,
|
||||
escapeHtml,
|
||||
clearPanel,
|
||||
renderResults,
|
||||
rotateAwarenessPages,
|
||||
startAwarenessPageRotation,
|
||||
|
||||
@@ -22,55 +22,75 @@ export function createRendering({
|
||||
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
const CARDINALS = [
|
||||
['N', 0],
|
||||
['E', 90],
|
||||
['S', 180],
|
||||
['W', 270],
|
||||
];
|
||||
|
||||
/**
|
||||
* The direction overlay as its view renders it: the compass ring, its
|
||||
* cardinals and heading, and up to three contact markers inside the
|
||||
* viewer's container. Each part keeps its last placement while hidden.
|
||||
*/
|
||||
function ensureDirectionOverlay() {
|
||||
if (layerState.directionRoot || !layerState.viewer)
|
||||
return layerState.directionRoot;
|
||||
const root = document.createElement('div');
|
||||
root.id = 'military-awareness-direction-overlay';
|
||||
root.hidden = true;
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const compass = document.createElement('div');
|
||||
compass.className = 'military-awareness-compass-ring';
|
||||
const cardinals = [
|
||||
['N', 0],
|
||||
['E', 90],
|
||||
['S', 180],
|
||||
['W', 270],
|
||||
];
|
||||
layerState.compassLabels = cardinals.map(([label, bearing]) => {
|
||||
const element = document.createElement('span');
|
||||
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
|
||||
element.textContent = label;
|
||||
element.dataset.bearing = String(bearing);
|
||||
root.appendChild(element);
|
||||
return element;
|
||||
if (layerState.directionView || !layerState.viewer)
|
||||
return layerState.directionView;
|
||||
presentDirection({
|
||||
container: layerState.viewer.container,
|
||||
hidden: true,
|
||||
ring: null,
|
||||
heading: { text: '', left: null, top: null },
|
||||
labels: CARDINALS.map(([label, bearing]) => ({
|
||||
label,
|
||||
bearing,
|
||||
left: null,
|
||||
top: null,
|
||||
})),
|
||||
markers: Array.from({ length: 3 }, () => ({
|
||||
hidden: false,
|
||||
style: null,
|
||||
arrow: null,
|
||||
caption: { text: '', style: null },
|
||||
})),
|
||||
});
|
||||
const heading = document.createElement('span');
|
||||
heading.className = 'military-awareness-compass-heading';
|
||||
root.append(compass, heading);
|
||||
return layerState.directionView;
|
||||
}
|
||||
|
||||
layerState.directionMarkers = Array.from({ length: 3 }, () => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'military-awareness-direction-marker';
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'military-awareness-direction-arrow';
|
||||
arrow.textContent = '➜';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'military-awareness-direction-label';
|
||||
marker.append(arrow, label);
|
||||
marker._arrow = arrow;
|
||||
marker._label = label;
|
||||
marker._lastAngle = 0;
|
||||
root.appendChild(marker);
|
||||
return marker;
|
||||
});
|
||||
/** Publish a change to the overlay, skipping one that changes nothing. */
|
||||
function presentDirection(patch) {
|
||||
const next = patch && { ...layerState.directionView, ...patch };
|
||||
const signature = JSON.stringify(next, (name, value) =>
|
||||
name === 'container' ? undefined : value,
|
||||
);
|
||||
if (next && signature === layerState.directionSignature) return;
|
||||
layerState.directionSignature = next ? signature : '';
|
||||
layerState.directionView = next && Object.freeze(next);
|
||||
for (const listener of [...layerState.directionListeners])
|
||||
listener(layerState.directionView);
|
||||
}
|
||||
|
||||
layerState.compassRing = compass;
|
||||
layerState.compassHeading = heading;
|
||||
layerState.viewer.container.appendChild(root);
|
||||
layerState.directionRoot = root;
|
||||
return root;
|
||||
/**
|
||||
* Receive the direction overlay now and after every change.
|
||||
* @param {(view: ?object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribeDirectionOverlay(listener) {
|
||||
layerState.directionListeners.add(listener);
|
||||
listener(layerState.directionView);
|
||||
return () => layerState.directionListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Hide the overlay, keeping its last placement. */
|
||||
function hideDirectionOverlay() {
|
||||
if (layerState.directionView) presentDirection({ hidden: true });
|
||||
}
|
||||
|
||||
/** Withdraw the overlay when the layer is destroyed. */
|
||||
function removeDirectionOverlay() {
|
||||
if (layerState.directionView) presentDirection(null);
|
||||
layerState.directionAngles = [0, 0, 0];
|
||||
}
|
||||
|
||||
function scheduleDirectionOverlayUpdate(force = false) {
|
||||
@@ -104,7 +124,7 @@ export function createRendering({
|
||||
!layerState.subject?.position ||
|
||||
!layerState.results
|
||||
) {
|
||||
if (root) root.hidden = true;
|
||||
if (root) presentDirection({ hidden: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,41 +136,43 @@ export function createRendering({
|
||||
: null;
|
||||
const geometry = getKeyholeGeometry(width, height);
|
||||
if (!(geometry.radius > 0)) {
|
||||
root.hidden = true;
|
||||
presentDirection({ hidden: true });
|
||||
return;
|
||||
}
|
||||
|
||||
root.hidden = false;
|
||||
// Keep the compass comfortably inside the shader keyhole so its cardinals
|
||||
// remain visible around the subject instead of hiding under edge panels.
|
||||
const compassRadius = Math.max(
|
||||
120,
|
||||
Math.min(geometry.radius - 48, geometry.radius * 0.66),
|
||||
);
|
||||
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
|
||||
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
|
||||
layerState.compassRing.style.width = `${compassRadius * 2}px`;
|
||||
layerState.compassRing.style.height = `${compassRadius * 2}px`;
|
||||
|
||||
const cameraHeading = layerState.viewer.camera.heading || 0;
|
||||
const headingDeg =
|
||||
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
|
||||
layerState.compassRing.style.setProperty(
|
||||
'--compass-rotation',
|
||||
`${-headingDeg}deg`,
|
||||
);
|
||||
layerState.compassHeading.textContent = t('HDG {value}°', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
});
|
||||
layerState.compassHeading.style.left = `${geometry.centerX}px`;
|
||||
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
||||
for (const label of layerState.compassLabels) {
|
||||
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
|
||||
const ring = {
|
||||
left: `${geometry.centerX - compassRadius}px`,
|
||||
top: `${geometry.centerY - compassRadius}px`,
|
||||
width: `${compassRadius * 2}px`,
|
||||
height: `${compassRadius * 2}px`,
|
||||
'--compass-rotation': `${-headingDeg}deg`,
|
||||
};
|
||||
const heading = {
|
||||
text: t('HDG {value}°', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
}),
|
||||
left: `${geometry.centerX}px`,
|
||||
top: `${geometry.centerY - compassRadius + 39}px`,
|
||||
};
|
||||
const labels = root.labels.map((label) => {
|
||||
const bearing = Cesium.Math.toRadians(label.bearing);
|
||||
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
|
||||
const labelRadius = compassRadius - 17;
|
||||
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
|
||||
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
|
||||
}
|
||||
return {
|
||||
...label,
|
||||
left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`,
|
||||
top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const directionalCohort =
|
||||
layerState.results.cohorts.find(
|
||||
@@ -163,8 +185,9 @@ export function createRendering({
|
||||
page,
|
||||
page + AWARENESS_PAGE_SIZE,
|
||||
) || [];
|
||||
for (let index = 0; index < layerState.directionMarkers.length; index++) {
|
||||
const marker = layerState.directionMarkers[index];
|
||||
const markers = root.markers.map((marker) => ({ ...marker }));
|
||||
for (let index = 0; index < markers.length; index++) {
|
||||
const marker = markers[index];
|
||||
const item = military[index];
|
||||
if (!item?.position) {
|
||||
marker.hidden = true;
|
||||
@@ -183,27 +206,32 @@ export function createRendering({
|
||||
const projection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
|
||||
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
|
||||
marker._lastAngle,
|
||||
layerState.directionAngles[index],
|
||||
);
|
||||
if (projection.stable) marker._lastAngle = projection.angle;
|
||||
if (projection.stable)
|
||||
layerState.directionAngles[index] = projection.angle;
|
||||
// Every detected contact shares the compass rim. Keeping the bearing
|
||||
// markers on one radius makes them read as compass contacts rather than
|
||||
// unconstrained labels floating over the map.
|
||||
const markerRadius = compassRadius;
|
||||
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||
marker.style.opacity = String(projection.opacity);
|
||||
marker.style = {
|
||||
left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`,
|
||||
top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`,
|
||||
opacity: String(projection.opacity),
|
||||
};
|
||||
const sonarFactor =
|
||||
sonar?.at(
|
||||
geometry.centerX + Math.cos(projection.angle) * markerRadius,
|
||||
geometry.centerY + Math.sin(projection.angle) * markerRadius,
|
||||
) ?? 1;
|
||||
marker._arrow.style.opacity = String(sonarFactor);
|
||||
marker._label.style.opacity = String(
|
||||
sonar ? sonar.label(sonarFactor) : 1,
|
||||
);
|
||||
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
|
||||
marker.arrow = {
|
||||
opacity: String(sonarFactor),
|
||||
transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`,
|
||||
};
|
||||
const labelStyle = {
|
||||
opacity: String(sonar ? sonar.label(sonarFactor) : 1),
|
||||
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
|
||||
};
|
||||
const label = formatAwarenessLabel(item);
|
||||
const subjectCartographic = Cesium.Cartographic.fromCartesian(
|
||||
layerState.subject.position,
|
||||
@@ -234,9 +262,13 @@ export function createRendering({
|
||||
value: formatAwarenessDirection(item.track),
|
||||
})}`
|
||||
: '';
|
||||
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||
marker.caption = {
|
||||
text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`,
|
||||
style: labelStyle,
|
||||
};
|
||||
marker.hidden = false;
|
||||
}
|
||||
presentDirection({ hidden: false, ring, heading, labels, markers });
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
@@ -303,6 +335,9 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
ensureDirectionOverlay,
|
||||
subscribeDirectionOverlay,
|
||||
hideDirectionOverlay,
|
||||
removeDirectionOverlay,
|
||||
scheduleDirectionOverlayUpdate,
|
||||
cancelDirectionOverlayUpdate,
|
||||
updateDirectionOverlay,
|
||||
|
||||
@@ -29,20 +29,26 @@ export function createState({ services }) {
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
panel: null,
|
||||
panelOwned: false,
|
||||
/** Whether layer views replace the panel's initial markup yet. */
|
||||
panelShown: false,
|
||||
/** The published panel view, its signature and its subscribers. */
|
||||
panelView: null,
|
||||
panelSignature: '',
|
||||
panelListeners: new Set(),
|
||||
/** Result renders, published or not; tests observe repaint storms. */
|
||||
panelRenders: 0,
|
||||
subjectListener: null,
|
||||
contextListener: null,
|
||||
clearListener: null,
|
||||
subjectClearListener: null,
|
||||
runtimeListenersAttached: false,
|
||||
preRenderRemover: null,
|
||||
panelClickListener: null,
|
||||
directionRoot: null,
|
||||
compassRing: null,
|
||||
compassLabels: [],
|
||||
compassHeading: null,
|
||||
directionMarkers: [],
|
||||
/** The published direction overlay, its signature and its subscribers. */
|
||||
directionView: null,
|
||||
directionSignature: '',
|
||||
directionListeners: new Set(),
|
||||
/** Each marker's last stable screen angle. */
|
||||
directionAngles: [0, 0, 0],
|
||||
navigationHistory: [],
|
||||
navigationVisited: new Set(),
|
||||
navigationIndex: -1,
|
||||
@@ -57,7 +63,6 @@ export function createState({ services }) {
|
||||
cameraMoving: false,
|
||||
lastEvaluatedPosition: null,
|
||||
sourceRevision: '',
|
||||
panelMarkup: '',
|
||||
directionFrame: null,
|
||||
lastDirectionUpdateMs: 0,
|
||||
activationId: 0,
|
||||
|
||||
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
pendingSelectionKey: layerState.pendingSelectionKey,
|
||||
};
|
||||
}
|
||||
return { _getAwarenessNavigationStateForTest };
|
||||
/** @returns {number} How many result renders ran, published or not. */
|
||||
function _getAwarenessPanelRendersForTest() {
|
||||
return layerState.panelRenders;
|
||||
}
|
||||
return {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
_getAwarenessPanelRendersForTest,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { isolate, msg, t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
@@ -97,6 +97,8 @@ export function createPresentation({
|
||||
const health = layerState._healthById.get(active.camera.id) || null;
|
||||
const calBadge = parts.calibration.deriveCalBadge(active.camera);
|
||||
|
||||
// Each part is isolated: in right-to-left text a code keeps its own value
|
||||
// (FOV 44°) instead of trading it with its neighbour.
|
||||
return [
|
||||
t('{city} CCTV', {
|
||||
city: active.camera.city.toUpperCase(),
|
||||
@@ -140,6 +142,7 @@ export function createPresentation({
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(isolate)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './labels.js';
|
||||
import {
|
||||
formatUtcDayMonth,
|
||||
isolate,
|
||||
t,
|
||||
tn,
|
||||
msg,
|
||||
@@ -25,9 +26,11 @@ import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
// The clock is isolated (a no-op outside RTL): a no-break space between two
|
||||
// numbers joins them into one run, which in Arabic split the date around it.
|
||||
const utc = (value) =>
|
||||
value
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${isolate(value.slice(11, 16))}\u00a0UTC`
|
||||
: t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -407,7 +407,6 @@ export function createModel({
|
||||
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
||||
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
|
||||
@@ -419,7 +418,12 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
title: `${cell.count} ${noun}`,
|
||||
// The count picks the plural form of the language ("حريقان (2)",
|
||||
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
|
||||
// other card titles.
|
||||
title: tn(cell.count, '{n} fire', '{n} fires', {
|
||||
n: cell.count,
|
||||
}).toLocaleUpperCase(getLocale()),
|
||||
details: [parts.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(cell.maxFrp) || 0,
|
||||
@@ -513,7 +517,7 @@ export function createModel({
|
||||
lon >= 0
|
||||
? t('{value}°E', { value: amount(lon) })
|
||||
: t('{value}°W', { value: amount(lon) });
|
||||
return `${latPart} ${lonPart}`;
|
||||
return `${isolate(latPart)} ${isolate(lonPart)}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
now = () => Date.now(),
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
windowMs: requestedWindowMs,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('GNSS integrity layer requires a snapshot source');
|
||||
// A source may set its own evidence window; anything but a positive finite
|
||||
// duration keeps the default.
|
||||
const windowMs =
|
||||
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
|
||||
? requestedWindowMs
|
||||
: GNSS_WINDOW_MS;
|
||||
let _viewer = null;
|
||||
let _primitives = null;
|
||||
let _request = null;
|
||||
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
|
||||
chips: [],
|
||||
info:
|
||||
t(
|
||||
'Regional sample · 250 nm radius · cells from visited views over 30 min',
|
||||
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
|
||||
{ minutes: formatNumber(provenance.window.minutes) },
|
||||
) +
|
||||
(_lastError && _retryAt
|
||||
? ` · ${translateFeedError(_lastError)} · ${
|
||||
|
||||
@@ -42,7 +42,10 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
parts.panel.commands,
|
||||
{
|
||||
subscribePanel: parts.panel.subscribePanel,
|
||||
subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay,
|
||||
satelliteParamsForSpaceMissions:
|
||||
parts.policyHelpers.satelliteParamsForSpaceMissions,
|
||||
satelliteParamsAfterSpaceMissions:
|
||||
@@ -69,10 +72,6 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
|
||||
createMissionRosterPreviewOwnership:
|
||||
parts.panel.createMissionRosterPreviewOwnership,
|
||||
bindMissionRosterItemKeyboardPreview:
|
||||
parts.panel.bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
|
||||
restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
|
||||
resolveMissionRosterPreviewLaunch:
|
||||
parts.panel.resolveMissionRosterPreviewLaunch,
|
||||
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
clearMissionPanel: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
|
||||
@@ -201,7 +201,6 @@ export function createLifecycle({
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.selection.setSelectedMission(
|
||||
layerState._selectedLaunchId,
|
||||
@@ -262,13 +261,7 @@ export function createLifecycle({
|
||||
layerState._animationStarts.clear();
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
layerState._missionPanel?.remove();
|
||||
layerState._missionPanel = null;
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRoster.onclick = null;
|
||||
}
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRoster = null;
|
||||
parts.panel.clearMissionPanel();
|
||||
layerState._viewer = null;
|
||||
if (layerState._dataSource)
|
||||
viewer.dataSources.remove(layerState._dataSource, true);
|
||||
|
||||
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
|
||||
const NOW = new Date('2026-07-27T00:00:00Z');
|
||||
|
||||
function shownStatus(status) {
|
||||
const outputs = new Map();
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector(selector) {
|
||||
if (!outputs.has(selector))
|
||||
outputs.set(selector, {
|
||||
textContent: '',
|
||||
closest: () => null,
|
||||
removeAttribute() {},
|
||||
});
|
||||
return outputs.get(selector);
|
||||
},
|
||||
closest: () => null,
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [
|
||||
{
|
||||
@@ -39,15 +26,14 @@ function shownStatus(status) {
|
||||
],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
createPanel({
|
||||
return createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
replay: { presentReplay: () => ({}) },
|
||||
},
|
||||
}).renderMissionPanel();
|
||||
return outputs.get('[data-mission-status]').textContent;
|
||||
}).presentMission().status;
|
||||
}
|
||||
|
||||
test('a launch without a status keeps the English fallback in its record', () => {
|
||||
|
||||
@@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
return ascending ? 'ascent' : 'orbit';
|
||||
}
|
||||
|
||||
function createReplayVehicleOverlay() {
|
||||
if (layerState._replayVehicleOverlay || typeof document === 'undefined')
|
||||
/**
|
||||
* Present the replay vehicle overlay to its subscribers, or null to remove
|
||||
* it. An unchanged presentation is not sent again.
|
||||
*/
|
||||
function presentReplayVehicleOverlay(patch) {
|
||||
const current = layerState._replayVehicleOverlay;
|
||||
const next = patch && { ...current, ...patch };
|
||||
if (
|
||||
next === current ||
|
||||
(next &&
|
||||
current &&
|
||||
Object.keys(next).every((k) => next[k] === current[k]))
|
||||
)
|
||||
return;
|
||||
const host = document.getElementById('cesiumContainer') || document.body;
|
||||
layerState._replayVehicleOverlay = document.createElement('div');
|
||||
layerState._replayVehicleOverlay.className =
|
||||
'mission-replay-vehicle-overlay';
|
||||
layerState._replayVehicleOverlay.hidden = true;
|
||||
layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true');
|
||||
layerState._replayVehicleOverlay.innerHTML = `
|
||||
<div class="mission-replay-flight-symbol">
|
||||
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
|
||||
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
|
||||
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
|
||||
</svg>
|
||||
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
|
||||
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
|
||||
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
|
||||
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
|
||||
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
|
||||
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
|
||||
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
|
||||
<div class="mission-replay-overlay-callout">
|
||||
<strong data-replay-overlay-title></strong>
|
||||
<span data-replay-overlay-detail></span>
|
||||
</div>`;
|
||||
host.appendChild(layerState._replayVehicleOverlay);
|
||||
layerState._replayVehicleOverlay._sonarIcons = [
|
||||
...layerState._replayVehicleOverlay.querySelectorAll(
|
||||
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
|
||||
),
|
||||
];
|
||||
layerState._replayVehicleOverlay._sonarLabel =
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'.mission-replay-overlay-callout',
|
||||
);
|
||||
layerState._replayVehicleOverlay = next;
|
||||
for (const listener of [...layerState._replayVehicleOverlayListeners])
|
||||
listener(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive every presentation of the replay vehicle overlay, starting with
|
||||
* the current one.
|
||||
* @param {(view: ?object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribeReplayOverlay(listener) {
|
||||
layerState._replayVehicleOverlayListeners.add(listener);
|
||||
listener(layerState._replayVehicleOverlay);
|
||||
return () => layerState._replayVehicleOverlayListeners.delete(listener);
|
||||
}
|
||||
|
||||
function createReplayVehicleOverlay() {
|
||||
if (layerState._replayVehicleOverlay) return;
|
||||
presentReplayVehicleOverlay({
|
||||
visible: false,
|
||||
mode: null,
|
||||
thrusting: false,
|
||||
paused: false,
|
||||
transform: null,
|
||||
rotation: null,
|
||||
iconOpacity: null,
|
||||
labelOpacity: null,
|
||||
title: '',
|
||||
detail: '',
|
||||
});
|
||||
}
|
||||
|
||||
function hideReplayVehicleOverlay() {
|
||||
if (!layerState._replayVehicleOverlay) return;
|
||||
layerState._replayVehicleOverlay.hidden = true;
|
||||
layerState._replayVehicleOverlay.classList.remove(
|
||||
'is-thrusting',
|
||||
'is-paused',
|
||||
);
|
||||
presentReplayVehicleOverlay({
|
||||
visible: false,
|
||||
thrusting: false,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
function destroyReplayVehicleOverlay() {
|
||||
layerState._replayVehicleOverlay?.remove();
|
||||
layerState._replayVehicleOverlay = null;
|
||||
layerState._replayVehicleOverlayText = '';
|
||||
presentReplayVehicleOverlay(null);
|
||||
}
|
||||
|
||||
function clearMissionOverlaySources() {
|
||||
@@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
: null;
|
||||
const factor =
|
||||
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
|
||||
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
|
||||
icon.style.opacity = String(factor);
|
||||
if (layerState._replayVehicleOverlay._sonarLabel)
|
||||
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
|
||||
sonar ? sonar.label(factor) : 1,
|
||||
);
|
||||
layerState._replayVehicleOverlay.hidden = false;
|
||||
layerState._replayVehicleOverlay.dataset.mode = mode;
|
||||
layerState._replayVehicleOverlay.classList.toggle(
|
||||
'is-thrusting',
|
||||
replayActive && state.ascending && !state.countdownActive,
|
||||
);
|
||||
layerState._replayVehicleOverlay.classList.toggle(
|
||||
'is-paused',
|
||||
replayActive && layerState._replayPaused,
|
||||
);
|
||||
layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`;
|
||||
const frame = {
|
||||
visible: true,
|
||||
mode,
|
||||
thrusting: replayActive && state.ascending && !state.countdownActive,
|
||||
paused: replayActive && layerState._replayPaused,
|
||||
transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`,
|
||||
iconOpacity: String(factor),
|
||||
labelOpacity: String(sonar ? sonar.label(factor) : 1),
|
||||
};
|
||||
let vehicleRotation = 0;
|
||||
if (replayActive && !state.countdownActive) {
|
||||
const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1));
|
||||
@@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
layerState._replayVehicleOverlay.style.setProperty(
|
||||
'--mission-replay-rotation',
|
||||
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
|
||||
);
|
||||
frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`;
|
||||
|
||||
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
|
||||
const siteName = compactLaunchSiteName(launch.launchSite);
|
||||
@@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
}
|
||||
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
|
||||
const nextText = `${title}\n${detail}`;
|
||||
if (nextText !== layerState._replayVehicleOverlayText) {
|
||||
layerState._replayVehicleOverlayText = nextText;
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'[data-replay-overlay-title]',
|
||||
).textContent = title;
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'[data-replay-overlay-detail]',
|
||||
).textContent = detail;
|
||||
}
|
||||
presentReplayVehicleOverlay({ ...frame, title, detail });
|
||||
return mode;
|
||||
}
|
||||
return {
|
||||
@@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
missionHoverReticleImage,
|
||||
replayOverlayMode,
|
||||
createReplayVehicleOverlay,
|
||||
subscribeReplayOverlay,
|
||||
hideReplayVehicleOverlay,
|
||||
destroyReplayVehicleOverlay,
|
||||
clearMissionOverlaySources,
|
||||
|
||||
+280
-363
@@ -1,11 +1,20 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
|
||||
import {
|
||||
MISSION_CLOSE_VIEW_RANGE_M,
|
||||
MISSION_GLOBE_VIEW_RANGE_M,
|
||||
} from './policy.js';
|
||||
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
|
||||
|
||||
// Landing endpoints carry an English accuracy code (the panel and the map
|
||||
// entity compare it); only the text shown in the stage table is translated.
|
||||
// The downrange estimate arrives already translated.
|
||||
function accuracyLabel(accuracy) {
|
||||
if (accuracy === 'CONFIRMED') return t('CONFIRMED');
|
||||
if (accuracy === 'PAD / RTLS') return t('PAD / RTLS');
|
||||
return accuracy;
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Preserve the user's globe scale for roster previews while avoiding an
|
||||
@@ -88,68 +97,6 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind native keyboard focus events for one rendered roster row.
|
||||
* @param {Element} button Mission roster button.
|
||||
* @param {number} index Source-array mission index.
|
||||
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
|
||||
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
|
||||
* @param {Element} roster Roster root used to reject focus targets outside the list.
|
||||
*/
|
||||
|
||||
function bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
ownership,
|
||||
roster,
|
||||
) {
|
||||
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
|
||||
button.addEventListener('mouseleave', () => ownership.pointerLeave());
|
||||
button.addEventListener('focus', () => ownership.focus(index));
|
||||
button.addEventListener('blur', (event) => {
|
||||
const nextButton = event.relatedTarget?.closest?.(
|
||||
'[data-mission-roster-index]',
|
||||
);
|
||||
const nextIndex =
|
||||
nextButton && roster.contains(nextButton)
|
||||
? Number(nextButton.dataset.missionRosterIndex)
|
||||
: null;
|
||||
ownership.blur(nextIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
|
||||
|
||||
function captureMissionRosterFocus(
|
||||
list,
|
||||
activeElement = globalThis.document?.activeElement,
|
||||
) {
|
||||
const button = activeElement?.closest?.('[data-mission-roster-id]');
|
||||
if (!button || !list?.contains(button)) return null;
|
||||
const launchId = button.dataset.missionRosterId;
|
||||
return launchId ? { launchId } : null;
|
||||
}
|
||||
|
||||
/** Restore focus by mission identity, or continue after the list if it departed. */
|
||||
|
||||
function restoreMissionRosterFocus(list, snapshot, continuation) {
|
||||
if (!snapshot?.launchId) return 'none';
|
||||
const button = Array.from(
|
||||
list?.querySelectorAll?.('[data-mission-roster-id]') || [],
|
||||
).find(
|
||||
(candidate) => candidate.dataset.missionRosterId === snapshot.launchId,
|
||||
);
|
||||
if (button) {
|
||||
button.focus({ preventScroll: true });
|
||||
return 'restored';
|
||||
}
|
||||
if (continuation?.focus) {
|
||||
continuation.focus({ preventScroll: true });
|
||||
return 'continued';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Resolve a pending preview against the current refresh, never a stale object. */
|
||||
|
||||
function resolveMissionRosterPreviewLaunch(launches, launchId) {
|
||||
@@ -158,213 +105,213 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function missionTableRows(items, columns, emptyText) {
|
||||
if (!items.length)
|
||||
return `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
|
||||
return items.map((item) => item).join('');
|
||||
/**
|
||||
* Publish a fresh view unless it is the one already shown. The roster
|
||||
* carries its own signature, so a telemetry update compares the mission.
|
||||
*/
|
||||
function publish(view) {
|
||||
const signature = `${layerState._missionRosterSignature}|${JSON.stringify(view.mission)}`;
|
||||
if (layerState._missionPanelSignature === signature) return;
|
||||
layerState._missionPanelSignature = signature;
|
||||
layerState._missionPanelView = view;
|
||||
for (const listener of [...layerState._missionPanelListeners])
|
||||
listener(view);
|
||||
}
|
||||
|
||||
function setMissionPanelField(selector, value, title = '') {
|
||||
const output = layerState._missionPanel?.querySelector(selector);
|
||||
if (!output) return;
|
||||
const row = output.closest('[data-mission-field]');
|
||||
const available =
|
||||
value !== null && value !== undefined && String(value).trim() !== '';
|
||||
if (row) row.hidden = !available;
|
||||
if (!available) {
|
||||
output.textContent = '';
|
||||
output.removeAttribute('title');
|
||||
return;
|
||||
}
|
||||
output.textContent = value;
|
||||
if (title) output.title = title;
|
||||
else output.removeAttribute('title');
|
||||
/**
|
||||
* Receive every published view, starting with the current one.
|
||||
* @param {(view: {roster: ?object, mission: ?object}) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribePanel(listener) {
|
||||
layerState._missionPanelListeners.add(listener);
|
||||
if (layerState._missionPanelView) listener(layerState._missionPanelView);
|
||||
return () => layerState._missionPanelListeners.delete(listener);
|
||||
}
|
||||
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanel) return;
|
||||
/** Publish the roster and the selected mission as they stand. */
|
||||
function publishPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
publish({
|
||||
roster: layerState._missionRosterView,
|
||||
mission: presentMission(),
|
||||
});
|
||||
}
|
||||
|
||||
/** A detail value, or null when the panel hides its row. */
|
||||
function presentField(value) {
|
||||
return value !== null && value !== undefined && String(value).trim() !== ''
|
||||
? String(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
function presentPayload(payload, launch) {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${formatNumber(payload.massKg)} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: `${payload.name ?? ''}${payload.amount > 1 ? ` ×${payload.amount}` : ''}`,
|
||||
detail,
|
||||
type: payload.type || t('UNSPECIFIED'),
|
||||
destination:
|
||||
payload.destination || launch.orbit?.name || t('UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
|
||||
function presentStage(stage, launch) {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: formatNumber(stage.downrangeKm),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const detail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: String(stage.name ?? ''),
|
||||
detail,
|
||||
status: String(stage.status ?? ''),
|
||||
destination: String(destination ?? ''),
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected mission as the panel shows it, or null when none is
|
||||
* selected and the panel hides. Text arrives in the UI language.
|
||||
*/
|
||||
function presentMission() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
const index = launch ? layerState._launches.indexOf(launch) : -1;
|
||||
layerState._missionPanel.hidden = !launch;
|
||||
if (!launch) return;
|
||||
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
|
||||
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
|
||||
setMissionPanelField('[data-mission-provider]', launch.provider);
|
||||
// Launch Library status names are provider data, shown as sent; only the
|
||||
// fallback for a launch without one is ours to translate.
|
||||
setMissionPanelField(
|
||||
'[data-mission-status]',
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
);
|
||||
setMissionPanelField(
|
||||
'[data-mission-site]',
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: null,
|
||||
);
|
||||
setMissionPanelField('[data-mission-time]', launch.launchTime);
|
||||
if (!launch) return null;
|
||||
const index = layerState._launches.indexOf(launch);
|
||||
const pathPresentation = parts.policyHelpers.missionPathPresentation(
|
||||
launch,
|
||||
layerState._replayTracks.has(launch.id),
|
||||
);
|
||||
setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-ascent-source]',
|
||||
).textContent = pathPresentation.ascent;
|
||||
const payloadRows = launch.payloads.length
|
||||
? launch.payloads.slice(0, 5).map((payload) => {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${payload.massKg.toLocaleString()} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-payloads]',
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('PAYLOAD DATA UNAVAILABLE'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
const telemetry = layerState._missionTelemetry;
|
||||
return {
|
||||
scrollRequest: layerState._missionPanelScrollRequest,
|
||||
title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
|
||||
provider: presentField(launch.provider),
|
||||
// Launch Library status names are provider data, shown as sent; only
|
||||
// the fallback for a launch without one is ours to translate.
|
||||
status: presentField(
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
),
|
||||
site: presentField(
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
: null,
|
||||
),
|
||||
time: presentField(launch.launchTime),
|
||||
orbit: presentField(pathPresentation.orbit),
|
||||
ascent: pathPresentation.ascent ?? '',
|
||||
distance: telemetry?.distance ?? null,
|
||||
speed: telemetry?.speed ?? null,
|
||||
speedTitle: telemetry?.speedTitle ?? null,
|
||||
payloads: launch.payloads
|
||||
.slice(0, 5)
|
||||
.map((payload) => presentPayload(payload, launch)),
|
||||
payloadsMore:
|
||||
launch.payloads.length > 5
|
||||
? t('+{count} additional payload records', {
|
||||
count: launch.payloads.length - 5,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
if (stageSection) stageSection.hidden = stageRows.length === 0;
|
||||
updateMissionTelemetry(true);
|
||||
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
|
||||
`${index + 1} / ${layerState._launches.length}`;
|
||||
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
|
||||
index <= 0;
|
||||
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
|
||||
index < 0 || index >= layerState._launches.length - 1;
|
||||
parts.replay.syncReplayButton();
|
||||
const panelScroller = layerState._missionPanel.closest(
|
||||
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
|
||||
);
|
||||
if (panelScroller) panelScroller.scrollTop = 0;
|
||||
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
|
||||
index: `${index + 1} / ${layerState._launches.length}`,
|
||||
previousDisabled: index <= 0,
|
||||
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
|
||||
replay: parts.replay.presentReplay(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionRoster) return;
|
||||
const list = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-list]',
|
||||
);
|
||||
const count = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count)
|
||||
count.textContent = t('{count} / 30D', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
const entries = parts.policyHelpers.missionRosterEntries(
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries
|
||||
/** Present the newly selected mission and return its panel to the top. */
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
layerState._missionTelemetry = null;
|
||||
updateMissionTelemetry(true, false);
|
||||
layerState._missionPanelScrollRequest++;
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
function presentRoster() {
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
|
||||
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
const index = Number(button.dataset.missionRosterIndex);
|
||||
bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
layerState._missionRosterPreviewOwnership,
|
||||
layerState._missionRoster,
|
||||
);
|
||||
});
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return {
|
||||
index,
|
||||
id: String(launch.id ?? ''),
|
||||
color: parts.model.missionMarkerColor(launch).toCssColorString(),
|
||||
label,
|
||||
ariaLabel: t('Select {mission}', { mission: label }),
|
||||
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
|
||||
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
|
||||
};
|
||||
});
|
||||
return {
|
||||
count: t('{count} / 30D', { count: layerState._launches.length }),
|
||||
entries,
|
||||
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeMissionText(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character],
|
||||
);
|
||||
/**
|
||||
* Present the roster. A changed roster drops the hover and keyboard
|
||||
* preview; the list restores keyboard focus by mission identity, which
|
||||
* resumes the preview.
|
||||
*/
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
const roster = presentRoster();
|
||||
const signature = JSON.stringify(roster);
|
||||
if (signature !== layerState._missionRosterSignature) {
|
||||
layerState._missionRosterSignature = signature;
|
||||
layerState._missionRosterView = roster;
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
}
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function updateMissionTelemetry(force = false) {
|
||||
/**
|
||||
* Read the selected mission's altitude and speed, at most four times a
|
||||
* second unless forced, and republish when they change.
|
||||
*/
|
||||
function updateMissionTelemetry(force = false, republish = true) {
|
||||
if (
|
||||
!layerState._missionPanel ||
|
||||
!layerState._missionPanelReady ||
|
||||
!layerState._selectedLaunchId ||
|
||||
!layerState._dataSource
|
||||
)
|
||||
@@ -381,33 +328,30 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const altitudeM = position
|
||||
? Cesium.Cartographic.fromCartesian(position)?.height
|
||||
: null;
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
layerState._missionTelemetry = {
|
||||
distance: Number.isFinite(altitudeM)
|
||||
? t('{value} KM', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
value: formatNumber(Math.max(0, altitudeM / 1000), {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
speed: Number.isFinite(speedMps)
|
||||
? t('{value} KM/S', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
value: formatNumber(speedMps / 1000, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
|
||||
: '',
|
||||
);
|
||||
speedTitle: Number.isFinite(speedMps)
|
||||
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
|
||||
: null,
|
||||
};
|
||||
if (republish) publishPanel();
|
||||
}
|
||||
|
||||
function selectMissionAt(index) {
|
||||
@@ -451,121 +395,94 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanel || typeof document === 'undefined') return;
|
||||
const host =
|
||||
document.getElementById('space-mission-panel-host') ||
|
||||
document.getElementById('right-context-rail');
|
||||
if (!host) return;
|
||||
layerState._missionRoster = document.getElementById('space-mission-roster');
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
layerState._missionRoster.onclick = (event) => {
|
||||
const button =
|
||||
event.target instanceof Element
|
||||
? event.target.closest('[data-mission-roster-index]')
|
||||
: null;
|
||||
if (!button) return;
|
||||
selectMissionAt(Number(button.dataset.missionRosterIndex));
|
||||
};
|
||||
}
|
||||
layerState._missionPanel = document.createElement('aside');
|
||||
layerState._missionPanel.id = 'space-mission-panel';
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('Selected Space Mission'),
|
||||
/** Select the mission before or after the selected one. */
|
||||
function selectAdjacentMission(step) {
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + step,
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-prev]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) - 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-next]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-close]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-show-all]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-focus]')
|
||||
.addEventListener('click', () => {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-toggle]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-cancel]')
|
||||
.addEventListener('click', parts.replay.stopMissionReplay);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-speed]')
|
||||
.addEventListener('input', (event) => {
|
||||
parts.replay.setReplaySpeed(event.currentTarget.value);
|
||||
});
|
||||
parts.replay.syncReplaySpeedControl();
|
||||
}
|
||||
|
||||
/** The panel's commands, called by Core's mission components. */
|
||||
const commands = {
|
||||
selectMissionAt,
|
||||
selectPreviousMission: () => selectAdjacentMission(-1),
|
||||
selectNextMission: () => selectAdjacentMission(1),
|
||||
deselectMission: () => parts.selection.setSelectedMission(null),
|
||||
focusSelectedLaunchSite() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
},
|
||||
startSelectedReplay() {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
},
|
||||
toggleReplayPause() {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
},
|
||||
cancelReplay: () => parts.replay.stopMissionReplay(),
|
||||
setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
|
||||
rosterPointerEnter: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerEnter(index),
|
||||
rosterPointerLeave: () =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerLeave(),
|
||||
rosterFocus: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.focus(index),
|
||||
rosterBlur: (nextIndex) =>
|
||||
layerState._missionRosterPreviewOwnership?.blur(nextIndex),
|
||||
};
|
||||
|
||||
/** Start presenting the roster and the selected mission. */
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanelReady) return;
|
||||
layerState._missionPanelReady = true;
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
// The roster and mission are worded again in a new locale.
|
||||
layerState._offMissionLocale = onLocaleChange(() => {
|
||||
renderMissionRoster();
|
||||
updateMissionTelemetry(true, false);
|
||||
publishPanel();
|
||||
});
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** Stop presenting; the components return to their initial markup. */
|
||||
function clearMissionPanel() {
|
||||
layerState._missionPanelReady = false;
|
||||
layerState._offMissionLocale?.();
|
||||
layerState._offMissionLocale = null;
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRosterView = null;
|
||||
layerState._missionRosterSignature = null;
|
||||
layerState._missionTelemetry = null;
|
||||
layerState._replayTransportLabel = null;
|
||||
publish({ roster: null, mission: null });
|
||||
}
|
||||
|
||||
return {
|
||||
missionHoverPreviewRange,
|
||||
createMissionRosterPreviewOwnership,
|
||||
bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus,
|
||||
restoreMissionRosterFocus,
|
||||
resolveMissionRosterPreviewLaunch,
|
||||
missionTableRows,
|
||||
setMissionPanelField,
|
||||
subscribePanel,
|
||||
publishPanel,
|
||||
presentMission,
|
||||
renderMissionPanel,
|
||||
renderMissionRoster,
|
||||
escapeMissionText,
|
||||
updateMissionTelemetry,
|
||||
selectMissionAt,
|
||||
clearMissionRosterPreviewState,
|
||||
clearMissionRosterHover,
|
||||
scheduleMissionRosterPreview,
|
||||
createMissionPanel,
|
||||
clearMissionPanel,
|
||||
commands,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// The launches layer presents its roster and selected mission as plain views
|
||||
// and publishes them to subscribers; Core's mission components render them.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
function fixture() {
|
||||
const state = createState({
|
||||
services: { overlays: {}, render: {} },
|
||||
});
|
||||
const calls = [];
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
syncMissionOverlayEntries: () => calls.push(['overlays']),
|
||||
},
|
||||
policyHelpers: {
|
||||
missionRosterEntries: (launches) =>
|
||||
launches.map((launch, index) => ({ launch, index })),
|
||||
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
|
||||
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
|
||||
},
|
||||
model: {
|
||||
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
|
||||
},
|
||||
selection: {
|
||||
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
|
||||
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
|
||||
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
|
||||
},
|
||||
replay: {
|
||||
presentReplay: () => ({ available: false }),
|
||||
startMissionReplay: (id) => calls.push(['replay', id]),
|
||||
stopMissionReplay: () => calls.push(['cancel']),
|
||||
pauseMissionReplay: () => calls.push(['pause']),
|
||||
resumeMissionReplay: () => calls.push(['resume']),
|
||||
setReplaySpeed: (value) => calls.push(['speed', value]),
|
||||
},
|
||||
};
|
||||
const panel = createPanel({ state, parts });
|
||||
const views = [];
|
||||
panel.subscribePanel((view) => views.push(view));
|
||||
return { state, panel, views, calls };
|
||||
}
|
||||
|
||||
const launch = (id, extra = {}) => ({
|
||||
id,
|
||||
name: `Mission ${id}`,
|
||||
provider: 'SpaceX',
|
||||
status: 'Go',
|
||||
launchSite: 'Unknown launch site',
|
||||
launchTime: '2026-11-01T10:00:00Z',
|
||||
payloads: [],
|
||||
recoveryStages: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('the panel publishes only once the layer starts presenting', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, 0);
|
||||
panel.createMissionPanel();
|
||||
assert.deepEqual(views, [{ roster: null, mission: null }]);
|
||||
panel.renderMissionRoster();
|
||||
const { roster } = views.at(-1);
|
||||
assert.equal(roster.count, '1 / 30D');
|
||||
assert.deepEqual(roster.entries, [
|
||||
{
|
||||
index: 0,
|
||||
id: 'a',
|
||||
color: 'rgb(1, 2, 3)',
|
||||
label: 'MISSION A',
|
||||
ariaLabel: 'Select MISSION A',
|
||||
provider: 'SpaceX',
|
||||
date: '2026-11-01',
|
||||
},
|
||||
]);
|
||||
const late = [];
|
||||
panel.subscribePanel((view) => late.push(view));
|
||||
assert.equal(late[0], views.at(-1));
|
||||
panel.clearMissionPanel();
|
||||
assert.deepEqual(views.at(-1), { roster: null, mission: null });
|
||||
});
|
||||
|
||||
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
|
||||
const { state, panel, views, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b')];
|
||||
panel.createMissionPanel();
|
||||
panel.renderMissionRoster();
|
||||
const published = views.length;
|
||||
const roster = views.at(-1).roster;
|
||||
panel.commands.rosterFocus(1);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, published);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
await new Promise((resolve) => setTimeout(resolve, 160));
|
||||
assert.deepEqual(calls.at(-1), ['preview', 'b']);
|
||||
state._launches = [launch('b'), launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(state._hoveredRosterLaunchId, null);
|
||||
assert.notEqual(views.at(-1).roster, roster);
|
||||
// The list restores focus on the mission, which takes the preview back.
|
||||
panel.commands.rosterFocus(0);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.clearMissionPanel();
|
||||
});
|
||||
|
||||
test('the selected mission is presented with its rows and returns to the top', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [
|
||||
launch('a'),
|
||||
launch('b', {
|
||||
payloads: Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `Sat ${index}`,
|
||||
amount: index === 0 ? 2 : 1,
|
||||
manufacturer: 'Maker',
|
||||
massKg: 500,
|
||||
})),
|
||||
recoveryStages: [
|
||||
{
|
||||
name: 'B1080',
|
||||
status: 'Landed',
|
||||
flightNumber: 9,
|
||||
reused: true,
|
||||
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
panel.createMissionPanel();
|
||||
state._selectedLaunchId = 'b';
|
||||
panel.renderMissionPanel();
|
||||
const first = views.at(-1).mission;
|
||||
assert.equal(first.title, 'MISSION B');
|
||||
assert.equal(first.site, null);
|
||||
assert.equal(first.orbit, 'LEO');
|
||||
assert.equal(first.index, '2 / 2');
|
||||
assert.equal(first.previousDisabled, false);
|
||||
assert.equal(first.nextDisabled, true);
|
||||
assert.equal(first.payloads.length, 5);
|
||||
assert.deepEqual(first.payloads[0], {
|
||||
name: 'Sat 0 ×2',
|
||||
detail: 'Maker · 500 KG',
|
||||
type: 'UNSPECIFIED',
|
||||
destination: 'UNAVAILABLE',
|
||||
});
|
||||
assert.equal(first.payloadsMore, '+1 additional payload records');
|
||||
assert.deepEqual(first.stages, [
|
||||
{
|
||||
name: 'B1080',
|
||||
detail: 'FLIGHT 9 · REUSED',
|
||||
status: 'Landed',
|
||||
destination: 'UNAVAILABLE',
|
||||
position: '28.40, -77.10 · DRONESHIP',
|
||||
},
|
||||
]);
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
|
||||
state._selectedLaunchId = null;
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission, null);
|
||||
});
|
||||
|
||||
test('the panel commands act on the selected mission', () => {
|
||||
const { state, panel, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b'), launch('c')];
|
||||
state._selectedLaunchId = 'b';
|
||||
const { commands } = panel;
|
||||
commands.selectPreviousMission();
|
||||
commands.selectNextMission();
|
||||
commands.selectMissionAt(9);
|
||||
commands.focusSelectedLaunchSite();
|
||||
commands.startSelectedReplay();
|
||||
commands.toggleReplayPause();
|
||||
state._replayPaused = true;
|
||||
commands.toggleReplayPause();
|
||||
commands.cancelReplay();
|
||||
commands.setReplaySpeed('2');
|
||||
commands.deselectMission();
|
||||
assert.deepEqual(calls, [
|
||||
['select', 'a', true],
|
||||
['focus-mission', 'a'],
|
||||
['select', 'c', true],
|
||||
['focus-mission', 'c'],
|
||||
['focus-site', 'b'],
|
||||
['replay', 'b'],
|
||||
['pause'],
|
||||
['resume'],
|
||||
['cancel'],
|
||||
['speed', '2'],
|
||||
['select', null, undefined],
|
||||
]);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
|
||||
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
|
||||
for (const cyber of [true, false]) {
|
||||
const body = { scrollTop: 180 };
|
||||
const inner = { scrollTop: 90 };
|
||||
const output = { closest: () => null, removeAttribute() {} };
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector: () => output,
|
||||
closest(selector) {
|
||||
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
|
||||
return cyber ? body : inner;
|
||||
},
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
const panel = createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
},
|
||||
});
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(body.scrollTop, cyber ? 0 : 180);
|
||||
assert.equal(inner.scrollTop, cyber ? 90 : 0);
|
||||
}
|
||||
});
|
||||
@@ -179,60 +179,41 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Republish the mission panel after a replay state change. */
|
||||
function syncReplayButton() {
|
||||
const button = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay]',
|
||||
);
|
||||
const transport = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-transport]',
|
||||
);
|
||||
const speedControl = layerState._missionPanel?.querySelector(
|
||||
'.mission-replay-speed-control',
|
||||
);
|
||||
if (!button) return;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay controls as the mission panel shows them: the start button,
|
||||
* the transport with its pause toggle, and the speed slider.
|
||||
*/
|
||||
function presentReplay() {
|
||||
const active = Boolean(
|
||||
layerState._replayCameraLaunchId &&
|
||||
layerState._replayCameraLaunchId === layerState._selectedLaunchId,
|
||||
);
|
||||
const replayAvailable = Boolean(
|
||||
const available = Boolean(
|
||||
layerState._selectedLaunchId &&
|
||||
layerState._replayTracks.has(layerState._selectedLaunchId),
|
||||
);
|
||||
if (speedControl) speedControl.hidden = !replayAvailable;
|
||||
button.hidden = active || !replayAvailable;
|
||||
button.disabled = !replayAvailable;
|
||||
button.textContent = t('REPLAY ASCENT');
|
||||
button.classList.remove('active');
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.title = t('Replay the estimated ascent with a following camera');
|
||||
if (transport) {
|
||||
transport.hidden = !active;
|
||||
transport.classList.toggle(
|
||||
'is-paused',
|
||||
active && layerState._replayPaused,
|
||||
);
|
||||
const toggleButton = transport.querySelector(
|
||||
'[data-mission-replay-toggle]',
|
||||
);
|
||||
if (toggleButton) {
|
||||
toggleButton.disabled = !active;
|
||||
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
|
||||
toggleButton.setAttribute(
|
||||
'aria-label',
|
||||
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
|
||||
);
|
||||
toggleButton.title = layerState._replayPaused
|
||||
? t('Resume replay')
|
||||
: t('Pause replay');
|
||||
}
|
||||
}
|
||||
const paused = layerState._replayPaused;
|
||||
const speed = layerState._replaySpeed;
|
||||
return {
|
||||
available,
|
||||
active,
|
||||
paused: active && paused,
|
||||
toggleText: paused ? '▶' : 'Ⅱ',
|
||||
toggleLabel: paused ? t('Resume replay') : t('Pause replay'),
|
||||
transportLabel: layerState._replayTransportLabel,
|
||||
speed: String(speed),
|
||||
speedText: `${speed.toFixed(speed % 1 ? 2 : 0)}×`,
|
||||
speedProgress: `${((speed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
function syncReplayCountdownButton(state) {
|
||||
const transport = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-transport]',
|
||||
);
|
||||
if (!transport || !layerState._replayCameraLaunchId) return;
|
||||
if (!layerState._replayCameraLaunchId) return;
|
||||
const phase = state.countdownActive
|
||||
? t('T minus {seconds}', {
|
||||
seconds: state.countdownSeconds,
|
||||
@@ -244,29 +225,14 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
: state.ascending
|
||||
? t('Ascent replay')
|
||||
: t('Orbit replay');
|
||||
transport.setAttribute(
|
||||
'aria-label',
|
||||
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
|
||||
);
|
||||
const label = `${phase}${layerState._replayPaused ? t(', paused') : ''}`;
|
||||
if (label === layerState._replayTransportLabel) return;
|
||||
layerState._replayTransportLabel = label;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
function syncReplaySpeedControl() {
|
||||
const input = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-speed]',
|
||||
);
|
||||
const output = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-speed-output]',
|
||||
);
|
||||
if (input) {
|
||||
input.value = String(layerState._replaySpeed);
|
||||
const progress =
|
||||
((layerState._replaySpeed - REPLAY_SPEED_MIN) /
|
||||
(REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) *
|
||||
100;
|
||||
input.style.setProperty('--replay-speed-progress', `${progress}%`);
|
||||
}
|
||||
if (output)
|
||||
output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
function setReplaySpeed(value) {
|
||||
@@ -548,6 +514,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
replayStartAfterPause,
|
||||
replayState,
|
||||
syncReplayButton,
|
||||
presentReplay,
|
||||
syncReplayCountdownButton,
|
||||
syncReplaySpeedControl,
|
||||
setReplaySpeed,
|
||||
|
||||
Loaded 100 of 593 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user