mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.
- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
item (handyman). LayerPanel renders the Directions rows into its list with
LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
which render what the draw tool publishes to the display store and call
its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
drawMarkList.js is gone. A row's fly-to is an immediate navigation like
Import's ('drawing' gets its Cockpit refusal), the name field keeps a
draft through v-field-value, and the colours are one radio group with a
single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
buttons.
- The catalogs take main's languages with this branch's strings, Arabic
included, and the tests follow the views to their new homes.
This commit is contained in:
commit
4ebb287d36
699 files changed
+46673
-20476
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
sampleGroundHeight,
|
||||
} from './annotationResolver.js';
|
||||
import { ringCentroid } from './drawMode.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
@@ -1693,9 +1694,7 @@ function greatCircleM(a, b) {
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
if (!Number.isFinite(m)) return null;
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return `${Math.round(m / 10) * 10} m`;
|
||||
return formatDistanceText(m, { metreStep: 10 });
|
||||
}
|
||||
|
||||
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { resolveAnnotationTarget } from './annotationResolver.js';
|
||||
import {
|
||||
getRenderGovernorDiagnostics,
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
@@ -971,3 +972,18 @@ test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
|
||||
assert.equal(engine.restyle(voiceMark.id, { name: 'Renamed' }), false);
|
||||
assert.equal(voiceMark.label, 'Capitol');
|
||||
});
|
||||
|
||||
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const labels = [];
|
||||
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
for (const metres of [994, 996, 9940, 9960]) {
|
||||
await engine.annotate([
|
||||
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
|
||||
]);
|
||||
}
|
||||
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* The shapes drawn by hand, listed under Draw the way Import lists its files:
|
||||
* one row per shape with its colour, its name, and show/hide, fly-to and
|
||||
* remove. The name is edited in place and the colour picked from the row, so
|
||||
* a shape is labelled and coloured after it is drawn, when it can be seen.
|
||||
*
|
||||
* The board is the annotation engine's: this list only reads `list()`, asks
|
||||
* for changes through `remove`, `setHidden`, `frame` and `restyle`, and
|
||||
* re-renders on `onChange`. Voice marks are not listed; only `manual` ones.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const DRAW_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
/** The swatch for each colour, matching the preview and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
const COLOR_NAMES = Object.freeze({
|
||||
primary: msg('Primary'),
|
||||
amber: msg('Amber'),
|
||||
cyan: msg('Cyan'),
|
||||
green: msg('Green'),
|
||||
red: msg('Red'),
|
||||
});
|
||||
const SHAPE_NAMES = Object.freeze({
|
||||
area: msg('Area'),
|
||||
route: msg('Line'),
|
||||
pin: msg('Pin'),
|
||||
});
|
||||
|
||||
/** What a row calls a shape that has no name of its own. */
|
||||
export function drawMarkFallbackName(mark) {
|
||||
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
|
||||
// An unnamed line's label is its length; keep it, it is the useful part.
|
||||
return mark?.type === 'route' && !mark.name && mark.label
|
||||
? `${shape} · ${mark.label}`
|
||||
: shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* listEl: HTMLElement,
|
||||
* annotations: {list: Function, remove: Function, setHidden: Function,
|
||||
* frame: Function, restyle: Function, onChange?: Function},
|
||||
* listen: (target: EventTarget, type: string, listener: Function, options?: object) => void,
|
||||
* }} deps
|
||||
* @returns {{render: Function, marks: Function, destroy: Function}}
|
||||
*/
|
||||
export function createDrawMarkList({ listEl, annotations, listen }) {
|
||||
const doc = listEl.ownerDocument;
|
||||
let destroyed = false;
|
||||
let picking = null; // the id whose colour picker is open
|
||||
/** @type {Map<string, object>} each listed mark's row nodes */
|
||||
const rows = new Map();
|
||||
let rendering = false;
|
||||
let rerender = false;
|
||||
|
||||
const marks = () =>
|
||||
(annotations.list?.() || []).filter((mark) => mark?.manual === true);
|
||||
const find = (id) => marks().find((mark) => mark.id === id) || null;
|
||||
|
||||
function actionButton(icon, control) {
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'geo-import-action';
|
||||
button.dataset.control = control;
|
||||
const glyph = doc.createElement('span');
|
||||
glyph.className = 'material-symbols-outlined';
|
||||
glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = icon;
|
||||
button.append(glyph);
|
||||
return button;
|
||||
}
|
||||
|
||||
function setLabel(node, label) {
|
||||
node.title = label;
|
||||
node.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
function colorPicker() {
|
||||
const picker = doc.createElement('div');
|
||||
picker.className = 'draw-colors draw-mark-colors';
|
||||
picker.dataset.control = 'picker';
|
||||
picker.setAttribute('role', 'radiogroup');
|
||||
for (const color of DRAW_COLORS) {
|
||||
const swatch = doc.createElement('button');
|
||||
swatch.type = 'button';
|
||||
swatch.className = 'draw-color';
|
||||
swatch.dataset.color = color;
|
||||
swatch.dataset.control = `color-${color}`;
|
||||
swatch.setAttribute('role', 'radio');
|
||||
setLabel(swatch, t(COLOR_NAMES[color]));
|
||||
picker.append(swatch);
|
||||
}
|
||||
return picker;
|
||||
}
|
||||
|
||||
const label = (mark) => mark.name || drawMarkFallbackName(mark);
|
||||
|
||||
/** One row's nodes, built once and kept while its mark is on the board. */
|
||||
function createRow(id) {
|
||||
const row = doc.createElement('li');
|
||||
row.className = 'geo-import-row draw-mark-row';
|
||||
row.dataset.markId = id;
|
||||
|
||||
const swatch = doc.createElement('button');
|
||||
swatch.type = 'button';
|
||||
swatch.className = 'geo-import-swatch draw-mark-swatch';
|
||||
swatch.dataset.control = 'swatch';
|
||||
|
||||
// textContent and value only, never markup: the name is typed text.
|
||||
const name = doc.createElement('input');
|
||||
name.type = 'text';
|
||||
name.className = 'geo-import-name draw-mark-name';
|
||||
name.dataset.control = 'name';
|
||||
name.maxLength = 120;
|
||||
name.autocomplete = 'off';
|
||||
name.spellcheck = false;
|
||||
|
||||
const toggle = actionButton('visibility', 'toggle');
|
||||
const fly = actionButton('my_location', 'fly');
|
||||
const remove = actionButton('close', 'remove');
|
||||
row.append(swatch, name, toggle, fly, remove);
|
||||
return { row, swatch, name, toggle, fly, remove, picker: null };
|
||||
}
|
||||
|
||||
function updateRow(nodes, mark) {
|
||||
const { row, swatch, name, toggle, fly, remove } = nodes;
|
||||
const title = label(mark);
|
||||
row.classList.toggle('hidden-mark', Boolean(mark.hidden));
|
||||
swatch.style.background = DRAW_COLOR_CSS[mark.color] || '';
|
||||
setLabel(swatch, t('Change the colour of {name}', { name: title }));
|
||||
swatch.setAttribute('aria-expanded', String(picking === mark.id));
|
||||
// A name being typed is the person's draft: a board change elsewhere
|
||||
// must not overwrite it.
|
||||
if (doc.activeElement !== name) name.value = mark.name || '';
|
||||
name.placeholder = drawMarkFallbackName(mark);
|
||||
name.setAttribute('aria-label', t('Name of {name}', { name: title }));
|
||||
const visible = !mark.hidden;
|
||||
toggle.children[0].textContent = visible ? 'visibility' : 'visibility_off';
|
||||
setLabel(
|
||||
toggle,
|
||||
visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
);
|
||||
toggle.setAttribute('aria-pressed', String(visible));
|
||||
setLabel(fly, t('Fly to {name}', { name: title }));
|
||||
setLabel(remove, t('Remove {name}', { name: title }));
|
||||
|
||||
if (picking === mark.id) {
|
||||
nodes.picker ||= colorPicker();
|
||||
nodes.picker.setAttribute(
|
||||
'aria-label',
|
||||
t('Colour of {name}', { name: title }),
|
||||
);
|
||||
for (const button of nodes.picker.children) {
|
||||
const on = button.dataset.color === mark.color;
|
||||
button.classList.toggle('active', on);
|
||||
button.setAttribute('aria-checked', String(on));
|
||||
}
|
||||
if (nodes.picker.parentNode !== row) row.append(nodes.picker);
|
||||
} else if (nodes.picker) {
|
||||
nodes.picker.remove();
|
||||
nodes.picker = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the rows in line with the board. Rows are updated in place, never
|
||||
* rebuilt: a rebuild would detach the button under a click that is half
|
||||
* done, and the name field someone is typing in.
|
||||
*/
|
||||
function render() {
|
||||
if (destroyed) return;
|
||||
if (rendering) {
|
||||
rerender = true;
|
||||
return;
|
||||
}
|
||||
rendering = true;
|
||||
try {
|
||||
do {
|
||||
rerender = false;
|
||||
reconcile();
|
||||
} while (rerender && !destroyed);
|
||||
} finally {
|
||||
rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcile() {
|
||||
const list = marks();
|
||||
if (picking && !list.some((mark) => mark.id === picking)) picking = null;
|
||||
const live = new Set(list.map((mark) => mark.id));
|
||||
for (const [id, nodes] of rows) {
|
||||
if (live.has(id)) continue;
|
||||
nodes.row.remove();
|
||||
rows.delete(id);
|
||||
}
|
||||
list.forEach((mark, index) => {
|
||||
let nodes = rows.get(mark.id);
|
||||
if (!nodes) {
|
||||
nodes = createRow(mark.id);
|
||||
rows.set(mark.id, nodes);
|
||||
}
|
||||
updateRow(nodes, mark);
|
||||
// Move a row only when it is out of place, so a focused one stays put.
|
||||
if (listEl.children[index] !== nodes.row)
|
||||
listEl.insertBefore(nodes.row, listEl.children[index] || null);
|
||||
});
|
||||
listEl.classList.toggle('visible', list.length > 0);
|
||||
}
|
||||
|
||||
const rowId = (node) => node?.closest?.('[data-mark-id]')?.dataset.markId;
|
||||
|
||||
/** Commit a typed name; an unchanged one costs nothing. */
|
||||
function commitName(input) {
|
||||
const id = rowId(input);
|
||||
const mark = id && find(id);
|
||||
if (!mark) return;
|
||||
const next = input.value.trim();
|
||||
input.value = next;
|
||||
if (next === (mark.name || '')) return;
|
||||
annotations.restyle(id, { name: next });
|
||||
}
|
||||
|
||||
/** Close a row's colour picker and give focus back to its swatch. */
|
||||
function closePicker(id) {
|
||||
picking = null;
|
||||
render();
|
||||
rows.get(id)?.swatch.focus?.();
|
||||
}
|
||||
|
||||
listen(listEl, 'click', (event) => {
|
||||
const control = event.target?.closest?.('[data-control]');
|
||||
const id = rowId(control);
|
||||
if (!control || !id) return;
|
||||
const kind = control.dataset.control;
|
||||
if (kind === 'swatch') {
|
||||
picking = picking === id ? null : id;
|
||||
render();
|
||||
} else if (kind.startsWith('color-')) {
|
||||
const color = control.dataset.color;
|
||||
picking = null;
|
||||
if (find(id)?.color !== color) annotations.restyle(id, { color });
|
||||
closePicker(id);
|
||||
} else if (kind === 'toggle') annotations.setHidden(id, !find(id)?.hidden);
|
||||
else if (kind === 'fly') annotations.frame(id);
|
||||
else if (kind === 'remove') annotations.remove(id);
|
||||
});
|
||||
listen(listEl, 'keydown', (event) => {
|
||||
const input = event.target;
|
||||
if (event.key === 'Escape' && picking && rowId(input) === picking) {
|
||||
// Escape closes the picker; it must not also leave Draw.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closePicker(picking);
|
||||
return;
|
||||
}
|
||||
if (input?.dataset?.control !== 'name') return;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitName(input);
|
||||
input.blur?.();
|
||||
} else if (event.key === 'Escape') {
|
||||
// Escape undoes the edit; it must not also leave Draw or cancel a shape.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
input.value = find(rowId(input))?.name || '';
|
||||
input.blur?.();
|
||||
}
|
||||
});
|
||||
listen(listEl, 'focusout', (event) => {
|
||||
if (event.target?.dataset?.control === 'name') commitName(event.target);
|
||||
});
|
||||
|
||||
const unsubscribe = annotations.onChange?.(render) || (() => {});
|
||||
render();
|
||||
|
||||
return {
|
||||
render,
|
||||
marks,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
unsubscribe();
|
||||
rows.clear();
|
||||
listEl.replaceChildren();
|
||||
listEl.classList.remove('visible');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDrawMarkList, drawMarkFallbackName } from './drawMarkList.js';
|
||||
|
||||
/** Just enough DOM for the list: rows, inputs, buttons, attributes, focus. */
|
||||
function fakeDocument() {
|
||||
const doc = { activeElement: null };
|
||||
const matches = (node, selector) => {
|
||||
const attr = /^\[data-([a-z-]+)(?:="([^"]*)")?\]$/.exec(selector);
|
||||
if (!attr) return false;
|
||||
const key = attr[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
return attr[2] === undefined
|
||||
? node.dataset[key] !== undefined
|
||||
: node.dataset[key] === attr[2];
|
||||
};
|
||||
doc.createElement = (tag) => {
|
||||
const classes = new Set();
|
||||
const node = {
|
||||
tagName: tag.toUpperCase(),
|
||||
ownerDocument: doc,
|
||||
parent: null,
|
||||
children: [],
|
||||
dataset: {},
|
||||
style: {},
|
||||
attributes: new Map(),
|
||||
value: '',
|
||||
placeholder: '',
|
||||
title: '',
|
||||
textContent: '',
|
||||
classList: {
|
||||
add: (name) => classes.add(name),
|
||||
remove: (name) => classes.delete(name),
|
||||
contains: (name) => classes.has(name),
|
||||
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
|
||||
},
|
||||
set className(value) {
|
||||
classes.clear();
|
||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||
classes.add(name);
|
||||
},
|
||||
get className() {
|
||||
return [...classes].join(' ');
|
||||
},
|
||||
setAttribute(key, value) {
|
||||
this.attributes.set(key, String(value));
|
||||
},
|
||||
getAttribute(key) {
|
||||
return this.attributes.get(key) ?? null;
|
||||
},
|
||||
get parentNode() {
|
||||
return this.parent;
|
||||
},
|
||||
append(...nodes) {
|
||||
for (const child of nodes) this.insertBefore(child, null);
|
||||
},
|
||||
insertBefore(child, before) {
|
||||
child.remove();
|
||||
child.parent = this;
|
||||
const at = before ? this.children.indexOf(before) : -1;
|
||||
if (at < 0) this.children.push(child);
|
||||
else this.children.splice(at, 0, child);
|
||||
},
|
||||
remove() {
|
||||
// Like a browser, taking the focused node out of the page blurs it.
|
||||
if (doc.activeElement && this.contains(doc.activeElement))
|
||||
doc.activeElement = null;
|
||||
if (this.parent)
|
||||
this.parent.children = this.parent.children.filter(
|
||||
(node) => node !== this,
|
||||
);
|
||||
this.parent = null;
|
||||
},
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of [...this.children]) child.remove();
|
||||
this.append(...nodes);
|
||||
},
|
||||
closest(selector) {
|
||||
let node = this;
|
||||
while (node && !matches(node, selector)) node = node.parent;
|
||||
return node || null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const walk = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (matches(child, selector)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return found;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
},
|
||||
contains(other) {
|
||||
for (let node = other; node; node = node.parent)
|
||||
if (node === this) return true;
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
doc.activeElement = this;
|
||||
},
|
||||
blur() {
|
||||
if (doc.activeElement === this) doc.activeElement = null;
|
||||
},
|
||||
};
|
||||
return node;
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
function harness(initial) {
|
||||
const doc = fakeDocument();
|
||||
const listEl = doc.createElement('ul');
|
||||
const listeners = {};
|
||||
const listen = (target, type, listener) => {
|
||||
listeners[type] = listener;
|
||||
};
|
||||
let marks = initial.map((mark) => ({ hidden: false, ...mark }));
|
||||
const changeListeners = new Set();
|
||||
const changed = () => changeListeners.forEach((listener) => listener());
|
||||
const calls = [];
|
||||
const annotations = {
|
||||
list: () => marks,
|
||||
onChange: (listener) => {
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
},
|
||||
remove(id) {
|
||||
calls.push(['remove', id]);
|
||||
marks = marks.filter((mark) => mark.id !== id);
|
||||
changed();
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.push(['hide', id, hidden]);
|
||||
marks.find((mark) => mark.id === id).hidden = hidden;
|
||||
changed();
|
||||
},
|
||||
frame(id) {
|
||||
calls.push(['frame', id]);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.push(['restyle', id, change]);
|
||||
Object.assign(
|
||||
marks.find((mark) => mark.id === id),
|
||||
change,
|
||||
);
|
||||
changed();
|
||||
},
|
||||
};
|
||||
const list = createDrawMarkList({ listEl, annotations, listen });
|
||||
const rows = () => listEl.children;
|
||||
const control = (row, name) => row.querySelector(`[data-control="${name}"]`);
|
||||
const click = (target) => listeners.click({ target });
|
||||
const addMark = (mark) => {
|
||||
marks = [...marks, { hidden: false, ...mark }];
|
||||
changed();
|
||||
};
|
||||
return {
|
||||
list,
|
||||
listEl,
|
||||
listeners,
|
||||
rows,
|
||||
control,
|
||||
click,
|
||||
calls,
|
||||
changed,
|
||||
addMark,
|
||||
changeListeners,
|
||||
};
|
||||
}
|
||||
|
||||
const LINE = {
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '1.2 km',
|
||||
color: 'primary',
|
||||
};
|
||||
const PIN = {
|
||||
id: 'a2',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
label: 'Camp',
|
||||
color: 'amber',
|
||||
};
|
||||
const VOICE = {
|
||||
id: 'a3',
|
||||
type: 'area',
|
||||
manual: false,
|
||||
label: 'Texas',
|
||||
color: 'primary',
|
||||
};
|
||||
|
||||
test('lists only hand-drawn marks, named or by shape, with Import-style actions', () => {
|
||||
const { rows, control, listEl } = harness([LINE, PIN, VOICE]);
|
||||
assert.deepEqual(
|
||||
rows().map((row) => row.dataset.markId),
|
||||
['a1', 'a2'],
|
||||
);
|
||||
assert.ok(listEl.classList.contains('visible'));
|
||||
const [line, pin] = rows();
|
||||
assert.equal(control(line, 'name').value, '');
|
||||
assert.equal(control(line, 'name').placeholder, 'Line · 1.2 km');
|
||||
assert.equal(control(pin, 'name').value, 'Camp');
|
||||
assert.equal(control(pin, 'swatch').style.background, '#ffb547');
|
||||
assert.equal(control(pin, 'toggle').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(control(pin, 'fly').getAttribute('aria-label'), 'Fly to Camp');
|
||||
assert.equal(
|
||||
control(pin, 'remove').getAttribute('aria-label'),
|
||||
'Remove Camp',
|
||||
);
|
||||
});
|
||||
|
||||
test('show/hide, fly to and remove act on that one mark', () => {
|
||||
const { rows, control, click, calls } = harness([LINE, PIN]);
|
||||
click(control(rows()[1], 'toggle'));
|
||||
assert.deepEqual(calls.at(-1), ['hide', 'a2', true]);
|
||||
assert.equal(
|
||||
control(rows()[1], 'toggle').getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
assert.ok(rows()[1].classList.contains('hidden-mark'));
|
||||
click(control(rows()[0], 'fly'));
|
||||
assert.deepEqual(calls.at(-1), ['frame', 'a1']);
|
||||
click(control(rows()[0], 'remove'));
|
||||
assert.deepEqual(calls.at(-1), ['remove', 'a1']);
|
||||
assert.deepEqual(
|
||||
rows().map((row) => row.dataset.markId),
|
||||
['a2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the swatch opens a colour picker in the row; a colour restyles the mark', () => {
|
||||
const { rows, control, click, calls } = harness([LINE]);
|
||||
click(control(rows()[0], 'swatch'));
|
||||
assert.equal(
|
||||
control(rows()[0], 'swatch').getAttribute('aria-expanded'),
|
||||
'true',
|
||||
);
|
||||
const red = control(rows()[0], 'color-red');
|
||||
assert.ok(red, 'the picker offers every colour');
|
||||
click(red);
|
||||
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { color: 'red' }]);
|
||||
assert.equal(
|
||||
control(rows()[0], 'color-red'),
|
||||
null,
|
||||
'choosing closes the picker',
|
||||
);
|
||||
assert.equal(control(rows()[0], 'swatch').style.background, '#ff6b6b');
|
||||
});
|
||||
|
||||
test('a name is committed on Enter or blur, and Escape undoes the edit without leaving Draw', () => {
|
||||
const { rows, control, listeners, calls } = harness([LINE, PIN]);
|
||||
const name = control(rows()[0], 'name');
|
||||
name.value = ' Ridge walk ';
|
||||
listeners.keydown({ key: 'Enter', target: name, preventDefault() {} });
|
||||
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { name: 'Ridge walk' }]);
|
||||
|
||||
const pinName = control(rows()[1], 'name');
|
||||
pinName.value = 'Base';
|
||||
let stopped = false;
|
||||
listeners.keydown({
|
||||
key: 'Escape',
|
||||
target: pinName,
|
||||
preventDefault() {},
|
||||
stopPropagation() {
|
||||
stopped = true;
|
||||
},
|
||||
});
|
||||
assert.equal(pinName.value, 'Camp', 'Escape restores the name');
|
||||
assert.ok(stopped, 'Escape stays in the field');
|
||||
|
||||
const count = calls.length;
|
||||
listeners.focusout({ target: control(rows()[1], 'name') });
|
||||
assert.equal(calls.length, count, 'an unchanged name costs nothing');
|
||||
control(rows()[1], 'name').value = 'Base';
|
||||
listeners.focusout({ target: control(rows()[1], 'name') });
|
||||
assert.deepEqual(calls.at(-1), ['restyle', 'a2', { name: 'Base' }]);
|
||||
});
|
||||
|
||||
test('a re-render keeps focus on the same control, and destroy stops listening', () => {
|
||||
const { rows, control, list, listEl, changed, changeListeners } = harness([
|
||||
LINE,
|
||||
]);
|
||||
control(rows()[0], 'fly').focus();
|
||||
changed();
|
||||
assert.equal(listEl.ownerDocument.activeElement, control(rows()[0], 'fly'));
|
||||
list.destroy();
|
||||
assert.equal(changeListeners.size, 0, 'destroy unsubscribes from the board');
|
||||
assert.equal(rows().length, 0);
|
||||
assert.ok(!listEl.classList.contains('visible'));
|
||||
changed();
|
||||
assert.equal(rows().length, 0, 'a destroyed list does not re-render');
|
||||
});
|
||||
|
||||
test('an unnamed shape is called by its kind', () => {
|
||||
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
|
||||
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
|
||||
assert.equal(
|
||||
drawMarkFallbackName({ type: 'route', label: '300 m' }),
|
||||
'Line · 300 m',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkFallbackName({
|
||||
type: 'route',
|
||||
name: 'Walk',
|
||||
label: 'Walk — 300 m',
|
||||
}),
|
||||
'Line',
|
||||
);
|
||||
});
|
||||
|
||||
test('a board change while a name is being typed keeps the draft and the row', () => {
|
||||
const { rows, control, listEl, addMark, calls } = harness([LINE, PIN]);
|
||||
const name = control(rows()[0], 'name');
|
||||
name.focus();
|
||||
name.value = 'Half-typ';
|
||||
// A voice mark lands, and another hand-drawn one: neither may wipe the draft.
|
||||
addMark(VOICE);
|
||||
addMark({ ...PIN, id: 'a4', name: null, label: '' });
|
||||
assert.equal(control(rows()[0], 'name'), name, 'the row is not rebuilt');
|
||||
assert.equal(name.value, 'Half-typ', 'the typed text survives');
|
||||
assert.equal(listEl.ownerDocument.activeElement, name, 'focus stays');
|
||||
assert.equal(calls.length, 0, 'nothing is committed early');
|
||||
assert.deepEqual(
|
||||
rows().map((row) => row.dataset.markId),
|
||||
['a1', 'a2', 'a4'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a click right after typing a name still reaches its button', () => {
|
||||
const { rows, control, listeners, click, calls } = harness([LINE, PIN]);
|
||||
const name = control(rows()[0], 'name');
|
||||
name.focus();
|
||||
name.value = 'Home';
|
||||
const hide = control(rows()[1], 'toggle');
|
||||
const remove = control(rows()[0], 'remove');
|
||||
// mousedown on a button blurs the field first, which commits the name.
|
||||
name.blur();
|
||||
listeners.focusout({ target: name });
|
||||
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { name: 'Home' }]);
|
||||
assert.equal(control(rows()[1], 'toggle'), hide, 'the button is still there');
|
||||
click(hide);
|
||||
assert.deepEqual(calls.at(-1), ['hide', 'a2', true]);
|
||||
click(remove);
|
||||
assert.deepEqual(calls.at(-1), ['remove', 'a1']);
|
||||
});
|
||||
|
||||
test('picking a colour returns focus to the swatch; Escape closes the picker', () => {
|
||||
const { rows, control, click, listeners, listEl, calls } = harness([LINE]);
|
||||
const doc = listEl.ownerDocument;
|
||||
click(control(rows()[0], 'swatch'));
|
||||
click(control(rows()[0], 'color-primary'));
|
||||
assert.equal(calls.length, 0, 'the colour it already has costs nothing');
|
||||
assert.equal(doc.activeElement, control(rows()[0], 'swatch'));
|
||||
|
||||
click(control(rows()[0], 'swatch'));
|
||||
const amber = control(rows()[0], 'color-amber');
|
||||
amber.focus();
|
||||
let stopped = false;
|
||||
listeners.keydown({
|
||||
key: 'Escape',
|
||||
target: amber,
|
||||
preventDefault() {},
|
||||
stopPropagation() {
|
||||
stopped = true;
|
||||
},
|
||||
});
|
||||
assert.ok(stopped, 'Escape does not reach Draw');
|
||||
assert.equal(control(rows()[0], 'color-amber'), null, 'the picker closed');
|
||||
assert.equal(doc.activeElement, control(rows()[0], 'swatch'));
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
|
||||
* with its colour, its name, and show/hide, fly-to and remove, the way Import
|
||||
* lists its files. The board is the annotation engine's; voice marks are not
|
||||
* listed, only `manual` ones.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const DRAW_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
export const DRAW_COLOR_NAMES = Object.freeze({
|
||||
primary: msg('Primary'),
|
||||
amber: msg('Amber'),
|
||||
cyan: msg('Cyan'),
|
||||
green: msg('Green'),
|
||||
red: msg('Red'),
|
||||
});
|
||||
const SHAPE_NAMES = Object.freeze({
|
||||
area: msg('Area'),
|
||||
route: msg('Line'),
|
||||
pin: msg('Pin'),
|
||||
});
|
||||
|
||||
/** What a row calls a shape that has no name of its own. */
|
||||
export function drawMarkFallbackName(mark) {
|
||||
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
|
||||
// An unnamed line's label is its length; keep it, it is the useful part.
|
||||
return mark?.type === 'route' && !mark.name && mark.label
|
||||
? `${shape} · ${mark.label}`
|
||||
: shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
colors: t('Colour of {name}', { name: title }),
|
||||
toggle: visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
fly: t('Fly to {name}', { name: title }),
|
||||
remove: t('Remove {name}', { name: title }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
|
||||
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
|
||||
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
|
||||
assert.equal(
|
||||
drawMarkFallbackName({ type: 'route', label: '300 m' }),
|
||||
'Line · 300 m',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkFallbackName({
|
||||
type: 'route',
|
||||
name: 'Walk',
|
||||
label: 'Walk — 300 m',
|
||||
}),
|
||||
'Line',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows list only hand-drawn marks, in board order, with their words', () => {
|
||||
const rows = presentDrawMarks([
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '950 m',
|
||||
color: 'amber',
|
||||
},
|
||||
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
color: 'teal',
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ id, name, placeholder, color, hidden }) => ({
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
color,
|
||||
hidden,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
placeholder: 'Line · 950 m',
|
||||
color: 'amber',
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Camp',
|
||||
placeholder: 'Pin',
|
||||
color: 'primary',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
|
||||
assert.equal(rows[1].labels.toggle, 'Show Camp');
|
||||
assert.equal(rows[1].labels.fly, 'Fly to Camp');
|
||||
assert.equal(rows[1].labels.colors, 'Colour of Camp');
|
||||
});
|
||||
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
|
||||
*/
|
||||
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
|
||||
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
@@ -207,17 +208,16 @@ export function ringCentroid(vertices) {
|
||||
export function formatMeasure(session) {
|
||||
if (!session) return '';
|
||||
if (session.shape === 'line') {
|
||||
const m = pathLengthM(session.vertices);
|
||||
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
|
||||
const whole = Math.round(m);
|
||||
if (whole < 1000) return `${whole} m`;
|
||||
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
|
||||
}
|
||||
if (session.shape === 'area') {
|
||||
const m2 = ringAreaM2(session.vertices);
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
|
||||
return `${Math.round(m2)} m²`;
|
||||
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
|
||||
const whole = Math.round(m2);
|
||||
const ha = (m2 / 1e4).toFixed(1);
|
||||
if (whole < 1e4) return `${whole} m²`;
|
||||
if (Number(ha) < 100) return `${ha} ha`;
|
||||
return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -345,3 +345,51 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||
assert.equal(formatMeasure(line), '1.0 km');
|
||||
assert.match(drawHint(line), /^1\.0 km · /);
|
||||
});
|
||||
|
||||
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
const line = createDrawSession('line');
|
||||
addVertex(line, { lon: 0, lat: 0 });
|
||||
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
|
||||
assert.equal(formatMeasure(line), '10 km');
|
||||
});
|
||||
|
||||
test('an area that rounds up to the next unit reads in that unit', () => {
|
||||
// A square on the equator whose shoelace area is about `m2`.
|
||||
const square = (m2) => {
|
||||
const side = Math.sqrt(m2) / 111320;
|
||||
const area = createDrawSession('area');
|
||||
for (const [lon, lat] of [
|
||||
[0, 0],
|
||||
[side, 0],
|
||||
[side, side],
|
||||
[0, side],
|
||||
])
|
||||
addVertex(area, { lon, lat });
|
||||
return area;
|
||||
};
|
||||
const almostHectare = square(9999.6);
|
||||
const metres = ringAreaM2(almostHectare.vertices);
|
||||
assert.ok(
|
||||
metres >= 9999.5 && metres < 1e4,
|
||||
`area should round to 10000 m², got ${metres}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostHectare), '1.0 ha');
|
||||
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
|
||||
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
|
||||
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
|
||||
const almostKm2 = square(999_960);
|
||||
const nearKm2 = ringAreaM2(almostKm2.vertices);
|
||||
assert.ok(
|
||||
nearKm2 >= 999_500 && nearKm2 < 1e6,
|
||||
`area should round to 100.0 ha, got ${nearKm2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostKm2), '1.00 km²');
|
||||
const under = square(999_400);
|
||||
const underM2 = ringAreaM2(under.vertices);
|
||||
assert.ok(
|
||||
underM2 >= 998_500 && underM2 < 999_500,
|
||||
`area should round to 99.9 ha, got ${underM2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(under), '99.9 ha');
|
||||
});
|
||||
+115
-99
@@ -2,9 +2,9 @@
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin), click the vertices on the real world, double-click
|
||||
* or press Enter to finish, then name and colour it in its row. Each finished
|
||||
* shape goes through the
|
||||
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
||||
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
||||
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
|
||||
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
||||
* supplied and `manual: true`, so it renders with the whiteboard look,
|
||||
* persists, de-dups, shows up in `.list()`, and clears with the board.
|
||||
@@ -31,7 +31,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,
|
||||
@@ -51,13 +50,9 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
createDrawMarkList,
|
||||
} from './drawMarkList.js';
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.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 +60,34 @@ const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
const PREVIEW = DRAW_COLOR_CSS;
|
||||
|
||||
/**
|
||||
* 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, hint,
|
||||
* marks }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, 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 colorRow = document.getElementById('draw-colors');
|
||||
const markListEl = document.getElementById('draw-mark-list');
|
||||
const clearButton = document.getElementById('draw-clear');
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (!viewer || !annotations || !toggle) return null;
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation = null,
|
||||
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 hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
@@ -95,25 +98,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 +115,25 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
// 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 +168,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 +194,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
setHint(drawHint(session));
|
||||
setHint(() => drawHint(session));
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
|
||||
@@ -210,14 +217,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,7 +242,7 @@ 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;
|
||||
}
|
||||
// Named afterwards, in the shape's row.
|
||||
@@ -253,11 +260,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,7 +287,7 @@ 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 ----
|
||||
@@ -312,7 +320,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (!active || destroyed || typingElsewhere(event)) return;
|
||||
// Keys on the shape list's own controls are the list's (Esc closes a
|
||||
// colour picker there), not the drawing's.
|
||||
if (markListEl?.contains?.(event.target)) return;
|
||||
if (event.target?.closest?.('#draw-mark-list')) return;
|
||||
// Enter goes to finish() once a shape has been started; finish() owns the
|
||||
// decision and says WHY it refused. Swallowing the key on a degenerate
|
||||
// shape left the person pressing Enter at a silent panel. A focused button
|
||||
@@ -348,7 +356,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())),
|
||||
}),
|
||||
@@ -357,10 +365,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);
|
||||
@@ -422,8 +427,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;
|
||||
@@ -432,40 +436,56 @@ 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
const colorControl = createSegmentedControl({
|
||||
buttons: [...(colorRow?.querySelectorAll('.draw-color[data-color]') || [])],
|
||||
valueOf: (button) => button.dataset.color,
|
||||
listen,
|
||||
onChange: (value) => {
|
||||
/**
|
||||
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
||||
* and the current orbit, follow or track lets go first.
|
||||
*/
|
||||
function flyToMark(id) {
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('drawing', () =>
|
||||
annotations.frame?.(id),
|
||||
) !== false
|
||||
);
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/** 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 = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
colorControl.update(color);
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
||||
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
||||
toggleDrawMark: (id) => {
|
||||
const mark = annotations.list?.().find((entry) => entry.id === id);
|
||||
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
});
|
||||
listen(clearButton, 'click', clearAll);
|
||||
setHint(drawHint(null));
|
||||
const markList = markListEl
|
||||
? createDrawMarkList({ listEl: markListEl, annotations, listen })
|
||||
: null;
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
const offBoard =
|
||||
annotations.onChange?.(() => {
|
||||
if (!destroyed) present();
|
||||
}) || (() => {});
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
get active() {
|
||||
@@ -478,12 +498,15 @@ 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,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -494,11 +517,6 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
finish,
|
||||
cancel,
|
||||
clearAll,
|
||||
/** The hand-drawn marks the list shows. */
|
||||
marks: () => markList?.marks() || [],
|
||||
setColor(next) {
|
||||
colorRow?.querySelector(`.draw-color[data-color="${next}"]`)?.click();
|
||||
},
|
||||
shapes: DRAW_SHAPES,
|
||||
/** What this tool currently holds — for teardown and leak assertions. */
|
||||
diagnostics() {
|
||||
@@ -537,8 +555,9 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
markList?.destroy();
|
||||
offLocale();
|
||||
offBoard();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
@@ -558,11 +577,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 navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,49 +62,67 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// UTILITIES 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/layer-panels.html');
|
||||
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
|
||||
// drawn shapes' rows), rendered into the Utilities panel.
|
||||
const html = [
|
||||
read('src/ui/views/panels/DrawControls.vue'),
|
||||
read('src/ui/views/panels/DrawMarkList.vue'),
|
||||
].join('\n');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
'draw-label-row',
|
||||
'draw-colors',
|
||||
'draw-mark-list',
|
||||
'draw-label-row',
|
||||
'draw-clear',
|
||||
'draw-hint',
|
||||
'draw-mark-list',
|
||||
];
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"`),
|
||||
`#${id} missing from index.html`,
|
||||
`#${id} missing from the Draw views`,
|
||||
);
|
||||
}
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
for (const [shape, name] of [
|
||||
['area', 'Area'],
|
||||
['line', 'Line'],
|
||||
['pin', 'Pin'],
|
||||
for (const [shape, icon] of [
|
||||
['area', 'pentagon'],
|
||||
['line', 'timeline'],
|
||||
['pin', 'location_on'],
|
||||
]) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(
|
||||
`data-shape="${shape}"[^>]*aria-label="${name}"[^>]*title="${name}"`,
|
||||
),
|
||||
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
for (const color of ['primary', 'amber', 'cyan', 'green', 'red'])
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`class="draw-color[^"]*" type="button" data-color="${color}"`),
|
||||
);
|
||||
for (const id of ids) {
|
||||
assert.ok(
|
||||
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
|
||||
'the Utilities panel renders Draw',
|
||||
);
|
||||
assert.ok(
|
||||
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
|
||||
'Draw is no longer in DISPLAY',
|
||||
);
|
||||
// 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('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
"act('renameDrawMark', row.id, next)",
|
||||
"act('recolorDrawMark', row.id, color)",
|
||||
"act('toggleDrawMark', row.id)",
|
||||
"act('flyToDrawMark', row.id)",
|
||||
"act('removeDrawMark', row.id)",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `the Draw views 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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -114,10 +132,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|draw-color/,
|
||||
|
||||
@@ -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,17 +96,10 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, and a focused button
|
||||
// whose Enter is its own.
|
||||
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-colors');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-hint', 'SPAN');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -194,9 +175,15 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
function fakeAnnotations() {
|
||||
const calls = { annotate: [], clear: 0 };
|
||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
||||
const marks = [];
|
||||
const listeners = new Set();
|
||||
const changed = () => listeners.forEach((listener) => listener());
|
||||
const find = (id) => marks.find((mark) => mark.id === id);
|
||||
return {
|
||||
calls,
|
||||
marks,
|
||||
changed,
|
||||
async annotate(specs, options) {
|
||||
calls.annotate.push({ specs, options });
|
||||
return { drawn: specs.length };
|
||||
@@ -204,11 +191,38 @@ function fakeAnnotations() {
|
||||
clear() {
|
||||
calls.clear += 1;
|
||||
},
|
||||
list: () => marks,
|
||||
onChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.restyle.push([id, change]);
|
||||
Object.assign(find(id), change);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.setHidden.push([id, hidden]);
|
||||
find(id).hidden = hidden;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
frame(id) {
|
||||
calls.frame.push(id);
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
calls.remove.push(id);
|
||||
marks.splice(marks.indexOf(find(id)), 1);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness() {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -217,14 +231,30 @@ 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,
|
||||
navigation,
|
||||
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;
|
||||
@@ -240,38 +270,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 {
|
||||
@@ -283,18 +290,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();
|
||||
@@ -304,13 +311,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(
|
||||
@@ -335,7 +342,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,
|
||||
@@ -346,7 +353,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,
|
||||
@@ -364,7 +371,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);
|
||||
@@ -394,7 +401,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);
|
||||
@@ -448,7 +455,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);
|
||||
@@ -462,7 +469,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,
|
||||
@@ -480,15 +487,145 @@ 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 and colour the next shape is drawn with; it is named in its row', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
||||
'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.ok(!spec.label, 'a shape is named afterwards, in its row');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
|
||||
const navigations = [];
|
||||
const h = harness({
|
||||
navigation: {
|
||||
runImmediateNavigation: (noun, navigate) => {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||
);
|
||||
h.act('renameDrawMark', 'a1', 'Ridge walk');
|
||||
h.act('recolorDrawMark', 'a1', 'green');
|
||||
h.act('toggleDrawMark', 'a1');
|
||||
h.act('flyToDrawMark', 'a1');
|
||||
h.act('removeDrawMark', 'a1');
|
||||
assert.deepEqual(board.calls.restyle, [
|
||||
['a1', { name: 'Ridge walk' }],
|
||||
['a1', { color: 'green' }],
|
||||
]);
|
||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||
assert.deepEqual(board.calls.frame, ['a1']);
|
||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
||||
assert.deepEqual(board.calls.remove, ['a1']);
|
||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const listButton = new FakeElement('BUTTON');
|
||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
||||
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: 'Escape', target: listButton });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||
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: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
@@ -16,11 +16,15 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
* label-less) so every caption shares one consistent style.
|
||||
*
|
||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
||||
* one route change that spans both sub-renderers.
|
||||
* one route change that spans both sub-renderers. `screen` passes options to the
|
||||
* screen renderer (its store, in tests).
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(viewer) {
|
||||
export function createHybridAnnotationRenderer(
|
||||
viewer,
|
||||
{ screen: screenOptions } = {},
|
||||
) {
|
||||
const world = createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
|
||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||
const routed = new Map();
|
||||
|
||||
|
||||
@@ -1,143 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||
function hybridWithBoard(viewer) {
|
||||
const store = useScreenAnnotationsStore(createPinia());
|
||||
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
|
||||
return { store, renderer };
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
/** The screen marks the board shows, fading ones included. */
|
||||
function annotationGroups(store) {
|
||||
return store.board?.marks ?? [];
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
const childrenWithClass = (mark, name) =>
|
||||
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
|
||||
|
||||
function cameraLookingAt(lon, lat) {
|
||||
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
@@ -184,7 +65,6 @@ function cameraLookingAt(lon, lat) {
|
||||
|
||||
/** Install the browser globals both renderers touch, restored after the test. */
|
||||
function installBrowserGlobals(t) {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalBrowserConstructors = new Map([
|
||||
@@ -193,13 +73,10 @@ function installBrowserGlobals(t) {
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
@@ -244,9 +121,6 @@ function fakeViewer(lon, lat) {
|
||||
};
|
||||
}
|
||||
|
||||
function annotationGroups(svg) {
|
||||
return svg.children.filter((child) => child.classList.contains('gev-anno'));
|
||||
}
|
||||
|
||||
function naiveRingCentroid(ring) {
|
||||
const totals = ring.reduce(
|
||||
@@ -257,7 +131,6 @@ function naiveRingCentroid(ring) {
|
||||
}
|
||||
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalBrowserConstructors = new Map([
|
||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||
@@ -265,12 +138,9 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
@@ -305,7 +175,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = {
|
||||
id: 'anno-hybrid-fb3',
|
||||
type: 'area',
|
||||
@@ -319,10 +189,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
const originalCallout = before.group.querySelector('.gev-anno-callout');
|
||||
const originalDot = before.group.querySelector('.gev-anno-dot');
|
||||
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
|
||||
const [before] = annotationGroups(store);
|
||||
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
|
||||
assert.equal(dataSources.length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
@@ -339,18 +207,22 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
anno.footprintKind = 'area';
|
||||
renderer.update(anno);
|
||||
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
|
||||
);
|
||||
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
|
||||
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
|
||||
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
|
||||
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
|
||||
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
|
||||
assert.equal(others.length, 0);
|
||||
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.deepEqual(
|
||||
after.children.map((child) => child.key),
|
||||
['dot', 'leader', 'callout'],
|
||||
'the dot, leader and callout are retained',
|
||||
);
|
||||
const dot = after.children.find((child) => child.key === 'dot');
|
||||
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
|
||||
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
|
||||
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
|
||||
@@ -370,7 +242,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-add',
|
||||
@@ -386,20 +258,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
|
||||
// The world drape lands first; the screen caption then fails the way a lost
|
||||
// context does, AFTER the world geometry is already on the board.
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
const appendChild = svg.appendChild.bind(svg);
|
||||
const publish = store.publish;
|
||||
let failNextInsert = true;
|
||||
svg.appendChild = (child) => {
|
||||
store.publish = (board) => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('screen insert failed');
|
||||
}
|
||||
return appendChild(child);
|
||||
return publish(board);
|
||||
};
|
||||
|
||||
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
|
||||
|
||||
// The engine's rollback path — it must reach the partial state by id.
|
||||
renderer.remove(anno);
|
||||
@@ -409,7 +280,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
// an orphan nothing owns.
|
||||
renderer.add(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
|
||||
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
|
||||
renderer.remove(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
@@ -418,7 +289,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-release-throw',
|
||||
@@ -433,35 +304,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(dataSources[0].entities.values.length, 2);
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
|
||||
// The world route releases fine, then the screen release fails part-way
|
||||
// (its fade-out timer throws) — so its SVG group is still in the document.
|
||||
// (its fade-out timer throws) — so its mark is still on the board.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
|
||||
// Dropping the routing entry here would make that orphan permanently
|
||||
// unreachable. It must still be addressable, so a retry finishes the job.
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
|
||||
|
||||
// Fully released now — a third call is inert.
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-world-add',
|
||||
@@ -493,9 +363,8 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
|
||||
assert.equal(entities.values.length, 0,
|
||||
'the rollback must see the entities that landed before the throw');
|
||||
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
renderer.add(anno);
|
||||
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
@@ -27,11 +30,17 @@ function outlineUnavailableLabel(label) {
|
||||
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
|
||||
* anything that projects off-screen / behind the camera is hidden.
|
||||
*
|
||||
* The board is presented to the screen annotations store, whose view renders
|
||||
* the SVG overlay; each mark's parts are plain attribute records here, and the
|
||||
* view hands back the elements this measures (callout text and mark groups).
|
||||
*
|
||||
* Screen-renderer contract (update is used by the hybrid outline-upgrade path):
|
||||
* add(anno) / update(anno) / remove(anno) / sync(map) / destroy()
|
||||
*/
|
||||
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
// Keys each mark, so a mark re-added while its predecessor fades out never
|
||||
// reuses the fading one's elements.
|
||||
let marks = 0;
|
||||
const PALETTE = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
@@ -62,18 +71,26 @@ export function createScreenAnnotationRenderer(
|
||||
{
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
store = useScreenAnnotationsStore(applicationPinia()),
|
||||
} = {},
|
||||
) {
|
||||
injectStyles();
|
||||
const { layer, svg, defs } = buildOverlay();
|
||||
document.body.appendChild(layer);
|
||||
// The arrowhead markers the board's defs hold, in the order first used.
|
||||
const arrowMarkers = new Map();
|
||||
let viewBox = null;
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||
const records = new Map(); // anno.id -> { key, anno, group, parts }
|
||||
const leaving = new Map(); // key -> a removed mark's record while it fades out
|
||||
const byKey = new Map(); // key -> record, live or fading
|
||||
const timers = new Set();
|
||||
const owner = {};
|
||||
let published = '';
|
||||
// Only the renderer whose board the store shows may change or remove it.
|
||||
const shown = () => store.board?.owner === owner;
|
||||
const scratch = new Cesium.Cartesian2();
|
||||
const scratchDir = new Cesium.Cartesian3();
|
||||
// Per-point ground height cache. Marks must sit on the real surface, not at
|
||||
@@ -151,13 +168,13 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
function add(anno) {
|
||||
const c = color(anno);
|
||||
const group = svgEl('g', { class: 'gev-anno', opacity: '0' });
|
||||
const group = { opacity: '0', display: '', entered: false };
|
||||
const parts = {};
|
||||
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
|
||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||
parts.poly = svgEl('polygon', {
|
||||
parts.poly = svgPart('polygon', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c,
|
||||
stroke: c,
|
||||
@@ -166,16 +183,14 @@ export function createScreenAnnotationRenderer(
|
||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
parts.path = svgPart('path', {
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
@@ -183,22 +198,20 @@ export function createScreenAnnotationRenderer(
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||
parts.poly = svgEl('polyline', {
|
||||
parts.poly = svgPart('polyline', {
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
@@ -206,37 +219,33 @@ export function createScreenAnnotationRenderer(
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.poly);
|
||||
parts.dots = anno.path.map(() => {
|
||||
const dot = svgEl('circle', {
|
||||
ensureArrowMarker(arrowMarkers, anno.color || 'primary', c);
|
||||
parts.dots = anno.path.map(() =>
|
||||
svgPart('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: '4',
|
||||
});
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
}),
|
||||
);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
if (anno.type !== 'label') {
|
||||
parts.ringOuter = svgEl('circle', {
|
||||
parts.ringOuter = svgPart('circle', {
|
||||
class: 'gev-anno-ring gev-pulse',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '2',
|
||||
r: '34',
|
||||
});
|
||||
parts.ringInner = svgEl('circle', {
|
||||
parts.ringInner = svgPart('circle', {
|
||||
class: 'gev-anno-ring',
|
||||
fill: c,
|
||||
'fill-opacity': '0.12',
|
||||
@@ -245,70 +254,57 @@ export function createScreenAnnotationRenderer(
|
||||
r: '18',
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.ringOuter);
|
||||
group.appendChild(parts.ringInner);
|
||||
}
|
||||
parts.dot = svgEl('circle', {
|
||||
parts.dot = svgPart('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: anno.type === 'label' ? '4' : '5',
|
||||
});
|
||||
group.appendChild(parts.dot);
|
||||
parts.leader = svgEl('line', {
|
||||
parts.leader = svgPart('line', {
|
||||
class: 'gev-anno-leader',
|
||||
stroke: c,
|
||||
'stroke-width': '1.5',
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
// Record the mark BEFORE it touches the live document, then unwind on any
|
||||
// throw from here down (projectAll reaches into Cesium and can fail on a
|
||||
// lost context). Previously a throw past the DOM insert left an ORPHANED
|
||||
// <g>: the engine's rollback only deletes its own map entry, so the next
|
||||
// annotate of the same geometry stacked a fresh mark on top of the corpse.
|
||||
records.set(anno.id, { anno, group, parts });
|
||||
// Record the mark BEFORE it reaches the board, then unwind on any throw
|
||||
// from here down (projectAll reaches into Cesium and can fail on a lost
|
||||
// context). Previously a throw past the insert left an ORPHANED mark: the
|
||||
// engine's rollback only deletes its own map entry, so the next annotate
|
||||
// of the same geometry stacked a fresh mark on top of the corpse.
|
||||
const rec = { key: ++marks, anno, group, parts, drawn: false };
|
||||
records.set(anno.id, rec);
|
||||
byKey.set(rec.key, rec);
|
||||
try {
|
||||
svg.appendChild(group);
|
||||
// trigger draw-on / fade-in on the next frame
|
||||
requestAnimationFrame(() => group.classList.add('gev-in'));
|
||||
// The draw-on effect uses a fixed stroke-dasharray of 1400 (see CSS). Any
|
||||
// stroke longer than 1400px (a long arrow/route on a wide window) would keep
|
||||
// a permanent dash gap — the arrowhead detaches from the truncated line (H6).
|
||||
// Once the dashoffset transition finishes the stroke is meant to be fully
|
||||
// drawn, so drop the dasharray entirely then and the stroke reads solid at
|
||||
// any length. Guarded on the specific property so an unrelated transition
|
||||
// (none here, but defensive) doesn't clear it early.
|
||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||
drawEl.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
publish({ force: true });
|
||||
// Trigger draw-on / fade-in on the next frame, once the view has the
|
||||
// mark in the document. The draw-on effect uses a fixed
|
||||
// stroke-dasharray of 1400 (see CSS); the view reports the end of the
|
||||
// dashoffset transition, after which the stroke drops its dasharray so
|
||||
// a stroke longer than 1400px reads solid (H6).
|
||||
requestAnimationFrame(() => {
|
||||
group.entered = true;
|
||||
publish();
|
||||
});
|
||||
projectAll();
|
||||
} catch (error) {
|
||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||
// fade out, and the caller (engine rollback) must find a clean board.
|
||||
records.delete(anno.id);
|
||||
byKey.delete(rec.key);
|
||||
try {
|
||||
group.remove();
|
||||
publish({ force: true });
|
||||
} catch {
|
||||
/* never inserted */
|
||||
/* the board is unreachable */
|
||||
}
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
@@ -328,12 +324,18 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
rec.parts.label.text = outlineUnavailableLabel(anno.label);
|
||||
// Re-measure once the view shows the new text.
|
||||
const label = rec.parts.label;
|
||||
publish();
|
||||
void nextTick(() => {
|
||||
if (rec.parts.label !== label || !sizeCallout(label)) return;
|
||||
projectAll();
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
rec.parts = {};
|
||||
rec.hidden = true;
|
||||
projectAll();
|
||||
@@ -346,11 +348,9 @@ export function createScreenAnnotationRenderer(
|
||||
// label, whose dot/leader/callout already exist; remove the two reticle rings and
|
||||
// keep every remaining node (especially the group and callout) alive.
|
||||
if (anno.type === 'label' && rec.parts.dot) {
|
||||
rec.parts.ringOuter?.remove();
|
||||
rec.parts.ringInner?.remove();
|
||||
delete rec.parts.ringOuter;
|
||||
delete rec.parts.ringInner;
|
||||
rec.parts.dot.setAttribute('r', String(LABEL_DOT_R));
|
||||
rec.parts.dot.set.r = String(LABEL_DOT_R);
|
||||
projectAll();
|
||||
return;
|
||||
}
|
||||
@@ -466,13 +466,16 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
|
||||
function projectAll() {
|
||||
if (!records.size) return;
|
||||
if (!records.size) {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
viewBox = `0 0 ${w} ${h}`;
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(
|
||||
viewer.camera.positionCartographic?.height ?? 1000,
|
||||
@@ -481,10 +484,16 @@ export function createScreenAnnotationRenderer(
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
if (rec.hidden) {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
continue;
|
||||
}
|
||||
group.setAttribute('opacity', String(anno.alpha ?? 1));
|
||||
// The last frame's tracked fade holds until this frame's is measured.
|
||||
group.opacity = String(
|
||||
(anno.alpha ?? 1) *
|
||||
(rec._trackedFade !== undefined && rec._trackedFade !== 1
|
||||
? rec._trackedFade
|
||||
: 1),
|
||||
);
|
||||
|
||||
if (anno.type === 'area' && parts.poly) {
|
||||
const pts = [];
|
||||
@@ -500,12 +509,12 @@ export function createScreenAnnotationRenderer(
|
||||
n++;
|
||||
}
|
||||
if (pts.length >= 3) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
group.style.display = '';
|
||||
parts.poly.set.points = pts.join(' ');
|
||||
group.display = '';
|
||||
if (parts.label)
|
||||
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'arrow' && parts.path) {
|
||||
const a = project(anno.anchor.lon, anno.anchor.lat);
|
||||
@@ -520,11 +529,8 @@ export function createScreenAnnotationRenderer(
|
||||
const bow = Math.min(60, len * 0.18);
|
||||
const ctrlX = mx - (dy / len) * bow;
|
||||
const ctrlY = my + (dx / len) * bow;
|
||||
parts.path.setAttribute(
|
||||
'd',
|
||||
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
|
||||
);
|
||||
group.style.display = '';
|
||||
parts.path.set.d = `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;
|
||||
group.display = '';
|
||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||
if (parts.label)
|
||||
positionCallout(
|
||||
@@ -534,7 +540,7 @@ export function createScreenAnnotationRenderer(
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'route' && parts.poly) {
|
||||
const projected = anno.path.map((pt) => project(pt.lon, pt.lat));
|
||||
@@ -543,19 +549,19 @@ export function createScreenAnnotationRenderer(
|
||||
if (p) pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
}
|
||||
if (pts.length >= 2) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
parts.poly.set.points = pts.join(' ');
|
||||
// dots at each resolvable waypoint; hide the rest
|
||||
parts.dots.forEach((dot, i) => {
|
||||
const p = projected[i];
|
||||
if (p) {
|
||||
dot.setAttribute('cx', p.x.toFixed(1));
|
||||
dot.setAttribute('cy', p.y.toFixed(1));
|
||||
dot.style.display = '';
|
||||
dot.set.cx = p.x.toFixed(1);
|
||||
dot.set.cy = p.y.toFixed(1);
|
||||
dot.display = '';
|
||||
} else {
|
||||
dot.style.display = 'none';
|
||||
dot.display = 'none';
|
||||
}
|
||||
});
|
||||
group.style.display = '';
|
||||
group.display = '';
|
||||
// label at the middle waypoint
|
||||
const mid =
|
||||
projected.filter(Boolean)[
|
||||
@@ -564,33 +570,22 @@ export function createScreenAnnotationRenderer(
|
||||
if (parts.label && mid)
|
||||
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
} else {
|
||||
const p = project(anno.anchor.lon, anno.anchor.lat);
|
||||
if (p) {
|
||||
group.style.display = '';
|
||||
group.display = '';
|
||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||
if (parts.dot) {
|
||||
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
||||
}
|
||||
if (parts.ringOuter) {
|
||||
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringOuter.setAttribute(
|
||||
'r',
|
||||
(RING_OUTER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.ringInner) {
|
||||
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringInner.setAttribute(
|
||||
'r',
|
||||
(RING_INNER_R * mScale).toFixed(1),
|
||||
);
|
||||
for (const [part, radius] of [
|
||||
[parts.dot, baseDotR],
|
||||
[parts.ringOuter, RING_OUTER_R],
|
||||
[parts.ringInner, RING_INNER_R],
|
||||
]) {
|
||||
if (!part) continue;
|
||||
part.set.cx = p.x.toFixed(1);
|
||||
part.set.cy = p.y.toFixed(1);
|
||||
part.set.r = (radius * mScale).toFixed(1);
|
||||
}
|
||||
if (parts.label) {
|
||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||
@@ -598,18 +593,15 @@ export function createScreenAnnotationRenderer(
|
||||
const ly = p.y - 34 * mScale;
|
||||
positionCallout(parts.label, lx, ly, false);
|
||||
if (parts.leader) {
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
||||
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1));
|
||||
parts.leader.setAttribute(
|
||||
'y2',
|
||||
(ly + parts.label.height).toFixed(1),
|
||||
);
|
||||
parts.leader.set.x1 = p.x.toFixed(1);
|
||||
parts.leader.set.y1 = p.y.toFixed(1);
|
||||
parts.leader.set.x2 = lx.toFixed(1);
|
||||
parts.leader.set.y2 = (ly + parts.label.height).toFixed(1);
|
||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
group.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,7 +613,7 @@ export function createScreenAnnotationRenderer(
|
||||
if (
|
||||
cal &&
|
||||
cal.sized &&
|
||||
rec.group.style.display !== 'none' &&
|
||||
rec.group.display !== 'none' &&
|
||||
Number.isFinite(cal._x)
|
||||
) {
|
||||
placed.push(cal);
|
||||
@@ -629,23 +621,44 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
if (placed.length > 1) decollideCallouts(placed);
|
||||
|
||||
// Tracked-entity z-order — runs AFTER all geometry + callout de-collision so each
|
||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox INTERSECTS
|
||||
// the tracked entity's screen footprint is faded toward FULLY HIDDEN (target 0, not a
|
||||
// partial dim) so the tracked plane/ship reads as genuinely ON TOP. The fade is EASED
|
||||
// per frame (no abrupt pop as the entity crosses a mark) and reverses smoothly when
|
||||
// it moves off. Every screen mark here is point-like (pins/labels/reticles/arrows) —
|
||||
// the big area/route footprints live in the world layer — so fully hiding an
|
||||
// overlapped one is safe and not jarring.
|
||||
publish();
|
||||
// Tracked-entity z-order — measured once the view shows this frame's
|
||||
// geometry (still before the browser paints it).
|
||||
const fading = [...records.values()].some(
|
||||
(rec) => rec._trackedFade !== undefined && rec._trackedFade !== 1,
|
||||
);
|
||||
if ((trackedRect || fading) && !trackedPending) {
|
||||
trackedPending = true;
|
||||
void nextTick(() => {
|
||||
trackedPending = false;
|
||||
fadeTracked(trackedRect);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let trackedPending = false;
|
||||
|
||||
// Runs AFTER all geometry + callout de-collision are in the document so each
|
||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox
|
||||
// INTERSECTS the tracked entity's screen footprint is faded toward FULLY
|
||||
// HIDDEN (target 0, not a partial dim) so the tracked plane/ship reads as
|
||||
// genuinely ON TOP. The fade is EASED per frame (no abrupt pop as the entity
|
||||
// crosses a mark) and reverses smoothly when it moves off. Every screen mark
|
||||
// here is point-like (pins/labels/reticles/arrows) — the big area/route
|
||||
// footprints live in the world layer — so fully hiding an overlapped one is
|
||||
// safe and not jarring.
|
||||
function fadeTracked(trackedRect) {
|
||||
if (!shown()) return;
|
||||
let changed = false;
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group } = rec;
|
||||
if (group.style.display === 'none') continue;
|
||||
if (group.display === 'none') continue;
|
||||
const cur = rec._trackedFade ?? 1;
|
||||
let overlapping = false;
|
||||
if (trackedRect) {
|
||||
let bb = null;
|
||||
try {
|
||||
bb = group.getBBox();
|
||||
bb = rec.element?.getBBox() ?? null;
|
||||
} catch {
|
||||
bb = null;
|
||||
}
|
||||
@@ -666,32 +679,109 @@ export function createScreenAnnotationRenderer(
|
||||
const target = overlapping ? 0 : 1;
|
||||
const next = cur + (target - cur) * TRACKED_FADE_EASE;
|
||||
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
|
||||
// Only override when faded — otherwise leave the base alpha (set above) intact.
|
||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||
if (rec._trackedFade !== 1) {
|
||||
group.setAttribute(
|
||||
'opacity',
|
||||
String((anno.alpha ?? 1) * rec._trackedFade),
|
||||
);
|
||||
// The base alpha applies unless faded. (The CSS `transition: opacity` was
|
||||
// removed from .gev-anno so this per-frame JS ease is the ONLY easing —
|
||||
// previously the two fought and produced an opacity oscillation.)
|
||||
const opacity = String(
|
||||
(anno.alpha ?? 1) * (rec._trackedFade !== 1 ? rec._trackedFade : 1),
|
||||
);
|
||||
if (opacity !== group.opacity) {
|
||||
group.opacity = opacity;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) publish();
|
||||
}
|
||||
|
||||
/** The board as its view renders it: one entry per live or fading mark. */
|
||||
function boardView() {
|
||||
const view = (rec, fading) => ({
|
||||
key: rec.key,
|
||||
className: fading
|
||||
? 'gev-anno gev-out'
|
||||
: rec.group.entered
|
||||
? 'gev-anno gev-in'
|
||||
: 'gev-anno',
|
||||
opacity: rec.group.opacity,
|
||||
display: rec.group.display,
|
||||
drawn: rec.drawn,
|
||||
children: partViews(rec.parts),
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
viewBox,
|
||||
markers: [...arrowMarkers].map(([key, fill]) => ({ key, fill })),
|
||||
// In the order the marks were added, fading ones where they were.
|
||||
marks: [
|
||||
...[...records.values()].map((rec) => view(rec, false)),
|
||||
...[...leaving.values()].map((rec) => rec.view),
|
||||
].sort((a, b) => a.key - b.key),
|
||||
};
|
||||
}
|
||||
|
||||
/** Present the board; an unchanged one is not sent again unless forced. */
|
||||
function publish({ force = false } = {}) {
|
||||
if (!shown()) return;
|
||||
const board = boardView();
|
||||
const signature = JSON.stringify(board, (name, value) =>
|
||||
name === 'owner' ? undefined : value,
|
||||
);
|
||||
if (!force && signature === published) return;
|
||||
published = signature;
|
||||
store.publish(board);
|
||||
}
|
||||
|
||||
/** Take (or, with null, drop) an element the view rendered for a mark. */
|
||||
function attach(key, kind, element) {
|
||||
const rec = byKey.get(key);
|
||||
if (!rec) return;
|
||||
if (kind === 'group') {
|
||||
rec.element = element;
|
||||
// Lay the new mark out before its next-frame `gev-in`, so the draw-on
|
||||
// transition starts from the undrawn stroke.
|
||||
try {
|
||||
element?.getBBox();
|
||||
} catch {
|
||||
/* not laid out */
|
||||
}
|
||||
} else if (kind === 'text' && rec.parts.label) {
|
||||
rec.parts.label.element = element;
|
||||
// Size the card to its text once the text is in the document.
|
||||
if (element && sizeCallout(rec.parts.label) && records.has(rec.anno.id))
|
||||
projectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** The draw-on stroke finished: it reads solid from now on. */
|
||||
function drawn(key) {
|
||||
const rec = byKey.get(key);
|
||||
if (!rec || rec.drawn) return;
|
||||
rec.drawn = true;
|
||||
publish();
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
const rec = records.get(anno.id);
|
||||
if (!rec) return;
|
||||
rec.group.classList.remove('gev-in');
|
||||
rec.group.classList.add('gev-out');
|
||||
const node = rec.group;
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
node.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}, 360);
|
||||
// The fading mark keeps the look it had; it leaves the board after 360 ms.
|
||||
rec.view = { ...boardView().marks.find((mark) => mark.key === rec.key) };
|
||||
rec.view.className = 'gev-anno gev-out';
|
||||
leaving.set(rec.key, rec);
|
||||
let timer;
|
||||
try {
|
||||
timer = window.setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
leaving.delete(rec.key);
|
||||
byKey.delete(rec.key);
|
||||
publish();
|
||||
}, 360);
|
||||
} catch (error) {
|
||||
leaving.delete(rec.key);
|
||||
throw error;
|
||||
}
|
||||
if (leaving.has(rec.key)) timers.add(timer);
|
||||
records.delete(anno.id);
|
||||
publish();
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) heightCache.clear();
|
||||
}
|
||||
@@ -707,87 +797,104 @@ export function createScreenAnnotationRenderer(
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
timers.clear();
|
||||
if (shown()) {
|
||||
store.connect(null);
|
||||
store.publish(null);
|
||||
}
|
||||
records.clear();
|
||||
leaving.clear();
|
||||
byKey.clear();
|
||||
heightCache.clear();
|
||||
}
|
||||
|
||||
// Claim the board; from here on only this renderer's own board changes.
|
||||
store.publish(boardView());
|
||||
store.connect({
|
||||
attach: (from, key, kind, element) => {
|
||||
if (from === owner && shown()) attach(key, kind, element);
|
||||
},
|
||||
drawn: (from, key) => {
|
||||
if (from === owner && shown()) drawn(key);
|
||||
},
|
||||
});
|
||||
|
||||
return { add, update, remove, sync, destroy };
|
||||
}
|
||||
|
||||
// --- SVG helpers ------------------------------------------------------------
|
||||
|
||||
function buildOverlay() {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'gev-screen-whiteboard';
|
||||
const svg = document.createElementNS(SVGNS, 'svg');
|
||||
svg.setAttribute('class', 'gev-screen-whiteboard-svg');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
const defs = document.createElementNS(SVGNS, 'defs');
|
||||
// subtle hand-drawn wobble for strokes
|
||||
defs.innerHTML = `
|
||||
<filter id="gev-sketch" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.014" numOctaves="2" seed="7" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>`;
|
||||
svg.appendChild(defs);
|
||||
layer.appendChild(svg);
|
||||
return { layer, svg, defs };
|
||||
/**
|
||||
* One SVG element of a mark as plain data: the attributes it starts with, the
|
||||
* ones each frame sets, and whether it is shown.
|
||||
*/
|
||||
function svgPart(tag, attrs) {
|
||||
return { tag, attrs, set: {}, display: '' };
|
||||
}
|
||||
|
||||
/** A mark's parts in document order, as the view renders them. */
|
||||
function partViews(parts) {
|
||||
const views = [];
|
||||
const element = (key, part) =>
|
||||
views.push({
|
||||
key,
|
||||
tag: part.tag,
|
||||
attrs: { ...part.attrs, ...part.set },
|
||||
display: part.display,
|
||||
});
|
||||
if (parts.poly) element('poly', parts.poly);
|
||||
if (parts.path) element('path', parts.path);
|
||||
parts.dots?.forEach((dot, index) => element(`dot-${index}`, dot));
|
||||
if (parts.ringOuter) element('ring-outer', parts.ringOuter);
|
||||
if (parts.ringInner) element('ring-inner', parts.ringInner);
|
||||
if (parts.dot) element('dot', parts.dot);
|
||||
if (parts.leader) element('leader', parts.leader);
|
||||
const label = parts.label;
|
||||
if (label)
|
||||
views.push({
|
||||
key: 'callout',
|
||||
tag: 'callout',
|
||||
text: label.text,
|
||||
fill: label.fill,
|
||||
transform: label.transform,
|
||||
// The card is sized to its text once the text has been measured.
|
||||
card: label.sized
|
||||
? { width: String(label.width), height: String(label.height) }
|
||||
: {},
|
||||
accent: label.sized
|
||||
? { height: String(label.height - 8), x: '0', y: '4' }
|
||||
: {},
|
||||
});
|
||||
return views;
|
||||
}
|
||||
|
||||
function makeCallout(text, c) {
|
||||
if (!text) return null;
|
||||
const node = document.createElementNS(SVGNS, 'g');
|
||||
node.setAttribute('class', 'gev-anno-callout');
|
||||
const rect = document.createElementNS(SVGNS, 'rect');
|
||||
rect.setAttribute('rx', '5');
|
||||
rect.setAttribute('class', 'gev-anno-card');
|
||||
const t = document.createElementNS(SVGNS, 'text');
|
||||
t.setAttribute('class', 'gev-anno-text');
|
||||
t.setAttribute('x', '9');
|
||||
t.setAttribute('y', '16');
|
||||
t.textContent = text;
|
||||
const accent = document.createElementNS(SVGNS, 'rect');
|
||||
accent.setAttribute('class', 'gev-anno-accent');
|
||||
accent.setAttribute('width', '3');
|
||||
accent.setAttribute('rx', '1.5');
|
||||
accent.setAttribute('fill', c);
|
||||
node.appendChild(rect);
|
||||
node.appendChild(accent);
|
||||
node.appendChild(t);
|
||||
const callout = {
|
||||
node,
|
||||
rect,
|
||||
text: t,
|
||||
accent,
|
||||
return {
|
||||
text,
|
||||
fill: c,
|
||||
element: null,
|
||||
transform: null,
|
||||
width: 0,
|
||||
height: 26,
|
||||
sized: false,
|
||||
};
|
||||
// size the card to the text once it is in the DOM
|
||||
requestAnimationFrame(() => sizeCallout(callout));
|
||||
return callout;
|
||||
}
|
||||
|
||||
/** Size a callout to its rendered text; false until the text is laid out. */
|
||||
function sizeCallout(callout) {
|
||||
try {
|
||||
const bbox = callout.text.getBBox();
|
||||
const bbox = callout.element.getBBox();
|
||||
const w = Math.max(24, bbox.width + 18);
|
||||
const h = Math.max(22, bbox.height + 10);
|
||||
callout.rect.setAttribute('width', String(w));
|
||||
callout.rect.setAttribute('height', String(h));
|
||||
callout.accent.setAttribute('height', String(h - 8));
|
||||
callout.accent.setAttribute('x', '0');
|
||||
callout.accent.setAttribute('y', '4');
|
||||
callout.width = w;
|
||||
callout.height = h;
|
||||
callout.sized = true;
|
||||
return true;
|
||||
} catch {
|
||||
/* not laid out yet */
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,10 +903,7 @@ function positionCallout(callout, x, y, center) {
|
||||
const tx = center ? x - callout.width / 2 : x;
|
||||
callout._x = tx;
|
||||
callout._y = y;
|
||||
callout.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
|
||||
);
|
||||
callout.transform = `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -817,71 +921,17 @@ function decollideCallouts(callouts) {
|
||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||
if (overlapX && overlapY) {
|
||||
a._y = b._y + b.height + 5;
|
||||
a.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
||||
);
|
||||
a.transform = `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`;
|
||||
if (a._leader) {
|
||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||
a._leader.set.x2 = a._x.toFixed(1);
|
||||
a._leader.set.y2 = a._y.toFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArrowMarker(defs, key, c) {
|
||||
if (defs.querySelector(`#gev-arrow-${key}`)) return;
|
||||
const marker = document.createElementNS(SVGNS, 'marker');
|
||||
marker.setAttribute('id', `gev-arrow-${key}`);
|
||||
marker.setAttribute('viewBox', '0 0 10 10');
|
||||
marker.setAttribute('refX', '8');
|
||||
marker.setAttribute('refY', '5');
|
||||
marker.setAttribute('markerWidth', '7');
|
||||
marker.setAttribute('markerHeight', '7');
|
||||
marker.setAttribute('orient', 'auto-start-reverse');
|
||||
const path = document.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||||
path.setAttribute('fill', c);
|
||||
marker.appendChild(path);
|
||||
defs.appendChild(marker);
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS(SVGNS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('gev-screen-whiteboard-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'gev-screen-whiteboard-styles';
|
||||
style.textContent = `
|
||||
.gev-screen-whiteboard { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
|
||||
.gev-screen-whiteboard-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
/* No CSS opacity transition here on purpose: group opacity is driven per-frame in JS
|
||||
(the 260ms fade-in via computeAlpha, and the tracked-entity z-order ease). A CSS
|
||||
transition fought those per-frame writes and made the tracked-fade opacity oscillate
|
||||
(flicker). Fade-OUT keeps its own transition via .gev-anno.gev-out below. */
|
||||
.gev-anno-ring { filter: drop-shadow(0 0 6px currentColor); }
|
||||
.gev-anno-dot { filter: drop-shadow(0 0 5px rgba(255,255,255,0.6)); }
|
||||
.gev-anno-area { filter: drop-shadow(0 0 5px currentColor); }
|
||||
.gev-anno-arrow { filter: drop-shadow(0 0 4px currentColor); stroke-linecap: round; }
|
||||
.gev-anno-leader { stroke-dasharray: 2 3; }
|
||||
.gev-anno-card { fill: rgba(8,18,28,0.78); stroke: rgba(255,255,255,0.14); stroke-width: 1; }
|
||||
.gev-anno-text { fill: #eaf6ff; font: 600 13px "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
/* draw-on: outlined shapes reveal their stroke */
|
||||
.gev-draw { stroke-dasharray: 1400; stroke-dashoffset: 1400; }
|
||||
.gev-anno.gev-in .gev-draw { transition: stroke-dashoffset 900ms ease-out; stroke-dashoffset: 0; }
|
||||
/* opacity-only fade-in: a CSS transform here would override the SVG transform
|
||||
attribute used to POSITION the callout and snap it to 0,0. */
|
||||
.gev-anno-callout { opacity: 0; }
|
||||
.gev-anno.gev-in .gev-anno-callout { animation: gev-pop 320ms ease-out both; }
|
||||
.gev-anno.gev-out { opacity: 0 !important; transition: opacity 320ms ease; }
|
||||
.gev-pulse { animation: gev-ring-pulse 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes gev-ring-pulse { 0%,100% { opacity: 0.9; stroke-width: 2; } 50% { opacity: 0.35; stroke-width: 3.5; } }
|
||||
@keyframes gev-pop { from { opacity: 0; } to { opacity: 1; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
/** Record the arrowhead marker for a color, unless one is already defined. */
|
||||
function ensureArrowMarker(markers, key, c) {
|
||||
if (!markers.has(key)) markers.set(key, c);
|
||||
}
|
||||
@@ -1,164 +1,177 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
|
||||
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
|
||||
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
/** Mount the board's view; animation frames run when `frames()` is called. */
|
||||
function board(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useScreenAnnotationsStore(pinia);
|
||||
const app = createApp(ScreenAnnotations).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const queued = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
app.unmount();
|
||||
});
|
||||
const frames = () => {
|
||||
for (const callback of queued.splice(0)) callback();
|
||||
};
|
||||
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
|
||||
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
|
||||
return { store, frames, svg, groups };
|
||||
}
|
||||
|
||||
function cameraOver(lon, lat) {
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
// Low enough that point marks keep their full size.
|
||||
return { positionWC, directionWC, positionCartographic: { height: 500 } };
|
||||
}
|
||||
|
||||
function sceneFor(camera, extra = {}) {
|
||||
return {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project every point to the given window position for the test's length. */
|
||||
function projectTo(t, at) {
|
||||
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = original;
|
||||
});
|
||||
}
|
||||
|
||||
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'pin',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Capitol',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
await settle();
|
||||
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
|
||||
assert.ok(layer);
|
||||
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
|
||||
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
|
||||
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
|
||||
const [group] = groups();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno');
|
||||
assert.equal(group.getAttribute('opacity'), '1');
|
||||
assert.deepEqual(
|
||||
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
|
||||
[
|
||||
['circle', 'gev-anno-ring gev-pulse'],
|
||||
['circle', 'gev-anno-ring'],
|
||||
['circle', 'gev-anno-dot'],
|
||||
['line', 'gev-anno-leader'],
|
||||
['g', 'gev-anno-callout'],
|
||||
],
|
||||
);
|
||||
const [outer, inner, dot, leader, callout] = group.children;
|
||||
assert.equal(outer.getAttribute('stroke'), '#ffb547');
|
||||
assert.equal(outer.getAttribute('cx'), '200.0');
|
||||
assert.equal(outer.getAttribute('r'), '34.0');
|
||||
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
|
||||
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
|
||||
assert.equal(dot.getAttribute('r'), '5.0');
|
||||
assert.equal(leader.getAttribute('x1'), '200.0');
|
||||
assert.equal(leader.getAttribute('x2'), '218.0');
|
||||
// The card is sized to its measured text, and the leader meets its bottom.
|
||||
const [card, accent, text] = callout.children;
|
||||
assert.equal(text.textContent, 'Capitol');
|
||||
assert.equal(text.getAttribute('x'), '9');
|
||||
assert.equal(card.getAttribute('width'), '58');
|
||||
assert.equal(card.getAttribute('height'), '26');
|
||||
assert.equal(accent.getAttribute('height'), '18');
|
||||
assert.equal(accent.getAttribute('fill'), '#ffb547');
|
||||
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
|
||||
assert.equal(leader.getAttribute('y2'), '292.0');
|
||||
frames();
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
|
||||
});
|
||||
|
||||
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
let calls = 0;
|
||||
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'arrow',
|
||||
type: 'arrow',
|
||||
color: 'green',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
to: { lon: 0.01, lat: 0 },
|
||||
});
|
||||
await settle();
|
||||
const marker = svg().querySelector('defs > marker#gev-arrow-green');
|
||||
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
|
||||
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
|
||||
const path = groups()[0].querySelector('path');
|
||||
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
|
||||
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
|
||||
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
|
||||
frames();
|
||||
await settle();
|
||||
const ended = new Event('transitionend', { bubbles: true });
|
||||
ended.propertyName = 'stroke-dashoffset';
|
||||
path.dispatchEvent(ended);
|
||||
await settle();
|
||||
assert.equal(path.style.strokeDasharray, 'none');
|
||||
});
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
const sampledAnchors = [];
|
||||
const scene = {
|
||||
camera,
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
]);
|
||||
return world;
|
||||
},
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
});
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-fb3',
|
||||
type: 'area',
|
||||
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
ring: null,
|
||||
};
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
await settle();
|
||||
const [before] = groups();
|
||||
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
const callout = before.querySelector('.gev-anno-callout');
|
||||
const dot = before.querySelector('.gev-anno-dot');
|
||||
|
||||
const labelProxy = Object.assign(Object.create(anno), {
|
||||
type: 'label',
|
||||
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
||||
});
|
||||
renderer.update(labelProxy);
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [after] = groups();
|
||||
|
||||
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
|
||||
assert.ok(after === before, 'the rendered group is mutated, not replaced');
|
||||
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
|
||||
renderer.destroy();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
|
||||
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
|
||||
assert.ok(svg());
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
test('a callout whose text changes is sized to the new text', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
// Text measures 7px a character; everything else keeps the shared box.
|
||||
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
|
||||
return this.tagName.toLowerCase() === 'text'
|
||||
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
|
||||
: original.call(this);
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = original;
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'upgrade',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const card = () => groups()[0].querySelector('.gev-anno-card');
|
||||
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
|
||||
renderer.update({ ...anno, outlineUnavailable: true });
|
||||
await settle();
|
||||
const text = 'Texas · Detailed outline unavailable';
|
||||
assert.equal(groups()[0].querySelector('text').textContent, text);
|
||||
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 0, y: 0 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const paintRectCalls = [];
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{
|
||||
store,
|
||||
activeTrackedReadoutId: () => 'installations:test',
|
||||
overlayPaintRect(sourceId, entryId) {
|
||||
paintRectCalls.push({ sourceId, entryId });
|
||||
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'anno-overlap',
|
||||
type: 'label',
|
||||
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
assert.deepEqual(paintRectCalls.at(-1), {
|
||||
sourceId: 'tracked',
|
||||
entryId: 'installations:test',
|
||||
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
Number(group.getAttribute('opacity')) < 1,
|
||||
'final annotation bbox fades against the complete painted card rectangle',
|
||||
);
|
||||
});
|
||||
|
||||
test('a removed mark fades out, then leaves the board', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'fading',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Gone soon',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
renderer.remove(anno);
|
||||
// A new mark for the same place gets its own group beside the fading one.
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
|
||||
assert.equal(groups().length, 2);
|
||||
assert.ok(groups()[1] !== group);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await settle();
|
||||
assert.deepEqual(groups().length, 1);
|
||||
assert.equal(group.isConnected, false);
|
||||
renderer.destroy();
|
||||
await settle();
|
||||
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
|
||||
});
|
||||
|
||||
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
||||
//
|
||||
// add() inserts the group and records it, then does more live-document work
|
||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
||||
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
||||
// deletes its own map entry — so re-annotating the same place stacked a second
|
||||
// mark over the corpse.
|
||||
// add() records the mark, then does more work (presenting it, the first
|
||||
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
|
||||
// record nobody owned — the engine's rollback only deletes its own map entry —
|
||||
// so re-annotating the same place stacked a second mark over the corpse.
|
||||
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-partial-screen',
|
||||
type: 'pin',
|
||||
@@ -303,26 +357,36 @@ test('an add that throws after inserting its group unwinds instead of orphaning
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
|
||||
// The group is in the document and recorded by the time add() reaches its
|
||||
// first projection pass — so a throw there is real partial state, not a
|
||||
// clean bail-out.
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
||||
// The mark is on the board by the time add() reaches its first projection
|
||||
// pass — so a throw there is real partial state, not a clean bail-out.
|
||||
projectTo(t, () => {
|
||||
throw new Error('projection failed');
|
||||
});
|
||||
assert.throws(() => renderer.add(anno), /projection failed/);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
0,
|
||||
'the half-built group must not stay in the document',
|
||||
);
|
||||
await settle();
|
||||
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
|
||||
|
||||
// The id is free again: a retry draws exactly one mark, and remove() still
|
||||
// owns it (a leaked record would leave a second group behind).
|
||||
renderer.add(anno);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
1,
|
||||
'the retry must draw one mark, not stack over an orphan',
|
||||
);
|
||||
renderer.destroy();
|
||||
await settle();
|
||||
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
|
||||
});
|
||||
|
||||
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
|
||||
const options = { store, activeTrackedReadoutId: () => null };
|
||||
const earlier = createScreenAnnotationRenderer(viewer, options);
|
||||
const later = createScreenAnnotationRenderer(viewer, options);
|
||||
t.after(() => later.destroy());
|
||||
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
|
||||
});
|
||||
@@ -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...')
|
||||
|
||||
+20
-2
@@ -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,24 @@ 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);
|
||||
};
|
||||
// UTILITIES ▸ 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 });
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// UTILITIES ▸ 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 +82,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,
|
||||
@@ -700,7 +702,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(
|
||||
@@ -761,7 +763,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,
|
||||
@@ -799,35 +801,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', () => {
|
||||
@@ -889,21 +885,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 \}\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -941,7 +940,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(
|
||||
@@ -977,7 +976,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,
|
||||
@@ -1062,15 +1061,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,
|
||||
@@ -1092,7 +1092,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,
|
||||
@@ -1177,7 +1177,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"/);
|
||||
@@ -1189,7 +1189,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],
|
||||
@@ -1201,9 +1201,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'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1259,7 +1263,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"/);
|
||||
@@ -1270,15 +1274,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
|
||||
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
|
||||
);
|
||||
|
||||
const update = setBriefAutoRotate.toString();
|
||||
assert.ok(update, 'cockpit briefing cycle state updater is missing');
|
||||
assert.match(
|
||||
update,
|
||||
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
|
||||
);
|
||||
assert.match(update, /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update, /\.title = help;/);
|
||||
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
|
||||
// The presented label is the state; the help is only the tooltip.
|
||||
const views = [];
|
||||
const owner = {
|
||||
brief: createBriefState(),
|
||||
publishBrief: (view) => views.push(view),
|
||||
publishBriefView,
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
};
|
||||
setBriefAutoRotate.call(owner, true);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
|
||||
assert.equal(views.at(-1).autoRotate, true);
|
||||
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
|
||||
setBriefAutoRotate.call(owner, false);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
|
||||
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
|
||||
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from './data/headingDegrees.js';
|
||||
|
||||
/** Normalize a heading into the [0, 360) range. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
export { normalizeHeading };
|
||||
|
||||
/**
|
||||
* Three-digit heading readout, 000–359.
|
||||
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
|
||||
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.4), '359');
|
||||
assert.equal(formatHeadingReadout(0), '000');
|
||||
assert.equal(formatHeadingReadout(10.2), '010');
|
||||
assert.equal(formatHeadingReadout(-0.6), '359');
|
||||
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||
});
|
||||
|
||||
|
||||
+264
-243
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -172,10 +123,10 @@ test('Context reads as one segmented control with legible type and no halo', ()
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
// Default theme only, expanded only: Cyber keeps its red titles and the
|
||||
// collapsed launcher matches its sibling launchers.
|
||||
assert.match(
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
/color:\s*var\(--text-secondary\)/,
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
|
||||
|
||||
const KNOT_TO_MPS = 0.514444;
|
||||
const FOOT_TO_M = 0.3048;
|
||||
const FPM_TO_MPS = 0.00508;
|
||||
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm =
|
||||
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
|
||||
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -856,15 +860,6 @@ function makeTrailSpy() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassList(...initial) {
|
||||
const classes = new Set(initial);
|
||||
return {
|
||||
add(value) { classes.add(value); },
|
||||
remove(value) { classes.delete(value); },
|
||||
contains(value) { return classes.has(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteractionHandler() {
|
||||
return {
|
||||
click: null,
|
||||
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
};
|
||||
const record = makeRecord();
|
||||
const trail = makeTrailSpy();
|
||||
presentSelectedVessel({ text: `AIS: ${record.name}` });
|
||||
let vesselReadout;
|
||||
const stopReadout = subscribeSelectedVessel(
|
||||
(readout) => (vesselReadout = readout),
|
||||
);
|
||||
const hud = {
|
||||
textContent: `AIS: ${record.name}`,
|
||||
classList: makeClassList('active'),
|
||||
get readout() {
|
||||
return vesselReadout;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.window = windowTarget;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||||
getElementById: () => null,
|
||||
};
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
@@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||||
@@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Empty pick is a deliberate deselect — see the note above.
|
||||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||||
} finally {
|
||||
@@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||||
try {
|
||||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1322,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||||
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
|
||||
assert.equal(routeDistanceText(9960), '10\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Kilometre decimals for a distance: one below 10 km, none from there on.
|
||||
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
|
||||
* @param {number} meters
|
||||
* @returns {0|1}
|
||||
*/
|
||||
export function kilometreDigits(meters) {
|
||||
return Math.round(meters / 100) < 100 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit and precision a distance prints with: metres below 1 km, then
|
||||
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
|
||||
* except below `exactBelowM`, where they stay whole. Each cut is taken after
|
||||
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
|
||||
* @param {number} meters
|
||||
* @param {{metreStep?: number, exactBelowM?: number}} [options]
|
||||
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
|
||||
* distance that is negative or not finite. The value is already rounded to
|
||||
* `digits`, so toFixed and Intl render it the same way.
|
||||
*/
|
||||
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return null;
|
||||
const step = meters < exactBelowM ? 1 : metreStep;
|
||||
const shownMeters = Math.round(meters / step) * step;
|
||||
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
|
||||
const digits = kilometreDigits(meters);
|
||||
const value =
|
||||
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
|
||||
return { value, unit: 'km', digits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
|
||||
* @param {number} meters
|
||||
* @param {{metreStep?: number, exactBelowM?: number}} [options]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatDistanceText(meters, options) {
|
||||
const parts = distanceParts(meters, options);
|
||||
if (!parts) return null;
|
||||
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
distanceParts,
|
||||
formatDistanceText,
|
||||
kilometreDigits,
|
||||
} from './distanceFormat.js';
|
||||
|
||||
test('a distance switches to kilometres where its metres round to 1,000', () => {
|
||||
assert.equal(formatDistanceText(999.4), '999 m');
|
||||
assert.equal(formatDistanceText(999.5), '1.0 km');
|
||||
assert.equal(formatDistanceText(999.6), '1.0 km');
|
||||
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
|
||||
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
|
||||
});
|
||||
|
||||
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
|
||||
assert.equal(kilometreDigits(9949), 1);
|
||||
assert.equal(kilometreDigits(9950), 0);
|
||||
assert.equal(formatDistanceText(9949), '9.9 km');
|
||||
assert.equal(formatDistanceText(9950), '10 km');
|
||||
assert.equal(formatDistanceText(9960), '10 km');
|
||||
assert.equal(formatDistanceText(99950), '100 km');
|
||||
});
|
||||
|
||||
test('metres below exactBelowM stay whole while the rest round to the step', () => {
|
||||
const options = { metreStep: 10, exactBelowM: 100 };
|
||||
assert.equal(formatDistanceText(8, options), '8 m');
|
||||
assert.equal(formatDistanceText(99.4, options), '99 m');
|
||||
assert.equal(formatDistanceText(846, options), '850 m');
|
||||
assert.deepEqual(distanceParts(846, options), {
|
||||
value: 850,
|
||||
unit: 'm',
|
||||
digits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a negative or non-finite distance has no parts', () => {
|
||||
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
|
||||
assert.equal(distanceParts(value), null);
|
||||
assert.equal(formatDistanceText(value), null);
|
||||
}
|
||||
assert.equal(formatDistanceText(0), '0 m');
|
||||
});
|
||||
|
||||
test('kilometres come back rounded, so toFixed and Intl agree', () => {
|
||||
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
|
||||
assert.equal(formatDistanceText(1450), '1.5 km');
|
||||
assert.equal(formatDistanceText(1150), '1.2 km');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import dnsLookup from 'node:dns';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
import {
|
||||
describeFetchError,
|
||||
fetchFirms,
|
||||
fetchIpv4,
|
||||
isAddressSelectionFailure,
|
||||
} from '../../server/providers/firms.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
@@ -17,8 +29,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
@@ -78,3 +90,220 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
|
||||
// #68: Node's network-family auto-selection gives each address a short attempt
|
||||
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
|
||||
// reports only `fetch failed`. This is the shape Node 24 throws for it.
|
||||
function addressSelectionFailure() {
|
||||
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
|
||||
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
|
||||
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
|
||||
function wrappedAddressSelectionFailure() {
|
||||
const inner = addressSelectionFailure().cause;
|
||||
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
assert.equal(described,
|
||||
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
|
||||
assert.ok(!described.includes('SECRET'));
|
||||
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
|
||||
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
|
||||
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
|
||||
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
|
||||
});
|
||||
|
||||
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
|
||||
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
|
||||
const retried = [];
|
||||
await fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw shape(); },
|
||||
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
|
||||
warn() {},
|
||||
});
|
||||
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
|
||||
}
|
||||
const signal = new AbortController().signal;
|
||||
const ok = new Response('csv');
|
||||
const retried = [];
|
||||
const warnings = [];
|
||||
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
assert.equal(response, ok);
|
||||
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
|
||||
assert.ok(!warnings[0].includes('SECRET'));
|
||||
|
||||
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async () => { throw failure; },
|
||||
warn() {},
|
||||
}), (err) => err === failure, 'a failed retry reports its own error');
|
||||
});
|
||||
|
||||
test('FIRMS does not retry anything but an address-selection failure', async () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
fetchImpl,
|
||||
fetchIpv4Impl: async () => assert.fail('must not retry'),
|
||||
warn: () => assert.fail('must not warn'),
|
||||
});
|
||||
const serverError = new Response('down', { status: 503 });
|
||||
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
|
||||
for (const thrown of [
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: new AggregateError([]) }),
|
||||
new TypeError('fetch failed'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = addressSelectionFailure();
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
|
||||
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
|
||||
});
|
||||
|
||||
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
||||
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
|
||||
// same port and record which one the retry reaches; the TLS handshake then
|
||||
// fails against a plain socket, which is fine — only the connection matters.
|
||||
const reached = [];
|
||||
const listen = (host, port) => new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
|
||||
server.once('error', reject).listen(port, host, () => resolve(server));
|
||||
});
|
||||
const v4 = await listen('127.0.0.1', 0);
|
||||
t.after(() => v4.close());
|
||||
const { port } = v4.address();
|
||||
let v6;
|
||||
try {
|
||||
v6 = await listen('::1', port);
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
t.after(() => v6.close());
|
||||
const order = await dns.lookup('localhost', { all: true });
|
||||
if (order[0]?.family !== 6) {
|
||||
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||
assert.deepEqual(reached, ['127.0.0.1']);
|
||||
});
|
||||
|
||||
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of crashing the test runner.
|
||||
function mockIpv4Status(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'text/csv' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||
});
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port refuses both loopback addresses; resolve
|
||||
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host');
|
||||
return;
|
||||
}
|
||||
const { port } = probe.address();
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
|
||||
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
});
|
||||
|
||||
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||
const calls = [];
|
||||
const response = new Response('latitude,longitude\n');
|
||||
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||
() => [],
|
||||
1024,
|
||||
AbortSignal,
|
||||
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||
);
|
||||
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][0], 'fetchFirms');
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
// Only a negative remainder takes the extra turn, so a positive bearing
|
||||
// keeps its precision (0.49999999999999 must not become 360.5).
|
||||
const remainder = value % 360;
|
||||
const wrapped = remainder < 0 ? remainder + 360 : remainder;
|
||||
return wrapped === 360 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
|
||||
* and negative or multi-turn values wrap. Null when the value is not finite,
|
||||
* so each caller keeps its own placeholder.
|
||||
*/
|
||||
export function roundHeadingDegrees(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.round(normalizeHeading(value)) % 360;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
|
||||
|
||||
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
|
||||
assert.equal(normalizeHeading(370), 10);
|
||||
assert.equal(normalizeHeading(-10), 350);
|
||||
assert.equal(normalizeHeading(720), 0);
|
||||
assert.equal(normalizeHeading(Number.NaN), 0);
|
||||
assert.equal(normalizeHeading(Infinity), 0);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
|
||||
assert.equal(roundHeadingDegrees(359.4), 359);
|
||||
assert.equal(roundHeadingDegrees(359.5), 0);
|
||||
assert.equal(roundHeadingDegrees(359.6), 0);
|
||||
assert.equal(roundHeadingDegrees(360), 0);
|
||||
assert.equal(roundHeadingDegrees(0), 0);
|
||||
assert.equal(roundHeadingDegrees(194.2), 194);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
|
||||
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
|
||||
assert.equal(roundHeadingDegrees(-0.6), 359);
|
||||
assert.equal(roundHeadingDegrees(-1), 359);
|
||||
assert.equal(roundHeadingDegrees(719.6), 0);
|
||||
assert.equal(roundHeadingDegrees(-360.6), 359);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees returns null for a value that is not finite', () => {
|
||||
for (const value of [
|
||||
Number.NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
null,
|
||||
undefined,
|
||||
'12',
|
||||
]) {
|
||||
assert.equal(roundHeadingDegrees(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
|
||||
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
|
||||
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
|
||||
assert.equal(normalizeHeading(-1e-15) < 360, true);
|
||||
});
|
||||
@@ -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',
|
||||
@@ -76,6 +79,9 @@ test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
|
||||
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
|
||||
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
|
||||
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
|
||||
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
|
||||
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
|
||||
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
|
||||
});
|
||||
|
||||
function installAwarenessRuntime({
|
||||
@@ -98,27 +104,12 @@ function installAwarenessRuntime({
|
||||
const listeners = new Map();
|
||||
let nextTimer = 1;
|
||||
let preRenderListener = null;
|
||||
const createdElements = [];
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.classList = { contains: () => false };
|
||||
this.style = { setProperty() {} };
|
||||
this._hidden = false;
|
||||
// renderResults() writes `hidden = false` on EVERY call, ahead of its
|
||||
// markup diff, so counting these writes observes "was the panel
|
||||
// repaint path entered?" independently of whether the markup changed.
|
||||
this.hiddenWrites = 0;
|
||||
this.innerHTML = '';
|
||||
}
|
||||
|
||||
get hidden() {
|
||||
return this._hidden;
|
||||
}
|
||||
|
||||
set hidden(value) {
|
||||
this._hidden = value;
|
||||
this.hiddenWrites += 1;
|
||||
this.hidden = false;
|
||||
}
|
||||
|
||||
setAttribute() {}
|
||||
@@ -213,9 +204,7 @@ function installAwarenessRuntime({
|
||||
return null;
|
||||
},
|
||||
createElement() {
|
||||
const element = new FakeElement();
|
||||
createdElements.push(element);
|
||||
return element;
|
||||
return new FakeElement();
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
@@ -229,6 +218,10 @@ function installAwarenessRuntime({
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
const panelViews = [];
|
||||
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
||||
panelViews.push(view),
|
||||
);
|
||||
militaryAwarenessLayer.enable();
|
||||
|
||||
return {
|
||||
@@ -248,15 +241,16 @@ function installAwarenessRuntime({
|
||||
);
|
||||
return taken;
|
||||
},
|
||||
/** The standalone Context panel element renderResults() paints into. */
|
||||
/** The Contacts panel as the layer publishes it to the view. */
|
||||
panel() {
|
||||
return (
|
||||
createdElements.find(
|
||||
(element) => element.id === 'military-awareness-panel',
|
||||
) || null
|
||||
);
|
||||
return {
|
||||
view: panelViews.at(-1) ?? null,
|
||||
publishes: panelViews.length,
|
||||
renders: _getAwarenessPanelRendersForTest(),
|
||||
};
|
||||
},
|
||||
restore() {
|
||||
unsubscribePanel();
|
||||
militaryAwarenessLayer.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
@@ -264,144 +258,24 @@ function installAwarenessRuntime({
|
||||
};
|
||||
}
|
||||
|
||||
function awarenessControl(dataset, { disabled = false } = {}) {
|
||||
return {
|
||||
dataset,
|
||||
disabled,
|
||||
focusCalls: 0,
|
||||
closest(selector) {
|
||||
if (selector === '[data-awareness-focus-continuation]') {
|
||||
return this.isContinuation ? this : null;
|
||||
}
|
||||
return selector.includes('button') && !this.isContinuation ? this : null;
|
||||
},
|
||||
matches(selector) {
|
||||
return (
|
||||
selector === '[data-awareness-focus-continuation]' &&
|
||||
this.isContinuation
|
||||
);
|
||||
},
|
||||
focus(options) {
|
||||
this.focusCalls += 1;
|
||||
this.focusOptions = options;
|
||||
},
|
||||
};
|
||||
/** The count a published Contacts panel shows for one cohort row. */
|
||||
function panelCount(runtime, label) {
|
||||
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
|
||||
}
|
||||
|
||||
function awarenessPanel(controls) {
|
||||
const continuation = awarenessControl({});
|
||||
continuation.isContinuation = true;
|
||||
return {
|
||||
controls,
|
||||
continuation,
|
||||
contains(element) {
|
||||
return controls.includes(element) || element === continuation;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return this.controls;
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === '[data-awareness-focus-continuation]'
|
||||
? continuation
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
|
||||
const previous = awarenessControl({ awarenessAction: 'previous' });
|
||||
const focus = awarenessControl({ awarenessAction: 'focus' });
|
||||
const next = awarenessControl({ awarenessAction: 'next' });
|
||||
const alpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const bravo = awarenessControl({
|
||||
awarenessLayer: 'military',
|
||||
awarenessId: 'def456',
|
||||
});
|
||||
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
|
||||
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
|
||||
|
||||
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
|
||||
assert.equal(awarenessPanelControlKey(next), 'action:next');
|
||||
|
||||
const retainedAlpha = awarenessControl({
|
||||
awarenessLayer: 'flights',
|
||||
awarenessId: 'abc123',
|
||||
});
|
||||
const retained = awarenessPanel([
|
||||
previous,
|
||||
focus,
|
||||
next,
|
||||
bravo,
|
||||
retainedAlpha,
|
||||
]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(retained, alphaSnapshot),
|
||||
retainedAlpha,
|
||||
);
|
||||
assert.equal(
|
||||
retainedAlpha.focusCalls,
|
||||
1,
|
||||
'identity wins even when live distance reorders rows',
|
||||
);
|
||||
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
|
||||
|
||||
const forward = awarenessControl({
|
||||
awarenessLayer: 'ais-live-vessels',
|
||||
awarenessId: '789',
|
||||
});
|
||||
const departed = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(departed, alphaSnapshot),
|
||||
departed.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
forward.focusCalls,
|
||||
0,
|
||||
'automatic paging must not transfer focus to another contact',
|
||||
);
|
||||
assert.equal(departed.continuation.focusCalls, 1);
|
||||
|
||||
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
|
||||
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
|
||||
noRemainingRow.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
noRemainingRow.continuation.focusCalls,
|
||||
1,
|
||||
'the end sentinel continues Tab beyond the list',
|
||||
);
|
||||
|
||||
const continuationSnapshot = captureAwarenessPanelFocus(
|
||||
departed,
|
||||
departed.continuation,
|
||||
);
|
||||
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
|
||||
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
|
||||
repaintedAgain.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
repaintedAgain.continuation.focusCalls,
|
||||
1,
|
||||
'later live repaints keep focus on the continuation point until the user tabs onward',
|
||||
);
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
assert.match(
|
||||
awarenessPanelSource,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
awarenessPanelSource,
|
||||
/<span[^>]*data-awareness-focus-continuation/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
|
||||
militaryAwarenessSource,
|
||||
/'Open-source mapped\/observed context\./,
|
||||
);
|
||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
||||
});
|
||||
|
||||
test('awareness disable settles every owned dependency release before resolving', async () => {
|
||||
@@ -599,9 +473,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
'loading is not an observed empty cohort',
|
||||
);
|
||||
assert.match(installations?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'Mapped installations'),
|
||||
'?',
|
||||
'the operator sees ? rather than an all-clear 0 while installations load',
|
||||
);
|
||||
} finally {
|
||||
@@ -741,15 +615,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
|
||||
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
|
||||
);
|
||||
assert.match(vessels?.reason || '', /unavailable/i);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
|
||||
'the panel prints ? for the whole settled-but-connecting window',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
'the panel must never print an all-clear 0 before the feed has answered once',
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'?',
|
||||
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
|
||||
);
|
||||
} finally {
|
||||
restoreCollections();
|
||||
@@ -789,9 +658,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
|
||||
0,
|
||||
'an answered empty viewport is a real observation',
|
||||
);
|
||||
assert.match(
|
||||
runtime.panel()?.innerHTML || '',
|
||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
||||
assert.equal(
|
||||
panelCount(runtime, 'AIS vessels'),
|
||||
'0',
|
||||
'the operator sees the real count once the feed answers',
|
||||
);
|
||||
} finally {
|
||||
@@ -1254,17 +1123,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
id,
|
||||
);
|
||||
const panel = runtime.panel();
|
||||
assert.ok(panel, 'the Context panel must exist');
|
||||
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
|
||||
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
|
||||
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
|
||||
|
||||
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
||||
const settledWrites = panel.hiddenWrites;
|
||||
const settledRenders = runtime.panel().renders;
|
||||
const settledPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(
|
||||
panel.hiddenWrites,
|
||||
settledWrites,
|
||||
runtime.panel().renders,
|
||||
settledRenders,
|
||||
'an unchanged label must not trigger a panel repaint',
|
||||
);
|
||||
assert.equal(
|
||||
runtime.panel().publishes,
|
||||
settledPublishes,
|
||||
'an unchanged refresh publishes no new panel, results or standby',
|
||||
);
|
||||
|
||||
// Enrichment answers. The contact has NOT moved and no source revision
|
||||
// changed, so the label is the only thing making this render-worthy.
|
||||
@@ -1283,17 +1158,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
'subject identity must stay the ICAO hex',
|
||||
);
|
||||
// The RENDERED panel is the surface the operator actually reads.
|
||||
const repainted = runtime.panel();
|
||||
assert.ok(
|
||||
panel.hiddenWrites > settledWrites,
|
||||
repainted.publishes > settledPublishes,
|
||||
'a changed label must repaint the standalone Context panel',
|
||||
);
|
||||
assert.match(
|
||||
panel.innerHTML,
|
||||
repainted.view.subject,
|
||||
/N123AB/,
|
||||
'the rendered Context panel must show the registration without a position change',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel.innerHTML,
|
||||
JSON.stringify(repainted.view),
|
||||
/ae1fa4/,
|
||||
'the hex must be gone from the panel',
|
||||
);
|
||||
@@ -2240,9 +2116,10 @@ test('expanded Context results omit the redundant status heading', () => {
|
||||
militaryAwarenessSource,
|
||||
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
|
||||
);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-subject/);
|
||||
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-subject/);
|
||||
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
|
||||
assert.match(militaryAwarenessSource, /military-awareness-controls/);
|
||||
assert.match(awarenessPanelSource, /military-awareness-controls/);
|
||||
});
|
||||
|
||||
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
|
||||
@@ -4229,7 +4106,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
}
|
||||
});
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
test('a selected installation card keeps every member name as text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
@@ -4252,16 +4129,15 @@ test('a selected installation card keeps every member name and escapes source te
|
||||
},
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of [
|
||||
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
|
||||
const names = runtime.panel().view?.subjectNames;
|
||||
assert.equal(names?.summary, 'Named areas (4)');
|
||||
assert.deepEqual(names?.names, [
|
||||
'Area one',
|
||||
'Area two',
|
||||
'Area three',
|
||||
'<Area four>',
|
||||
])
|
||||
assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
'<Area four>',
|
||||
]);
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
import { formatDistanceText } from './distanceFormat.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
@@ -46,10 +47,7 @@ export function findByDoublingRadius(
|
||||
|
||||
/** @param {number} meters @returns {string} */
|
||||
export function formatAwarenessDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||
return meters < 1000
|
||||
? `${Math.round(meters)} m`
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
return formatDistanceText(meters) ?? '—';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,15 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid
|
||||
assert.equal(formatAwarenessLabel(undefined), '—');
|
||||
});
|
||||
|
||||
test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => {
|
||||
assert.equal(formatAwarenessDistance(999.4), '999 m');
|
||||
assert.equal(formatAwarenessDistance(999.6), '1.0 km');
|
||||
assert.equal(formatAwarenessDistance(4600), '4.6 km');
|
||||
assert.equal(formatAwarenessDistance(9949), '9.9 km');
|
||||
assert.equal(formatAwarenessDistance(9950), '10 km');
|
||||
assert.equal(formatAwarenessDistance(9960), '10 km');
|
||||
});
|
||||
|
||||
test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => {
|
||||
const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]);
|
||||
assert.equal(result.relationship, 'NEARBY');
|
||||
|
||||
@@ -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,
|
||||
@@ -448,6 +445,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp
|
||||
assert.ok(liftoff.phaseProgress > 0);
|
||||
});
|
||||
|
||||
test('runs the five-second tile hold before a full T-10 countdown', () => {
|
||||
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
|
||||
// REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead.
|
||||
const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5);
|
||||
assert.equal(hold.preCountdownActive, true);
|
||||
assert.equal(hold.countdownActive, false);
|
||||
const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5);
|
||||
assert.equal(countdown.preCountdownActive, false);
|
||||
assert.equal(countdown.countdownActive, true);
|
||||
assert.equal(countdown.countdownSeconds, 10);
|
||||
});
|
||||
|
||||
test('hands replay directly from ascent into orbit', () => {
|
||||
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
|
||||
const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000);
|
||||
@@ -514,42 +523,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
|
||||
]);
|
||||
});
|
||||
|
||||
test('rendered mission row focus and blur drive the preview ownership controller', () => {
|
||||
const listeners = new Map();
|
||||
const calls = [];
|
||||
const button = {
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
};
|
||||
const nextButton = {
|
||||
dataset: { missionRosterIndex: '4' },
|
||||
closest: () => nextButton,
|
||||
};
|
||||
const roster = { contains: (candidate) => candidate === nextButton };
|
||||
const ownership = {
|
||||
pointerEnter: (index) => calls.push(['pointer-enter', index]),
|
||||
pointerLeave: () => calls.push(['pointer-leave']),
|
||||
focus: (index) => calls.push(['focus', index]),
|
||||
blur: (index) => calls.push(['blur', index]),
|
||||
};
|
||||
|
||||
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
|
||||
listeners.get('mouseenter')();
|
||||
listeners.get('mouseleave')();
|
||||
listeners.get('focus')();
|
||||
listeners.get('blur')({ relatedTarget: nextButton });
|
||||
listeners.get('blur')({ relatedTarget: null });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['pointer-enter', 2],
|
||||
['pointer-leave'],
|
||||
['focus', 2],
|
||||
['blur', 4],
|
||||
['blur', null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
|
||||
const calls = [];
|
||||
const ownership = createMissionRosterPreviewOwnership({
|
||||
@@ -568,50 +541,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
|
||||
]);
|
||||
});
|
||||
|
||||
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let restored = false;
|
||||
const replacement = {
|
||||
dataset: { missionRosterId: 'mission-b' },
|
||||
focus: ({ preventScroll }) => { restored = preventScroll; },
|
||||
};
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [
|
||||
{ dataset: { missionRosterId: 'mission-c' } },
|
||||
replacement,
|
||||
{ dataset: { missionRosterId: 'mission-a' } },
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.deepEqual(snapshot, { launchId: 'mission-b' });
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
|
||||
assert.equal(restored, true);
|
||||
});
|
||||
|
||||
test('mission roster refresh continues after the list when the focused mission departed', () => {
|
||||
const focused = {
|
||||
dataset: { missionRosterId: 'departed' },
|
||||
closest: () => focused,
|
||||
};
|
||||
let continuationFocused = false;
|
||||
const list = {
|
||||
contains: (candidate) => candidate === focused,
|
||||
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
|
||||
};
|
||||
const continuation = {
|
||||
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
|
||||
};
|
||||
|
||||
const snapshot = captureMissionRosterFocus(list, focused);
|
||||
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
|
||||
assert.equal(continuationFocused, true);
|
||||
});
|
||||
|
||||
test('pending mission preview resolves the current refreshed record by identity', () => {
|
||||
const stale = { id: 'mission-a', name: 'Stale' };
|
||||
const current = { id: 'mission-a', name: 'Current' };
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
|
||||
*/
|
||||
|
||||
import { formatDistanceText } from './distanceFormat.js';
|
||||
|
||||
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
|
||||
export const ROUTE_STEPS_MAX = 200;
|
||||
|
||||
@@ -216,11 +218,7 @@ export function normalizeOsrmSteps(route) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRouteDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '';
|
||||
if (meters < 100) return `${Math.round(meters)} m`;
|
||||
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
|
||||
const km = meters / 1000;
|
||||
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
|
||||
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -236,6 +236,8 @@ test('distance and duration formatting', () => {
|
||||
assert.equal(formatRouteDistance(8), '8 m');
|
||||
assert.equal(formatRouteDistance(846), '850 m');
|
||||
assert.equal(formatRouteDistance(1234), '1.2 km');
|
||||
assert.equal(formatRouteDistance(996), '1.0 km');
|
||||
assert.equal(formatRouteDistance(9960), '10 km');
|
||||
assert.equal(formatRouteDistance(21479), '21 km');
|
||||
assert.equal(formatRouteDistance(-1), '');
|
||||
assert.equal(formatRouteDuration(40), '40 s');
|
||||
|
||||
@@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
function multipartCableFixture() {
|
||||
return [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: 'alpha',
|
||||
name: 'Alpha cable',
|
||||
color: '#ff0000',
|
||||
coordinates: [-40, 35],
|
||||
},
|
||||
geometry: {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[-40, 35],
|
||||
[-39, 36],
|
||||
],
|
||||
[
|
||||
[-38, 37],
|
||||
[-37, 38],
|
||||
],
|
||||
[
|
||||
[-36, 39],
|
||||
[-35, 40],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: 'beta',
|
||||
name: 'Beta cable',
|
||||
color: '#0000ff',
|
||||
coordinates: [140, -35],
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[140, -35],
|
||||
[141, -34],
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function geometrySource(cables, landingPoints = []) {
|
||||
return {
|
||||
label: 'Multipart test source',
|
||||
async fetch() {
|
||||
return {
|
||||
cables: { type: 'FeatureCollection', features: cables },
|
||||
landingPoints: { type: 'FeatureCollection', features: landingPoints },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('multipart association accepts frozen provider collections without modifying properties', async () => {
|
||||
const payload = {
|
||||
cables: { type: 'FeatureCollection', features: multipartCableFixture() },
|
||||
landingPoints: {
|
||||
type: 'FeatureCollection',
|
||||
features: multipartCableFixture(),
|
||||
},
|
||||
};
|
||||
payload.cables.features[0].properties.__gevSourceFeatureIndex =
|
||||
'provider value';
|
||||
const before = structuredClone(payload);
|
||||
const freeze = (value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const child of Object.values(value)) freeze(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
freeze(payload);
|
||||
const env = await createRealCableLayerHarness({
|
||||
source: { label: 'Frozen multipart source', fetch: async () => payload },
|
||||
});
|
||||
try {
|
||||
for (const dataSource of env.dataSources.slice(0, 2)) {
|
||||
assert.deepEqual(
|
||||
dataSource.entities.values.map(
|
||||
(entity) => entity.__gevTeleGeography?.label,
|
||||
),
|
||||
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
||||
);
|
||||
}
|
||||
assert.equal(env.dataSources[2].entities.values.length, 4);
|
||||
assert.deepEqual(payload, before);
|
||||
const entity = env.dataSources[0].entities.values[0];
|
||||
assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0);
|
||||
assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/);
|
||||
} finally {
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('multipart cables retain their own styling, picking and culling on every segment', async () => {
|
||||
const features = multipartCableFixture();
|
||||
const before = JSON.stringify(features);
|
||||
const env = await createRealCableLayerHarness({
|
||||
source: geometrySource(features),
|
||||
});
|
||||
try {
|
||||
const entities = env.dataSources[0].entities.values;
|
||||
assert.equal(entities.length, 4);
|
||||
for (const [index, entity] of entities.entries()) {
|
||||
const feature = features[index < 3 ? 0 : 1];
|
||||
const info = entity.__gevTeleGeography;
|
||||
assert.equal(info?.label, feature.properties.name, entity.id);
|
||||
assert.deepEqual(info.reference, {
|
||||
lon: feature.properties.coordinates[0],
|
||||
lat: feature.properties.coordinates[1],
|
||||
});
|
||||
assert.deepEqual(
|
||||
entity.polyline.material.color.getValue(),
|
||||
Cesium.Color.fromCssColorString(feature.properties.color).withAlpha(
|
||||
0.92,
|
||||
),
|
||||
);
|
||||
assert.equal(entity.polyline.width.getValue(), 2.5);
|
||||
}
|
||||
env.raiseSweep();
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.show),
|
||||
[true, true, true, false],
|
||||
);
|
||||
assert.equal(
|
||||
env.dataSources[2].entities.values.length,
|
||||
2,
|
||||
'one reference per feature',
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(features),
|
||||
before,
|
||||
'source features stay unchanged',
|
||||
);
|
||||
} finally {
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => {
|
||||
const features = multipartCableFixture();
|
||||
features[1].id = 'alpha_2';
|
||||
delete features[1].properties.id;
|
||||
const generated = structuredClone(features[1]);
|
||||
delete generated.id;
|
||||
generated.properties.name = 'Generated cable';
|
||||
features.push(generated);
|
||||
const repeated = structuredClone(features[1]);
|
||||
repeated.properties.id = 'alpha';
|
||||
repeated.properties.name = 'Repeated ID cable';
|
||||
features.push(repeated);
|
||||
const env = await createRealCableLayerHarness({
|
||||
source: geometrySource(features),
|
||||
});
|
||||
try {
|
||||
const entities = env.dataSources[0].entities.values;
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.id),
|
||||
['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.__gevTeleGeography?.label),
|
||||
[
|
||||
'Alpha cable',
|
||||
'Alpha cable',
|
||||
'Alpha cable',
|
||||
'Beta cable',
|
||||
'Generated cable',
|
||||
'Repeated ID cable',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
env.dataSources[2].entities.values.map(
|
||||
(entity) => entity.__gevTeleGeography.featureId,
|
||||
),
|
||||
['alpha', 'alpha_2', 'cable-2', 'alpha'],
|
||||
);
|
||||
} finally {
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('multipart landing geometry keeps every pick with one reference per feature', async () => {
|
||||
const features = multipartCableFixture();
|
||||
const env = await createRealCableLayerHarness({
|
||||
source: geometrySource([], features),
|
||||
});
|
||||
try {
|
||||
const entities = env.dataSources[1].entities.values;
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.__gevTeleGeography?.label),
|
||||
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
||||
);
|
||||
assert.ok(
|
||||
entities.every(
|
||||
(entity) => entity.__gevTeleGeography.kind === 'landing-point',
|
||||
),
|
||||
);
|
||||
assert.equal(env.dataSources[2].entities.values.length, 2);
|
||||
} finally {
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('every bundled cable segment is associated with its source feature', async () => {
|
||||
const { features } = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// Some bundled features share a cable ID but have different references.
|
||||
const expandedFeatures = features.flatMap((feature) =>
|
||||
feature.geometry.coordinates.map(() => feature),
|
||||
);
|
||||
assert.ok(expandedFeatures.length > features.length);
|
||||
const env = await createRealCableLayerHarness({
|
||||
source: geometrySource(features),
|
||||
});
|
||||
try {
|
||||
const entities = env.dataSources[0].entities.values;
|
||||
assert.equal(entities.length, expandedFeatures.length);
|
||||
for (const [index, entity] of entities.entries()) {
|
||||
const feature = expandedFeatures[index];
|
||||
assert.equal(
|
||||
entity.__gevTeleGeography?.label,
|
||||
feature.properties.name,
|
||||
entity.id,
|
||||
);
|
||||
assert.deepEqual(entity.__gevTeleGeography.reference, {
|
||||
lon: feature.properties.coordinates[0],
|
||||
lat: feature.properties.coordinates[1],
|
||||
});
|
||||
}
|
||||
assert.equal(env.dataSources[2].entities.values.length, features.length);
|
||||
} finally {
|
||||
env.layer.destroy(env.viewer);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -136,6 +139,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
const timeouts = new Map();
|
||||
let timerId = 0;
|
||||
let server;
|
||||
const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-'));
|
||||
let trafficLayer;
|
||||
let viewer;
|
||||
|
||||
@@ -159,6 +163,12 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
};
|
||||
server = await createServer({
|
||||
root: fileURLToPath(new URL('../..', import.meta.url)),
|
||||
// Its own optimizer cache, not node_modules/.vite in the package tree:
|
||||
// a host running Core's suite refuses files inside Core.
|
||||
cacheDir,
|
||||
// The test loads modules over SSR; the browser dependency optimizer would
|
||||
// keep writing its cache after the server closes.
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
configFile: false,
|
||||
appType: 'custom',
|
||||
logLevel: 'silent',
|
||||
@@ -328,6 +338,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
console.warn = originalWarn;
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -1066,10 +1029,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Panel adds timeout and named voice/provider targets; consolidated guidance
|
||||
// preserves the mission mapping and globe-rotation arguments.
|
||||
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
||||
assert.equal(block.length, 31206, 'serialized tool schema length drifted');
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
assert.equal(block.length, 30901, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
|
||||
'9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -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
+36
-22
@@ -153,7 +153,6 @@
|
||||
"NIGHT": "NACHT",
|
||||
"▲ {frp} MW": "▲ {frp} MW",
|
||||
"FIRE": "BRAND",
|
||||
"FIRES": "BRÄNDE",
|
||||
"max {value} MW": "max. {value} MW",
|
||||
"new {value}": "zuletzt vor {value}",
|
||||
"just now": "gerade eben",
|
||||
@@ -676,8 +675,6 @@
|
||||
"Cockpit vision style": "Sichtstil im Cockpit",
|
||||
"Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil",
|
||||
"Previous vision style": "Vorheriger Sichtstil",
|
||||
"Current cockpit vision style: NORMAL. Activate for next style.": "Aktueller Cockpit-Sichtstil: NORMAL. Aktivieren für den nächsten Stil.",
|
||||
"Current style: NORMAL — click for next": "Aktueller Stil: NORMAL. Klicken für den nächsten",
|
||||
"CURRENT": "AKTUELL",
|
||||
"Next cockpit vision style": "Nächster Cockpit-Sichtstil",
|
||||
"Next vision style": "Nächster Sichtstil",
|
||||
@@ -748,7 +745,6 @@
|
||||
"Search any location...": "Ort suchen...",
|
||||
"WEATHER": "WETTER",
|
||||
"CONTEXT": "KONTEXT",
|
||||
"Expand panel": "Bereich ausklappen",
|
||||
"Context mode": "Kontextmodus",
|
||||
"CONTACTS": "KONTAKTE",
|
||||
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Wechselt zwischen den nächstgelegenen Kontakten des gewählten Typs: Flugzeuge, Schiffe, Anlagen. Satelliten werden unabhängig verfolgt.",
|
||||
@@ -837,13 +833,7 @@
|
||||
"SAVE CAL": "KAL. SPEICHERN",
|
||||
"RESET CAL": "KAL. ZURÜCKSETZEN",
|
||||
"SCENE SUMMARY": "SZENEN-ÜBERSICHT",
|
||||
"SCENES": "SZENEN",
|
||||
"Scene recipe": "Szenenrezept",
|
||||
"NEW": "NEU",
|
||||
"CAPTURE SHOT": "AUFNAHME SPEICHERN",
|
||||
"UPDATE SHOT": "AUFNAHME AKTUALISIEREN",
|
||||
"START": "START",
|
||||
"EXPORT PRESETS": "PRESETS EXPORTIEREN",
|
||||
"IMPORT": "IMPORTIEREN",
|
||||
"RUN LOG": "PROTOKOLL",
|
||||
"POWER UP": "STARTKLAR",
|
||||
@@ -881,7 +871,6 @@
|
||||
"That line has no length — click somewhere further away.": "Diese Linie hat keine Länge – weiter entfernt klicken.",
|
||||
" · {max}-point limit reached": " · Limit von {max} Punkten erreicht",
|
||||
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · Doppelklick oder Enter beendet, Rücktaste macht rückgängig, Esc bricht ab.{full}",
|
||||
"drawings on the map": "Zeichnungen auf der Karte",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "Diese Form hat bereits {count} Punkte – abschließen oder Rücktaste drücken.",
|
||||
"That point is off the globe — click on the world.": "Dieser Punkt liegt außerhalb des Globus – auf die Erde klicken.",
|
||||
"That shape could not be placed.": "Diese Form konnte nicht platziert werden.",
|
||||
@@ -1182,7 +1171,6 @@
|
||||
"GET KEY ↗": "SCHLÜSSEL HOLEN ↗",
|
||||
"{envVar} saved — paste to replace": "{envVar} gespeichert – zum Ersetzen einfügen",
|
||||
"paste {envVar}": "{envVar} einfügen",
|
||||
"unsaved provider keys": "ungespeicherte Anbieter-Schlüssel",
|
||||
"Project keys: {status}": "Projektschlüssel: {status}",
|
||||
"Cloud voice will use OPENAI_API_KEY on the next session.": "Die Cloud-Sprachsteuerung verwendet ab der nächsten Sitzung OPENAI_API_KEY.",
|
||||
"Checking local ChatGPT OAuth sign-in…": "Lokale ChatGPT-OAuth-Anmeldung wird geprüft…",
|
||||
@@ -1464,7 +1452,7 @@
|
||||
"Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %",
|
||||
"Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %",
|
||||
"GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar",
|
||||
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über 30 min",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min",
|
||||
"Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC",
|
||||
"Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.",
|
||||
@@ -1765,6 +1753,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen",
|
||||
"Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze",
|
||||
"Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³",
|
||||
"Color: {units}": "Farbe: {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert",
|
||||
"North America · infrared imagery": "Nordamerika · Infrarotbilder",
|
||||
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
|
||||
@@ -2058,9 +2048,6 @@
|
||||
"Sensitivity": "Empfindlichkeit",
|
||||
"WHOT/BHOT": "WHOT/BHOT",
|
||||
"Ironbow": "Ironbow",
|
||||
"unsaved CCTV calibration": "ungespeicherte CCTV-Kalibrierung",
|
||||
"the open cockpit": "das geöffnete Cockpit",
|
||||
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Ein Sprachwechsel lädt die Seite neu. Dabei geht Folgendes verloren: {items}. Trotzdem wechseln?",
|
||||
"Link shared": "Link geteilt",
|
||||
"Return map to straight-down view": "Karte wieder senkrecht von oben zeigen",
|
||||
"Tilt map to oblique view": "Karte in Schrägansicht kippen",
|
||||
@@ -2107,7 +2094,6 @@
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL",
|
||||
"Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `",
|
||||
"imported map files": "importierte Kartendateien",
|
||||
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.",
|
||||
"{count} map objects": {
|
||||
"one": "{count} Kartenobjekt",
|
||||
@@ -2687,7 +2673,6 @@
|
||||
"Activate to toggle voice": "Aktivieren, um die Sprachsteuerung umzuschalten",
|
||||
"Voice active": "Sprachsteuerung aktiv",
|
||||
"Voice could not be started.": "Sprachsteuerung konnte nicht gestartet werden.",
|
||||
"the active voice session": "die aktive Sprachsitzung",
|
||||
"{value} feet": "{value} Fuß",
|
||||
"Fire · FRP {frp} MW": "Brand · FRP {frp} MW",
|
||||
"The flights layer is not enabled": "Die Ebene „Live-Flüge“ ist nicht aktiviert",
|
||||
@@ -2923,11 +2908,40 @@
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
|
||||
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
|
||||
"route proxy error": "Fehler des Routen-Proxys",
|
||||
"Colour of {name}": "Farbe von {name}",
|
||||
"Change the colour of {name}": "Farbe von {name} ändern",
|
||||
"scene|PLAY": "START",
|
||||
"AUTHORING": "ERSTELLUNG",
|
||||
"Create and edit scenes": "Szenen erstellen und bearbeiten",
|
||||
"SCENE": "SZENE",
|
||||
"Scene": "Szene",
|
||||
"NEW SCENE": "NEUE SZENE",
|
||||
"Start a new, empty scene": "Neue, leere Szene beginnen",
|
||||
"DELETE": "LÖSCHEN",
|
||||
"Delete this scene and all of its shots": "Diese Szene mit allen Aufnahmen löschen",
|
||||
"SHOTS": "AUFNAHMEN",
|
||||
"+ CAPTURE SHOT": "+ AUFNAHME SPEICHERN",
|
||||
"Add the current view, style and layers as the next shot": "Aktuelle Ansicht, Stil und Ebenen als nächste Aufnahme hinzufügen",
|
||||
"Replace the selected shot with the current view": "Ausgewählte Aufnahme durch die aktuelle Ansicht ersetzen",
|
||||
"Click a shot to select it, double-click to rename.": "Aufnahme anklicken zum Auswählen, Doppelklick zum Umbenennen.",
|
||||
"Play from this scene through the rest of the project": "Ab dieser Szene bis zum Ende des Projekts abspielen",
|
||||
"Stop playback (Esc)": "Wiedergabe stoppen (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Aufnahme nach der ausgewählten abspielen, bei Bedarf in der nächsten Szene",
|
||||
"SAVE & SHARE": "SPEICHERN & TEILEN",
|
||||
"Download every scene as a JSON file you can import later": "Alle Szenen als JSON-Datei zum späteren Import herunterladen",
|
||||
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
|
||||
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
|
||||
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
|
||||
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
|
||||
"Left panels": "Linke Panels",
|
||||
"Right panels": "Rechte Panels",
|
||||
"CONFIRMED": "BESTÄTIGT",
|
||||
"PAD / RTLS": "STARTPLATZ / RTLS",
|
||||
"Name of {name}": "Name von {name}",
|
||||
"Change the colour of {name}": "Farbe von {name} ändern",
|
||||
"Colour of {name}": "Farbe von {name}",
|
||||
"Colour of the next shape": "Farbe der nächsten Form",
|
||||
"Drawn shapes": "Gezeichnete Formen",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"Colour of the next shape": "Farbe der nächsten Form",
|
||||
"Drawn shapes": "Gezeichnete Formen"
|
||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
|
||||
}
|
||||
+34
-20
@@ -129,8 +129,6 @@
|
||||
"PREV": "ANT.",
|
||||
"Previous cockpit vision style": "Estilo de visión de cabina anterior",
|
||||
"Previous vision style": "Estilo de visión anterior",
|
||||
"Current cockpit vision style: NORMAL. Activate for next style.": "Estilo de visión de cabina actual: NORMAL. Actívalo para pasar al siguiente estilo.",
|
||||
"Current style: NORMAL — click for next": "Estilo actual: NORMAL — haz clic para el siguiente",
|
||||
"NEXT": "SIG.",
|
||||
"Next cockpit vision style": "Siguiente estilo de visión de cabina",
|
||||
"Next vision style": "Siguiente estilo de visión",
|
||||
@@ -178,7 +176,6 @@
|
||||
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SOLO DATOS DISPONIBLES · NO IMPLICA TODO DESPEJADO",
|
||||
"Enable cockpit weather effects": "Activar los efectos climáticos de la cabina",
|
||||
"CONTEXT": "CONTEXTO",
|
||||
"Expand panel": "Expandir panel",
|
||||
"Context mode": "Modo de contexto",
|
||||
"CONTACTS": "CONTACTOS",
|
||||
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Recorre los contactos más cercanos del tipo que elijas — aeronaves, buques, instalaciones. Los satélites se rastrean por separado.",
|
||||
@@ -665,7 +662,6 @@
|
||||
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
|
||||
"▲ {frp} MW": "▲ {frp} MW",
|
||||
"FIRE": "INCENDIO",
|
||||
"FIRES": "INCENDIOS",
|
||||
"{value} conf": "confianza {value}",
|
||||
"{value} ago": "hace {value}",
|
||||
"max {value} MW": "máx {value} MW",
|
||||
@@ -732,6 +728,8 @@
|
||||
"awaiting usable AIS positions…": "esperando posiciones AIS utilizables…",
|
||||
"awaiting first AIS message…": "esperando el primer mensaje AIS…",
|
||||
"EST. DOWNRANGE": "DIST. ESTIMADA",
|
||||
"CONFIRMED": "CONFIRMADA",
|
||||
"PAD / RTLS": "PLATAFORMA / RTLS",
|
||||
"MISSION CONTROL · FIRST LAUNCH": "CENTRO DE MANDO · PRIMER INICIO",
|
||||
"Choose your first view": "Elige tu primera vista",
|
||||
"LIVE CONTACTS": "CONTACTOS EN VIVO",
|
||||
@@ -752,15 +750,8 @@
|
||||
"The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "El globo ya funciona sin claves. Cada clave de abajo activa otra fuente real — pega una y queda guardada en la configuración local de esta app, y luego el servidor se reinicia solo. Las claves del servidor se quedan en esta máquina; Google Maps y Cesium ion funcionan en el navegador y sus claves deben restringirse en la consola del proveedor. Las claves que configuraste en otro lugar se muestran, pero nunca se modifican.",
|
||||
"ESC to close": "ESC para cerrar",
|
||||
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
|
||||
"SCENES": "ESCENAS",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Scene recipe": "Receta de escena",
|
||||
"NEW": "NUEVA",
|
||||
"DEL": "BORRAR",
|
||||
"CAPTURE SHOT": "CAPTURAR PLANO",
|
||||
"UPDATE SHOT": "ACTUALIZAR PLANO",
|
||||
"START": "INICIAR",
|
||||
"EXPORT PRESETS": "EXPORTAR PREAJUSTES",
|
||||
"IMPORT": "IMPORTAR",
|
||||
"RUN LOG": "REGISTRO DE EJECUCIÓN",
|
||||
"Starting live contacts…": "Iniciando contactos en vivo…",
|
||||
@@ -1268,6 +1259,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",
|
||||
@@ -2167,7 +2160,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.",
|
||||
@@ -2478,9 +2471,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",
|
||||
@@ -2666,9 +2656,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",
|
||||
@@ -2869,7 +2856,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",
|
||||
@@ -2999,5 +2985,33 @@
|
||||
"Preview is off.": "La vista previa está desactivada.",
|
||||
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas"
|
||||
"route proxy error": "error del proxy de rutas",
|
||||
"AUTHORING": "CREACIÓN",
|
||||
"Create and edit scenes": "Crea y edita escenas",
|
||||
"SCENE": "ESCENA",
|
||||
"Scene": "Escena",
|
||||
"NEW SCENE": "NUEVA ESCENA",
|
||||
"Start a new, empty scene": "Empieza una escena nueva y vacía",
|
||||
"DELETE": "ELIMINAR",
|
||||
"Delete this scene and all of its shots": "Elimina esta escena y todos sus planos",
|
||||
"SHOTS": "PLANOS",
|
||||
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
|
||||
"Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano",
|
||||
"Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual",
|
||||
"Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.",
|
||||
"scene|PLAY": "REPROD.",
|
||||
"Stop playback (Esc)": "Detén la reproducción (Esc)",
|
||||
"SAVE & SHARE": "GUARDAR Y COMPARTIR",
|
||||
"Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde",
|
||||
"Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado",
|
||||
"Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción",
|
||||
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
|
||||
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
|
||||
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
|
||||
"Left panels": "Paneles izquierdos",
|
||||
"Right panels": "Paneles derechos",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
|
||||
}
|
||||
+36
-22
@@ -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.",
|
||||
@@ -2955,13 +2946,7 @@
|
||||
"ALL": "TOUTES",
|
||||
"FLAT": "CLASSIQUES",
|
||||
"SINCE": "DEPUIS",
|
||||
"SCENES": "SCÈNES",
|
||||
"Scene recipe": "Recette de scène",
|
||||
"NEW": "NOUV.",
|
||||
"CAPTURE SHOT": "CAPTURER UN PLAN",
|
||||
"UPDATE SHOT": "MAJ DU PLAN",
|
||||
"START": "LANCER",
|
||||
"EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES",
|
||||
"IMPORT": "IMPORTER",
|
||||
"RUN LOG": "JOURNAL",
|
||||
"POWER UP": "ALLUMER",
|
||||
@@ -2993,11 +2978,40 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
"SCENE": "SCÈNE",
|
||||
"Scene": "Scène",
|
||||
"NEW SCENE": "NOUVELLE SCÈNE",
|
||||
"Start a new, empty scene": "Commencer une nouvelle scène vide",
|
||||
"DELETE": "SUPPRIMER",
|
||||
"Delete this scene and all of its shots": "Supprimer cette scène et tous ses plans",
|
||||
"SHOTS": "PLANS",
|
||||
"+ CAPTURE SHOT": "+ CAPTURER UN PLAN",
|
||||
"Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant",
|
||||
"Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle",
|
||||
"Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.",
|
||||
"Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet",
|
||||
"Stop playback (Esc)": "Arrêter la lecture (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin",
|
||||
"SAVE & SHARE": "ENREGISTRER ET PARTAGER",
|
||||
"Download every scene as a JSON file you can import later": "Télécharger toutes les scènes dans un fichier JSON à importer plus tard",
|
||||
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
|
||||
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
|
||||
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
|
||||
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources",
|
||||
"Left panels": "Panneaux de gauche",
|
||||
"Right panels": "Panneaux de droite",
|
||||
"CONFIRMED": "CONFIRMÉE",
|
||||
"PAD / RTLS": "PAS DE TIR / RTLS",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
"Colour of the next shape": "Couleur de la prochaine forme",
|
||||
"Drawn shapes": "Formes dessinées",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"Colour of the next shape": "Couleur de la prochaine forme",
|
||||
"Drawn shapes": "Formes dessinées"
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
|
||||
}
|
||||
+36
-22
@@ -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.",
|
||||
@@ -2955,13 +2946,7 @@
|
||||
"ALL": "TUTTE",
|
||||
"FLAT": "PIATTE",
|
||||
"SINCE": "DAL",
|
||||
"SCENES": "SCENE",
|
||||
"Scene recipe": "Ricetta della scena",
|
||||
"NEW": "NUOVA",
|
||||
"CAPTURE SHOT": "CATTURA SCATTO",
|
||||
"UPDATE SHOT": "AGGIORNA SCATTO",
|
||||
"START": "AVVIA",
|
||||
"EXPORT PRESETS": "ESPORTA PRESET",
|
||||
"IMPORT": "IMPORTA",
|
||||
"RUN LOG": "LOG ESECUZIONE",
|
||||
"POWER UP": "ACCENDI",
|
||||
@@ -2993,11 +2978,40 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
|
||||
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
|
||||
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
"SCENE": "SCENA",
|
||||
"Scene": "Scena",
|
||||
"NEW SCENE": "NUOVA SCENA",
|
||||
"Start a new, empty scene": "Inizia una nuova scena vuota",
|
||||
"DELETE": "ELIMINA",
|
||||
"Delete this scene and all of its shots": "Elimina questa scena e tutti i suoi scatti",
|
||||
"SHOTS": "SCATTI",
|
||||
"+ CAPTURE SHOT": "+ CATTURA SCATTO",
|
||||
"Add the current view, style and layers as the next shot": "Aggiungi la vista, lo stile e i livelli attuali come scatto successivo",
|
||||
"Replace the selected shot with the current view": "Sostituisci lo scatto selezionato con la vista attuale",
|
||||
"Click a shot to select it, double-click to rename.": "Fai clic su uno scatto per selezionarlo, doppio clic per rinominarlo.",
|
||||
"Play from this scene through the rest of the project": "Riproduci da questa scena fino alla fine del progetto",
|
||||
"Stop playback (Esc)": "Interrompi la riproduzione (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Riproduci lo scatto dopo quello selezionato, passando alla scena successiva se serve",
|
||||
"SAVE & SHARE": "SALVA E CONDIVIDI",
|
||||
"Download every scene as a JSON file you can import later": "Scarica tutte le scene in un file JSON da importare in seguito",
|
||||
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
|
||||
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
|
||||
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
|
||||
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse",
|
||||
"Left panels": "Pannelli a sinistra",
|
||||
"Right panels": "Pannelli a destra",
|
||||
"CONFIRMED": "CONFERMATA",
|
||||
"PAD / RTLS": "SITO DI LANCIO / RTLS",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
"Colour of the next shape": "Colore della prossima forma",
|
||||
"Drawn shapes": "Forme disegnate",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"Colour of the next shape": "Colore della prossima forma",
|
||||
"Drawn shapes": "Forme disegnate"
|
||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
|
||||
}
|
||||
+36
-22
@@ -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.": "選択した種類(航空機、船舶、施設)の最寄りのコンタクトを順に切り替えます。衛星は独立して追跡されます。",
|
||||
@@ -2813,13 +2804,7 @@
|
||||
"ALL": "すべて",
|
||||
"FLAT": "平面",
|
||||
"SINCE": "期間",
|
||||
"SCENES": "シーン",
|
||||
"Scene recipe": "シーンレシピ",
|
||||
"NEW": "新規",
|
||||
"CAPTURE SHOT": "ショット取得",
|
||||
"UPDATE SHOT": "ショット更新",
|
||||
"START": "開始",
|
||||
"EXPORT PRESETS": "エクスポート",
|
||||
"IMPORT": "インポート",
|
||||
"RUN LOG": "実行ログ",
|
||||
"POWER UP": "パワーアップ",
|
||||
@@ -2853,11 +2838,40 @@
|
||||
"route proxy error": "ルート検索プロキシでエラーが発生しました",
|
||||
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
|
||||
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
|
||||
"Colour of {name}": "{name}の色",
|
||||
"Change the colour of {name}": "{name}の色を変更",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
"SCENE": "シーン",
|
||||
"Scene": "シーン",
|
||||
"NEW SCENE": "新規シーン",
|
||||
"Start a new, empty scene": "空の新しいシーンを作成",
|
||||
"DELETE": "削除",
|
||||
"Delete this scene and all of its shots": "このシーンとすべてのショットを削除",
|
||||
"SHOTS": "ショット",
|
||||
"+ CAPTURE SHOT": "+ ショット取得",
|
||||
"Add the current view, style and layers as the next shot": "現在の視点・スタイル・レイヤーを次のショットとして追加",
|
||||
"Replace the selected shot with the current view": "選択中のショットを現在の視点で置き換え",
|
||||
"Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。",
|
||||
"Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生",
|
||||
"Stop playback (Esc)": "再生を停止(Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)",
|
||||
"SAVE & SHARE": "保存と共有",
|
||||
"Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード",
|
||||
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
|
||||
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
|
||||
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
|
||||
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
|
||||
"Left panels": "左パネル",
|
||||
"Right panels": "右パネル",
|
||||
"CONFIRMED": "確認済み",
|
||||
"PAD / RTLS": "射場 / RTLS",
|
||||
"Name of {name}": "{name}の名前",
|
||||
"Change the colour of {name}": "{name}の色を変更",
|
||||
"Colour of {name}": "{name}の色",
|
||||
"Colour of the next shape": "次の図形の色",
|
||||
"Drawn shapes": "描いた図形",
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Colour of the next shape": "次の図形の色",
|
||||
"Drawn shapes": "描いた図形"
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
|
||||
}
|
||||
+36
-22
@@ -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.",
|
||||
@@ -2957,13 +2948,7 @@
|
||||
"ALL": "TODAS",
|
||||
"FLAT": "PLANAS",
|
||||
"SINCE": "DESDE",
|
||||
"SCENES": "CENAS",
|
||||
"Scene recipe": "Receita de cena",
|
||||
"NEW": "NOVA",
|
||||
"CAPTURE SHOT": "CAPTURAR PLANO",
|
||||
"UPDATE SHOT": "ATUALIZAR PLANO",
|
||||
"START": "INICIAR",
|
||||
"EXPORT PRESETS": "EXPORTAR PREDEF.",
|
||||
"IMPORT": "IMPORTAR",
|
||||
"RUN LOG": "REGISTRO",
|
||||
"POWER UP": "ATIVAR",
|
||||
@@ -2993,11 +2978,40 @@
|
||||
"Don't show this again": "Não mostrar novamente",
|
||||
"ESC to dismiss": "ESC para fechar",
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
"SCENE": "CENA",
|
||||
"Scene": "Cena",
|
||||
"NEW SCENE": "NOVA CENA",
|
||||
"Start a new, empty scene": "Começar uma nova cena vazia",
|
||||
"DELETE": "EXCLUIR",
|
||||
"Delete this scene and all of its shots": "Excluir esta cena e todos os seus planos",
|
||||
"SHOTS": "PLANOS",
|
||||
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
|
||||
"Add the current view, style and layers as the next shot": "Adicionar a vista, o estilo e as camadas atuais como próximo plano",
|
||||
"Replace the selected shot with the current view": "Substituir o plano selecionado pela vista atual",
|
||||
"Click a shot to select it, double-click to rename.": "Clique em um plano para selecioná-lo e clique duas vezes para renomeá-lo.",
|
||||
"Play from this scene through the rest of the project": "Reproduzir desta cena até o fim do projeto",
|
||||
"Stop playback (Esc)": "Parar a reprodução (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduzir o plano após o selecionado, passando à próxima cena se necessário",
|
||||
"SAVE & SHARE": "SALVAR E COMPARTILHAR",
|
||||
"Download every scene as a JSON file you can import later": "Baixar todas as cenas em um arquivo JSON para importar depois",
|
||||
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
|
||||
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
|
||||
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
|
||||
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos",
|
||||
"Left panels": "Painéis à esquerda",
|
||||
"Right panels": "Painéis à direita",
|
||||
"CONFIRMED": "CONFIRMADA",
|
||||
"PAD / RTLS": "PLATAFORMA / RTLS",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
"Colour of the next shape": "Cor da próxima forma",
|
||||
"Drawn shapes": "Formas desenhadas",
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Colour of the next shape": "Cor da próxima forma",
|
||||
"Drawn shapes": "Formas desenhadas"
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
|
||||
}
|
||||
+34
-20
@@ -135,7 +135,6 @@
|
||||
"NIGHT": "НОЧЬ",
|
||||
"▲ {frp} MW": "▲ {frp} МВт",
|
||||
"FIRE": "ПОЖАР",
|
||||
"FIRES": "ПОЖ.",
|
||||
"max {value} MW": "макс. {value} МВт",
|
||||
"new {value}": "последний {value}",
|
||||
"just now": "только что",
|
||||
@@ -217,6 +216,8 @@
|
||||
"Pause replay": "Приостановить повтор",
|
||||
"Cancel replay": "Отменить повтор",
|
||||
"EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ",
|
||||
"CONFIRMED": "ПОДТВЕРЖДЕНО",
|
||||
"PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS",
|
||||
"PLANNED · ": "ПЛАН · ",
|
||||
"SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ",
|
||||
"RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА",
|
||||
@@ -626,8 +627,6 @@
|
||||
"Cockpit display and Radio controls": "Управление экраном и радио кабины",
|
||||
"Previous cockpit vision style": "Предыдущий стиль обзора кабины",
|
||||
"Previous vision style": "Предыдущий стиль обзора",
|
||||
"Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: ОБЫЧНЫЙ. Активируйте для следующего стиля.",
|
||||
"Current style: NORMAL — click for next": "Текущий стиль: ОБЫЧНЫЙ — нажмите для следующего",
|
||||
"Next cockpit vision style": "Следующий стиль обзора кабины",
|
||||
"Next vision style": "Следующий стиль обзора",
|
||||
"Previous briefing page": "Предыдущая страница брифинга",
|
||||
@@ -709,7 +708,6 @@
|
||||
"FLIR": "FLIR",
|
||||
"Style": "Стиль",
|
||||
"STATION SITE": "САЙТ СТАНЦИИ",
|
||||
"Expand panel": "Развернуть панель",
|
||||
"CONTACTS": "КОНТАКТЫ",
|
||||
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
|
||||
"SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ",
|
||||
@@ -795,22 +793,15 @@
|
||||
"East offset from catalog position (m) — click to type": "Смещение на восток от позиции каталога (м) — нажмите для ввода",
|
||||
"SAVE CAL": "СОХРАНИТЬ КАЛИБР.",
|
||||
"RESET CAL": "СБРОСИТЬ КАЛИБР.",
|
||||
"NEW": "НОВАЯ",
|
||||
"DEL": "УДАЛ.",
|
||||
"CAPTURE SHOT": "СДЕЛАТЬ КАДР",
|
||||
"UPDATE SHOT": "ОБНОВИТЬ КАДР",
|
||||
"START": "СТАРТ",
|
||||
"EXPORT PRESETS": "ЭКСПОРТ ПРЕСЕТОВ",
|
||||
"IMPORT": "ИМПОРТ",
|
||||
"RUN LOG": "ЖУРНАЛ ЗАПУСКОВ",
|
||||
"CALIBRATION": "КАЛИБРОВКА",
|
||||
"Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести",
|
||||
"SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ",
|
||||
"CCTV camera": "Камера CCTV",
|
||||
"Scene recipe": "Рецепт сцены",
|
||||
"DATA LAYERS": "СЛОИ ДАННЫХ",
|
||||
"SCENES": "СЦЕНЫ",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Close key setup": "Закрыть настройку ключей",
|
||||
"SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ",
|
||||
"Power up the globe": "Подключите потоки данных",
|
||||
@@ -1286,6 +1277,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": "Уменьшение движения · воспроизведение истории недоступно",
|
||||
@@ -2201,7 +2194,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 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
|
||||
@@ -2520,9 +2513,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": "подключите приёмник в разделе «Радио»",
|
||||
@@ -2724,9 +2714,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": "Турбовинтовой самолёт",
|
||||
@@ -2937,7 +2924,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": "Выйдите из кабины, чтобы показать контакты в кадре",
|
||||
@@ -3069,5 +3055,33 @@
|
||||
"Preview is off.": "Предпросмотр отключён.",
|
||||
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов"
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
"AUTHORING": "РЕДАКТОР",
|
||||
"Create and edit scenes": "Создание и редактирование сцен",
|
||||
"SCENE": "СЦЕНА",
|
||||
"Scene": "Сцена",
|
||||
"NEW SCENE": "НОВАЯ СЦЕНА",
|
||||
"Start a new, empty scene": "Создать новую пустую сцену",
|
||||
"DELETE": "УДАЛИТЬ",
|
||||
"Delete this scene and all of its shots": "Удалить эту сцену и все её кадры",
|
||||
"SHOTS": "КАДРЫ",
|
||||
"+ CAPTURE SHOT": "+ СДЕЛАТЬ КАДР",
|
||||
"Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром",
|
||||
"Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом",
|
||||
"Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.",
|
||||
"scene|PLAY": "ПУСК",
|
||||
"Stop playback (Esc)": "Остановить воспроизведение (Esc)",
|
||||
"SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ",
|
||||
"Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта",
|
||||
"Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON",
|
||||
"Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения",
|
||||
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
|
||||
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
|
||||
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
|
||||
"Left panels": "Левые панели",
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
|
||||
}
|
||||
+36
-22
@@ -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.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.",
|
||||
@@ -3025,13 +3016,7 @@
|
||||
"ALL": "УСІ",
|
||||
"FLAT": "ПЛОСКІ",
|
||||
"SINCE": "З ДАТИ",
|
||||
"SCENES": "СЦЕНИ",
|
||||
"Scene recipe": "Рецепт сцени",
|
||||
"NEW": "НОВА",
|
||||
"CAPTURE SHOT": "ЗНЯТИ КАДР",
|
||||
"UPDATE SHOT": "ОНОВИТИ КАДР",
|
||||
"START": "СТАРТ",
|
||||
"EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ",
|
||||
"IMPORT": "ІМПОРТУВАТИ",
|
||||
"RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ",
|
||||
"POWER UP": "ЗАПУСК",
|
||||
@@ -3063,11 +3048,40 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"Colour of {name}": "Колір: {name}",
|
||||
"Change the colour of {name}": "Змінити колір: {name}",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
"AUTHORING": "РЕДАКТОР",
|
||||
"Create and edit scenes": "Створення й редагування сцен",
|
||||
"SCENE": "СЦЕНА",
|
||||
"Scene": "Сцена",
|
||||
"NEW SCENE": "НОВА СЦЕНА",
|
||||
"Start a new, empty scene": "Створити нову порожню сцену",
|
||||
"DELETE": "ВИДАЛИТИ",
|
||||
"Delete this scene and all of its shots": "Видалити цю сцену з усіма кадрами",
|
||||
"SHOTS": "КАДРИ",
|
||||
"+ CAPTURE SHOT": "+ ЗНЯТИ КАДР",
|
||||
"Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр",
|
||||
"Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом",
|
||||
"Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
|
||||
"Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту",
|
||||
"Stop playback (Esc)": "Зупинити відтворення (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени",
|
||||
"SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ",
|
||||
"Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту",
|
||||
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
|
||||
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
|
||||
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
|
||||
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
|
||||
"Left panels": "Ліві панелі",
|
||||
"Right panels": "Праві панелі",
|
||||
"CONFIRMED": "ПІДТВЕРДЖЕНО",
|
||||
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS",
|
||||
"Name of {name}": "Назва: {name}",
|
||||
"Change the colour of {name}": "Змінити колір: {name}",
|
||||
"Colour of {name}": "Колір: {name}",
|
||||
"Colour of the next shape": "Колір наступної фігури",
|
||||
"Drawn shapes": "Намальовані фігури",
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Colour of the next shape": "Колір наступної фігури",
|
||||
"Drawn shapes": "Намальовані фігури"
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
|
||||
}
|
||||
+34
-20
@@ -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} 个地图对象"
|
||||
@@ -2316,7 +2313,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": "军用航班图层未开启",
|
||||
@@ -2460,8 +2456,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": "下一个视觉样式",
|
||||
@@ -2548,7 +2542,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.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。",
|
||||
@@ -2714,14 +2707,7 @@
|
||||
"ALL": "全部",
|
||||
"FLAT": "平面",
|
||||
"SINCE": "起始时间",
|
||||
"SCENES": "场景",
|
||||
"UTILITIES": "工具",
|
||||
"Scene recipe": "场景方案",
|
||||
"NEW": "新建",
|
||||
"CAPTURE SHOT": "保存镜头",
|
||||
"UPDATE SHOT": "更新镜头",
|
||||
"START": "启动",
|
||||
"EXPORT PRESETS": "导出预设",
|
||||
"IMPORT": "导入",
|
||||
"RUN LOG": "运行日志",
|
||||
"POWER UP": "增强",
|
||||
@@ -2859,5 +2845,33 @@
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
|
||||
"route proxy error": "路线代理出错"
|
||||
"route proxy error": "路线代理出错",
|
||||
"AUTHORING": "创作",
|
||||
"Create and edit scenes": "创建和编辑场景",
|
||||
"SCENE": "场景",
|
||||
"Scene": "场景",
|
||||
"NEW SCENE": "新建场景",
|
||||
"Start a new, empty scene": "新建一个空场景",
|
||||
"DELETE": "删除",
|
||||
"Delete this scene and all of its shots": "删除此场景及其所有镜头",
|
||||
"SHOTS": "镜头",
|
||||
"+ CAPTURE SHOT": "+ 保存镜头",
|
||||
"Add the current view, style and layers as the next shot": "将当前视角、样式和图层添加为下一个镜头",
|
||||
"Replace the selected shot with the current view": "用当前视角替换所选镜头",
|
||||
"Click a shot to select it, double-click to rename.": "单击镜头即可选中,双击可重命名。",
|
||||
"scene|PLAY": "播放",
|
||||
"Stop playback (Esc)": "停止播放(Esc)",
|
||||
"SAVE & SHARE": "保存与分享",
|
||||
"Download every scene as a JSON file you can import later": "将所有场景下载为 JSON 文件,以便日后导入",
|
||||
"Load scenes from an exported JSON file": "从导出的 JSON 文件加载场景",
|
||||
"Download the timing log of the last play": "下载上次播放的计时日志",
|
||||
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
|
||||
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
|
||||
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
|
||||
"Left panels": "左侧面板",
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
|
||||
}
|
||||
@@ -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} 個地圖對象"
|
||||
@@ -2304,7 +2301,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": "軍用航班圖層未開啟",
|
||||
@@ -2448,8 +2444,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": "下一個視覺樣式",
|
||||
@@ -2536,7 +2530,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.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。",
|
||||
@@ -2702,14 +2695,7 @@
|
||||
"ALL": "全部",
|
||||
"FLAT": "平面",
|
||||
"SINCE": "起始時間",
|
||||
"SCENES": "場景",
|
||||
"UTILITIES": "工具",
|
||||
"Scene recipe": "場景方案",
|
||||
"NEW": "新建",
|
||||
"CAPTURE SHOT": "保存鏡頭",
|
||||
"UPDATE SHOT": "更新鏡頭",
|
||||
"START": "啟動",
|
||||
"EXPORT PRESETS": "導出預設",
|
||||
"IMPORT": "導入",
|
||||
"RUN LOG": "運行日誌",
|
||||
"POWER UP": "增強",
|
||||
@@ -2859,5 +2845,33 @@
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
|
||||
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
|
||||
"route proxy error": "路線代理出錯"
|
||||
"route proxy error": "路線代理出錯",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
"Scene": "場景",
|
||||
"NEW SCENE": "新建場景",
|
||||
"Start a new, empty scene": "新建一個空場景",
|
||||
"DELETE": "刪除",
|
||||
"Delete this scene and all of its shots": "刪除此場景及其所有鏡頭",
|
||||
"SHOTS": "鏡頭",
|
||||
"+ CAPTURE SHOT": "+ 保存鏡頭",
|
||||
"Add the current view, style and layers as the next shot": "將當前視角、樣式和圖層添加為下一個鏡頭",
|
||||
"Replace the selected shot with the current view": "用當前視角替換所選鏡頭",
|
||||
"Click a shot to select it, double-click to rename.": "單擊鏡頭即可選中,雙擊可重命名。",
|
||||
"scene|PLAY": "播放",
|
||||
"Stop playback (Esc)": "停止播放(Esc)",
|
||||
"SAVE & SHARE": "保存與分享",
|
||||
"Download every scene as a JSON file you can import later": "將所有場景下載為 JSON 文件,以便日後導入",
|
||||
"Load scenes from an exported JSON file": "從導出的 JSON 文件加載場景",
|
||||
"Download the timing log of the last play": "下載上次播放的計時日誌",
|
||||
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
|
||||
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -94,6 +128,7 @@
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REGION",
|
||||
@@ -186,6 +221,7 @@
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Color: {units}",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
@@ -237,6 +273,7 @@
|
||||
"Pop",
|
||||
"Punk",
|
||||
"R&B",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"RANGE",
|
||||
"REC",
|
||||
@@ -403,6 +440,7 @@
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"Radio",
|
||||
@@ -566,6 +604,7 @@
|
||||
"PRECIP",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"RESET",
|
||||
@@ -769,6 +808,7 @@
|
||||
"Paris",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Reggae",
|
||||
|
||||
Loaded 100 of 699 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user