mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into fix/multipart-cable-feature-ownership
This commit is contained in:
commit
c1b12a6aa4
1214 files changed
+106591
-22945
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).
|
||||
@@ -1572,9 +1573,7 @@ function greatCircleM(a, b) {
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
if (!Number.isFinite(m)) return null;
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return `${Math.round(m / 10) * 10} m`;
|
||||
return formatDistanceText(m, { metreStep: 10 });
|
||||
}
|
||||
|
||||
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
|
||||
|
||||
@@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const labels = [];
|
||||
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
for (const metres of [994, 996, 9940, 9960]) {
|
||||
await engine.annotate([
|
||||
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
|
||||
]);
|
||||
}
|
||||
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
|
||||
engine.destroy();
|
||||
});
|
||||
+12
-11
@@ -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 });
|
||||
@@ -31,10 +32,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
*/
|
||||
export const MAX_VERTICES = 512;
|
||||
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
|
||||
export const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
|
||||
export const MIN_PATH_LENGTH_M = 1;
|
||||
export const MIN_AREA_M2 = 1;
|
||||
const MIN_PATH_LENGTH_M = 1;
|
||||
const MIN_AREA_M2 = 1;
|
||||
|
||||
/** @param {string} shape @returns {'area'|'line'|'pin'} */
|
||||
export function normalizeShape(shape) {
|
||||
@@ -50,7 +51,7 @@ export function createDrawSession(shape = 'area') {
|
||||
}
|
||||
|
||||
/** Great-circle distance in metres between two {lon, lat} points. */
|
||||
export function greatCircleM(a, b) {
|
||||
function greatCircleM(a, b) {
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
@@ -207,16 +208,16 @@ export function ringCentroid(vertices) {
|
||||
export function formatMeasure(session) {
|
||||
if (!session) return '';
|
||||
if (session.shape === 'line') {
|
||||
const m = pathLengthM(session.vertices);
|
||||
return m >= 1000
|
||||
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
|
||||
: `${Math.round(m)} m`;
|
||||
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 '';
|
||||
}
|
||||
|
||||
@@ -330,3 +330,66 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
|
||||
assert.equal(wrapLongitude(180), -180);
|
||||
assert.equal(unwrapLongitudes([]).length, 0);
|
||||
});
|
||||
|
||||
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
const lat = 999.6 / metresPerDegree;
|
||||
const line = createDrawSession('line');
|
||||
addVertex(line, { lon: 0, lat: 0 });
|
||||
addVertex(line, { lon: 0, lat });
|
||||
const metres = pathLengthM(line.vertices);
|
||||
assert.ok(
|
||||
metres >= 999.5 && metres < 1000,
|
||||
`length should round to 1000 m, got ${metres}`,
|
||||
);
|
||||
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');
|
||||
});
|
||||
+80
-90
@@ -30,7 +30,6 @@
|
||||
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSegmentedControl } from '../ui/segmentedControl.js';
|
||||
import { pickWorldFromScreen } from './annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
@@ -50,9 +49,9 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
@@ -65,26 +64,32 @@ const PREVIEW = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire the Draw control. Returns a handle with a `destroy()` the application
|
||||
* Run the Draw control. Returns a handle with a `destroy()` the application
|
||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
|
||||
* The tool hands what the controls show (`{ active, shape, color, label,
|
||||
* hint }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({ viewer, annotations }) {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
const modeRow = document.getElementById('draw-mode-row');
|
||||
const labelRow = document.getElementById('draw-label-row');
|
||||
const labelInput = document.getElementById('draw-label-input');
|
||||
const colorSelect = document.getElementById('draw-color-select');
|
||||
const clearButton = document.getElementById('draw-clear');
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (!viewer || !annotations || !toggle) return null;
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
let session = null;
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let label = '';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
@@ -95,25 +100,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// one of those must not write its outcome over the newer state.
|
||||
let generation = 0;
|
||||
const previewEntities = [];
|
||||
// Every DOM listener this tool adds, so teardown is one loop rather than a
|
||||
// list that can fall out of step with the bindings below.
|
||||
// Every DOM listener this tool adds (the session's keydown), so teardown is
|
||||
// one loop rather than a list that can fall out of step with the bindings.
|
||||
const domListeners = [];
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target) return;
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -124,9 +117,18 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () => publish({ active, shape, color, label, hint });
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
const setHint = (text) => {
|
||||
if (hint) hint.textContent = text;
|
||||
hintText = text;
|
||||
hint = text();
|
||||
present();
|
||||
};
|
||||
const offLocale = onLocaleChange(() => {
|
||||
if (!destroyed) setHint(hintText);
|
||||
});
|
||||
|
||||
// ---- preview ---------------------------------------------------------
|
||||
const vertexPositions = () =>
|
||||
@@ -161,7 +163,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
previewEntities.length = 0;
|
||||
if (!session) {
|
||||
previewLine.show = false;
|
||||
setHint(drawHint(null));
|
||||
setHint(() => drawHint(null));
|
||||
viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +189,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
setHint(drawHint(session));
|
||||
setHint(() => drawHint(session));
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
|
||||
@@ -210,14 +212,14 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// A click that changed nothing still deserves an answer when the reason is
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
setHint(() => t('That point is off the globe — click on the world.'));
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
@@ -235,17 +237,18 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (reason !== 'ok') {
|
||||
// 'too-few' is just "keep clicking" and the hint already says so;
|
||||
// a degenerate or off-globe shape needs to be told why it was refused.
|
||||
if (reason !== 'too-few') setHint(drawHint(session));
|
||||
if (reason !== 'too-few') setHint(() => drawHint(session));
|
||||
return null;
|
||||
}
|
||||
const spec = finishSpec(session, { label: labelInput?.value || '', color });
|
||||
const spec = finishSpec(session, { label, color });
|
||||
if (!spec) return null;
|
||||
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
||||
// half of a double-click cannot submit the same shape twice.
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
if (labelInput) labelInput.value = '';
|
||||
label = '';
|
||||
present();
|
||||
const attempt = generation;
|
||||
try {
|
||||
const result = await annotations.annotate([spec], {
|
||||
@@ -253,11 +256,12 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
flyTo: false,
|
||||
});
|
||||
if (destroyed || attempt !== generation) return result;
|
||||
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
|
||||
if (result?.drawn === 0)
|
||||
setHint(() => t('That shape could not be placed.'));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
@@ -279,13 +283,15 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (session) session = createDrawSession(shape);
|
||||
annotations.clear();
|
||||
syncPreview();
|
||||
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||
if (!destroyed && !active) setHint(() => t('Board cleared.'));
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
// The label field is this tool's own: typing there still drives drawing.
|
||||
const isLabelField = (target) => target?.id === 'draw-label-input';
|
||||
const typingElsewhere = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return false;
|
||||
if (!t || isLabelField(t)) return false;
|
||||
return (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
||||
);
|
||||
@@ -298,7 +304,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
*/
|
||||
const enterBelongsToDrawing = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return true;
|
||||
if (!t || isLabelField(t)) return true;
|
||||
if (
|
||||
typeof t.closest === 'function' &&
|
||||
t.closest('button, a, select, [role="button"], [role="radio"]')
|
||||
@@ -328,7 +334,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
else setActive(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && event.target !== labelInput) {
|
||||
if (event.key === 'Backspace' && !isLabelField(event.target)) {
|
||||
if (removeLastVertex(session)) {
|
||||
event.preventDefault();
|
||||
syncPreview();
|
||||
@@ -345,7 +351,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
@@ -354,10 +360,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
active = next;
|
||||
toggle.classList.toggle('active', active);
|
||||
toggle.setAttribute('aria-pressed', String(active));
|
||||
modeRow?.classList.toggle('visible', active);
|
||||
labelRow?.classList.toggle('visible', active);
|
||||
present();
|
||||
document.body.classList.toggle('gev-drawing', active);
|
||||
if (active) {
|
||||
session = createDrawSession(shape);
|
||||
@@ -419,8 +422,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
);
|
||||
savedDoubleClick = null;
|
||||
}
|
||||
// Drop just the keydown listener bound alongside this handler; the control
|
||||
// listeners below outlive a session and come off in destroy().
|
||||
// Drop the keydown listener bound alongside this handler.
|
||||
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
||||
const [target, type, listener, options] = domListeners[i];
|
||||
if (listener !== onKey) continue;
|
||||
@@ -429,37 +431,32 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
|
||||
listen(toggle, 'click', () => setActive(!active));
|
||||
const shapeButtons = [
|
||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||
];
|
||||
const shapeControl = createSegmentedControl({
|
||||
buttons: shapeButtons,
|
||||
valueOf: (button) => button.dataset.shape,
|
||||
listen,
|
||||
onChange: (value) => {
|
||||
shape = normalizeShape(value);
|
||||
shapeControl.update(shape);
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
},
|
||||
});
|
||||
listen(colorSelect, 'change', () => {
|
||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||
syncPreview();
|
||||
});
|
||||
listen(clearButton, 'click', clearAll);
|
||||
listen(labelInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter' && session?.vertices?.length) {
|
||||
event.preventDefault();
|
||||
void finish();
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
present();
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
}
|
||||
const disconnect = connect({
|
||||
toggleDraw: () => setActive(!active),
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
setDrawLabel: (text) => {
|
||||
label = String(text ?? '');
|
||||
present();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
});
|
||||
setHint(drawHint(null));
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
get active() {
|
||||
@@ -472,12 +469,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
return session;
|
||||
},
|
||||
setActive,
|
||||
setShape(next) {
|
||||
const btn = modeRow?.querySelector(
|
||||
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
|
||||
);
|
||||
btn?.click();
|
||||
},
|
||||
setShape: chooseShape,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -526,7 +518,8 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
offLocale();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
@@ -546,11 +539,8 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
});
|
||||
document.body.classList.remove('gev-drawing');
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-pressed', 'false');
|
||||
modeRow?.classList.remove('visible');
|
||||
labelRow?.classList.remove('visible');
|
||||
if (hint) hint.textContent = '';
|
||||
hint = '';
|
||||
present();
|
||||
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
||||
return attaching;
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
||||
const tools = read('src/app/tools.js');
|
||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,9 +62,9 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// The DISPLAY rail is a component template now, assembled into index.html at
|
||||
// build time — the markup is not in index.html any more.
|
||||
const html = read('src/ui/templates/display-controls.html');
|
||||
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
|
||||
// the markup is not in index.html any more.
|
||||
const html = read('src/ui/views/panels/DisplayPanel.vue');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
@@ -84,15 +84,25 @@ test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`data-shape="${shape}"`),
|
||||
new RegExp(`'data-shape': '${shape}'`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
for (const id of ids) {
|
||||
// The rows and the hint render what the tool publishes; the controls
|
||||
// call the actions the tool connects to the display store.
|
||||
for (const action of [
|
||||
"act('toggleDraw')",
|
||||
"act('setDrawShape', $event)",
|
||||
"act('setDrawLabel', $event.target.value)",
|
||||
"act('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
|
||||
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
|
||||
assert.match(
|
||||
tool(),
|
||||
new RegExp(`getElementById\\('${id}'\\)`),
|
||||
`drawTool must bind #${id}`,
|
||||
new RegExp(`\\b${name}: `),
|
||||
`drawTool must answer ${name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -102,10 +112,11 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
|
||||
// these rules lived in the root style.css. That file is an import shim now,
|
||||
// and putting rules back into it would silently undo the split.
|
||||
const shim = read('style.css');
|
||||
assert.ok(
|
||||
shim.split('\n').length < 40,
|
||||
'style.css is an import shim, not a stylesheet',
|
||||
);
|
||||
const rules = shim
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
|
||||
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
|
||||
assert.doesNotMatch(
|
||||
shim,
|
||||
/draw-hint|pp-text-input/,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -47,7 +46,6 @@ class FakeElement {
|
||||
this.attributes = new Map();
|
||||
this.textContent = '';
|
||||
this.value = '';
|
||||
this.children = [];
|
||||
this.listeners = [];
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
@@ -65,16 +63,6 @@ class FakeElement {
|
||||
);
|
||||
if (at >= 0) this.listeners.splice(at, 1);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.pp-mode-btn[data-shape]')
|
||||
return this.children.filter((child) => child.dataset.shape);
|
||||
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
|
||||
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
|
||||
return [];
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
click() {
|
||||
return this.emit('click');
|
||||
}
|
||||
@@ -108,18 +96,11 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, a focused button
|
||||
// whose Enter is its own, and the tool's label field.
|
||||
make('draw-toggle', 'BUTTON');
|
||||
const modeRow = make('draw-mode-row');
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
const button = new FakeElement('BUTTON');
|
||||
button.dataset.shape = shape;
|
||||
modeRow.children.push(button);
|
||||
}
|
||||
make('draw-label-row');
|
||||
make('draw-label-input', 'INPUT');
|
||||
make('draw-color-select', 'SELECT');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-hint', 'SPAN');
|
||||
make('draw-label-input', 'INPUT');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -218,14 +199,29 @@ function harness() {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.document = documentDouble;
|
||||
globalThis.window = globalThis.window || {};
|
||||
const tool = initDrawTool({ viewer, annotations });
|
||||
const shown = { view: null };
|
||||
// The controls' actions, as the display store holds them.
|
||||
const controls = { actions: null };
|
||||
const tool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
return () => (controls.actions = null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
tool,
|
||||
shown,
|
||||
viewer,
|
||||
annotations,
|
||||
byId,
|
||||
documentDouble,
|
||||
element: (id) => byId.get(id),
|
||||
controls,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||
restore() {
|
||||
resetPointerOwnership();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
@@ -241,38 +237,15 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// First in the file: tools left by later tests would answer the same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
assert.equal(isPointerFree(), true);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
|
||||
assert.equal(h.tool.active, true);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
|
||||
assert.equal(h.tool.active, false);
|
||||
} finally {
|
||||
@@ -284,18 +257,18 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
const h = harness();
|
||||
try {
|
||||
const other = claimPointer('directions');
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
// Named by its tool, not by the internal owner id.
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
h.shown.view.hint,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -305,13 +278,13 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
|
||||
const first = harness();
|
||||
try {
|
||||
first.element('draw-toggle').emit('click');
|
||||
first.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
// The replacement takes over before the old one finishes tearing down.
|
||||
first.tool.setActive(false);
|
||||
const second = harness();
|
||||
try {
|
||||
second.element('draw-toggle').emit('click');
|
||||
second.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
await first.tool.destroy();
|
||||
assert.equal(
|
||||
@@ -336,7 +309,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
undefined,
|
||||
@@ -347,7 +320,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
undefined,
|
||||
);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
@@ -365,7 +338,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
@@ -395,7 +368,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('line');
|
||||
h.tool.addVertex(-97.75, 30.26, 187.5);
|
||||
h.tool.addVertex(-97.74, 30.26, 212.25);
|
||||
@@ -449,7 +422,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
try {
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.ok(h.tool.diagnostics().domListeners > 0);
|
||||
@@ -463,7 +436,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||
assert.equal(h.element('draw-toggle').listeners.length, 0);
|
||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
@@ -481,15 +454,99 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
test('Clear wipes the board and the shape in progress', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
assert.equal(h.tool.session.vertices.length, 2);
|
||||
|
||||
h.element('draw-clear').emit('click');
|
||||
h.act('clearDraw');
|
||||
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
h.act('setDrawLabel', 'Route A');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
|
||||
'the view shows what the controls chose',
|
||||
);
|
||||
h.act('setDrawColor', 'purple');
|
||||
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
|
||||
h.act('setDrawColor', 'cyan');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
// Choosing a shape again starts the open one over.
|
||||
h.act('setDrawShape', 'line');
|
||||
assert.equal(h.tool.session.vertices.length, 0);
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
|
||||
assert.equal(spec.color, 'cyan');
|
||||
assert.equal(spec.label, 'Route A');
|
||||
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const label = h.element('draw-label-input');
|
||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
|
||||
assert.equal(h.tool.active, true, 'and leaves Draw on');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a page without the Draw controls gets no draw tool', () => {
|
||||
resetPointerOwnership();
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
try {
|
||||
let connected = false;
|
||||
const tool = initDrawTool({
|
||||
viewer: fakeViewer(),
|
||||
annotations: fakeAnnotations(),
|
||||
connect: () => {
|
||||
connected = true;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
assert.equal(tool, null);
|
||||
assert.equal(connected, false);
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
@@ -103,18 +103,6 @@ export function createAnnotationResolver({
|
||||
cacheWrite(cache, key, null);
|
||||
}
|
||||
|
||||
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
|
||||
function linkAbort(controller, externalSignal) {
|
||||
if (!externalSignal) return () => {};
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
return () => {};
|
||||
}
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
return () => externalSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single annotation target to a normalized world anchor.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
`
|
||||
|
||||
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
assert.equal(dataManager.layers.size, 0);
|
||||
});
|
||||
|
||||
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
getElementById: () => null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
t.after(() => {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
if (previous === undefined) delete globalThis.document;
|
||||
else globalThis.document = previous;
|
||||
});
|
||||
const releases = [];
|
||||
const { dataManager } = createApplicationData({
|
||||
scene: { viewer: {} },
|
||||
controls: { styleManager: { attachDataManager() {} } },
|
||||
catalog: createLayerCatalog([], []),
|
||||
defer: (release) => releases.push(release),
|
||||
});
|
||||
const visibility = dataManager._visibilityTarget;
|
||||
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
assert.equal(visibility.hidden, false);
|
||||
assert.equal(visibility.visibilityState, 'visible');
|
||||
for (const release of releases.reverse()) await release();
|
||||
});
|
||||
|
||||
test('controls bind catalog instances rather than similarly named defaults', () => {
|
||||
const ids = [
|
||||
'traffic',
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
@@ -125,7 +126,9 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
createApplicationGnssIntegrity({
|
||||
source: sources['gnss-integrity'],
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 32);
|
||||
assert.equal(first.layers.length, 33);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
@@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
);
|
||||
assert.ok(first.get('fire-perimeters'));
|
||||
assert.ok(first.get('gnss-integrity'));
|
||||
assert.ok(first.get('s2-ghostmaps'));
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
@@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 32);
|
||||
assert.equal(catalog.layers.length, 33);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
@@ -192,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
+11
-1
@@ -4,7 +4,12 @@ import { flyToAustin } from '../camera.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
/**
|
||||
* 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. `localePicker: false` removes the language
|
||||
* picker, for a host that does not initialize Core's locale.
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
loaderStatus,
|
||||
@@ -13,6 +18,8 @@ export function createApplicationControls({
|
||||
catalog,
|
||||
placeSearch,
|
||||
defer,
|
||||
initialShareCode = null,
|
||||
localePicker = true,
|
||||
}) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new Controls(viewer, {
|
||||
@@ -27,6 +34,9 @@ export function createApplicationControls({
|
||||
requestServices: operations.requests,
|
||||
mapStackController,
|
||||
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());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
import { panelAwareVisibility } from './embed.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -13,6 +14,9 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
// A panel's host may report it hidden while it is on screen; its layers
|
||||
// keep polling (see keepPanelRendering in embed.js).
|
||||
visibilityTarget: panelAwareVisibility(document),
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
|
||||
+25
-3
@@ -15,9 +15,9 @@ import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
* inline panel always reads as visible.
|
||||
*/
|
||||
export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const hidden = () =>
|
||||
(doc.hidden === true || doc.visibilityState === 'hidden') &&
|
||||
!isEmbeddedInline();
|
||||
return {
|
||||
get hidden() {
|
||||
return hidden();
|
||||
},
|
||||
get visibilityState() {
|
||||
return hidden() ? 'hidden' : 'visible';
|
||||
},
|
||||
addEventListener: (type, listener) => doc.addEventListener(type, listener),
|
||||
removeEventListener: (type, listener) =>
|
||||
doc.removeEventListener(type, listener),
|
||||
};
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
@@ -144,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
// A view kept from before Snow was retired still applies, on Normal.
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 4,
|
||||
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
@@ -159,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted[3].id, 4);
|
||||
assert.equal(posted[3].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
@@ -166,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
@@ -211,6 +227,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
};
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
let updates = 0;
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ visibilityTarget: panelAwareVisibility(doc) },
|
||||
);
|
||||
manager.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: 1000,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {
|
||||
updates++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
await manager.setEnabled('flights', true);
|
||||
await settle();
|
||||
const enabled = updates;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'the panel keeps polling');
|
||||
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
await manager.destroyAll();
|
||||
}
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||
export function createApplicationGhostMaps(options) {
|
||||
return createGhostMapsLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
|
||||
test('every built-in provider is exported, so hosts can choose their own list', async () => {
|
||||
const google = await import('gods-eye-view/layers/street-level/google');
|
||||
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
|
||||
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
|
||||
const layer = createApplicationStreetLevel({
|
||||
providers: [
|
||||
google.createGoogleProvider({ getApiKey: () => null }),
|
||||
kartaview.createKartaViewProvider(),
|
||||
panoramax.createPanoramaxProvider(),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
|
||||
});
|
||||
+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...')
|
||||
|
||||
@@ -48,12 +48,13 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 32);
|
||||
assert.equal(actual.layers.length, 33);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
@@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej
|
||||
);
|
||||
});
|
||||
|
||||
test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => {
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
throw new Error('Unexpected standalone acquisition');
|
||||
});
|
||||
const missing = catalog({}, t);
|
||||
assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: missing.getSourceAvailability },
|
||||
);
|
||||
manager.register(missing.get('s2-ghostmaps'));
|
||||
assert.equal(await manager.setEnabled('s2-ghostmaps', true), false);
|
||||
assert.equal(manager.layers.get('s2-ghostmaps').initialized, false);
|
||||
assert.equal(
|
||||
createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'],
|
||||
null,
|
||||
);
|
||||
|
||||
let reads = 0;
|
||||
let subscriptions = 0;
|
||||
let unsubscribed = 0;
|
||||
const source = {
|
||||
getCatalog: async () => {
|
||||
reads++;
|
||||
return { commit: 'a'.repeat(40), products: [] };
|
||||
},
|
||||
getFile: async () => {
|
||||
throw new Error('Empty catalog must not read files');
|
||||
},
|
||||
subscribe: () => {
|
||||
subscriptions++;
|
||||
return () => {
|
||||
unsubscribed++;
|
||||
};
|
||||
},
|
||||
};
|
||||
const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source });
|
||||
assert.equal(supplied['s2-ghostmaps'], source);
|
||||
const configured = catalog(supplied, t);
|
||||
assert.equal(
|
||||
configured.getSourceAvailability('s2-ghostmaps').available,
|
||||
true,
|
||||
);
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
globalThis.document = { addEventListener() {}, removeEventListener() {} };
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'document', previous);
|
||||
else delete globalThis.document;
|
||||
});
|
||||
const layer = configured.get('s2-ghostmaps');
|
||||
t.after(() => layer.destroy());
|
||||
layer.init({ scene: { requestRender() {} } });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.disable();
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(subscriptions, 1);
|
||||
assert.equal(unsubscribed, 1);
|
||||
assert.equal(layer.getStats().commit, 'a'.repeat(40));
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
const manager = new LayerLifecycle(
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Compatibility entry; source contracts are owned by sources/.
|
||||
export { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
@@ -1,24 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSourceSlot } from './sourceSlot.js';
|
||||
|
||||
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
|
||||
let finish;
|
||||
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
|
||||
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const pending = slot.source.read();
|
||||
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
|
||||
old();
|
||||
finish('stale');
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(await slot.source.read(), 'replacement');
|
||||
assert.equal(slot.source.attribution.name, 'Replacement');
|
||||
stop();
|
||||
assert.throws(() => slot.source.read(), /not configured/);
|
||||
});
|
||||
|
||||
test('an invalid replacement leaves the working source intact', async () => {
|
||||
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
|
||||
assert.throws(() => slot.configure({}), TypeError);
|
||||
assert.equal(await slot.source.read(), 42);
|
||||
});
|
||||
+27
-1
@@ -2,11 +2,14 @@ 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';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
getRenderGovernorDiagnostics,
|
||||
@@ -52,10 +55,22 @@ export function createApplicationTools({
|
||||
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
||||
annotations.destroy();
|
||||
});
|
||||
const displayStore = useDisplayStore(applicationPinia());
|
||||
// The Display panel's controls answer through the store; returns the
|
||||
// disconnect.
|
||||
const connectDisplay = (actions) => {
|
||||
const token = displayStore.connect(actions);
|
||||
return () => displayStore.detach(token);
|
||||
};
|
||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
@@ -65,6 +80,8 @@ export function createApplicationTools({
|
||||
navigation: styleManager,
|
||||
ground: operations.surface?.groundFloor,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
publish: (view) => displayStore.publishGeoImport(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => geoFileImport?.destroy());
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
@@ -110,6 +127,15 @@ export function createApplicationTools({
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||
// alive in reused per-pass arrays; trim them after each disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
installScopeMask(viewer);
|
||||
|
||||
@@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
|
||||
// Keep Cesium's distance-aware zoom and collision limits, with less travel
|
||||
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Camera presets for notable locations.
|
||||
* Phase 1 default: fly to Austin, TX on load.
|
||||
*/
|
||||
export const CAMERA_PRESETS = {
|
||||
austin: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
sf: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(30),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
nyc: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(-20),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to a preset location with a smooth animation.
|
||||
*/
|
||||
export function flyToPreset(viewer, presetName, duration = 3.0) {
|
||||
const preset = CAMERA_PRESETS[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: preset.destination,
|
||||
orientation: preset.orientation,
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
@@ -262,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
});
|
||||
|
||||
@@ -354,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');
|
||||
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
|
||||
});
|
||||
|
||||
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
|
||||
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
|
||||
for (const source of [vessels, firms]) {
|
||||
assert.match(source, /requestWorldFocus\(\{/);
|
||||
assert.doesNotMatch(source, /camera\.flyTo/);
|
||||
}
|
||||
|
||||
+300
-12
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
|
||||
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
export function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
runCctvLayerEnableFocus,
|
||||
runCctvLayerEnableTransition,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
|
||||
|
||||
test('celestial ring is available only in Normal style', () => {
|
||||
assert.equal(isCelestialRingStyleSupported('normal'), true);
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
|
||||
assert.equal(isCelestialRingStyleSupported(style), false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
*/
|
||||
export class CockpitCloudEffectsController {
|
||||
class CockpitCloudEffectsController {
|
||||
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||
this.weatherService = weatherService;
|
||||
this.viewer = viewer;
|
||||
|
||||
+91
-84
@@ -2,7 +2,7 @@ import {
|
||||
readShellSource,
|
||||
shellMethod,
|
||||
} from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||
import { readShellElements } from './ui/shellElements.js';
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
readAircraftInfo,
|
||||
_adoptTrackedEntity,
|
||||
} from './ui/cockpitTrackingController.js';
|
||||
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { createVisionState } from './ui/cockpitInstrumentState.js';
|
||||
import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js';
|
||||
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
|
||||
import {
|
||||
syncSignalLayout,
|
||||
setContextCollapsed,
|
||||
setSignalCollapsed,
|
||||
} from './ui/cockpitLayout.js';
|
||||
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
|
||||
import { createBriefState } from './ui/cockpitBriefPages.js';
|
||||
import {
|
||||
publishBriefView,
|
||||
setBriefAutoRotate,
|
||||
} from './ui/cockpitBriefing.js';
|
||||
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
@@ -46,7 +52,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
|
||||
);
|
||||
const ui = readShellSource();
|
||||
@@ -149,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
|
||||
assert.deepEqual(
|
||||
COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label),
|
||||
['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'],
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(
|
||||
@@ -227,14 +232,10 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
);
|
||||
});
|
||||
|
||||
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(
|
||||
keydown,
|
||||
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
|
||||
);
|
||||
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
@@ -250,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.context\.hidden = true/g) || []).length,
|
||||
(body.match(/this\.showContextCard\(false\)/g) || []).length,
|
||||
1,
|
||||
'the panel has exactly one hide path, and it is the no-snapshot standby case',
|
||||
);
|
||||
@@ -268,7 +269,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
// A culled subject holds last-known content behind the CONTACT LOST cue.
|
||||
assert.match(body, /readout\.contactLost/);
|
||||
assert.match(body, /CONTACT LOST/);
|
||||
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
|
||||
assert.match(body, /view\.state = 'lost'/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(readout\.contactLost\) \{/,
|
||||
@@ -276,14 +277,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
// PREVIOUS/NEXT must be written before any early return, so the operator can
|
||||
// always step off the current contact — including a lost one.
|
||||
const navIndex = body.indexOf('this.contextNext.disabled');
|
||||
const navIndex = body.indexOf('view.canNext =');
|
||||
const lostIndex = body.indexOf('if (readout.contactLost)');
|
||||
assert.ok(
|
||||
navIndex >= 0 && lostIndex > navIndex,
|
||||
'nav state must be written before the CONTACT LOST return',
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.contextNext\.disabled/g) || []).length,
|
||||
(body.match(/view\.canNext =/g) || []).length,
|
||||
1,
|
||||
'one owner for the NEXT enabled state, reached by every visible path',
|
||||
);
|
||||
@@ -443,7 +444,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
|
||||
assert.doesNotMatch(
|
||||
cockpitRadio[0],
|
||||
/context-radio-details-btn|radio-filter|radio-tuner/,
|
||||
/radio-filter|radio-tuner/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -475,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
css,
|
||||
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
|
||||
@@ -534,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
);
|
||||
assert.match(
|
||||
syncSignalLayout.toString(),
|
||||
/setAttribute\('aria-hidden', String\(hiddenSibling\)\)/,
|
||||
/primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -550,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
|
||||
1,
|
||||
'Parameters must have one DOM owner',
|
||||
);
|
||||
// The markup places Parameters below Detection; nothing re-homes it.
|
||||
assert.match(
|
||||
html,
|
||||
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
|
||||
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
|
||||
/_sliderPanel/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -703,7 +701,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
);
|
||||
assert.match(
|
||||
callback[1],
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
|
||||
"entry must remember each panel's exact collapsed state before hiding it",
|
||||
);
|
||||
assert.match(
|
||||
@@ -764,7 +762,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
@@ -802,35 +800,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
assert.ok(resetStart >= 0);
|
||||
assert.ok(contextRelease > resetStart);
|
||||
assert.ok(satelliteRelease > contextRelease);
|
||||
assert.match(
|
||||
readShellElements.toString(),
|
||||
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
|
||||
);
|
||||
const controls = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
|
||||
);
|
||||
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
|
||||
assert.match(
|
||||
controls,
|
||||
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
|
||||
'both reset controls delegate to the same supplied reset action',
|
||||
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
|
||||
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
|
||||
// Both reset buttons ask the one reset owner.
|
||||
const globeActions = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
|
||||
const cockpitView = readRadioSource(
|
||||
new URL('./ui/views/CockpitView.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/,
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitTrackingController.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/,
|
||||
/Object\.assign\(this\.presence, \{\s*active: true,/,
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
|
||||
});
|
||||
|
||||
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
|
||||
@@ -892,21 +884,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
|
||||
);
|
||||
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
||||
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
||||
assert.match(radioBindings, /\n cycleRadio,\n/);
|
||||
const panel = fs.readFileSync(
|
||||
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cockpit = fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/);
|
||||
assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -944,7 +939,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
|
||||
/publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/,
|
||||
'Cockpit owns the utility strip anchor and republishes it every layout tick',
|
||||
);
|
||||
assert.match(
|
||||
@@ -980,7 +975,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
);
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/,
|
||||
/maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -1065,15 +1060,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
assert.doesNotMatch(ui, /\['presets',/);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.toString(),
|
||||
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
/window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
);
|
||||
// The portal publishes the slots; the Display panel teleports its groups.
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.setActive.toString(),
|
||||
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
|
||||
/this\.publish\(nextActive \? this\.slots : null\)/,
|
||||
);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.destroy.toString(),
|
||||
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
|
||||
/this\.stop\(\)[\s\S]*?this\.publish\(null\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
@@ -1095,7 +1091,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
@@ -1180,7 +1176,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
|
||||
|
||||
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit weather toggle markup is missing');
|
||||
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
|
||||
@@ -1192,7 +1188,7 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
|
||||
assert.ok(match, 'cockpit Contact summary is missing');
|
||||
assert.match(match[0], /aria-label="Contact cockpit summary"/);
|
||||
assert.match(match[0], /class="cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /aria-label="Contact navigation"/);
|
||||
assert.match(
|
||||
match[0],
|
||||
@@ -1204,9 +1200,13 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
);
|
||||
assert.match(match[0], /aria-label="Collapse Contact panel"/);
|
||||
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
|
||||
// The toggle renders the collapse the controller presents.
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
|
||||
fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1262,7 +1262,7 @@ test('Global Context standby describes both chooser modes', () => {
|
||||
|
||||
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit briefing cycle toggle is missing');
|
||||
assert.match(match[0], /aria-label="CYCLE OFF"/);
|
||||
@@ -1273,15 +1273,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
|
||||
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
|
||||
);
|
||||
|
||||
const update = setBriefAutoRotate.toString();
|
||||
assert.ok(update, 'cockpit briefing cycle state updater is missing');
|
||||
assert.match(
|
||||
update,
|
||||
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
|
||||
);
|
||||
assert.match(update, /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update, /\.title = help;/);
|
||||
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
|
||||
// The presented label is the state; the help is only the tooltip.
|
||||
const views = [];
|
||||
const owner = {
|
||||
brief: createBriefState(),
|
||||
publishBrief: (view) => views.push(view),
|
||||
publishBriefView,
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
};
|
||||
setBriefAutoRotate.call(owner, true);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
|
||||
assert.equal(views.at(-1).autoRotate, true);
|
||||
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
|
||||
setBriefAutoRotate.call(owner, false);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
|
||||
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
|
||||
|
||||
+14
-5
@@ -1,9 +1,18 @@
|
||||
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.
|
||||
* Rounding 359.5° and above lands on north (000), not 360.
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
formatCompassDivision,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -155,6 +156,15 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
||||
assert.equal(formatCompassDivision(30), '030');
|
||||
});
|
||||
|
||||
test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.6), '000');
|
||||
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), '---');
|
||||
});
|
||||
|
||||
test('great-circle bearing resolves cardinal directions', () => {
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
|
||||
|
||||
+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,
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
|
||||
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_syncContactsDetection() {'),
|
||||
uiSource.indexOf('/** Apply a temporary cockpit-only'),
|
||||
);
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
|
||||
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
|
||||
assert.match(helper, /active: this\._contextMode === 'flights'/);
|
||||
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
|
||||
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
|
||||
uiSource.indexOf('_syncIrBoost() {'),
|
||||
);
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
|
||||
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.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 radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
|
||||
// - the right-rail entry ignored the activation result entirely.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readShellSource();
|
||||
|
||||
@@ -97,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout'
|
||||
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
|
||||
});
|
||||
|
||||
test('Clear All during the Contacts settle stops the snapshot coming back', () => {
|
||||
const restore = _restoreContextSession.toString();
|
||||
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(');
|
||||
const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff);
|
||||
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff);
|
||||
assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles');
|
||||
assert.ok(fanout > cancelled, 'and before the snapshot fans out');
|
||||
});
|
||||
|
||||
test('context restore preserves its exact pending target after a failed transition', () => {
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
||||
@@ -162,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/);
|
||||
@@ -172,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,20 +1,16 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
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 radioControlsSource = readRadioSource(new URL('./ui/radioControls.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 ui = readShellSource();
|
||||
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', () => {
|
||||
@@ -25,77 +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 syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,20 +65,68 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
|
||||
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
|
||||
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
|
||||
assert.ok(search, 'nearby sites search is missing');
|
||||
assert.doesNotMatch(search, /:disabled|\sdisabled/);
|
||||
assert.match(search, /:aria-busy=/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
|
||||
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
|
||||
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
|
||||
assert.match(cockpitEnable, /:aria-busy=/);
|
||||
// The Radio view never disables its Enable button; it marks it busy.
|
||||
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(enable, 'Radio Enable button is missing');
|
||||
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
|
||||
assert.match(enable, /:aria-busy=/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
// The globe actions view marks the clear button busy, never disabled.
|
||||
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
|
||||
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
|
||||
assert.ok(clearButton, 'Clear selected layers button is missing');
|
||||
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
|
||||
assert.match(clearButton, /:aria-busy=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(panel, 'Global Context panel is missing');
|
||||
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(header, 'Global Context header is missing');
|
||||
// The kit's own ui-* classes carry no Core styles.
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
|
||||
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
});
|
||||
|
||||
test('Context reads as one segmented control with legible type and no halo', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
|
||||
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
|
||||
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
|
||||
|
||||
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
|
||||
const modes = rule('.global-context-modes');
|
||||
assert.match(modes, /border:\s*1px solid/);
|
||||
assert.match(modes, /padding:\s*2px/);
|
||||
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
|
||||
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
|
||||
const standby = rule('.context-mode-standby');
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const ui = readShellSource();
|
||||
|
||||
/*
|
||||
* Required-attribution keep-out pin.
|
||||
@@ -247,6 +245,9 @@ function ownBoxEntries() {
|
||||
const compound = lastCompound(part);
|
||||
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
|
||||
if (compound.includes('::')) continue; // a pseudo-element is its own box
|
||||
// The phone shell is its own layout; mobileShell.test.mjs pins its
|
||||
// attribution clearance.
|
||||
if (part.startsWith('body.mobile-shell')) continue;
|
||||
entries.push({ rule, part });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
*
|
||||
* Cesium gives each Model a dynamic environment map: it renders the sky around
|
||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||
* when the model is first placed and again when it moves more than 1 km, when
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||
*/
|
||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||
enabled: false,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
|
||||
/**
|
||||
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||
* the glTF cache and are never added to the scene, so they are left out:
|
||||
* drawn models are the calls that set a minimum pixel size.
|
||||
*/
|
||||
const SOURCES = [
|
||||
{
|
||||
name: 'flights',
|
||||
source: readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'militaryFlights',
|
||||
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'localAdsb',
|
||||
source: readFileSync(
|
||||
new URL('../layers/localAdsb/models.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
test('the shared option turns the dynamic environment map off', () => {
|
||||
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
|
||||
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
|
||||
});
|
||||
|
||||
for (const { name, source } of SOURCES) {
|
||||
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||
const calls = [
|
||||
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
|
||||
for (const [index, options] of calls.entries())
|
||||
assert.match(
|
||||
options,
|
||||
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
|
||||
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1011,6 +1013,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
@@ -1063,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'.
|
||||
@@ -1091,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 {
|
||||
@@ -1105,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();
|
||||
}
|
||||
@@ -1294,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');
|
||||
@@ -1691,3 +1739,50 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AISSTREAM_STALE_MS,
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
readAisTrack,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
|
||||
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||
);
|
||||
// AISStream's MetaData carries the last known position on every message,
|
||||
// static data included.
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: {
|
||||
ShipStaticData: {
|
||||
UserID: 244690000,
|
||||
Name: 'EXAMPLE VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
},
|
||||
MetaData: {
|
||||
MMSI: '244690000',
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const row = rowFor('244690000');
|
||||
assert.equal(row.speed, 12.4);
|
||||
assert.equal(row.course, 86.7);
|
||||
assert.equal(row.heading, 87);
|
||||
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||
assert.equal(row.destination, 'ROTTERDAM');
|
||||
assert.equal(row.imo, '9876543');
|
||||
});
|
||||
|
||||
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||
const classBStatic = (part, value) => ({
|
||||
MessageID: 24,
|
||||
RepeatIndicator: 0,
|
||||
Valid: true,
|
||||
Reserved: 0,
|
||||
PartNumber: part === 'B',
|
||||
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||
ReportB: {
|
||||
Valid: part === 'B',
|
||||
ShipType: part === 'B' ? value : 0,
|
||||
VendorIDName: '',
|
||||
VenderIDModel: 0,
|
||||
VenderIDSerial: 0,
|
||||
CallSign: '',
|
||||
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||
FixType: 0,
|
||||
Spare: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, scenario] of [
|
||||
{
|
||||
type: 'ShipStaticData',
|
||||
initial: [
|
||||
{
|
||||
Name: 'OLD VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
Name: 'NEW VESSEL',
|
||||
Type: 80,
|
||||
Destination: 'HAMBURG',
|
||||
ImoNumber: 9876544,
|
||||
},
|
||||
],
|
||||
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||
expected: {
|
||||
name: 'NEW VESSEL',
|
||||
type: '80',
|
||||
destination: 'HAMBURG',
|
||||
imo: '9876544',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'StaticDataReport',
|
||||
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||
empty: classBStatic('A', ''),
|
||||
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||
},
|
||||
].entries()) {
|
||||
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||
const mmsi = String(244690100 + index);
|
||||
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||
let now = start;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const position = (
|
||||
lat,
|
||||
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||
) => {
|
||||
const envelope = positionReport(mmsi, body);
|
||||
Object.assign(envelope.MetaData, {
|
||||
latitude: lat,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
});
|
||||
return ingestAisStreamEnvelope(envelope);
|
||||
};
|
||||
const sendStatic = (body) =>
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: scenario.type,
|
||||
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
ShipName: 'STALE HEADER',
|
||||
latitude: 54,
|
||||
longitude: 7,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
},
|
||||
});
|
||||
position(53.40879);
|
||||
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||
now += 60_000;
|
||||
position(53.41879);
|
||||
const before = rowFor(mmsi);
|
||||
const track = readAisTrack(mmsi);
|
||||
assert.equal(track.length, 2);
|
||||
|
||||
now += 5 * 60_000;
|
||||
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
|
||||
// Empty or omitted static fields must not erase known metadata, including
|
||||
// the cached values used when the next actual position arrives.
|
||||
assert.equal(sendStatic(scenario.empty), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
if (scenario.type === 'StaticDataReport') {
|
||||
sendStatic({
|
||||
...classBStatic('A', ''),
|
||||
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||
});
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
}
|
||||
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(
|
||||
rowFor(mmsi),
|
||||
undefined,
|
||||
'static traffic must not renew position freshness',
|
||||
);
|
||||
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||
const next = rowFor(mmsi);
|
||||
for (const [key, value] of Object.entries(scenario.expected))
|
||||
assert.equal(next[key], value, key);
|
||||
assert.equal(next.speed, 0);
|
||||
assert.equal(next.course, 0);
|
||||
assert.equal(next.heading, 0);
|
||||
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||
assert.equal(readAisTrack(mmsi).length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||
const mmsi = '244690200';
|
||||
const staticReport = (body) => ({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||
const extended = positionReport(mmsi, {});
|
||||
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||
extended.Message = {
|
||||
ExtendedClassBPositionReport: {
|
||||
UserID: Number(mmsi),
|
||||
Type: 80,
|
||||
Sog: 12,
|
||||
Cog: 86,
|
||||
},
|
||||
};
|
||||
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||
ingestAisStreamEnvelope(extended);
|
||||
const before = rowFor(mmsi);
|
||||
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||
assert.equal(rowFor(mmsi).type, '80');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
});
|
||||
+1
-21
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze([
|
||||
'transport',
|
||||
'auth',
|
||||
'rate-limit',
|
||||
]);
|
||||
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
|
||||
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { shownEntityLabel } from '../voice/speech.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
}
|
||||
});
|
||||
|
||||
// The voice model reads the label as data; the voice card translates it.
|
||||
test('the camera label stays English data in a translated interface', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([
|
||||
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click(h.source.entities.values[0].id);
|
||||
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
'Камера ALPR',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
||||
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||
|
||||
@@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
|
||||
/** Derived per-record field for layers with a time field (hours before now). */
|
||||
const AGE_FIELD = 'ageHours';
|
||||
/** Spatial scope kinds `query()` accepts. */
|
||||
export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'view',
|
||||
'region',
|
||||
'radius',
|
||||
@@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
* @param {string[]} layerKeys Queried layer ids.
|
||||
* @returns {string[]} Field names, common fields first.
|
||||
*/
|
||||
export function analystFieldsFor(layerKeys) {
|
||||
function analystFieldsFor(layerKeys) {
|
||||
const fields = new Set(COMMON_FIELDS);
|
||||
for (const key of layerKeys) {
|
||||
const layer = ANALYST_LAYERS[key];
|
||||
@@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
|
||||
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
|
||||
*/
|
||||
export function applyFilter(records, filter, { time = false } = {}) {
|
||||
const { field, op } = filter || {};
|
||||
if (!field || !op) return records;
|
||||
const value = time ? toEpochMs(filter.value) : filter.value;
|
||||
return records.filter((r) => {
|
||||
const got = time ? toEpochMs(r[field]) : r[field];
|
||||
if (got === null || got === undefined || (time && !Number.isFinite(got)))
|
||||
return false;
|
||||
if (time && (op === 'eq' || op === 'neq'))
|
||||
return (got === value) === (op === 'eq');
|
||||
switch (op) {
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
@@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) {
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Numeric summary for the narration layer. */
|
||||
function summarize(items, sortField) {
|
||||
const summary = { count: items.length };
|
||||
if (sortField && items.length) {
|
||||
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
|
||||
// the engine's argument limit.
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of items) {
|
||||
const value = Number(item[sortField]);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
if (Number.isFinite(min)) {
|
||||
summary[`${sortField}Min`] = min;
|
||||
summary[`${sortField}Max`] = max;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
const OPS_BY_TYPE = Object.freeze({
|
||||
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
@@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
|
||||
* @param {string} field Field name.
|
||||
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
|
||||
*/
|
||||
export function analystFieldType(layerKeys, field) {
|
||||
function analystFieldType(layerKeys, field) {
|
||||
if (!analystFieldsFor(layerKeys).includes(field)) return null;
|
||||
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
|
||||
for (const key of layerKeys) {
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
+164
-203
@@ -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';
|
||||
@@ -412,14 +414,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
const year = part({ year: 'numeric' });
|
||||
// Year-first languages (2021年10月16日) keep their own order and units.
|
||||
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
|
||||
if (whole.startsWith(year)) return whole;
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,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(
|
||||
@@ -925,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,
|
||||
@@ -1115,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;
|
||||
@@ -2719,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',
|
||||
@@ -2752,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() {
|
||||
@@ -3025,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
|
||||
);
|
||||
publishPanel();
|
||||
if (_sceneSeekFrame != null) return;
|
||||
const schedule =
|
||||
globalThis.requestAnimationFrame ||
|
||||
@@ -3298,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'disabled',
|
||||
!observation?.media?.sourceUrl,
|
||||
);
|
||||
syncCinematicButton();
|
||||
writeCinematicButton();
|
||||
setElementProperty(
|
||||
_panelRefs.splitInput,
|
||||
'value',
|
||||
@@ -3310,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`${beforePercent} / ${100 - beforePercent}`,
|
||||
);
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function syncPresentationMode() {
|
||||
@@ -3326,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)
|
||||
@@ -3360,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
if (_panelRefs?.openSourceButton)
|
||||
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
|
||||
publishPanel();
|
||||
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
|
||||
}
|
||||
|
||||
@@ -3557,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',
|
||||
@@ -3574,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,
|
||||
@@ -3669,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,9 +12,9 @@ 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';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
'bhote-koshi-locator-callout';
|
||||
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
|
||||
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
|
||||
const INCIDENT_CALLOUT_GAP_MS = 200;
|
||||
const CALLOUT_REVEAL_DURATION_MS = 1200;
|
||||
const CALLOUT_PULSE_DURATION_MS = 1800;
|
||||
const CALLOUT_DOT_SIZE_PX = 7;
|
||||
const CALLOUT_RING_MIN_SIZE_PX = 16;
|
||||
const CALLOUT_RING_SIZE_RANGE_PX = 14;
|
||||
const CITY_CALLOUT_START_DELAY_MS = 350;
|
||||
@@ -68,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({
|
||||
@@ -1248,6 +1250,3 @@ export function createBhoteKoshiLocatorLayer({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
|
||||
export default bhoteKoshiLocatorLayer;
|
||||
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_bumpSceneSeekGeneration:
|
||||
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -23,4 +23,3 @@ export {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../layers/bikeshare/index.js';
|
||||
export default layer;
|
||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
|
||||
.replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -83,16 +80,6 @@ import {
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'ui',
|
||||
'cctvPresentation.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ASPECT = 16 / 9;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const GESTURE_TYPES = {
|
||||
@@ -1302,19 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
|
||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||||
|
||||
const renderer = CCTV_PRESENTATION_SOURCE.match(
|
||||
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
|
||||
);
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
_setCctvOverlayHostForTest();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
export const PLANE_VERT_ASPECT = 16 / 9;
|
||||
const PLANE_VERT_ASPECT = 16 / 9;
|
||||
export const SUPPORT_KEYS = Object.freeze([
|
||||
'bl',
|
||||
'bm',
|
||||
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
|
||||
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
|
||||
* @returns {number}
|
||||
*/
|
||||
export function supportAltitude(capAltM, row, dims) {
|
||||
function supportAltitude(capAltM, row, dims) {
|
||||
return capAltM + row * dims.upVert;
|
||||
}
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
const { forwardHoriz } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
|
||||
+6
-3
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -94,7 +97,7 @@ export function incumbentRankKm(
|
||||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||||
|
||||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||||
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
@@ -172,8 +175,8 @@ export function blendCenterRankKm(
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Owner-tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
const CCTV_CARD_GRID_COLS = 5;
|
||||
const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Cockpit 3D aircraft policy.
|
||||
|
||||
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
export function validFix(fix) {
|
||||
function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
|
||||
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
|
||||
assert.equal(storm.issuedAt, TIME);
|
||||
assert.equal(storm.positionAt, TIME);
|
||||
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
|
||||
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
|
||||
assert.equal(storm.windKt, 25);
|
||||
assert.equal(storm.pressureHpa, 1006);
|
||||
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
|
||||
+17
-2
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
@@ -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:
|
||||
@@ -448,6 +455,14 @@ export const DATA_CREDITS = [
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 's2-ghostmaps',
|
||||
html:
|
||||
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
|
||||
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
}
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
function compareCohortContenders(a, b) {
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* module so density cannot drift into contradictory mode semantics again.
|
||||
*/
|
||||
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
return 'front';
|
||||
}
|
||||
|
||||
export const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
|
||||
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
|
||||
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
@@ -49,15 +49,11 @@ export {
|
||||
DIRECTIONS_MODES,
|
||||
DIRECTIONS_POINTER_OWNER,
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
|
||||
FLIGHT_PROGRESS_MS,
|
||||
POINTER_TOOL_EXITS,
|
||||
STEP_ANCHOR_DEADLINE_MS,
|
||||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||||
STEP_ANCHOR_RETRY_MS,
|
||||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||||
createDirectionsLayer,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
|
||||
@@ -846,9 +846,15 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
t.after(() => setLocale('en'));
|
||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||
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');
|
||||
assert.equal(routeDurationText(59.6), '1\u00a0min');
|
||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||
assert.equal(routeDistanceText(-1), '');
|
||||
@@ -871,4 +877,76 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const cases = [
|
||||
[
|
||||
'ru',
|
||||
'Въезжайте по съезду плавно налево на I-95',
|
||||
'Сверните на съезд резко направо',
|
||||
'Займите левую полосу',
|
||||
],
|
||||
[
|
||||
'es',
|
||||
'Toma la rampa ligeramente a la izquierda hacia I-95',
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
'从出口向右后方驶出',
|
||||
'使用左转车道',
|
||||
],
|
||||
];
|
||||
for (const [locale, ramp, exit, lane] of cases) {
|
||||
setLocale(locale);
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'on ramp',
|
||||
modifier: 'slight left',
|
||||
name: 'I-95',
|
||||
}),
|
||||
ramp,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
|
||||
exit,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
|
||||
lane,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
|
||||
} = {}) {
|
||||
return createApplicationEarthquakes({ source, ...options });
|
||||
}
|
||||
export default createEarthquakesLayer();
|
||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||
// into March, and the fire's age would describe a detection that never happened.
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const timeText = String(time ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59
|
||||
) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
const result =
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: 0;
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
]) {
|
||||
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: date, acqTime: time }),
|
||||
]);
|
||||
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
}
|
||||
const [leap] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||
]);
|
||||
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
assert.deepEqual(
|
||||
fires.map((f) => f.index),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||
]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
|
||||
@@ -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);
|
||||
@@ -53,6 +54,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
|
||||
assert.equal(card.details[0], 'high · N20');
|
||||
});
|
||||
|
||||
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||
const crossed = buildFireCard(
|
||||
{ fire: fire({ frp: 9.96 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(crossed.title, '▲ 10 MW');
|
||||
const below = buildFireCard(
|
||||
{ fire: fire({ frp: 9.94 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(below.title, '▲ 9.9 MW');
|
||||
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
@@ -85,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
|
||||
|
||||
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
|
||||
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
||||
import { fireDetectionKey } from './firmsLabels.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { getSelectedEntityContext } from './contextStore.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { shownEntityLabel } from '../voice/speech.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import path from 'node:path';
|
||||
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The voice model reads these labels as data (track_entity, get_entity_context,
|
||||
// pointer_context); the voice card translates them where it shows them.
|
||||
test('fire labels stay English data in a translated interface', () => {
|
||||
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.click();
|
||||
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
|
||||
// The card's pattern must match what the producer actually writes.
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
'Пожар · FRP 1520 МВт',
|
||||
);
|
||||
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
|
||||
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
|
||||
} finally {
|
||||
h.cleanup();
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on neither sprite nor card clears without moving the camera', () => {
|
||||
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
|
||||
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
|
||||
* @param {number} frp Megawatts.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFrpMw(frp) {
|
||||
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
|
||||
return Number(frp).toFixed(digits);
|
||||
}
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '')
|
||||
|
||||
Loaded 100 of 1214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user