Merge branch 'main' into feat/cctv-model-specs-refresh

This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-04 17:53:10 +04:00
commit 2d917c5909
1249 files changed
+105057 -21970

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
/**
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
* with its colour, its name, and show/hide, fly-to and remove, the way Import
* lists its files. The board is the annotation engine's; voice marks are not
* listed, only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
export const DRAW_COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
*/
export function presentDrawMarks(marks = []) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
colors: t('Colour of {name}', { name: title }),
toggle: visible
? t('Hide {name}', { name: title })
: t('Show {name}', { name: title }),
fly: t('Fly to {name}', { name: title }),
remove: t('Remove {name}', { name: title }),
},
};
});
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
test('rows list only hand-drawn marks, in board order, with their words', () => {
const rows = presentDrawMarks([
{
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '950 m',
color: 'amber',
},
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
{
id: 'a3',
type: 'pin',
manual: true,
name: 'Camp',
color: 'teal',
hidden: true,
},
]);
assert.deepEqual(
rows.map(({ id, name, placeholder, color, hidden }) => ({
id,
name,
placeholder,
color,
hidden,
})),
[
{
id: 'a1',
name: '',
placeholder: 'Line · 950 m',
color: 'amber',
hidden: false,
},
{
id: 'a3',
name: 'Camp',
placeholder: 'Pin',
color: 'primary',
hidden: true,
},
],
);
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
assert.equal(rows[1].labels.toggle, 'Show Camp');
assert.equal(rows[1].labels.fly, 'Fly to Camp');
assert.equal(rows[1].labels.colors, 'Colour of Camp');
});
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
+8 -8
View File
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
*/
import { greatCircleMeters } from '../geoDistance.js';
import { formatDistanceText } from '../data/distanceFormat.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
@@ -207,17 +208,16 @@ export function ringCentroid(vertices) {
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
const whole = Math.round(m2);
const ha = (m2 / 1e4).toFixed(1);
if (whole < 1e4) return `${whole} m²`;
if (Number(ha) < 100) return `${ha} ha`;
return `${(m2 / 1e6).toFixed(2)} km²`;
}
return '';
}
+48
View File
@@ -345,3 +345,51 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
assert.equal(formatMeasure(line), '10 km');
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
const side = Math.sqrt(m2) / 111320;
const area = createDrawSession('area');
for (const [lon, lat] of [
[0, 0],
[side, 0],
[side, side],
[0, side],
])
addVertex(area, { lon, lat });
return area;
};
const almostHectare = square(9999.6);
const metres = ringAreaM2(almostHectare.vertices);
assert.ok(
metres >= 9999.5 && metres < 1e4,
`area should round to 10000 m², got ${metres}`,
);
assert.equal(formatMeasure(almostHectare), '1.0 ha');
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
const almostKm2 = square(999_960);
const nearKm2 = ringAreaM2(almostKm2.vertices);
assert.ok(
nearKm2 >= 999_500 && nearKm2 < 1e6,
`area should round to 100.0 ha, got ${nearKm2}`,
);
assert.equal(formatMeasure(almostKm2), '1.00 km²');
const under = square(999_400);
const underM2 = ringAreaM2(under.vertices);
assert.ok(
underM2 >= 998_500 && underM2 < 999_500,
`area should round to 99.9 ha, got ${underM2}`,
);
assert.equal(formatMeasure(under), '99.9 ha');
});
+129 -101
View File
@@ -1,9 +1,10 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin) and a colour, click the vertices on the real world,
* double-click or press Enter to finish, then name, recolour, hide, fly to or
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -30,7 +31,6 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -50,41 +50,44 @@ import {
removeLastVertex,
} from './drawMode.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* Run the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* The tool hands what the controls show (`{ active, shape, color, hint,
* marks }`) to `publish`, which the view renders, and answers the controls
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const 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,
navigation = null,
publish = () => {},
connect = () => () => {},
}) {
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
let hint = '';
let handler = null;
let lease = null;
let savedDoubleClick = null;
@@ -95,25 +98,13 @@ export function initDrawTool({ viewer, annotations }) {
// one of those must not write its outcome over the newer state.
let generation = 0;
const previewEntities = [];
// Every DOM listener this tool adds, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
// Every DOM listener this tool adds (the session's keydown), so teardown is
// one loop rather than a list that can fall out of step with the bindings.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -124,9 +115,25 @@ export function initDrawTool({ viewer, annotations }) {
() => null,
);
const present = () =>
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
});
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
const setHint = (text) => {
if (hint) hint.textContent = text;
hintText = text;
hint = text();
present();
};
const offLocale = onLocaleChange(() => {
if (!destroyed) setHint(hintText);
});
// ---- preview ---------------------------------------------------------
const vertexPositions = () =>
@@ -161,7 +168,7 @@ export function initDrawTool({ viewer, annotations }) {
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
setHint(drawHint(null));
setHint(() => drawHint(null));
viewer.scene.requestRender();
return;
}
@@ -187,7 +194,7 @@ export function initDrawTool({ viewer, annotations }) {
}),
);
}
setHint(drawHint(session));
setHint(() => drawHint(session));
viewer.scene.requestRender();
};
@@ -210,14 +217,14 @@ export function initDrawTool({ viewer, annotations }) {
// A click that changed nothing still deserves an answer when the reason is
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
setHint(() =>
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint(t('That point is off the globe — click on the world.'));
setHint(() => t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -235,17 +242,17 @@ 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 });
// Named afterwards, in the shape's row.
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 = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -253,11 +260,12 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
if (result?.drawn === 0)
setHint(() => t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(
setHint(() =>
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
@@ -279,13 +287,13 @@ 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 ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -293,12 +301,12 @@ export function initDrawTool({ viewer, annotations }) {
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -310,6 +318,9 @@ export function initDrawTool({ viewer, annotations }) {
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Keys on the shape list's own controls are the list's (Esc closes a
// colour picker there), not the drawing's.
if (event.target?.closest?.('#draw-mark-list')) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
@@ -328,7 +339,7 @@ export function initDrawTool({ viewer, annotations }) {
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -345,7 +356,7 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(
setHint(() =>
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
@@ -354,10 +365,7 @@ export function initDrawTool({ viewer, annotations }) {
}
}
active = next;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
present();
document.body.classList.toggle('gev-drawing', active);
if (active) {
session = createDrawSession(shape);
@@ -419,8 +427,7 @@ export function initDrawTool({ viewer, annotations }) {
);
savedDoubleClick = null;
}
// Drop just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
// Drop the keydown listener bound alongside this handler.
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
@@ -429,37 +436,56 @@ export function initDrawTool({ viewer, annotations }) {
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
},
});
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();
/**
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
* and the current orbit, follow or track lets go first.
*/
function flyToMark(id) {
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('drawing', () =>
annotations.frame?.(id),
) !== false
);
return annotations.frame?.(id) ?? false;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
present();
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
syncPreview();
},
clearDraw: clearAll,
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
toggleDrawMark: (id) => {
const mark = annotations.list?.().find((entry) => entry.id === id);
if (mark) annotations.setHidden?.(id, !mark.hidden);
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
});
setHint(drawHint(null));
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
const offBoard =
annotations.onChange?.(() => {
if (!destroyed) present();
}) || (() => {});
setHint(() => drawHint(null));
const api = {
get active() {
@@ -472,12 +498,15 @@ export function initDrawTool({ viewer, annotations }) {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
@@ -526,7 +555,9 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
offLocale();
offBoard();
disconnect();
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -546,11 +577,8 @@ export function initDrawTool({ viewer, annotations }) {
}
});
document.body.classList.remove('gev-drawing');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
hint = '';
present();
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching;
},
+57 -21
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
@@ -62,37 +62,67 @@ 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');
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
// drawn shapes' rows), rendered into the Utilities panel.
const html = [
read('src/ui/views/panels/DrawControls.vue'),
read('src/ui/views/panels/DrawMarkList.vue'),
].join('\n');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-colors',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
'draw-mark-list',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
`#${id} missing from the Draw views`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, icon] of [
['area', 'pentagon'],
['line', 'timeline'],
['pin', 'location_on'],
]) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
`shape button ${shape} missing`,
);
}
for (const id of ids) {
assert.ok(
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
'the Utilities panel renders Draw',
);
assert.ok(
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
'Draw is no longer in DISPLAY',
);
// The rows and the hint render what the tool publishes; the controls
// call the actions the tool connects to the display store.
for (const action of [
"act('toggleDraw')",
"act('setDrawShape', $event)",
"act('setDrawColor', $event)",
"act('clearDraw')",
"act('renameDrawMark', row.id, next)",
"act('recolorDrawMark', row.id, color)",
"act('toggleDrawMark', row.id)",
"act('flyToDrawMark', row.id)",
"act('removeDrawMark', row.id)",
]) {
assert.ok(html.includes(action), `the Draw views must call ${action}`);
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
new RegExp(`\\b${name}: `),
`drawTool must answer ${name}`,
);
}
});
@@ -102,20 +132,22 @@ 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/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
@@ -138,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
+202 -63
View File
@@ -6,7 +6,6 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import { describeUnsavedWork } from '../i18n/index.js';
import {
isPointerFree,
pointerOwner,
@@ -47,7 +46,6 @@ class FakeElement {
this.attributes = new Map();
this.textContent = '';
this.value = '';
this.children = [];
this.listeners = [];
}
setAttribute(name, value) {
@@ -65,16 +63,6 @@ class FakeElement {
);
if (at >= 0) this.listeners.splice(at, 1);
}
querySelectorAll(selector) {
if (selector === '.pp-mode-btn[data-shape]')
return this.children.filter((child) => child.dataset.shape);
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
return [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
click() {
return this.emit('click');
}
@@ -108,18 +96,10 @@ function fakeDom() {
byId.set(id, element);
return element;
};
// The control whose presence means the page has Draw, and a focused button
// whose Enter is its own.
make('draw-toggle', 'BUTTON');
const modeRow = make('draw-mode-row');
for (const shape of ['area', 'line', 'pin']) {
const button = new FakeElement('BUTTON');
button.dataset.shape = shape;
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
const body = new FakeElement('BODY');
const documentDouble = {
@@ -195,9 +175,16 @@ function fakeViewer() {
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
const marks = [];
const listeners = new Set();
const changed = () => listeners.forEach((listener) => listener());
const find = (id) => marks.find((mark) => mark.id === id);
return {
calls,
marks,
listeners,
changed,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
@@ -205,11 +192,38 @@ function fakeAnnotations() {
clear() {
calls.clear += 1;
},
list: () => marks,
onChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
restyle(id, change) {
calls.restyle.push([id, change]);
Object.assign(find(id), change);
changed();
return true;
},
setHidden(id, hidden) {
calls.setHidden.push([id, hidden]);
find(id).hidden = hidden;
changed();
return true;
},
frame(id) {
calls.frame.push(id);
return true;
},
remove(id) {
calls.remove.push(id);
marks.splice(marks.indexOf(find(id)), 1);
changed();
return true;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
function harness({ navigation = null } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -218,14 +232,30 @@ function harness() {
const previousWindow = globalThis.window;
globalThis.document = documentDouble;
globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations });
const shown = { view: null };
// The controls' actions, as the display store holds them.
const controls = { actions: null };
const tool = initDrawTool({
viewer,
annotations,
navigation,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
return () => (controls.actions = null);
},
});
return {
tool,
shown,
viewer,
annotations,
byId,
documentDouble,
element: (id) => byId.get(id),
controls,
/** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args),
restore() {
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
@@ -241,38 +271,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 +291,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 +312,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 +343,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 +354,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 +372,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 +402,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,10 +456,11 @@ 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);
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
await h.tool.destroy();
await Promise.resolve();
@@ -463,13 +471,14 @@ 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,
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
@@ -481,15 +490,145 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
test('Clear wipes the board and the shape in progress', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
assert.equal(h.tool.session.vertices.length, 2);
h.element('draw-clear').emit('click');
h.act('clearDraw');
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
} finally {
h.restore();
}
});
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.act('setDrawShape', 'line');
h.act('setDrawColor', 'amber');
assert.deepEqual(
{ ...h.shown.view, hint: undefined },
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
'the view shows what the controls chose',
);
h.act('setDrawColor', 'purple');
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
h.act('setDrawColor', 'cyan');
h.tool.addVertex(-97.75, 30.26);
// Choosing a shape again starts the open one over.
h.act('setDrawShape', 'line');
assert.equal(h.tool.session.vertices.length, 0);
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
const spec = h.annotations.calls.annotate[0].specs[0];
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
assert.equal(spec.color, 'cyan');
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
} finally {
h.restore();
}
});
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
const navigations = [];
const h = harness({
navigation: {
runImmediateNavigation: (noun, navigate) => {
navigations.push(noun);
return navigate();
},
},
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
'only hand-drawn marks are listed, an unnamed line by its length',
);
h.act('renameDrawMark', 'a1', 'Ridge walk');
h.act('recolorDrawMark', 'a1', 'green');
h.act('toggleDrawMark', 'a1');
h.act('flyToDrawMark', 'a1');
h.act('removeDrawMark', 'a1');
assert.deepEqual(board.calls.restyle, [
['a1', { name: 'Ridge walk' }],
['a1', { color: 'green' }],
]);
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
assert.deepEqual(board.calls.frame, ['a1']);
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
assert.deepEqual(board.calls.remove, ['a1']);
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 1);
} finally {
h.restore();
}
});
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
const h = harness();
try {
const listButton = new FakeElement('BUTTON');
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
const elsewhere = new FakeElement('INPUT', 'location-search');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
assert.equal(h.tool.active, true, 'and keeps Draw on');
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
} finally {
h.restore();
}
});
test('a page without the Draw controls gets no draw tool', () => {
resetPointerOwnership();
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
let connected = false;
const tool = initDrawTool({
viewer: fakeViewer(),
annotations: fakeAnnotations(),
connect: () => {
connected = true;
return () => {};
},
});
assert.equal(tool, null);
assert.equal(connected, false);
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
});
+146 -10
View File
@@ -1,3 +1,4 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
@@ -16,15 +17,19 @@ 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();
function add(anno) {
function add(anno, options = {}) {
const entry = {};
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
@@ -36,7 +41,20 @@ export function createHybridAnnotationRenderer(viewer) {
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Drape the footprint in world space, but without its own label...
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (options.pendingVisible !== false) {
entry.screenProxy = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.add(entry.screenProxy);
}
return finishBuilding(anno, entry, completion, options);
}
// ...and place the caption as a screen-space callout at the centroid.
if (anno.label) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
@@ -71,27 +89,45 @@ export function createHybridAnnotationRenderer(viewer) {
* World geometry is new, but the reticle's SVG group is converted in place to
* the centroid callout so there is no overlapping fade-out/fade-in pair.
*/
function update(anno) {
function update(anno, options = {}) {
const entry = routed.get(anno.id);
if (!entry) {
add(anno);
return;
return add(anno, options);
}
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
if (anno.outlineUnavailable && entry.screenProxy)
if (anno.outlineUnavailable && entry.screenProxy) {
entry.screenProxy = liveProxy(anno, { ring: null });
screen.update(entry.screenProxy);
}
return;
}
if (!entry.worldProxy) {
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (entry.screenProxy) {
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
screen.update(pending);
entry.screenProxy = pending;
}
return finishBuilding(anno, entry, completion, options);
}
}
updateCaption(anno, entry);
}
function updateCaption(anno, entry) {
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
if (entry.screenProxy) {
screen.update(screenProxy, { empty: !anno.label });
} else if (anno.label) {
// Publish before add: a partially created caption must remain removable.
entry.screenProxy = screenProxy;
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy
@@ -101,6 +137,106 @@ export function createHybridAnnotationRenderer(viewer) {
: null;
}
function finishBuilding(anno, entry, completion, options) {
const workTrace = options.workTrace;
const proxy = entry.worldProxy;
return completion
.then((result) => {
const workStart = workTrace ? performance.now() : 0;
try {
if (
routed.get(anno.id) !== entry ||
entry.worldProxy !== proxy ||
options.signal?.aborted ||
!(options.isCurrent?.() ?? true)
) {
world.remove(proxy);
if (routed.get(anno.id) === entry) remove(anno);
if (workTrace) workTrace.rendererStatus = 'cancelled';
return { ...result, status: 'cancelled' };
}
if (result.status !== 'installed') {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = result.status;
return result;
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
const at = performance.now();
try {
updateCaption(anno, entry);
} finally {
const ms = performance.now() - at;
result.timing.workMs += ms;
result.timing.lastSliceMs += ms;
result.timing.maxSliceMs = Math.max(
result.timing.maxSliceMs,
result.timing.lastSliceMs,
);
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (options.pendingVisible === false) {
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
// The caption conversion already reshaped the group; redraw the pending point.
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = pending;
screen.add(pending);
}
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
if (workTrace) workTrace.rendererStatus = result.status;
return result;
} finally {
if (workTrace)
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
}
})
.catch((error) => {
const workStart = workTrace ? performance.now() : 0;
try {
// A later caption throw must roll back the already installed world parts too.
if (routed.get(anno.id) === entry) {
try {
remove(anno);
} catch {
/* original error wins; failed routes stay addressable */
}
} else world.remove(proxy);
throw error;
} finally {
if (workTrace) {
workTrace.rendererStatus = 'failed';
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
}
}
});
}
function worldOptions(anno, entry, options) {
const proxy = entry.worldProxy;
return {
...options,
isCurrent: () =>
routed.get(anno.id) === entry &&
entry.worldProxy === proxy &&
(options.isCurrent?.() ?? true),
};
}
/**
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
* its content) and an absent id, so it doubles as the rollback path for a
+459 -174
View File
@@ -1,143 +1,25 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
const store = useScreenAnnotationsStore(createPinia());
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
return { store, renderer };
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
/** The screen marks the board shows, fading ones included. */
function annotationGroups(store) {
return store.board?.marks ?? [];
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
const childrenWithClass = (mark, name) =>
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
function cameraLookingAt(lon, lat) {
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
@@ -184,7 +66,6 @@ function cameraLookingAt(lon, lat) {
/** Install the browser globals both renderers touch, restored after the test. */
function installBrowserGlobals(t) {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
const originalBrowserConstructors = new Map([
@@ -193,13 +74,10 @@ function installBrowserGlobals(t) {
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
@@ -213,19 +91,31 @@ function installBrowserGlobals(t) {
/** A viewer whose scene/camera is just enough for both sub-renderers. */
function fakeViewer(lon, lat) {
const credits = new Set();
const camera = cameraLookingAt(lon, lat);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
renderRequests: 0,
requestRender() {
scene.renderRequests += 1;
},
};
return {
dataSources,
frames,
viewer: {
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
removeStaticCredit: (credit) => credits.delete(credit),
},
scene,
camera,
trackedEntity: null,
@@ -244,8 +134,30 @@ function fakeViewer(lon, lat) {
};
}
function annotationGroups(svg) {
return svg.children.filter((child) => child.classList.contains('gev-anno'));
function controlledFrames() {
const listeners = new Set();
return {
event: {
addEventListener(listener) { listeners.add(listener); },
removeEventListener(listener) { listeners.delete(listener); },
},
emit() { for (const listener of [...listeners]) listener(); },
snapshot() { return [...listeners]; },
get size() { return listeners.size; },
};
}
function buildingAnnotation(id, offset = 0) {
const ring = [
[-99 + offset, 31], [-98.999 + offset, 31],
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
[-99 + offset, 31],
];
return {
id, type: 'area', color: 'primary', label: null, alpha: 1,
anchor: { ...naiveRingCentroid(ring), height: 100 },
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
};
}
function naiveRingCentroid(ring) {
@@ -256,23 +168,23 @@ function naiveRingCentroid(ring) {
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
const originalDocument = globalThis.document;
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
['HTMLImageElement', globalThis.HTMLImageElement],
['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;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
for (const [name, original] of originalBrowserConstructors) {
if (original === undefined) delete globalThis[name];
else globalThis[name] = original;
@@ -281,13 +193,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
const camera = cameraLookingAt(-99, 31);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
requestRender() {},
};
const viewer = {
scene,
@@ -305,7 +219,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
},
},
};
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const anno = {
id: 'anno-hybrid-fb3',
type: 'area',
@@ -319,10 +233,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
const originalCallout = before.group.querySelector('.gev-anno-callout');
const originalDot = before.group.querySelector('.gev-anno-dot');
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
const [before] = annotationGroups(store);
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
assert.equal(dataSources.length, 1);
assert.equal(dataSources[0].entities.values.length, 0);
@@ -339,23 +251,83 @@ 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);
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
const outer = [
[-99, 31],
[-98.999, 31],
[-98.999, 31.001],
[-99, 31.001],
[-99, 31],
];
const hole = [
[-98.9998, 31.0002],
[-98.9996, 31.0002],
[-98.9996, 31.0004],
[-98.9998, 31.0004],
[-98.9998, 31.0002],
];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const building = {
...anno,
id: 'anno-multipart-building',
label: 'Building',
anchor: { lon: -99, lat: 31, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
footprintKind: 'building',
buildingHeight: 20,
};
const completion = renderer.add(building);
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.equal((await completion).status, 'installed');
const added = dataSources[0].entities.values.slice(2);
const fills = added.filter(
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
);
assert.equal(
fills.length,
2,
'every building part gets a classification volume',
);
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
assert.equal(
fills[0].polygon.material,
fills[1].polygon.material,
'parts share the fill material',
);
assert.equal(
added.filter((entity) => entity.polyline).length,
3,
'all outer and hole outlines are retained',
);
renderer.remove(building);
assert.equal(
dataSources[0].entities.values.length,
2,
'multipart removal leaves only the earlier area',
);
renderer.destroy();
});
@@ -370,7 +342,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-add',
@@ -386,20 +358,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// The world drape lands first; the screen caption then fails the way a lost
// context does, AFTER the world geometry is already on the board.
const { svg } = findAnnotationGroup(globalThis.document);
const appendChild = svg.appendChild.bind(svg);
const publish = store.publish;
let failNextInsert = true;
svg.appendChild = (child) => {
store.publish = (board) => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('screen insert failed');
}
return appendChild(child);
return publish(board);
};
assert.throws(() => renderer.add(anno), /screen insert failed/);
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
// The engine's rollback path — it must reach the partial state by id.
renderer.remove(anno);
@@ -409,7 +380,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// an orphan nothing owns.
renderer.add(anno);
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
renderer.remove(anno);
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
@@ -418,7 +389,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-release-throw',
@@ -433,35 +404,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
};
renderer.add(anno);
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(dataSources[0].entities.values.length, 2);
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
// The world route releases fine, then the screen release fails part-way
// (its fade-out timer throws) — so its SVG group is still in the document.
// (its fade-out timer throws) — so its mark is still on the board.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
// Dropping the routing entry here would make that orphan permanently
// unreachable. It must still be addressable, so a retry finishes the job.
globalThis.window.setTimeout = workingSetTimeout;
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
// Fully released now — a third call is inert.
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0);
assert.equal(annotationGroups(store).length, 0);
renderer.destroy();
});
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-world-add',
@@ -493,9 +463,324 @@ 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();
});
function workTrace() {
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
}
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
second.outlineCredit = 'osm';
const otherOwner = {};
showOsmCredit(viewer, otherOwner);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
const cartographic = Cesium.Cartographic.fromCartesian(position);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
probes.push([lon, lat]);
const outcome = outcomes[probes.length - 1];
if (outcome instanceof Error) throw outcome;
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
};
// Suppress screen points: these counts concern the eight outside-building
// probes, not the screen renderer's separate anchor-height sampling.
const firstTrace = workTrace();
const secondTrace = workTrace();
let diagnosticWriteFaults = 0;
Object.defineProperty(secondTrace, 'groundDetailDropped', {
get: () => 0,
set() {
diagnosticWriteFaults += 1;
throw new Error('diagnostic sink unavailable');
},
enumerable: true,
});
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'pending outlines add no tile credit to the other owner');
for (let frame = 0; frame < 16; frame += 1) {
const before = probes.length;
frames.emit();
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
}
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
assert.equal(installed.status, 'installed');
assert.equal(fallback.status, 'installed');
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
assert.equal(installed.timing.probes, 8);
assert.equal(fallback.timing.probes, 8);
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
for (const trace of [firstTrace, secondTrace]) {
assert.equal(trace.probes, 8);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(trace.dropped, 0);
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
assert.equal(trace.timeOrigin, performance.timeOrigin);
const parents = trace.slices.filter(s => s.phase === 'ground-step');
for (const [index, parent] of parents.entries()) {
const detail = parent.detail;
assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) {
assert.equal(detail.commitStatus, 'attempted');
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
} else {
assert.equal(detail.commitStatus, 'not-run');
assert.equal(detail.commitStart, null);
assert.equal(detail.commitEnd, null);
}
}
}
assert.notEqual(firstTrace.slices, secondTrace.slices);
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
const centroid = naiveRingCentroid(first.ring);
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
const radius = Math.max(...first.ring.map(([lon, lat]) =>
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
const distance = radius * 1.5 + 12;
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
for (let index = 0; index < 8; index += 1) {
const [dx, dy] = directions[index];
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
}
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
assert.equal(fills.length, 2);
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
const afterCompletion = probes.length;
frames.emit();
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
renderer.remove(first);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'removing the last tile proxy retains the OSM building and other owner');
renderer.remove(second);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
hideOsmCredit(viewer, otherOwner);
assert.equal(viewer.credits.size, 0);
});
test('a building queued behind others gets its full budget on a slow device', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
// The renderers' per-frame budget checks read performance.now(); it follows
// the same mocked clock as the timers.
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
viewer.scene.clampToHeight = (position) => position;
const traces = [workTrace(), workTrace(), workTrace()];
const completions = [0, 0.01, 0.02].map((offset, index) =>
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
pendingVisible: false,
workTrace: traces[index],
}));
// Eight probes per building at one probe per frame, at about 5 fps: each
// building samples for 1.5 s, so the third waits 3 s in line. Pending
// promise work runs between frames, as it does in a browser.
for (let building = 0; building < 3; building += 1) {
for (let frame = 0; frame < 8; frame += 1) {
now += 187.5;
t.mock.timers.tick(187.5);
frames.emit();
await new Promise((resolve) => setImmediate(resolve));
}
}
const results = await Promise.all(completions);
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
assert.ok(traces[2].samplerHeadAt - traces[2].samplerStart >= 2000, 'the third waited longer than one budget');
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
let probeCount = 0;
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
const cancelled = buildingAnnotation('cancelled-building');
const cancelledTrace = workTrace();
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
const savedCancelled = frames.snapshot();
renderer.remove(cancelled);
assert.equal((await cancelledCompletion).status, 'cancelled');
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
for (const listener of savedCancelled) listener();
assert.equal(probeCount, 0);
assert.equal(viewer.credits.size, 0);
const head = buildingAnnotation('deadline-head');
const tail = buildingAnnotation('deadline-tail', 0.01);
const headCompletion = renderer.add(head, { pendingVisible: false });
const tailTrace = workTrace();
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: the head's deadline fires, then the
// tail gets its own budget from the moment it reaches the head and still
// times out without frames, even though it never had a chance to sample.
const rendersBefore = viewer.scene.renderRequests;
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
t.mock.timers.tick(2000);
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
assert.equal(tailTrace.samplerStatus, 'timeout');
assert.equal(tailTrace.rendererStatus, 'timeout');
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(viewer.credits.size, 0);
for (const listener of savedTimeout) listener();
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
assert.equal(dataSources[0].entities.values.length, 0);
const pending = buildingAnnotation('destroyed-building');
const destruction = renderer.add(pending, { pendingVisible: false });
const savedDestroy = frames.snapshot();
renderer.destroy();
assert.equal((await destruction).status, 'cancelled');
for (const listener of savedDestroy) listener();
t.mock.timers.tick(2000);
assert.equal(probeCount, 1);
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
assert.equal(frames.size, 0, 'destroy removes frame listeners');
assert.equal(viewer.credits.size, 0);
});
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
renderer.add(anno);
const [before] = annotationGroups(store);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
t.mock.timers.tick(2000);
assert.equal((await completion).status, 'timeout');
const [after, ...others] = annotationGroups(store);
assert.equal(after.key, before.key);
assert.equal(others.length, 0);
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
assert.equal(dataSources[0].entities.values.length, 0);
frames.emit();
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
});
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
anno.polygons = [[anno.ring, hole], [island]];
const captionTrace = workTrace();
const completion = renderer.update(anno, { workTrace: captionTrace });
// Frame projection is still ordinary pending-point work. Arm the failure
// only after sampling completes, before its promise converts the caption.
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
// The caption conversion fails when it presents the converted mark.
const publish = store.publish;
const failure = new Error('caption conversion failed');
let armed = true;
store.publish = (board) => {
if (armed) {
armed = false;
throw failure;
}
return publish(board);
};
// Also fail screen teardown once: the routing entry must survive this second
// failure so explicit cleanup can still reach the partial screen state.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
const rejected = assert.rejects(completion, (error) => error === failure);
await rejected;
assert.equal(captionTrace.rendererStatus, 'failed');
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
globalThis.window.setTimeout = workingSetTimeout;
store.publish = publish;
renderer.remove(anno);
assert.equal(annotationGroups(store).length, 0);
renderer.remove(anno);
renderer.destroy();
});
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
installBrowserGlobals(t);
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
// Unlabeled: the conversion empties the screen group rather than relabelling it.
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
for (let frame = 0; frame < 8; frame += 1) frames.emit();
// The clock passes the deadline while the conversion presents the board.
const publish = store.publish;
store.publish = (board) => {
now = 1e9;
return publish(board);
};
assert.equal((await completion).status, 'timeout');
store.publish = publish;
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
assert.equal(live.length, 1);
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
renderer.remove(anno);
renderer.destroy();
});
+164
View File
@@ -0,0 +1,164 @@
// Multipart outline identity and street highlights in the annotation engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
promoteStreetHighlight,
} from './annotationEngine.js';
function installAnimationFrameStubs(t) {
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
if (raf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = raf;
if (caf === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = caf;
});
}
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
const outer = [
[0, 0],
[0.01, 0],
[0.01, 0.01],
[0, 0.01],
[0, 0],
];
const hole = (d) => [
[0.004, 0.004],
[0.004 + d, 0.004],
[0.004 + d, 0.004 + d],
[0.004, 0.004],
];
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
installAnimationFrameStubs(t);
const shapes = {
a: [[outer, hole(0.001)]],
b: [[outer, hole(0.002)]],
same: [[outer, hole(0.001)]],
};
const resolveTarget = async ({ target }) => ({
lon: 0.005,
lat: 0.005,
height: 0,
label: target,
source: 'fake',
ring: outer,
polygons: shapes[target],
footprintKind: 'area',
});
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
assert.equal(engine.list().length, 2, 'different holes: different marks');
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
});
test('a street highlight asks for the street outline', () => {
assert.deepEqual(
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'Lombard St, San Francisco',
}).type,
'area',
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'the Alamo',
entityKind: 'street',
}).type,
'area',
);
for (const spec of [
{ type: 'highlight', target: "St Peter's Basilica" },
{ type: 'highlight', target: 'Zilker Park' },
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
{ type: 'pin', target: 'Congress Avenue' },
])
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
});
test('the engine resolves a bare street highlight with its footprint', async (t) => {
installAnimationFrameStubs(t);
const seen = [];
const resolveTarget = async (args) => {
seen.push(args);
return {
lon: -97.74,
lat: 30.27,
height: 0,
label: args.target,
source: 'fake',
ring: null,
};
};
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({
type: 'highlight',
target: 'Congress Avenue',
label: 'Congress',
});
assert.equal(seen[0].footprint, true);
assert.equal(seen[0].entityKind, 'street');
});
test('a duplicate that fails to clean up never costs the new outline', async (t) => {
installAnimationFrameStubs(t);
const polygons = [[outer]];
const outline = { ring: outer, polygons, footprintKind: 'area' };
const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' };
// "Old" draws at once; "New" resolves the same polygon later.
const resolveTarget = async ({ target }) =>
target === 'old'
? { ...point, label: target, ...outline }
: {
...point,
label: target,
resolveOutline: async () => ({ ...point, ...outline }),
};
const stuck = new Set();
const outcomes = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: {
...renderer,
add(anno) {
if (anno.label === 'Old') stuck.add(anno);
},
remove(anno) {
if (stuck.has(anno)) throw new Error('renderer refused');
},
},
resolveTarget,
});
engine.onOutlineEvent((event) => outcomes.push(event));
await engine.annotate({ type: 'area', target: 'old', label: 'Old' });
await engine.annotate({ type: 'area', target: 'new', label: 'New' });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const fresh = engine.list().find((mark) => mark.label === 'New');
assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline');
assert.notEqual(fresh.outlineUnavailable, true);
assert.deepEqual(
outcomes.filter((event) => event.label === 'New').map((e) => e.status),
['resolved'],
);
});
+376
View File
@@ -0,0 +1,376 @@
import {
bufferCorridor,
ringAreaM2,
ringCentroid,
} from '../sources/featureGeometry.js';
import { isUnavailableCapability } from '../sources/capability.js';
import { containsAnchor } from '../data/adminBoundaries.js';
import { lonLatToTile } from '../data/tomtomTiles.js';
import {
BUILDING_MAX_DISTANCE_M,
GROUNDS_WORDS_RE,
OUTLINE_TILE_ZOOM,
STREET_RADIUS_M,
boxAround,
buildingFromTiles,
createOpenFreeMapOutlineSource,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from '../sources/openFreeMapOutlines.js';
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
export { containsAnchor };
/**
* Outline rungs that need no Overpass, around the resolver's existing ladder:
*
* bundled rungs (inside the base ladder)
* → OpenFreeMap tiles for streets and pointed-at buildings
* → operator Overpass, when configured (the base ladder, unchanged)
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
* parks, lakes, campuses, named buildings)
* → OpenFreeMap building at the point, for a named building nobody outlined
* → the base answer: an honest point marked "outline unavailable".
*
* Every rung returns the resolver's footprint shape
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
* resolver's `finish`, which applies the scope caps and drift bound and
* re-centres the anchor. Nothing here fetches without an explicit ask.
*/
/** Half-width (m) of the ribbon drawn along a street. */
const STREET_HALF_WIDTH_M = 11;
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
const GROUNDS_MIN_AREA_M2 = 20_000;
/** Words that point at the thing rather than name it. */
const DEICTIC_RE =
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
/** Whether the ask names a thing (vs. "this building" at a pointer). */
export function isNamedAsk(target) {
const text = String(target || '').trim();
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
}
/** The words sent to Nominatim: the ask without grounds words or articles. */
export function outlineQueryText(target) {
return String(target || '')
.replace(GROUNDS_WORDS_RE, ' ')
.replace(/^\s*the\s+/i, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Which Nominatim ask kind (if any) suits a resolver scope.
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
*/
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
if (pointLike) return null;
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
if (around && !groundsLike) return null;
if (scope === 'city') return 'city';
if (scope === 'state' || scope === 'county' || scope === 'country')
return 'admin';
if (groundsLike) return 'landmark';
if (scope === 'building') return 'building';
if (scope === 'compound' || scope === 'auto') return 'landmark';
return null;
}
/**
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
* not pick the part the ask is about; with no part holding the anchor, the
* order is kept.
*/
export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex((polygon) =>
containsAnchor([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
}
return polygons;
}
/** Whether a base answer is final (a real outline, or "try again later"). */
function keepBase(result) {
if (result === undefined) return true;
if (result?.rateLimited === true) return true;
if (isUnavailableCapability(result)) return false;
return Boolean(result?.ring) && !result.synthesized;
}
/** Street polylines → one ribbon polygon per chain (longest first). */
export function streetFootprint(street) {
const polygons = street.lines
.filter((line) => line.length >= 2)
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
if (!polygons.length) return null;
return {
ring: polygons[0][0],
polygons,
lines: street.lines,
kind: 'area',
heightM: null,
synthesized: false,
};
}
// Rungs built without their own tiles share one outline source, so a second
// resolver instance cannot add another outline cache to the tile budget.
let defaultOutlineTiles = null;
const sharedOutlineTiles = () =>
(defaultOutlineTiles ??= createOpenFreeMapOutlineSource());
export function createOutlineRungs({
tiles = sharedOutlineTiles(),
nominatim = createNominatimOutlineClient(),
} = {}) {
/**
* Decoded tiles around a point, or null when the tiles cannot be read. With
* `needAnchor`, a partial load that lost the point's own tile is null too: a
* neighbouring tile alone cannot show what is at the point, so a nearby
* building or area would stand in for it.
*/
async function tilesAround(lat, lon, radiusM, signal, needAnchor = false) {
try {
const {
tiles: decoded,
partial,
loadedTiles,
} = await tiles.fetchBounds(boxAround(lat, lon, radiusM), {
zoom: OUTLINE_TILE_ZOOM,
signal,
});
if (needAnchor && partial && Array.isArray(loadedTiles)) {
const anchor = lonLatToTile(lon, lat, OUTLINE_TILE_ZOOM);
if (!loadedTiles.some((t) => t.x === anchor.x && t.y === anchor.y))
return null;
}
return decoded;
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
return null;
}
}
async function streetRung(ctx) {
const name = ctx.matchName || ctx.target;
if (!isNamedAsk(name)) return null;
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
STREET_RADIUS_M,
ctx.signal,
);
if (!decoded) return null;
const street = streetFromTiles(decoded, {
name,
lat: ctx.lat,
lon: ctx.lon,
});
return street ? streetFootprint(street) : null;
}
async function buildingRung(ctx, requireContainment = false) {
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
BUILDING_MAX_DISTANCE_M * 2,
ctx.signal,
true,
);
const building = decoded && buildingFromTiles(decoded, ctx);
if (!building || (requireContainment && building.contains !== true))
return null;
return {
ring: building.ring,
polygons: building.polygons,
kind: 'building',
heightM: building.heightM,
synthesized: false,
};
}
/** The open area enclosing `seed` (the named feature) or the anchor. */
async function groundsRung(ctx, seed = null) {
const at = seed || { lat: ctx.lat, lon: ctx.lon };
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal, true);
const area =
decoded &&
enclosingAreaFromTiles(decoded, {
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
// The kind the ask names ("… School grounds") lets unnamed tile
// landuse of that class answer; read before grounds words are cut.
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: area.polygons,
kind: 'area',
heightM: null,
synthesized: false,
};
}
async function nominatimRung(ctx, kind) {
const query = outlineQueryText(
kind === 'city' || kind === 'admin'
? ctx.matchName || ctx.target
: ctx.target || ctx.matchName,
);
if (!isNamedAsk(query)) return null;
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
const outline = await nominatim.lookup(
{
query,
kind,
lat: bias.lat,
lon: bias.lon,
anchorLat: ctx.lat,
anchorLon: ctx.lon,
},
{ signal: ctx.signal },
);
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
]);
const ring = polygons[0][0];
// A grounds ask answered by a building-sized polygon found the named
// building, not its grounds: keep its position to find what encloses it.
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
return { groundsSeed: ringCentroid(ring) };
return {
ring,
polygons,
kind:
kind === 'building' || outline.class === 'building'
? 'building'
: 'area',
heightM: null,
synthesized: false,
outlineName: outline.name,
adminArea: outline.adminArea || null,
adminLevel: outline.adminLevel || null,
};
}
/**
* Run the ladder for one resolved anchor.
*
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
* view centre, signal, and `credit(kind)`.
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
*/
async function resolve(ctx, { base, finish }) {
const { scope } = ctx;
const named = ctx.fromName && isNamedAsk(ctx.target);
// A cancelled ask (board cleared, superseded) stops between rungs, so it
// never spends a Nominatim request.
const alive = () => ctx.signal?.throwIfAborted();
const accept = (fp, asScope = scope, provider) => {
alive();
if (!fp) return null;
const patch = finish(fp, asScope);
if (patch?.ring) {
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
// Which source answered, so a stored area names it (OpenFreeMap or
// Nominatim) instead of defaulting to the Overpass rungs.
patch.outlineSource = provider;
ctx.credit?.(patch.outlineCredit);
// One line per answered outline, like the resolver's own trace.
console.log(
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
);
return patch;
}
return null;
};
// Tiles first for streets and for a building pointed at rather than named.
if (scope === 'street') {
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
if (street) return street;
} else if (scope === 'building' && !named && !ctx.around) {
const building = accept(
await buildingRung(ctx),
'building',
'openfreemap',
);
if (building) return building;
}
alive();
const baseResult = await base();
alive();
if (keepBase(baseResult)) return baseResult;
// A transient or busy Nominatim answer is kept aside while the tile
// fallbacks run; it is returned only if nothing else answers.
let retryLater = null;
let groundsSeed = null;
const kind = named ? nominatimKindFor(ctx) : null;
if (kind) {
const found = await nominatimRung(ctx, kind);
alive();
if (found?.groundsSeed) {
groundsSeed = found.groundsSeed;
} else if (found?.ring) {
const patch = accept(
found,
ctx.groundsLike ? 'compound' : scope,
'nominatim',
);
if (patch) return patch;
} else if (found === undefined || found?.rateLimited) {
retryLater = { value: found };
}
}
// Grounds absent from the named lookup: a matching named tile area.
if (ctx.groundsLike && named) {
// Around the named building when Nominatim found it, else the anchor.
let grounds = groundsSeed
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
: null;
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
if (grounds) return grounds;
}
// A named building nobody outlined: the footprint holding its point. Never for
// a grounds ask, where the building is the wrong shape.
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
const building = accept(
await buildingRung(ctx, true),
'building',
'openfreemap',
);
if (building) return building;
}
// Keep the mark retryable only when nothing else could answer. A
// definitive miss or an approximation from the configured ladder stands
// rather than inviting repeated lookups.
if (retryLater && isUnavailableCapability(baseResult))
return retryLater.value;
return baseResult;
}
return { resolve };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,216 @@
// Tile fallbacks still run after a busy or failed Nominatim answer, and
// grounds asks phrased "around" still reach their outline rungs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
y,
),
);
const UNAVAILABLE = { unavailable: true, retryable: false };
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
function setup(answer, tiles = TILES) {
const tileCalls = [];
const asks = [];
const rungs = createOutlineRungs({
tiles: {
fetchBounds: async (box) => {
tileCalls.push(box);
return { tiles };
},
},
nominatim: {
lookup: async (ask) => {
asks.push(ask);
return answer;
},
},
});
return { rungs, tileCalls, asks };
}
const ladder = {
base: async () => UNAVAILABLE,
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
};
const ctx = (overrides) => ({
scope: 'building',
target: 'Texas State Capitol',
matchName: 'Texas State Capitol',
fromName: true,
...CAPITOL,
view: CAPITOL,
...overrides,
});
for (const [label, answer] of [
['busy', { rateLimited: true, retryAfterMs: 5000 }],
['transient', undefined],
]) {
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
const building = setup(answer);
const found = await building.rungs.resolve(ctx(), ladder);
assert.equal(building.asks.length, 1);
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
assert.equal(found.footprintKind, 'building');
const grounds = setup(answer);
const lawn = await grounds.rungs.resolve(
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
});
}
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
const busy = { rateLimited: true, retryAfterMs: 5000 };
const rungs = createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim: { lookup: async () => busy },
});
assert.equal(await rungs.resolve(ctx(), ladder), busy);
});
test('"around the Capitol grounds" is still a grounds ask', async () => {
assert.equal(
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
'landmark',
);
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
const { rungs, asks } = setup(null);
const lawn = await rungs.resolve(
ctx({
target: 'the Texas Capitol grounds',
groundsLike: true,
around: true,
}),
ladder,
);
assert.equal(asks.length, 1);
});
test('Nominatim administrative identity survives the outline rung', async () => {
const boundary = {
polygons: [
[
[
[84.4, 26.9],
[86.6, 26.9],
[86.6, 28.4],
[84.4, 28.4],
[84.4, 26.9],
],
],
],
class: 'boundary',
type: 'administrative',
adminArea: 'Bagmati Province',
adminLevel: 'admin1',
};
const { rungs } = setup(boundary);
const result = await rungs.resolve(
ctx({
scope: 'state',
target: 'Bagmati Province',
matchName: 'Bagmati Province',
lat: 27.7,
lon: 85.3,
view: { lat: 27.7, lon: 85.3 },
}),
{
base: async () => UNAVAILABLE,
finish: (fp) => ({
ring: fp.ring,
footprintKind: fp.kind,
adminArea: fp.adminArea,
adminLevel: fp.adminLevel,
}),
},
);
assert.equal(result.adminArea, 'Bagmati Province');
assert.equal(result.adminLevel, 'admin1');
});
test('named grounds around a building seed still require the original anchor', async () => {
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
const offLawn = { lat: 30.27265, lon: -97.73925 };
const d = 0.0003;
const dome = {
polygons: [
[
[
[CAPITOL.lon - d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat - d],
],
],
],
class: 'office',
};
const lawnRadius = 0.001;
const namedTiles = [
{
areas: [
{
name: 'Texas Capitol',
class: 'park',
clipped: false,
rings: [
[
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
],
],
},
],
},
];
const withoutSeed = setup(null, namedTiles);
assert.equal(
await withoutSeed.rungs.resolve(
ctx({
...offLawn,
target: 'the Texas Capitol grounds',
groundsLike: true,
}),
ladder,
),
UNAVAILABLE,
'the anchor alone is not inside the grounds',
);
const { rungs, tileCalls } = setup(dome, namedTiles);
const lawn = await rungs.resolve(
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
assert.deepEqual(
tileCalls,
withoutSeed.tileCalls,
'a rejected polygon cannot add a shifted grounds-seed lookup',
);
assert.equal(
lawn,
UNAVAILABLE,
'a building seed cannot move the original anchor into the named grounds',
);
});
+727
View File
@@ -0,0 +1,727 @@
// Area annotations draw bundled US Census place and neighborhood outlines
// (San Francisco DataSF first, then Who's On First) with no network lookup.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
function viewerAt(lat, lon, height = 20_000) {
return {
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
},
},
};
}
/** A resolver whose boundary lookups and geocoder record every call. */
function harness(place = null, { outlineRungs } = {}) {
const lookups = [];
const featureSource = {};
for (const method of FEATURE_SOURCE_METHODS)
featureSource[method] = async () => {
lookups.push(method);
return null;
};
const geocodes = [];
const placeSearch = {
async geocode(query) {
geocodes.push(query);
return { place };
},
async textSearch() {
geocodes.push('text-search');
return [];
},
};
const { resolveAnnotationTarget } = createAnnotationResolver({
featureSource,
...(outlineRungs ? { outlineRungs } : {}),
});
const resolve = (target, viewer, extra = {}) =>
resolveAnnotationTarget({
placeSearch,
viewer,
target,
footprint: true,
deferFootprint: true,
...extra,
});
return { resolve, lookups, geocodes };
}
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
const LONDON = viewerAt(51.51, -0.16, 3000);
function assertBundledArea(resolved, label) {
assert.equal(resolved?.source, 'bundled', label);
assert.equal(resolved.label, label);
assert.equal(resolved.footprintKind, 'area');
assert.equal(resolved.synthesized, false);
assert.equal(
resolved.outlineCredit || null,
null,
'bundled data is not an OSM outline',
);
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
assert.ok(resolved.polygons.length >= 1);
}
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Sé lists "São Paulo" among its names; the ask means the city.
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
const city = await resolve('São Paulo', saoPaulo);
assert.notEqual(city?.label, 'Se');
assert.ok(geocodes.includes('São Paulo'));
// A district ask still takes the alias.
const district = await resolve('São Paulo', saoPaulo, {
entityKind: 'district',
});
assert.equal(district?.label, 'Se');
});
test('"Austin" over Austin draws the Census place with no lookup', async () => {
const { resolve, lookups, geocodes } = harness();
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
const resolved = await resolve(target, AUSTIN);
assertBundledArea(resolved, 'Austin');
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
}
// A qualified name needs no view.
const springfield = await resolve('Springfield, Illinois', AUSTIN);
assertBundledArea(springfield, 'Springfield');
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
assert.deepEqual(geocodes, []);
assert.deepEqual(lookups, []);
});
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
const { resolve, lookups, geocodes } = harness();
const sf = JSON.parse(
readFileSync(
new URL(
'../data/local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
),
);
const mission = sf.features.find((f) => f.properties.name === 'Mission');
const missionRing = mission.geometry.coordinates[0];
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
const resolved = await resolve(target, SAN_FRANCISCO, {
entityKind: 'district',
});
assertBundledArea(resolved, 'Mission');
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
}
// A name only WOF has in San Francisco falls through to it.
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
assertBundledArea(dogpatch, 'Dogpatch');
// A park asked for as a compound is not a neighborhood of its name.
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
entityKind: 'compound',
});
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
assert.deepEqual(lookups, []);
});
test("Who's On First neighborhoods resolve near the view", async () => {
const { resolve, geocodes } = harness();
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
assertBundledArea(
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
'Le Marais',
);
assertBundledArea(
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
'Williamsburg',
);
assert.deepEqual(geocodes, []);
});
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Stanford is a CDP, but a campus ask wants the campus.
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
entityKind: 'compound',
});
assert.notEqual(stanford?.source, 'bundled');
await resolve('the Capitol grounds', AUSTIN);
await resolve('Notting Hill', AUSTIN);
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
// A state settles a place name wherever the camera is: not Brooklyn's
// Williamsburg, the Virginia city.
const virginia = await resolve(
'Williamsburg, Virginia',
viewerAt(40.71, -73.96, 3000),
);
assertBundledArea(virginia, 'Williamsburg');
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
});
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
const city = harness({
lat: 30.2672,
lng: -97.7431,
label: 'Austin, TX, USA',
name: 'Austin',
types: ['locality', 'political'],
});
const controller = new AbortController();
const credits = [];
const cancelled = await city.resolve(
'Austin',
{
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
},
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(
credits,
[],
'a cancelled bundled continuation cannot mutate viewer credits',
);
// Seen from far away: the view does not settle the name, the geocoder does.
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.adminArea, 'Austin');
assert.ok(outline.polygons.length >= 4);
assert.deepEqual(city.lookups, [], 'no boundary lookup');
const hood = harness({
lat: 51.5118,
lng: -0.2046,
label: 'Notting Hill, London, UK',
name: 'Notting Hill',
types: ['neighborhood', 'political'],
});
const nottingHill = await hood.resolve(
'Notting Hill',
viewerAt(48, 10, 5_000_000),
);
const hoodOutline = await nottingHill.resolveOutline();
assert.equal(hoodOutline.adminArea, 'Notting Hill');
assert.deepEqual(hood.lookups, []);
});
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
const { resolve, geocodes } = harness();
// "Richmond" over SF's Richmond District: never Richmond, California.
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
entityKind: 'district',
});
assertBundledArea(richmond, 'Richmond District');
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
// Omitted kind at a street-level view: the district in view, too.
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
assertBundledArea(bare, 'Richmond District');
// Over the East Bay city, the city.
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
assert.equal(wide?.source, 'bundled');
assert.ok(wide.lat > 37.88, 'Richmond, California');
assert.deepEqual(geocodes, []);
});
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
const manhattan = viewerAt(40.78, -73.965, 3000);
const cases = [
['Central Park', {}],
['Central Park', { entityKind: 'compound' }],
['Central Park', { intent: 'around_the_thing' }],
['Hyde Park', {}],
['Notting Hill', { intent: 'around_the_thing' }],
];
for (const [target, extra] of cases) {
const { resolve, geocodes } = harness();
const viewer = target === 'Central Park' ? manhattan : LONDON;
const resolved = await resolve(target, viewer, extra);
assert.notEqual(
resolved?.source,
'bundled',
`${target} ${JSON.stringify(extra)}`,
);
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
}
// Named as a district, the WOF neighborhood answers.
const { resolve } = harness();
const park = await resolve('Central Park', manhattan, {
entityKind: 'district',
});
assert.equal(park?.source, 'bundled');
// "Park Slope" ends in no landmark word: still a neighborhood.
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
assertBundledArea(slope, 'Park Slope');
});
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
function rungsAnswering(answer) {
const asked = [];
return {
asked,
resolve: async (ctx, { base, finish }) => {
asked.push({ scope: ctx.scope, target: ctx.target });
const baseResult = await base();
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
return answer ? finish(answer, ctx.scope) : baseResult;
},
};
}
const PARIS_PLACE = {
lat: 48.8566,
lng: 2.3522,
label: 'Paris, France',
name: 'Paris',
types: ['locality', 'political'],
};
const square = (lat, lon, d) => [
[lon - d, lat - d],
[lon + d, lat - d],
[lon + d, lat + d],
[lon - d, lat + d],
[lon - d, lat - d],
];
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
const lat = 0.0005;
const lon = 0.0005;
const ring = square(lat, lon, 0.0005);
const hole = square(lat, lon, 0.0002);
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
const pending = await resolve(
'this building',
viewerAt(lat, lon, 1000),
options,
);
const outline = await pending.resolveOutline();
assert.equal(
outline,
null,
'neither the mean nor the courtyard point is filled',
);
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
...options,
deferFootprint: false,
});
assert.equal(inline.ring, null);
assert.deepEqual(
[inline.lat, inline.lon],
[lat, lon],
'keep the honest point',
);
for (const resolved of [outline, inline]) {
if (resolved?.polygons)
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
}
});
test('a bent street keeps its outline when the anchor sits beside the centreline', async () => {
// 500 m east then 500 m north: the ring mean lands off the 22 m ribbon.
const lat0 = 30.27;
const lon0 = -97.74;
const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180));
const dLat = 500 / 111320;
const line = [
[lon0, lat0],
[lon0 + dLon / 2, lat0],
[lon0 + dLon, lat0],
[lon0 + dLon, lat0 + dLat / 2],
[lon0 + dLon, lat0 + dLat],
];
const footprint = streetFootprint({ lines: [line] });
// A geocoded point 15 m north of the first leg, outside the ribbon.
const lat = lat0 + 15 / 111320;
const lon = lon0 + dLon / 2;
assert.equal(polygonsContain(footprint.polygons, lat, lon), false);
const { resolve } = harness(
{ lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] },
{ outlineRungs: rungsAnswering(footprint) },
);
const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), {
entityKind: 'street',
});
const outline = await pending.resolveOutline();
assert.equal(outline?.ring, footprint.ring, 'the street is still drawn');
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street');
assert.ok(Math.abs(outline.lon - lon) < 1e-7);
// A same-name street that never comes near the anchor is not that street.
const far = lat0 + 300 / 111320;
const away = harness(
{
lat: far,
lng: lon,
name: 'Bent Street',
label: 'Bent St',
types: ['route'],
},
{ outlineRungs: rungsAnswering(footprint) },
);
const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), {
entityKind: 'street',
});
assert.equal((await distant.resolveOutline())?.ring ?? null, null);
});
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
const ring = [
[179.9995, -0.0005],
[-179.9995, -0.0005],
[-179.9995, 0.0005],
[179.9995, 0.0005],
[179.9995, -0.0005],
];
const hole = [
[179.9998, -0.0003],
[-179.9998, -0.0003],
[-179.9998, 0.0003],
[179.9998, 0.0003],
[179.9998, -0.0003],
];
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
for (const deferFootprint of [true, false]) {
for (const [lat, lon, filled] of [
[0.0004, 179.9996, true],
[0, 179.99999, false],
[0.0004, 0, false],
]) {
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
latitude: lat,
longitude: lon,
entityKind: 'building',
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
if (!filled) {
assert.equal(outline?.ring ?? null, null);
continue;
}
assert.equal(outline.ring, ring);
assert.equal(outline.polygons, polygons);
assert.deepEqual(
[outline.lat, outline.lon],
[lat, lon],
'keep the filled shell anchor',
);
}
}
const plain = harness(null, {
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
});
const pending = await plain.resolve(
'this building',
viewerAt(0, 179.9999, 1000),
{
latitude: 0,
longitude: 179.9999,
entityKind: 'building',
},
);
const centered = await pending.resolveOutline();
assert.equal(centered.ring, ring);
assert.ok(
Math.abs(centered.lon) > 179.999 &&
centered.lon >= -180 &&
centered.lon < 180,
);
});
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
// The outer-ring vertex mean is in the hole; the resolved anchor is in
// the shell. Completion must not move that valid point into the hole.
const ring = square(48.8566, 2.3822, 0.04);
const hole = square(48.8566, 2.3822, 0.02);
const polygons = [[ring, hole]];
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
const resolved = await fine.resolve('Paris', overParis);
assert.equal(
resolved.source,
'geocode',
'the 11-vertex admin-1 is not drawn at once',
);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
assert.deepEqual(outline.polygons, polygons);
assert.equal(outline.lat, PARIS_PLACE.lat);
assert.equal(outline.lon, PARIS_PLACE.lng);
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
const inline = await fine.resolve('Paris', overParis, {
deferFootprint: false,
});
assert.deepEqual(inline.polygons, polygons);
assert.equal(inline.lat, outline.lat);
assert.equal(inline.lon, outline.lon);
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
const fallback = await (
await coarse.resolve('Paris', overParis)
).resolveOutline();
assert.equal(fallback.adminArea, 'Paris');
assert.ok(fallback.ring.length < 32);
// No geocoder answer at all: the bundled shape at once.
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
const direct = await offline.resolve('Paris', overParis);
assert.equal(direct.source, 'bundled');
assert.equal(direct.label, 'Paris');
const overDallas = viewerAt(32.7767, -96.797, 20_000);
assert.equal(
await offline.resolve('Paris', overDallas),
null,
'a missing geocode cannot select the distant coarse homonym',
);
assert.equal(
await coarse.resolve('Paris', overDallas),
null,
'a rejected foreign geocode cannot restore the same distant polygon',
);
const outsideParis = harness(
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
{ outlineRungs: rungsAnswering(null) },
);
const anchored = await outsideParis.resolve('Paris', overParis);
assert.equal(anchored.source, 'geocode');
assert.equal(
await anchored.resolveOutline(),
undefined,
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
);
});
test('deferred outlines preserve provider administrative identity metadata', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
const ring = square(48.8566, 2.3522, 0.05);
const { resolve } = harness(PARIS_PLACE, {
outlineRungs: rungsAnswering({
ring,
kind: 'area',
heightM: null,
adminArea: 'Île-de-France',
adminLevel: 'admin1',
}),
});
const outline = await (await resolve('Paris', overParis)).resolveOutline();
assert.equal(outline.adminArea, 'Île-de-France');
assert.equal(outline.adminLevel, 'admin1');
});
test('an explicitly distant area keeps its deferred outline', async () => {
const kathmandu = {
lat: 27.7172,
lng: 85.324,
label: 'Kathmandu, Nepal',
name: 'Kathmandu',
types: ['locality', 'political'],
};
const ring = square(27.7172, 85.324, 0.05);
const { resolve } = harness(kathmandu, {
outlineRungs: rungsAnswering({ ring, kind: 'area', heightM: null }),
});
const resolved = await resolve('Kathmandu', SAN_FRANCISCO, {
allowDistant: true,
});
assert.equal(resolved.source, 'geocode');
assert.deepEqual(
(await resolved.resolveOutline()).ring,
ring,
'the deferred centroid gate honors the same explicit distance policy as the anchor',
);
});
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
for (const [name, lat, lon] of [
['Berlin', 52.52, 13.4],
['Bavaria', 48.9, 11.4],
['Texas', 31, -99],
]) {
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
assert.equal(resolved?.source, 'bundled', name);
}
// Countries are never coarse cities, however few vertices.
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
assert.equal(singapore?.source, 'bundled');
// An explicit admin ask takes the admin unit as it is.
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
entityKind: 'state',
});
assert.equal(dc?.source, 'bundled');
});
test('a campus ask reaches the campus, not the Census place of its town', async () => {
const stanfordTown = {
lat: 37.4241,
lng: -122.1661,
label: 'Stanford, CA, USA',
name: 'Stanford',
types: ['locality', 'political'],
};
const campus = square(37.4275, -122.1697, 0.01);
for (const ask of ['Stanford University', 'Stanford campus']) {
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, campus, ask);
assert.equal(
outline.outlineCredit,
null,
'an injected outline is not inferred OSM',
);
assert.equal(
rungs.asked[0].scope,
'compound',
`${ask}: past the Census rung`,
);
}
// "Stanford" said to be a compound is the campus too.
const kindRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
const compound = await (
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
entityKind: 'compound',
})
).resolveOutline();
assert.deepEqual(compound.ring, campus);
assert.equal(kindRungs.asked[0].scope, 'compound');
const labelRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
const labelled = await byLabel.resolve(
'Stanford',
viewerAt(37.43, -122.17, 8000),
{ labelHint: 'Stanford campus' },
);
assert.notEqual(labelled.source, 'bundled');
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
assert.equal(labelRungs.asked[0].scope, 'compound');
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
// college stays a town.
const { resolve } = harness(stanfordTown, {
outlineRungs: rungsAnswering(null),
});
const town = await (
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(town.adminArea, 'Stanford');
const station = harness(
{
lat: 30.628,
lng: -96.3344,
label: 'College Station, TX, USA',
name: 'College Station',
types: ['locality', 'political'],
},
{ outlineRungs: rungsAnswering(null) },
);
const collegeStation = await (
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(collegeStation.adminArea, 'College Station');
});
test('a grounds ask never takes the state the geocoder typed', async () => {
const texas = {
lat: 31.0,
lng: -99.0,
label: 'Texas, USA',
name: 'Texas',
types: ['administrative_area_level_1', 'political'],
};
for (const [ask, extra] of [
['the Texas Capitol grounds', {}],
['Texas Capitol', { entityKind: 'compound' }],
// The grounds wording is only in the label.
['Texas Capitol', { labelHint: 'Capitol grounds' }],
]) {
const rungs = rungsAnswering(null);
const { resolve } = harness(texas, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.notEqual(outline?.adminArea, 'Texas', ask);
assert.equal(rungs.asked[0].scope, 'compound', ask);
}
});
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
const ring = square(31, -99, 0.001);
let calls = 0;
const resolver = createAnnotationResolver({
boundarySource: {
query: async () => {
calls++;
return [
{
type: 'way',
id: 1,
tags: { name: 'Library', building: 'yes' },
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
},
];
},
},
outlineRungs: { resolve: (_ctx, { base }) => base() },
});
for (const deferFootprint of [false, true]) {
const result = await resolver.resolveAnnotationTarget({
viewer: viewerAt(31, -99, 3000),
latitude: 31,
longitude: -99,
target: 'Library',
entityKind: 'building',
footprint: true,
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
assert.equal(outline.footprintKind, 'building');
assert.equal(outline.outlineCredit, 'osm');
assert.deepEqual(outline.ring, ring);
}
assert.equal(calls, 1, 'cached geometry retains its original provider');
});
+424 -21
View File
@@ -11,6 +11,7 @@ import {
approximateAreaM2,
approximateDistanceM,
pointInPolygon,
nearestPointOnLines,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
@@ -19,18 +20,84 @@ import {
lookupNaturalRegionOutline,
findNaturalRegion,
} from '../data/naturalEarthRegions.js';
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
import {
findAdminArea,
findAdminAreaAt,
polygonsContain as polygonsContainPoint,
} from '../data/adminBoundaries.js';
import {
findNeighborhoodArea,
findNeighborhoodAreaAt,
findPlaceArea,
findPlaceAreaAt,
NEIGHBORHOOD_NEAR_KM,
normalizePlaceName,
PLACE_NEAR_KM,
} from '../data/placeBoundaries.js';
import {
registerDynamicCredit,
NATURAL_EARTH_CREDIT,
US_CENSUS_CREDIT,
OSM_CREDIT,
OPENMAPTILES_CREDIT,
WOF_CREDIT,
} from '../data/dataCredits.js';
import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
let west = Infinity;
let east = -Infinity;
for (const [lon] of fp.ring) {
west = Math.min(west, lon);
east = Math.max(east, lon);
}
if (east - west <= 180) return original;
const reference = fp.ring[0][0];
west = Infinity;
east = -Infinity;
const shift = (ring) =>
ring.map(([lon, lat]) => {
const x = lon - 360 * Math.round((lon - reference) / 360);
west = Math.min(west, x);
east = Math.max(east, x);
return [x, lat];
});
const ring = shift(fp.ring);
const polygons = Array.isArray(fp.polygons)
? fp.polygons.map((part) => part.map(shift))
: fp.polygons;
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
// are never rewritten, including holes and unrelated multipart geometry.
if (!Number.isFinite(east - west) || east - west >= 180) return original;
return { ring, polygons, reference };
}
/** Ms the analyst region lookup waits on geocode + admin boundary. */
const REGION_FALLBACK_BUDGET_MS = 3_000;
/** How far a street may run from its anchor when the ribbon holds neither. */
const STREET_SNAP_MAX_M = 100;
/**
* Names that end like a landmark rather than a district: "Central Park",
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
* Slope" and "Notting Hill" stay districts.
*/
const LANDMARK_NAME =
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
/** The name part of an ask, before any comma qualifier. */
function stripQualifiers(query) {
return String(query || '')
.split(',')[0]
.trim();
}
/** Entity facts that rule out a state/county reading of the ask. */
const NON_ADMIN_ENTITY_KINDS = new Set([
'building',
@@ -47,8 +114,12 @@ export function createAnnotationResolver({
boundarySource,
signal: lifetime,
}),
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
outlineRungs = createOutlineRungs(),
} = {}) {
requireFeatureSource(featureSource);
const featureOutlineCredit =
featureSource.outlineCredit === 'osm' ? 'osm' : null;
lifetime?.throwIfAborted();
/**
@@ -164,6 +235,21 @@ export function createAnnotationResolver({
// locality suffixes; the types classify the entity (point-like vs area-like).
let placesPrimary = null;
let placeTypes = [];
// A coarse bundled admin-1 shape that carries a city's name (Natural
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
// (Census, Nominatim) try first.
let coarseCityShape = null;
let bundledCenter = null;
const coarseCityHolds = (point) =>
coarseCityShape &&
point &&
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
const compoundAsk =
entityKind === 'compound' ||
isCampusAsk(target) ||
isGroundsLikeQuery(target) ||
isGroundsLikeQuery(labelHint);
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
const trace = {
query: String(target || '').trim(),
@@ -181,15 +267,32 @@ export function createAnnotationResolver({
footprint &&
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
trace.query &&
!compoundAsk &&
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
bundledCenter = center;
const admin = await findAdminArea(trace.query, { near: center }).catch(
() => null,
);
signal?.throwIfAborted();
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape = admin;
else if (admin)
return bundledAdminTarget(viewer, admin, trace.query, signal);
// Bundled US places and neighborhoods named at or near the view. A
// campus, grounds or monument ask ("Stanford" as a compound, "the
// Capitol grounds") wants that feature, not a district of its name,
// and "the area around X" is drawn by the ladder below.
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
const area = await bundledAreaNear(trace.query, center, {
entityKind,
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
});
signal?.throwIfAborted();
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
@@ -317,7 +420,10 @@ export function createAnnotationResolver({
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
@@ -382,7 +488,9 @@ export function createAnnotationResolver({
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
);
}
return null;
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
}
}
@@ -402,7 +510,18 @@ export function createAnnotationResolver({
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
// building for a premise. The voice model's entityKind (an entity FACT) refines an
// unresolved 'auto' scope only — real geocode types always win.
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
// A campus or grounds ask ("Stanford University", "Stanford campus",
// "the Capitol grounds", in the target or its label) or a compound by
// entity fact wants that compound even when the geocoder types the town
// or the state: never a Census place, neighborhood or admin boundary.
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
const baseScope =
compoundAsk &&
['country', 'state', 'county', 'city', 'neighborhood'].includes(
typedScope,
)
? 'compound'
: typedScope;
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
@@ -431,7 +550,7 @@ export function createAnnotationResolver({
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
const resolveBaseOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
@@ -461,6 +580,7 @@ export function createAnnotationResolver({
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
traceBundledOutline(ne.name, 'natural-earth');
const neCentroid = ringCentroid(ne.ring);
return {
ring: ne.ring,
@@ -468,6 +588,7 @@ export function createAnnotationResolver({
buildingHeight: null,
synthesized: false,
naturalRegion: ne.name,
outlineSource: 'natural-earth',
lat: neCentroid?.lat ?? lat,
lon: neCentroid?.lon ?? lon,
height: sampleGroundHeight(
@@ -516,7 +637,20 @@ export function createAnnotationResolver({
lon,
scope,
).catch(() => null);
if (admin) return bundledAdminOutline(viewer, admin);
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape ||= admin;
else if (admin) return bundledAdminOutline(viewer, admin, signal);
}
// A geocoded US city, town or CDP: the Census place that holds it.
if (scope === 'city') {
const place = await findPlaceAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (place) return bundledAdminOutline(viewer, place, signal);
}
// Pure admin: only an admin boundary is correct — never fall back to a
// building/landuse (a city is never a single building).
@@ -527,7 +661,21 @@ export function createAnnotationResolver({
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
signal?.throwIfAborted();
if (ext) {
traceBundledOutline(ext.name || matchName, 'datasf');
fp = { ring: ext.ring, kind: 'area', heightM: null };
}
// THEN a bundled Who's On First neighborhood carrying the name at the point.
if (!fp) {
const wof = await findNeighborhoodAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (wof) return bundledAdminOutline(viewer, wof, signal);
}
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
// returns a footprint, null (definitively no polygon), or undefined (transient
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
@@ -653,6 +801,12 @@ export function createAnnotationResolver({
}
}
}
return finishOutline(fp, scope);
};
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
const finishOutline = (fp, scope) => {
const frame = localOutlineFrame(fp);
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
// scope (a building/compound/neighborhood intent must never draw a state-sized
// blob). Synthesized discs are deliberately sized and exempt.
@@ -661,20 +815,50 @@ export function createAnnotationResolver({
!fp.synthesized &&
Array.isArray(fp.ring) &&
fp.ring.length >= 3 &&
exceedsScopeArea(fp, scope)
exceedsScopeArea(frame, scope)
) {
fp = null;
}
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
const centroid = ringCentroid(fp.ring);
let centroid = ringCentroid(frame.ring);
if (!centroid) return null;
// An outer-ring mean can lie in a hole or outside a concave part. Keep
// a filled original anchor or decline, without changing ordinary centering
// or inventing a new interior point.
if (
Array.isArray(frame.polygons) &&
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
) {
const anchorLon =
frame.reference === null
? lon
: lon - 360 * Math.round((lon - frame.reference) / 360);
if (polygonsContainPoint(frame.polygons, lat, anchorLon)) {
centroid = { lat, lon };
} else if (Array.isArray(fp.lines)) {
// A bent street's ribbon rarely holds its ring mean, and a geocoded
// anchor can sit beside the centreline: centre on the street itself,
// only when it runs beside the anchor.
const onStreet = nearestPointOnLines(fp.lines, lat, lon);
if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null;
centroid = { lat: onStreet.lat, lon: onStreet.lon };
} else {
return null;
}
}
if (
frame.reference !== null &&
(centroid.lon < -180 || centroid.lon >= 180)
)
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
// anchor out of plausibility is the wrong feature — drop it, keep the point.
if (
fromGeocode &&
!bypassNearViewGuards &&
!allowDistant &&
gateDrift(centroid.lat, centroid.lon)
)
return null;
@@ -683,17 +867,59 @@ export function createAnnotationResolver({
footprintKind: fp.kind,
buildingHeight: fp.heightM,
synthesized: Boolean(fp.synthesized),
outlineCredit: fp.outlineCredit || null,
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
...(fp.adminArea ? { adminArea: fp.adminArea } : {}),
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
lat: centroid.lat,
lon: centroid.lon,
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
};
};
// Streets and pointed-at buildings from tiles, then this ladder, then the
// guarded Nominatim outline (see outlineRungs.js).
const outlineContext = {
scope: baseScope,
target,
matchName,
lat,
lon,
fromName: fromGeocode || source === 'places' || source === 'osm-local',
pointLike,
groundsLike,
around: intent === 'around_the_thing',
view: viewportProximity(viewer),
signal,
credit: (kind) =>
registerDynamicCredit(
viewer,
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
),
};
const resolveOutline = async () => {
const result = await outlineRungs.resolve(outlineContext, {
base: resolveBaseOutline,
finish: finishOutline,
});
signal?.throwIfAborted();
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
// the coarse bundled shape beats a point.
if (
coarseCityHolds({ lat, lon }) &&
!(result?.ring && !result.synthesized)
)
return bundledAdminOutline(viewer, coarseCityShape, signal);
return result;
};
let ring = null;
let footprintKind = null; // 'building' | 'area'
let buildingHeight = null; // meters, only for buildings
let outlineUnavailable = false;
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
let polygons = null; // every part of a multi-part outline, when known
let outlineCredit = null;
if (footprint && !deferFootprint) {
const fp = await resolveOutline();
outlineUnavailable = isUnavailableCapability(fp);
@@ -702,6 +928,8 @@ export function createAnnotationResolver({
footprintKind = fp.footprintKind;
buildingHeight = fp.buildingHeight;
synthesized = fp.synthesized;
polygons = fp.polygons || null;
outlineCredit = fp.outlineCredit || null;
// Re-center the anchor on the resolved footprint centroid.
lat = fp.lat;
lon = fp.lon;
@@ -729,6 +957,8 @@ export function createAnnotationResolver({
source,
synthesized,
outlineUnavailable,
outlineCredit,
...(polygons ? { polygons } : {}),
viewport: placeViewport,
...(footprint && deferFootprint ? { resolveOutline } : {}),
};
@@ -768,13 +998,110 @@ export function createAnnotationResolver({
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
const COARSE_CITY_MAX_VERTICES = 32;
/** Credit the pack a bundled boundary came from. */
function creditAdminSource(viewer, admin) {
registerDynamicCredit(
viewer,
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
);
const credit = {
'natural-earth': NATURAL_EARTH_CREDIT,
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[admin.source];
if (credit) registerDynamicCredit(viewer, credit);
}
/**
* The bundled US place or neighborhood an ask names at or near the view,
* from Census places, the San Francisco DataSF pack (first in SF), then
* Who's On First. How far "near" reaches follows the view (half its
* radius); a state-qualified place ("Springfield, Illinois") needs no
* view. A district ask takes a neighborhood first and a place only when
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
* an entity kind the candidate that holds the view wins, else the nearer.
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
* left to the landmark ladder unless the ask is a district.
*/
async function bundledAreaNear(
query,
center,
{ entityKind = null, viewRadiusKm = null } = {},
) {
const district = entityKind === 'district';
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
const clamp = (km, lo, hi) =>
km === null ? hi : Math.min(hi, Math.max(lo, km));
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
const place = await findPlaceArea(query, {
near: center,
nearKm: placeReach,
}).catch(() => null);
const holds = (area) =>
Boolean(center) &&
polygonsContainPoint(area.polygons, center.lat, center.lon);
// A district ask only takes a place the view is inside (or one a state
// qualifier named outright).
const placeOk =
place && (!district || holds(place) || /,/.test(query)) ? place : null;
if (!center) return placeOk;
let hood = null;
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
exact: true,
}).catch(() => null);
if (sf) hood = sfNeighborhoodArea(sf);
else
hood = await findNeighborhoodArea(query, {
near: center,
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
}).catch(() => null);
// Outside a district ask, an alternate name unrelated to the name is too
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
// Whole words: an alias hit on "York" does not keep "Yorkville".
if (
!district &&
hood?.byAlias &&
!` ${normalizePlaceName(hood.name)} `.includes(
` ${normalizePlaceName(stripQualifiers(query))} `,
)
)
hood = null;
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
if (district) return hood;
const hoodHolds = holds(hood);
const placeHolds = holds(placeOk);
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
const distance = (area) =>
approximateDistanceM(
center.lat,
center.lon,
area.label.lat,
area.label.lon,
);
return distance(hood) <= distance(placeOk) ? hood : placeOk;
}
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
function sfNeighborhoodArea({ ring, name }) {
const lons = ring.map((p) => p[0]);
const lats = ring.map((p) => p[1]);
return {
kind: 'neighborhood',
name,
region: null,
source: 'datasf',
polygons: [[ring]],
ring,
bbox: [
Math.min(...lons),
Math.min(...lats),
Math.max(...lons),
Math.max(...lats),
],
label: ringCentroid(ring),
candidates: 1,
};
}
/**
@@ -784,8 +1111,10 @@ export function createAnnotationResolver({
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
* centroid drift bound: the unit IS the asked scope.
*/
function bundledAdminOutline(viewer, admin) {
function bundledAdminOutline(viewer, admin, signal) {
signal?.throwIfAborted();
creditAdminSource(viewer, admin);
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
const { lat, lon } = admin.label;
return {
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
@@ -794,15 +1123,41 @@ export function createAnnotationResolver({
buildingHeight: null,
synthesized: false,
adminArea: admin.name,
outlineSource: admin.source,
lat,
lon,
height: sampleGroundHeight(viewer, lon, lat),
};
}
/**
* Whether a bundled unit is too coarse to stand for a city of its name: a
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
* Bremen (39) and every larger state are not. Countries never are.
*/
function isCoarseCityShape(admin) {
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
return false;
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
const vertices = (admin.polygons || []).reduce(
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
0,
);
return vertices < COARSE_CITY_MAX_VERTICES;
}
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
function traceBundledOutline(name, source, parts = 1) {
console.log(
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
);
}
/** A complete resolved target for a bundled administrative unit (no outline pending). */
function bundledAdminTarget(viewer, admin, query) {
const outline = bundledAdminOutline(viewer, admin);
function bundledAdminTarget(viewer, admin, query, signal) {
const outline = bundledAdminOutline(viewer, admin, signal);
const [west, south, east, north] = admin.bbox;
console.log(
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
@@ -819,6 +1174,7 @@ export function createAnnotationResolver({
buildingHeight: null,
label: admin.name,
source: 'bundled',
outlineSource: admin.source,
synthesized: false,
outlineUnavailable: false,
viewport: {
@@ -1229,7 +1585,12 @@ export function createAnnotationResolver({
? 40
: 120;
ring = simplifyRing(ring, tolM);
const fp = { ring, kind: 'area', heightM: null };
const fp = {
ring,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
@@ -1313,7 +1674,14 @@ export function createAnnotationResolver({
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
}
}
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
return bestRing
? {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
}
: null;
}
/**
@@ -1356,7 +1724,12 @@ export function createAnnotationResolver({
}
}
if (bestRing) {
const fp = { ring: bestRing, kind: 'area', heightM: null };
const fp = {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
}
@@ -1387,6 +1760,7 @@ export function createAnnotationResolver({
ring: bufferCorridor(line, 11),
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
@@ -1525,7 +1899,12 @@ export function createAnnotationResolver({
areaM2 < bestArea * 0.999 ||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
if (better) {
best = { ring: ringLonLat, kind: 'area', heightM: null };
best = {
ring: ringLonLat,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
bestArea = areaM2;
bestNameMatch = nameMatch;
}
@@ -1551,6 +1930,19 @@ export function createAnnotationResolver({
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
* polygon, instead of failing outright. */
/**
* A university, college or campus named as such: "Stanford University",
* "Boston College", "University of Texas", "Stanford campus". Towns named
* after one ("College Station", "University Park") are not.
*/
function isCampusAsk(query) {
const text = stripQualifiers(String(query || '')).trim();
return (
/\b(?:university|college|campus)$/i.test(text) ||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
);
}
function isGroundsLikeQuery(query) {
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
String(query || ''),
@@ -1774,6 +2166,7 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
outlineCredit: featureOutlineCredit,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
@@ -2097,6 +2490,16 @@ export function createAnnotationResolver({
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
if (scope === 'city') {
const place = await findPlaceAreaAt(
[q, geo.primaryName],
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (place)
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
File diff suppressed because it is too large. Load diff
+477 -228
View File
@@ -1,164 +1,177 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
test('outline upgrade preserves the existing SVG group identity', (t) => {
const originalDocument = globalThis.document;
/** Mount the board's view; animation frames run when `frames()` is called. */
function board(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useScreenAnnotationsStore(pinia);
const app = createApp(ScreenAnnotations).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const queued = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
app.unmount();
});
const frames = () => {
for (const callback of queued.splice(0)) callback();
};
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
return { store, frames, svg, groups };
}
function cameraOver(lon, lat) {
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
// Low enough that point marks keep their full size.
return { positionWC, directionWC, positionCartographic: { height: 500 } };
}
function sceneFor(camera, extra = {}) {
return {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
...extra,
};
}
/** Project every point to the given window position for the test's length. */
function projectTo(t, at) {
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = original;
});
}
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
const { store, frames, svg, groups } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'pin',
type: 'pin',
color: 'amber',
label: 'Capitol',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
await settle();
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
assert.ok(layer);
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
const [group] = groups();
assert.equal(group.getAttribute('class'), 'gev-anno');
assert.equal(group.getAttribute('opacity'), '1');
assert.deepEqual(
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
[
['circle', 'gev-anno-ring gev-pulse'],
['circle', 'gev-anno-ring'],
['circle', 'gev-anno-dot'],
['line', 'gev-anno-leader'],
['g', 'gev-anno-callout'],
],
);
const [outer, inner, dot, leader, callout] = group.children;
assert.equal(outer.getAttribute('stroke'), '#ffb547');
assert.equal(outer.getAttribute('cx'), '200.0');
assert.equal(outer.getAttribute('r'), '34.0');
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
assert.equal(dot.getAttribute('r'), '5.0');
assert.equal(leader.getAttribute('x1'), '200.0');
assert.equal(leader.getAttribute('x2'), '218.0');
// The card is sized to its measured text, and the leader meets its bottom.
const [card, accent, text] = callout.children;
assert.equal(text.textContent, 'Capitol');
assert.equal(text.getAttribute('x'), '9');
assert.equal(card.getAttribute('width'), '58');
assert.equal(card.getAttribute('height'), '26');
assert.equal(accent.getAttribute('height'), '18');
assert.equal(accent.getAttribute('fill'), '#ffb547');
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
assert.equal(leader.getAttribute('y2'), '292.0');
frames();
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
});
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
const { store, frames, svg, groups } = board(t);
let calls = 0;
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'arrow',
type: 'arrow',
color: 'green',
alpha: 1,
anchor: { lon: 0, lat: 0 },
to: { lon: 0.01, lat: 0 },
});
await settle();
const marker = svg().querySelector('defs > marker#gev-arrow-green');
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
const path = groups()[0].querySelector('path');
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
frames();
await settle();
const ended = new Event('transitionend', { bubbles: true });
ended.propertyName = 'stroke-dashoffset';
path.dispatchEvent(ended);
await settle();
assert.equal(path.style.strokeDasharray, 'none');
});
test('outline upgrade preserves the existing SVG group identity', async (t) => {
const { store, svg, groups } = board(t);
const camera = {
positionWC: Cesium.Cartesian3.ZERO,
directionWC: Cesium.Cartesian3.ZERO,
positionCartographic: { height: 1000 },
};
const sampledAnchors = [];
const scene = {
camera,
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: true,
clampToHeight(world) {
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
]);
return world;
},
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
});
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'anno-fb3',
type: 'area',
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
ring: null,
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
await settle();
const [before] = groups();
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
const callout = before.querySelector('.gev-anno-callout');
const dot = before.querySelector('.gev-anno-dot');
const labelProxy = Object.assign(Object.create(anno), {
type: 'label',
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
anchor: { lon: -97.5, lat: 31.2, height: 0 },
});
renderer.update(labelProxy);
const after = findAnnotationGroup(globalThis.document);
await settle();
const [after] = groups();
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
renderer.destroy();
assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg());
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
test('a callout whose text changes is sized to the new text', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
// Text measures 7px a character; everything else keeps the shared box.
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
return this.tagName.toLowerCase() === 'text'
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
: original.call(this);
};
t.after(() => {
globalThis.SVGGraphicsElement.prototype.getBBox = original;
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'upgrade',
type: 'label',
color: 'primary',
label: 'Texas',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const card = () => groups()[0].querySelector('.gev-anno-card');
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
renderer.update({ ...anno, outlineUnavailable: true });
await settle();
const text = 'Texas · Detailed outline unavailable';
assert.equal(groups()[0].querySelector('text').textContent, text);
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 0, y: 0 });
const camera = cameraOver(0, 0);
const paintRectCalls = [];
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ scene: sceneFor(camera), camera, trackedEntity: null },
{
store,
activeTrackedReadoutId: () => 'installations:test',
overlayPaintRect(sourceId, entryId) {
paintRectCalls.push({ sourceId, entryId });
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
},
},
);
t.after(() => renderer.destroy());
renderer.add({
id: 'anno-overlap',
type: 'label',
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
const { group } = findAnnotationGroup(globalThis.document);
await settle();
const [group] = groups();
assert.deepEqual(paintRectCalls.at(-1), {
sourceId: 'tracked',
entryId: 'installations:test',
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
Number(group.getAttribute('opacity')) < 1,
'final annotation bbox fades against the complete painted card rectangle',
);
});
test('a removed mark fades out, then leaves the board', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'fading',
type: 'label',
color: 'primary',
label: 'Gone soon',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const [group] = groups();
renderer.remove(anno);
// A new mark for the same place gets its own group beside the fading one.
renderer.add(anno);
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
assert.equal(groups().length, 2);
assert.ok(groups()[1] !== group);
await new Promise((resolve) => setTimeout(resolve, 400));
await settle();
assert.deepEqual(groups().length, 1);
assert.equal(group.isConnected, false);
renderer.destroy();
await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
});
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
//
// add() inserts the group and records it, then does more live-document work
// (draw-on wiring, the first projection pass). A throw in that tail used to
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
// deletes its own map entry — so re-annotating the same place stacked a second
// mark over the corpse.
// add() records the mark, then does more work (presenting it, the first
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
// record nobody owned — the engine's rollback only deletes its own map entry —
// so re-annotating the same place stacked a second mark over the corpse.
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
const { store, groups } = board(t);
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
t.after(() => renderer.destroy());
const anno = {
id: 'anno-partial-screen',
type: 'pin',
@@ -303,26 +357,221 @@ test('an add that throws after inserting its group unwinds instead of orphaning
anchor: { lon: 0, lat: 0, height: 0 },
};
// The group is in the document and recorded by the time add() reaches its
// first projection pass — so a throw there is real partial state, not a
// clean bail-out.
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
// The mark is on the board by the time add() reaches its first projection
// pass — so a throw there is real partial state, not a clean bail-out.
projectTo(t, () => {
throw new Error('projection failed');
});
assert.throws(() => renderer.add(anno), /projection failed/);
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
0,
'the half-built group must not stay in the document',
);
await settle();
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
// The id is free again: a retry draws exactly one mark, and remove() still
// owns it (a leaked record would leave a second group behind).
renderer.add(anno);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
1,
'the retry must draw one mark, not stack over an orphan',
await settle();
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
});
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
const options = { store, activeTrackedReadoutId: () => null };
const earlier = createScreenAnnotationRenderer(viewer, options);
const later = createScreenAnnotationRenderer(viewer, options);
t.after(() => later.destroy());
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.destroy();
await settle();
assert.equal(groups().length, 1);
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
});
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
const { store, svg } = board(t);
// A fixed projection: 0.001 degrees spans 100 px.
projectTo(t, (_scene, world) => {
const c = Cesium.Cartographic.fromCartesian(world);
return {
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
};
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
renderer.destroy();
t.after(() => renderer.destroy());
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const anno = {
id: 'anno-multipart-screen',
label: 'Multipart',
type: 'area',
color: 'primary',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
synthesized: false,
};
renderer.add(anno);
await settle();
const area = () => svg().querySelector('.gev-anno-area');
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
assert.equal(area().tagName.toLowerCase(), 'path');
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
// The first part is behind the globe: the label centres on the next part.
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
assert.notEqual(area().parentNode.style.display, 'none');
assert.doesNotMatch(
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
/NaN/,
);
anno.polygons = [[outer, hole]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
assert.notEqual(area().parentNode.style.display, 'none');
anno.polygons = [[outer, hole], [island]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
assert.notEqual(area().parentNode.style.display, 'none');
});
test('Preview avoids the map callout without moving its world anchor', async (t) => {
const { store, svg, groups } = board(t);
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
});
const camera = cameraOver(0, 0);
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
});
const panel = document.body.appendChild(document.createElement('section'));
panel.id = 'gev-voice-panel';
panel.dataset.view = 'preview';
panel.hidden = true;
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
let panelVisible = true;
panel.getBoundingClientRect = () => panelRect;
panel.checkVisibility = () => panelVisible;
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
renderer.add(anno);
await settle();
// Non-unit, independent axis scales and a nonzero client offset: the
// overlay uses preserveAspectRatio="none", not letterboxing.
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
renderer.sync();
await settle();
const [group] = groups();
const callout = () => group.querySelector('.gev-anno-callout');
const leader = group.querySelector('.gev-anno-leader');
const dot = group.querySelector('.gev-anno-dot');
const natural = callout().getAttribute('transform');
const anchor = structuredClone(anno.anchor);
panel.hidden = false;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
assert.equal(leader.getAttribute('x2'), '418.0');
assert.equal(leader.getAttribute('y2'), '172.0');
assert.equal(leader.getAttribute('x1'), '400.0');
assert.equal(leader.getAttribute('y1'), '200.0');
assert.equal(dot.getAttribute('cx'), '400.0');
assert.equal(dot.getAttribute('cy'), '200.0');
assert.deepEqual(anno.anchor, anchor);
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
panel.hidden = true;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
panel.hidden = false;
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
panelVisible = variant !== 'invisible';
panelRect = variant === 'no-overlap'
? { left: 510, top: 120, width: 100, height: 120 }
: variant === 'no-room'
? { left: 210, top: 25, width: 100, height: 180 }
: { left: 210, top: 120, width: 100, height: 120 };
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, variant);
}
});
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
// happy-dom measures every SVG box the same, so callout spreading cannot
// settle here; a hidden mark simply has to stay out of drawing and
// placement, which is what keeps it from pushing a visible callout aside.
const { store, svg } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
let project = null;
const renderer = createScreenAnnotationRenderer(
{
scene: sceneFor(camera, {
postRender: {
addEventListener: (listener) => (project = listener),
removeEventListener() {},
},
}),
camera,
trackedEntity: null,
},
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const camp = {
id: 'camp',
type: 'pin',
color: 'amber',
label: 'Camp',
alpha: 1,
hidden: true,
anchor: { lon: 0, lat: 0, height: 0 },
};
renderer.add(camp);
project();
await settle();
const drawn = () =>
[...svg().querySelectorAll('g.gev-anno')].some(
(group) =>
group.textContent.includes('Camp') && group.style.display !== 'none',
);
assert.equal(drawn(), false, 'the hidden mark is not drawn');
camp.hidden = false;
project();
await settle();
assert.equal(drawn(), true, 'shown again, it is drawn');
});
+360 -87
View File
@@ -1,3 +1,5 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
@@ -51,6 +53,232 @@ export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
const osmAnnotations = new Set();
// Every mark with entities on the globe, so sync() can take a hidden one out.
const drawn = new Set();
const pendingGround = new Map();
const groundQueue = [];
let observingGround = false;
let destroyed = false;
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const queuedAt = performance.now();
// The 2 s budget starts when this task reaches the head of the queue, so
// waiting behind other buildings on a slow device never spends it.
let started = null;
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = queuedAt;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
try {
workTrace.groundDetailDropped = 0;
} catch {
groundDetailReady = false;
}
}
const task = {
anno,
points: groundProbePositions(anno.ring),
samples: [],
index: 0,
};
const timing = {
workMs: 0,
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
queuedMs: 0,
deadlineMs: null,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.begin = () => {
if (started !== null) return;
started = performance.now();
timing.queuedMs = started - queuedAt;
timing.deadlineMs = started + 2000;
if (workTrace) workTrace.samplerHeadAt = started;
task.timer = setTimeout(() => task.finish('timeout'), 2000);
// A task promoted outside a frame (its predecessor timed out or was
// cancelled) still needs frames in request-render mode.
try {
viewer.scene.requestRender?.();
} catch {
/* the enqueue path reports render failures */
}
};
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
const position = groundQueue.indexOf(task);
groundQueue.splice(position, 1);
clearTimeout(task.timer);
if (position === 0) groundQueue[0]?.begin();
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - (started ?? queuedAt);
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
}
if (error) reject(error);
else resolve({ status, timing });
};
task.abort = () => task.finish('cancelled');
task.step = () => {
const workStart = workTrace ? performance.now() : 0;
// Nested call/commit wall-time only; the original parent remains the gate.
const detail =
workTrace && groundDetailSlots++ < 8
? {
index: task.index,
status: groundDetailReady ? 'recorded' : 'missing',
clampStart: null,
clampEnd: null,
clampResult: 'not-run',
commitStart: null,
commitEnd: null,
commitStatus: 'not-run',
}
: undefined;
const note = detail
? (key) => {
try {
const at = performance.now();
if (Number.isFinite(at)) detail[key] = at;
else detail.status = 'missing';
} catch {
detail.status = 'missing';
}
}
: null;
if (workTrace && !detail) {
try {
workTrace.groundDetailDropped += 1;
} catch {
// Missing attribution must not change the sampler outcome.
}
}
try {
if (!task.current()) return task.finish('cancelled');
if (performance.now() - started >= 2000)
return task.finish('timeout');
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c;
note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try {
c = viewer.scene.clampToHeight(position);
if (detail) detail.clampResult = 'returned';
} finally {
note?.('clampEnd');
}
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000)
task.samples.push(h);
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
return;
}
if (!task.current()) return task.finish('cancelled');
if (task.index === task.points.length) {
if (detail) detail.commitStatus = 'attempted';
note?.('commitStart');
task.samples.sort((a, b) => a - b);
const ground = task.samples.length
? task.samples[Math.floor(task.samples.length * 0.3)]
: null;
add(anno, {
sampledGroundHeight: ground,
isCurrent: task.current,
});
if (performance.now() >= timing.deadlineMs) {
task.finish('timeout');
remove(anno);
return;
}
}
} catch (caught) {
error = caught;
} finally {
timing.lastSliceMs = performance.now() - sliceStart;
timing.workMs += timing.lastSliceMs;
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
}
if (error) {
task.finish('failed', error);
try {
remove(anno);
} catch {
/* retain the original commit error */
}
} else if (task.index === task.points.length)
task.finish('installed');
} finally {
// Commit includes sort/selection/add/deadline/finish or rollback,
// including intervening bookkeeping; it is not exclusive entity cost.
if (detail?.commitStatus === 'attempted') note('commitEnd');
if (workTrace)
recordWorkSlice(
workTrace,
'ground-step',
workStart,
undefined,
detail,
);
}
};
pendingGround.set(anno, task);
groundQueue.push(task);
if (groundQueue[0] === task) task.begin();
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
viewer.scene.postRender.addEventListener(onGroundFrame);
observingGround = true;
}
try {
viewer.scene.requestRender?.();
} catch (error) {
task.finish('failed', error);
}
});
}
function onGroundFrame() {
// One building probe TOTAL, not one per mark. Other renderers have their own work.
const task = groundQueue[0];
try {
task?.step();
if (groundQueue.length) viewer.scene.requestRender?.();
} catch (error) {
for (const pending of [...groundQueue]) pending.finish('failed', error);
}
}
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
// material the route pipeline renders. The animated `time` uniform is read straight
@@ -81,7 +309,7 @@ export function createWorldAnnotationRenderer(viewer) {
}, false);
}
function add(anno) {
function add(anno, options = {}) {
const base = colorFor(anno);
// Published to the mark BEFORE anything is added, and mutated in place as
// each entity lands: a mid-add failure (bad geometry, lost context) must
@@ -89,21 +317,42 @@ export function createWorldAnnotationRenderer(viewer) {
// path cannot reach them and they stay on the globe forever.
const entities = [];
anno._entities = entities;
drawn.add(anno);
if (
anno.ring?.length >= 3 &&
anno.footprintKind === 'building' &&
!Object.hasOwn(options, 'sampledGroundHeight')
) {
return sampleBuilding(anno, options);
}
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
const addBuildingEntity = (definition) => {
if (!(options.isCurrent?.() ?? true))
throw new Error('Building install cancelled');
const entity = dataSource.entities.add(definition);
if (!(options.isCurrent?.() ?? true)) {
dataSource.entities.remove(entity);
throw new Error('Building install cancelled');
}
return entity;
};
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
// extrusion is only 2.5D and misses domes; classification colors the
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const parts =
Array.isArray(anno.polygons) && anno.polygons.length
? anno.polygons
: [[anno.ring]];
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
options.sampledGroundHeight ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
@@ -112,55 +361,73 @@ export function createWorldAnnotationRenderer(viewer) {
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
const fillMaterial = new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
});
for (const [outer, ...holes] of parts) {
const buffered = bufferRing(outer, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
),
),
),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: fillMaterial,
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
for (const ring of [outer, ...holes]) {
entities.push(
addBuildingEntity({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
closedRing(ring).flat(),
),
width: 3,
material: lineMaterial,
clampToGround: true,
classificationType: CLASSIFY,
},
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
);
}
}
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park / state) → flat fill draped on
// the tiles + a glowing outline. Draping is right here: you can't extrude
@@ -247,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
() => anno.alpha ?? 1,
),
clampToGround: true,
classificationType: CLASSIFY,
@@ -300,6 +568,19 @@ export function createWorldAnnotationRenderer(viewer) {
}
entities.push(pointMarker(anno, base));
}
if (
entities.length &&
anno.ring?.length >= 3 &&
!anno.synthesized &&
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
(options.isCurrent?.() ?? true)
) {
// The actual world proxy owns both geometry and credit, including rollback.
osmAnnotations.add(anno);
showOsmCredit(viewer, anno, {
openMapTiles: anno.outlineCredit === 'tiles',
});
}
}
// Captions belong to the hybrid renderer's screen-space callouts.
@@ -318,8 +599,10 @@ export function createWorldAnnotationRenderer(viewer) {
}
function remove(anno) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
osmAnnotations.delete(anno);
drawn.delete(anno);
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
@@ -331,13 +614,25 @@ export function createWorldAnnotationRenderer(viewer) {
anno._entities = null;
}
/**
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
* out whole, so no material that keeps its own colour (the route's flow),
* ground classification or pick can still show it.
*/
function sync() {
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
for (const anno of drawn) {
const show = !anno.hidden;
for (const entity of anno._entities || [])
if (entity.show !== show) entity.show = show;
}
}
function destroy() {
destroyed = true;
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
drawn.clear();
try {
viewer.dataSources.remove(dataSource, true);
} catch {
@@ -407,8 +702,11 @@ export function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
export function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
// The mark's live alpha (fade in and out, hidden), applied every frame.
this._alphaOf = alphaOf;
this._live = this._color.clone();
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
@@ -431,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
// `time` is read straight from the wall clock so the dashes flow every rendered
// frame (the scene renders continuously; no requestRenderMode here).
if (!Cesium.defined(result)) result = {};
result.color = this._color;
this._live.alpha = this._color.alpha * this._alphaOf();
result.color = this._live;
result.time = performance.now() / 1000; // the per-frame animated value
result.repeat = 64.0;
result.duty = 0.46;
@@ -458,25 +757,11 @@ function decimateRing(ring, n) {
}
/**
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
* the centroid would clamp onto the roof, so we probe several points beyond the
* footprint radius and take a low percentile (≈ ground). Returns null if the
* tiles under those points aren't loaded yet.
* The original eight outside-footprint probes. Keep their coordinates and order
* while scheduling the expensive scene picks across rendered frames.
*/
function sampleGroundOutside(scene, ring) {
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
function groundProbePositions(ring) {
const { lon: clon, lat: clat } = ringCentroid(ring);
const latS = 111320;
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
let maxR = 0;
@@ -494,23 +779,11 @@ function sampleGroundOutside(scene, ring) {
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
return dirs.map(([dx, dy]) => {
const lon = clon + (dx * out) / lonS;
const lat = clat + (dy * out) / latS;
try {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * 0.3)];
return [lon, lat];
});
}
/**
@@ -0,0 +1,70 @@
// A mark hidden from Draw's list leaves the globe whole: every entity it put
// there is taken out on sync and comes back when shown, and the line's
// flowing stroke, which keeps its own colour, follows the mark's alpha.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import {
FlowMaterialProperty,
createWorldAnnotationRenderer,
} from './worldAnnotationRenderer.js';
const noop = () => {};
function viewer(t) {
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
const original = globalThis[name];
globalThis[name] = class {};
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
}
const before = globalThis.document;
globalThis.document = { addEventListener: noop, removeEventListener: noop };
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
return {
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
entities: new C.EntityCollection(),
dataSources: new C.DataSourceCollection(),
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
};
}
const marks = () => [
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
];
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
const renderer = createWorldAnnotationRenderer(viewer(t));
const [route, area, pin] = marks();
for (const anno of [route, area, pin]) renderer.add(anno);
const shown = (anno) => anno._entities.map((entity) => entity.show);
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
route.hidden = true;
area.hidden = true;
renderer.sync();
assert.ok(shown(route).every((show) => show === false), 'the line is off');
assert.ok(shown(area).every((show) => show === false), 'the area is off');
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
route.hidden = false;
renderer.sync();
assert.ok(shown(route).every((show) => show === true), 'the line is back');
renderer.remove(area);
renderer.sync();
assert.equal(area._entities, null, 'a removed mark is no longer synced');
renderer.destroy();
});
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
let alpha = 1;
const material = new FlowMaterialProperty('#ffb547', () => alpha);
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
alpha = 0;
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
alpha = 0.5;
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
});
+7 -1
View File
@@ -1,3 +1,4 @@
import { mountApplicationViews } from '../ui/views/index.js';
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
@@ -6,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
* Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* The page's views render first; `omitViews` names the ones this application
* leaves out (see mountApplicationViews).
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/
export function createApplication({
@@ -14,7 +17,10 @@ export function createApplication({
createControls,
createData,
createTools,
omitViews = [],
}) {
// A host's page needs its views before any phase reads the markup.
mountApplicationViews(globalThis.document, { omit: omitViews });
const factories = {
scene: createScene,
controls: createControls,
+2
View File
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
const result = spawnSync(
process.execPath,
[
// Views are single-file components; Node needs the loader hosts use.
'--import=gods-eye-view/build/register-vue',
'--input-type=module',
'-e',
`
+11
View File
@@ -15,6 +15,7 @@ import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationOsmPlaces } from './layers/osmPlaces.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
@@ -28,7 +29,9 @@ 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 { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -41,6 +44,8 @@ import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
*/
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
// Voice search results belong to this session, like a receiver to this machine.
Object.freeze({ id: 'osm-places', disposition: 'local-only' }),
]);
/** Serialization metadata for every layer the application catalog constructs. */
@@ -126,8 +131,12 @@ export function createApplicationCatalog({
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
@@ -143,6 +152,7 @@ export function createApplicationCatalog({
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
vessels,
installations,
createApplicationAwareness({
@@ -168,6 +178,7 @@ export function createApplicationCatalog({
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
+47 -2
View File
@@ -40,8 +40,13 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 33);
assert.equal(first.layers.length, 37);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'),
{ id: 'osm-places', disposition: 'local-only' },
'voice search results never enter share links or stored state',
);
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
{ id: 'local-adsb', disposition: 'local-only' },
@@ -62,6 +67,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('drone-airspace'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -155,7 +161,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, 33);
assert.equal(catalog.layers.length, 37);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
@@ -193,3 +199,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`,
);
});
+11 -1
View File
@@ -1,13 +1,15 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
/**
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one.
* whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that does not initialize Core's locale.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
@@ -18,6 +20,7 @@ export function createApplicationControls({
placeSearch,
defer,
initialShareCode = null,
localePicker = true,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -34,10 +37,17 @@ export function createApplicationControls({
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
localePicker,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
defer(
installZoomOutGuard(viewer, {
subscribeCameraHandoff: (listener) =>
styleManager.subscribeCameraHandoff?.(listener),
}),
);
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
+68
View File
@@ -0,0 +1,68 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import '../testSupport/stubMgrs.mjs';
import { installVueDom } from '../testSupport/vueDom.mjs';
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
const { createApplicationControls } = await import('./controls.js');
let uninstallDom;
before(() => {
uninstallDom = installVueDom();
});
after(() => uninstallDom());
test('application controls install the zoom-out guard and remove it on teardown', () => {
const canvas = document.createElement('canvas');
const controller = { maximumZoomDistance: Infinity };
const scene = {
mode: Cesium.SceneMode.SCENE3D,
screenSpaceCameraController: controller,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
};
const camera = {
position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000),
positionCartographic: { height: 1_000_000 },
};
const viewer = { scene, camera, canvas };
const handoffs = new Set();
class Controls {
hasShareState = true;
orbitController = { stop() {} };
hud = { destroy() {} };
dispose() {}
subscribeCameraHandoff(listener) {
handoffs.add(listener);
return () => handoffs.delete(listener);
}
}
const cleanups = [];
createApplicationControls({
scene: {
viewer,
mapStackController: {},
operations: {
surface: { controlServices: {} },
searchAndFlyTo() {},
requests: { regional: {}, weather: {} },
},
},
loaderStatus: {},
Controls,
catalog: { get: (id) => ({ id }) },
defer: (cleanup) => cleanups.push(cleanup),
});
assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(scene.preRender.numberOfListeners, 1);
assert.equal(scene.postUpdate.numberOfListeners, 1);
assert.equal(handoffs.size, 1);
for (const cleanup of cleanups.reverse()) cleanup();
assert.equal(controller.maximumZoomDistance, Infinity);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(handoffs.size, 0);
});
+2
View File
@@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
@@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) {
focus,
worldFocus,
render,
readouts,
},
});
}
+22
View File
@@ -0,0 +1,22 @@
import * as Cesium from 'cesium';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+7
View File
@@ -0,0 +1,7 @@
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire OSM Places labels to the application overlay host. */
export function createApplicationOsmPlaces() {
return createOsmPlacesLayer({ overlayHost });
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { t } from '../i18n/index.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
@@ -60,6 +60,7 @@ export async function createApplicationScene({
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken
? t('Loading Google 3D Tiles...')
+1
View File
@@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -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, 33);
assert.equal(actual.layers.length, 37);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+22 -4
View File
@@ -2,6 +2,8 @@ import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -53,12 +55,26 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
});
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
@@ -66,6 +82,8 @@ export function createApplicationTools({
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
+241
View File
@@ -0,0 +1,241 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
/** Whole-Earth input limit; explicit camera destinations can go farther. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
/** Below this, oblique browsing is left alone. */
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
/** Look this far below the Earth's limb, so the globe stays on screen. */
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
const PITCH_EASE = 0.35;
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
const HEIGHT_EPSILON_M = 0.0001;
const EARTH_RADIUS_M = 6_371_000;
const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) =>
[undefined, ...Object.values(Cesium.KeyboardEventModifier)].map(
(modifier) => ({
type,
modifier,
}),
),
);
/**
* The shallowest pitch (radians, negative is down) that still keeps the Earth
* framed from height `h`, or null where tilted views are left alone.
* The limb sits acos(R / (R + h)) below the local horizontal.
*/
export function maxPitchForHeight(h) {
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
}
function matchesZoomBinding(bindings, type, modifier) {
if (Array.isArray(bindings))
return bindings.some((binding) =>
matchesZoomBinding(binding, type, modifier),
);
return typeof bindings === 'number'
? bindings === type && modifier === undefined
: bindings?.eventType === type && bindings?.modifier === modifier;
}
function readInput(aggregator, bindings) {
let outward = false;
let cancel = false;
for (const { type, modifier } of INPUT_BINDINGS) {
if (!aggregator.isMoving(type, modifier)) continue;
const movement = aggregator.getMovement(type, modifier);
const pinch = type === Cesium.CameraEventType.PINCH;
const distance = pinch ? movement.distance : movement;
const delta = distance.endPosition.y - distance.startPosition.y;
// Cesium tilts by the fingers' midpoint while zooming by their spacing,
// both in the same quarter-pixel units. Fingers rarely keep a perfectly
// even spacing, so the larger of the two movements decides the gesture.
const tilt = pinch
? movement.angleAndHeight.endPosition.y -
movement.angleAndHeight.startPosition.y
: 0;
if (
matchesZoomBinding(bindings, type, modifier) &&
Math.abs(delta) > Math.abs(tilt)
) {
outward ||= delta < 0;
cancel ||= delta > 0;
} else {
// Includes modifier-specific tilts and parallel two-finger movement.
cancel = true;
}
}
aggregator.reset();
return { outward, cancel };
}
/**
* Keep outward user zooms bounded and framed, without taking camera ownership
* from tilts, tracked entities or flights. Returns an idempotent disposer.
*/
export function installZoomOutGuard(
viewer,
{
requestRender = governorRequestRender,
subscribeCameraHandoff,
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
} = {},
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
const canvas = viewer?.canvas;
if (
!controller ||
!camera ||
!canvas ||
!scene.preRender ||
!scene.postUpdate
)
throw new TypeError('A complete Cesium viewer is required');
const aggregator = createAggregator(canvas);
const originalMaximumZoomDistance = controller.maximumZoomDistance;
const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M;
let maximumZoomDistance = Math.min(
originalMaximumZoomDistance,
MAXIMUM_ZOOM_DISTANCE_M,
);
controller.maximumZoomDistance = maximumZoomDistance;
// Cesium clamps any zoom that starts above the limit to the limit, in either
// direction. A camera placed higher by a flight or share link keeps its own
// height as the limit, so it zooms in gradually and can't climb further.
const followCeiling = () => {
if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance)
return;
// While tracking, Cesium measures zoom from the target, not the Earth, so
// the camera's altitude would lift the ceiling with every outward step.
const untracked =
scene.mode === Cesium.SceneMode.SCENE3D &&
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const height = untracked ? camera.positionCartographic?.height : undefined;
maximumZoomDistance =
height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M;
controller.maximumZoomDistance = maximumZoomDistance;
};
followCeiling();
let lastHeight = camera.positionCartographic?.height;
let lastPosition = Cesium.Cartesian3.clone(camera.position);
let lastHeading = camera.heading;
let lastPitch = camera.pitch;
let lastRoll = camera.roll;
let zoomingOut = false;
let settling = false;
const cancel = () => {
zoomingOut = false;
settling = false;
};
const guard = () => {
const input = readInput(aggregator, controller.zoomEventTypes);
const height = camera.positionCartographic?.height;
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
const descending = height < lastHeight - HEIGHT_EPSILON_M;
try {
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
controller.enableInputs === false ||
controller.enableZoom === false ||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
// Cesium has no public running-flight flag.
camera._currentFlight ||
input.cancel ||
(descending && !settling)
) {
cancel();
return;
}
if (input.outward) {
zoomingOut = true;
// A genuine outward gesture can be blocked by the controller's ceiling.
settling = true;
}
if (climbing) {
if (!zoomingOut) return;
settling = true;
} else {
zoomingOut = false;
// Outward inertia can lower altitude when Cesium clamps slant range at
// the ceiling. Finish that correction; fresh inward input cancels above.
// A stationary correction owns only the pose it last produced.
if (
!input.outward &&
((!descending &&
!Cesium.Cartesian3.equals(camera.position, lastPosition)) ||
Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) >
1e-10 ||
Math.abs(camera.pitch - lastPitch) > 1e-10 ||
Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) >
1e-10)
)
settling = false;
if (!settling) return;
}
const maxPitch = maxPitchForHeight(height);
if (maxPitch === null || !(camera.pitch > maxPitch)) {
settling = false;
} else {
const error = maxPitch - camera.pitch;
const easedPitch =
Math.abs(error) <= PITCH_SNAP_RAD
? maxPitch
: camera.pitch + error * PITCH_EASE;
// A large wheel step must not leave the view above the limb while easing.
const limbPitch = -Math.acos(
EARTH_RADIUS_M / (EARTH_RADIUS_M + height),
);
const pitch = Math.min(easedPitch, limbPitch);
camera.setView({
orientation: { heading: camera.heading, pitch, roll: camera.roll },
});
settling = pitch > maxPitch;
}
// Observe the end of inertia too; no arbitrary timeout cuts off a long zoom.
if (zoomingOut || settling) requestRender('zoom-out-guard');
} finally {
lastHeight = camera.positionCartographic?.height;
lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition);
lastHeading = camera.heading;
lastPitch = camera.pitch;
lastRoll = camera.roll;
}
};
const removePreRender = scene.preRender.addEventListener(guard);
// Fires every frame, after Cesium has applied this frame's input, while
// preRender fires only on frames that draw. Input that didn't move the camera
// (such as wheel at the ceiling) would otherwise wait for an unrelated frame.
const removePostUpdate = scene.postUpdate.addEventListener(() => {
followCeiling();
if (
INPUT_BINDINGS.some(({ type, modifier }) =>
aggregator.isMoving(type, modifier),
)
)
requestRender('zoom-out-guard');
});
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
cancel();
// Discard input queued before an explicit navigation action took ownership.
aggregator.reset();
});
let active = true;
return () => {
if (!active) return;
active = false;
removeCameraHandoff?.();
removePreRender?.();
removePostUpdate?.();
aggregator.destroy();
if (controller.maximumZoomDistance === maximumZoomDistance)
controller.maximumZoomDistance = originalMaximumZoomDistance;
};
}
+764
View File
@@ -0,0 +1,764 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
installZoomOutGuard,
maxPitchForHeight,
MAXIMUM_ZOOM_DISTANCE_M,
} from './zoomOutGuard.js';
const deg = Cesium.Math.toDegrees;
const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } =
Cesium.CameraEventType;
const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier;
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
assert.equal(maxPitchForHeight(0), null);
assert.equal(maxPitchForHeight(299_999), null);
assert.equal(maxPitchForHeight(NaN), null);
assert.equal(maxPitchForHeight(undefined), null);
});
test('maxPitchForHeight looks 8 degrees below the limb', () => {
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
assert.equal(deg(maxPitchForHeight(1e12)), -90);
});
test('maxPitchForHeight steepens monotonically with height', () => {
let previous = 0;
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
const pitch = maxPitchForHeight(h);
assert.ok(pitch <= previous, `pitch at ${h} m`);
previous = pitch;
}
});
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
const preRender = new Set();
const postUpdate = new Set();
const handoffs = new Set();
const container = new EventTarget();
const canvas = new EventTarget();
const movements = new Map();
const key = (type, modifier) => `${type}:${modifier}`;
const aggregator = {
resets: 0,
destroys: 0,
isMoving: (type, modifier) => movements.has(key(type, modifier)),
getMovement: (type, modifier) => movements.get(key(type, modifier)),
reset() {
movements.clear();
this.resets++;
},
destroy() {
this.destroys++;
movements.clear();
},
};
const camera = {
height: 170_000,
longitude: 0,
latitude: 0,
heading: 0.4,
pitch: Cesium.Math.toRadians(-25),
roll: 0,
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
views: [],
get positionCartographic() {
return {
height: this.height,
longitude: this.longitude,
latitude: this.latitude,
};
},
get position() {
return this.positionWC;
},
get positionWC() {
return Cesium.Cartesian3.fromRadians(
this.longitude,
this.latitude,
this.height,
);
},
setView({ orientation }) {
this.views.push(orientation);
this.heading = orientation.heading;
this.pitch = orientation.pitch;
this.roll = orientation.roll;
},
};
const controller = {
maximumZoomDistance,
enableInputs: true,
enableZoom: true,
zoomEventTypes: [
RIGHT_DRAG,
WHEEL,
PINCH,
{ eventType: WHEEL, modifier: CTRL },
],
};
const scene = {
mode: Cesium.SceneMode.SCENE3D,
screenSpaceCameraController: controller,
preRender: {
addEventListener(handler) {
preRender.add(handler);
return () => preRender.delete(handler);
},
},
postUpdate: {
addEventListener(handler) {
postUpdate.add(handler);
return () => postUpdate.delete(handler);
},
},
};
const renders = [];
const dispose = installZoomOutGuard(
{ camera, container, canvas, scene },
{
subscribeCameraHandoff(listener) {
handoffs.add(listener);
return () => handoffs.delete(listener);
},
createAggregator(element) {
assert.equal(element, canvas);
return aggregator;
},
requestRender: (reason) => renders.push(reason),
},
);
// A frame Cesium skips drawing in request-render mode: no preRender.
const idleFrame = () => {
for (const handler of postUpdate) handler();
};
const frame = (height = camera.height) => {
camera.height = height;
idleFrame();
for (const handler of preRender) handler();
};
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
const distance = {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, deltaY),
};
movements.set(
key(type, modifier),
type === PINCH
? {
distance,
angleAndHeight: {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, tiltDeltaY),
},
}
: distance,
);
// Also exercise the former raw DOM path when running against the PR head.
const event = new Event(
type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove',
);
Object.assign(event, {
buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1,
ctrlKey: modifier === CTRL,
shiftKey: modifier === SHIFT,
altKey: modifier === ALT,
touches: type === PINCH ? [1, 2] : [],
});
container.dispatchEvent(event);
};
return {
camera,
controller,
scene,
aggregator,
preRender,
postUpdate,
handoffs,
handoff: () => {
for (const listener of handoffs) listener();
},
renders,
dispose,
frame,
idleFrame,
movement,
};
}
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
const f = guardFixture();
f.frame();
f.movement(WHEEL, -10);
f.frame(1_000_000);
const target = maxPitchForHeight(1_000_000);
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
assert.equal(f.camera.heading, 0.4);
assert.equal(f.camera.roll, 0);
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
);
assert.ok(f.renders.length > 0);
});
test('a large outward step immediately keeps the Earth limb in view', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -100);
f.frame(20_000_000);
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
assert.ok(f.camera.pitch <= limbPitch);
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
});
test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => {
for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(700_000);
assert.equal(f.camera.views.length, 1, `${type}:${modifier}`);
f.dispose();
}
});
test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(RIGHT_DRAG, 3, { modifier: CTRL });
// Real Cesium's orbit tilt raises altitude with no zoom movement.
f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313);
f.frame(529_392.3759876974);
assert.equal(f.camera.views.length, 0);
assert.equal(deg(f.camera.pitch), -26.07722459498313);
});
test('parallel two-finger tilt is not mistaken for pinch zoom', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, 0, { tiltDeltaY: 3 });
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
});
test('two-finger tilt with finger-spacing drift is left to Cesium', () => {
for (const [spacing, tilt] of [
[-0.01, 3],
[-2, 3],
[-3, -3],
]) {
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, spacing, { tiltDeltaY: tilt });
// Real Cesium's two-finger tilt climbs and picks its own pitch.
f.camera.pitch = Cesium.Math.toRadians(-26.07746);
f.frame(529_406);
assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`);
f.dispose();
}
});
test('a pinch that closes the fingers more than it moves them still zooms out', () => {
// One finger still, the other sliding towards it: the midpoint moves half as far.
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, -10, { tiltDeltaY: -5 });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
f.dispose();
});
test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => {
for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(600_000);
assert.equal(f.camera.views.length, 0, `${type}:${modifier}`);
f.dispose();
}
});
test('current controller zoom bindings are honored', () => {
const f = guardFixture();
f.frame(500_000);
f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT };
f.movement(WHEEL, -10);
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
f.movement(MIDDLE_DRAG, -10, { modifier: ALT });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
});
test('programmatic height changes alone never activate the guard', () => {
const f = guardFixture();
f.frame(170_000);
f.frame(2_000_000);
f.frame(3_000_000);
assert.equal(f.camera.views.length, 0);
});
test('inward zoom does not activate the guard even when altitude rises', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, 10);
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
});
test('outward input with falling height does not alter pitch', () => {
const f = guardFixture();
f.frame(2_000_000);
f.movement(WHEEL, -10);
f.frame(1_500_000);
f.frame(1_500_000);
assert.equal(f.camera.views.length, 0);
});
test('pitch finishes settling when the zoom stops changing height', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
const firstPitch = f.camera.pitch;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < firstPitch);
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
const views = f.camera.views.length;
f.frame();
assert.equal(
f.camera.views.length,
views,
'settled pose does not keep rendering',
);
});
test('completed settling does not authorize a later programmatic climb', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
for (let i = 0; i < 40; i++) f.frame();
const views = f.camera.views.length;
f.frame(3_000_000);
assert.equal(f.camera.views.length, views);
});
test('a fresh inward zoom or unrelated gesture cancels pending correction', () => {
for (const [type, delta, options] of [
[WHEEL, 10, {}],
[RIGHT_DRAG, 3, { modifier: CTRL }],
[LEFT_DRAG, 3, {}],
[MIDDLE_DRAG, 3, {}],
[PINCH, 0, { tiltDeltaY: 3 }],
]) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.movement(type, delta, options);
f.frame(800_000);
f.frame(900_000);
assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`);
f.dispose();
}
});
test('an external pose change at fixed height cancels pending correction', () => {
for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.camera[field] += 0.1;
f.frame();
f.frame(900_000);
assert.equal(f.camera.views.length, views, field);
f.dispose();
}
});
test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => {
const cases = [
(f) => {
f.controller.enableInputs = false;
return () => {
f.controller.enableInputs = true;
};
},
(f) => {
f.controller.enableZoom = false;
return () => {
f.controller.enableZoom = true;
};
},
(f) => {
f.camera._currentFlight = {};
return () => {
delete f.camera._currentFlight;
};
},
(f) => {
f.camera.transform = Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(1, 2, 3),
);
return () => {
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
};
},
(f) => {
f.scene.mode = Cesium.SceneMode.SCENE2D;
return () => {
f.scene.mode = Cesium.SceneMode.SCENE3D;
};
},
];
for (const block of cases) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
const restore = block(f);
f.movement(WHEEL, -10);
f.frame(800_000);
restore();
f.frame(900_000);
assert.equal(f.camera.views.length, views);
f.movement(WHEEL, -10);
f.frame(1_000_000);
assert.equal(f.camera.views.length, views + 1);
f.dispose();
}
});
test('installer sets the ceiling and disposer releases aggregator and frame listener', () => {
const f = guardFixture();
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(f.preRender.size, 1);
assert.equal(f.postUpdate.size, 1);
f.frame();
assert.ok(f.aggregator.resets > 0);
f.dispose();
f.dispose();
assert.equal(f.controller.maximumZoomDistance, Infinity);
assert.equal(f.aggregator.destroys, 1);
assert.equal(f.preRender.size, 0);
assert.equal(f.postUpdate.size, 0);
assert.equal(f.handoffs.size, 0);
});
test('installer preserves a stricter host zoom ceiling', () => {
const f = guardFixture({ maximumZoomDistance: 123 });
assert.equal(f.controller.maximumZoomDistance, 123);
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 123);
});
test('disposer keeps a ceiling someone else changed later', () => {
const f = guardFixture();
f.controller.maximumZoomDistance = 5;
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 5);
});
test('installer rejects an incomplete viewer', () => {
assert.throws(() => installZoomOutGuard({}), TypeError);
});
test('outward input at the ceiling reframes a deliberately tilted camera', () => {
const f = guardFixture();
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
f.movement(WHEEL, -10);
f.frame();
for (let i = 0; i < 40; i++) f.frame();
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) <
1e-10,
);
f.dispose();
});
test('input that leaves the camera still requests a frame to read it', () => {
const f = guardFixture();
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
f.idleFrame();
assert.deepEqual(f.renders, [], 'an idle scene stays idle');
// At the ceiling Cesium doesn't move the camera, so nothing would draw.
f.movement(WHEEL, -10);
f.idleFrame();
assert.deepEqual(f.renders, ['zoom-out-guard']);
f.frame();
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame');
f.dispose();
});
test('equivalent heading and roll wraps do not cancel stationary settling', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
f.camera.heading += Cesium.Math.TWO_PI;
f.camera.roll += Cesium.Math.TWO_PI;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
f.dispose();
});
test('a real Cesium camera settles across the north heading wrap at Istanbul', () => {
const scene = {
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
screenSpaceCameraController: {
maximumZoomDistance: Infinity,
enableInputs: true,
zoomEventTypes: [WHEEL],
},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
const setHeight = (height) =>
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 },
});
setHeight(1_000_000);
let outward = false;
const dispose = installZoomOutGuard(
{ scene, camera, canvas: scene.canvas },
{
createAggregator: () => ({
isMoving: (type, modifier) =>
outward && type === WHEEL && modifier === undefined,
getMovement: () => ({
startPosition: { y: 0 },
endPosition: { y: -10 },
}),
reset: () => {
outward = false;
},
destroy() {},
}),
requestRender() {},
},
);
try {
setHeight(MAXIMUM_ZOOM_DISTANCE_M);
outward = true;
for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene);
assert.ok(
Math.abs(
camera.pitch - maxPitchForHeight(camera.positionCartographic.height),
) < 1e-8,
);
} finally {
dispose();
}
});
test('an outward inertia ceiling adjustment can descend while pitch settles', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const before = f.camera.pitch;
// Cesium limits slant range on an inertial pick, slightly below the altitude cap.
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < before);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10,
);
f.dispose();
});
test('a descending programmatic tilt cancels pending correction', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const count = f.camera.views.length;
f.camera.pitch += 0.1;
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.equal(f.camera.views.length, count);
f.dispose();
});
test('explicit navigation cancels outward inertia before a climbing UI tilt', () => {
const f = guardFixture();
f.movement(WHEEL, -10);
f.frame(1_000_000);
f.movement(WHEEL, -10);
f.handoff();
const writes = f.camera.views.length;
f.camera.pitch = Cesium.Math.toRadians(-25);
f.frame(1_100_000);
f.frame(1_200_000);
assert.equal(f.camera.views.length, writes);
assert.equal(deg(f.camera.pitch), -25);
// A subsequent user zoom starts a new correction normally.
f.movement(WHEEL, -10);
f.frame(1_300_000);
assert.ok(f.camera.views.length > writes);
f.dispose();
});
// A real Cesium camera and controller, fed by DOM wheel events on the canvas.
function realControllerScene(startHeight) {
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1200,
clientHeight: 800,
width: 1200,
height: 800,
style: {},
// Keep Cesium's input handler off the missing `document`.
disableRootEvents: true,
onwheel: null,
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 1200,
height: 800,
}),
});
const scene = {
canvas,
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
verticalExaggeration: 1,
verticalExaggerationRelativeHeight: 0,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
const controller = new Cesium.ScreenSpaceCameraController(scene);
controller.inertiaZoom = 0;
scene.screenSpaceCameraController = controller;
const setHeight = (h) =>
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h),
orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 },
});
setHeight(startHeight);
const dispose = installZoomOutGuard(
{ scene, camera, canvas },
{ requestRender() {} },
);
// Scene.render order: the controller applies input, then postUpdate, then
// preRender on frames that draw.
const frame = () => {
controller.update();
scene.postUpdate.raiseEvent(scene);
scene.preRender.raiseEvent(scene);
};
const wheel = (deltaY) =>
canvas.dispatchEvent(
Object.assign(new Event('wheel'), {
deltaY,
deltaMode: 0,
clientX: 600,
clientY: 400,
}),
);
const height = () => camera.positionCartographic.height;
return {
scene,
camera,
controller,
setHeight,
frame,
wheel,
height,
dispose,
};
}
test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => {
const s = realControllerScene(60_000_000);
try {
s.frame();
s.wheel(1);
s.frame();
assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`);
s.wheel(-1);
s.frame();
const firstStep = s.height();
assert.ok(firstStep < 60_000_000, 'zooms in');
assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`);
// The camera can no longer climb back above where it has descended to.
s.wheel(1);
s.frame();
assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`);
for (let i = 0; i < 40; i++) {
s.wheel(-100);
s.frame();
}
assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
for (let i = 0; i < 80; i++) {
s.wheel(100);
s.frame();
}
assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000);
} finally {
s.dispose();
}
assert.equal(s.controller.maximumZoomDistance, Infinity);
});
test('a share-link view restored above the ceiling is honoured by later frames', () => {
const s = realControllerScene(1_000_000);
try {
s.frame();
s.setHeight(97_679_259);
s.frame();
s.wheel(100);
s.frame();
assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`);
} finally {
s.dispose();
}
});
test('a tracked camera cannot climb past the ceiling from its target', () => {
const s = realControllerScene(1_000_000);
try {
s.frame();
// In look-at mode Cesium measures zoom from the target, not the Earth.
s.camera.lookAt(
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
new Cesium.HeadingPitchRange(0, -0.5, 5_000_000),
);
for (let i = 0; i < 20; i++) {
s.wheel(100);
s.frame();
}
const range = Cesium.Cartesian3.magnitude(s.camera.position);
assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`);
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
} finally {
s.dispose();
}
});
+172
View File
@@ -0,0 +1,172 @@
/**
* @module awarenessEcho
* @description The Global Context sonar echo field. The awareness layer
* publishes it while its range pulses run; the detection overlay reads it to
* pulse the brackets of contacts a pulse front has just crossed. It holds no
* awareness policy of its own: the publisher passes radius, pulse timing and
* echo styling in, so the overlay never imports awareness internals.
*/
import * as Cesium from 'cesium';
/**
* @typedef {object} AwarenessEchoField
* @property {Cesium.Cartesian3} center Subject's ground point. The publisher
* may move it in place; it is read on every query.
* @property {number} radiusM Range the pulses grow to.
* @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @property {{fadeMs: number, layerIds: string[]}} echo
* @property {(nowMs: number) => number} [level] How far the pulses are
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
*/
/** @type {AwarenessEchoField|null} */
let field = null;
let layerIds = new Set();
const groundScratch = new Cesium.Cartesian3();
/**
* Start (or replace) the echo field.
* @param {AwarenessEchoField} next
* @returns {void}
*/
export function setAwarenessEchoField(next) {
field = next;
layerIds = new Set(next.echo.layerIds);
}
/** Stop all echoes. Safe when none were set. */
export function clearAwarenessEchoField() {
field = null;
layerIds = new Set();
}
/** @returns {AwarenessEchoField|null} The field the overlay styles from. */
export function getAwarenessEchoField() {
return field;
}
/**
* Whether any echo can light at `nowMs`: a field is set and its pulses are
* running. The overlay checks this once per frame, so resting pulses cost no
* per-contact work.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {boolean}
*/
export function awarenessEchoActive(nowMs) {
return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0;
}
/**
* How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches
* its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The
* strongest pulse wins. A front that has wrapped back to the centre still
* counts for the contact it last crossed, so rim contacts fade out instead of
* cutting off. Nothing nearer than the birth ring `startFraction` out echoes,
* and within the pulse's `fadeInFraction` the echo is scaled down with it.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} distanceM Contact's distance from the subject.
* @param {number} radiusM Range the pulses grow to.
* @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @param {{fadeMs: number}} echo
* @returns {number} 0 to 1.
*/
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
// Pulses are born `startFraction` out, so nothing nearer (the subject
// itself, its wingmen) is ever crossed; without this it lit on every birth.
if (
!Number.isFinite(distanceM) ||
distanceM < radiusM * pulse.startFraction ||
distanceM > radiusM
)
return 0;
// The phase at which a pulse front sits at this distance.
const touchPhase =
(distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction);
let strength = 0;
for (let index = 0; index < pulse.count; index++) {
const cycle = nowMs / pulse.periodMs + index / pulse.count;
let since = cycle - Math.floor(cycle) - touchPhase;
// Rounding at the exact crossing must not read as a whole period ago.
if (since < -1e-9) since += 1;
else if (since < 0) since = 0;
const elapsedMs = since * pulse.periodMs;
if (elapsedMs < echo.fadeMs)
strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2);
}
// A front still fading in over `fadeInFraction` of its travel echoes no
// brighter than it shows.
const fadeIn = pulse.fadeInFraction ?? 0;
return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength;
}
/**
* The echo for one contact of `layerId` at world `position`, or 0 when no
* field is set or the layer does not echo. Distance is measured between
* ground points, so an aircraft's altitude does not shift its echo.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {number} 0 to 1.
*/
export function awarenessEchoAt(layerId, position, nowMs) {
if (!field || !position || !layerIds.has(layerId)) return 0;
const level = field.level?.(nowMs) ?? 1;
if (!(level > 0)) return 0;
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
position,
groundScratch,
);
if (!ground) return 0;
return (
level *
awarenessEchoStrength(
nowMs,
Cesium.Cartesian3.distance(field.center, ground),
field.radiusM,
field.pulse,
field.echo,
)
);
}
/**
* The echo bracket the detection overlay draws around one contact, or null
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
* strength times the contact bracket's own `bracketAlpha` rounded to the
* nearest band, so an echo is never drawn brighter than its bracket; one
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
* outside the contact's bracket, 0 as the front arrives, growing to the
* field's `spreadPx` as it fades.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
* @param {number} steps Alpha bands the overlay batches by.
* @returns {{band: number, spreadPx: number}|null}
*/
export function awarenessEchoBracket(
layerId,
position,
nowMs,
bracketAlpha,
steps,
) {
const echo = awarenessEchoAt(layerId, position, nowMs);
if (!(echo > 0.01)) return null;
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
if (!(band >= 1)) return null;
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
}
/**
* The echo's stroke colour: the field's own colour on the default detection
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
* an echo never stands out of a monochrome palette.
* @param {string} styleLine The active detection style's line colour.
* @param {boolean} defaultStyle Whether the default detection style is active.
* @returns {string|null} Null when no field is set.
*/
export function awarenessEchoColor(styleLine, defaultStyle) {
if (!field) return null;
return defaultStyle ? field.echo.color : styleLine;
}
+141
View File
@@ -0,0 +1,141 @@
// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar
// pulse front crosses a flight or vessel, its bracket echo lights and fades.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoActive,
awarenessEchoAt,
awarenessEchoStrength,
clearAwarenessEchoField,
getAwarenessEchoField,
setAwarenessEchoField,
} from './awarenessEcho.js';
const R = 250_000;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] };
const strength = (nowMs, distanceM) =>
awarenessEchoStrength(nowMs, distanceM, R, pulse, echo);
/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */
const arrival = (distanceM) =>
((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 4;
test('an echo lights as a pulse front reaches the contact, then fades', () => {
const d = 100_000;
const t0 = arrival(d);
assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6);
assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6);
assert.equal(strength(t0 + echo.fadeMs + 1, d), 0);
assert.equal(strength(t0 - 50, d), 0, 'dark just before the front');
// Every pulse lights it once a period: `count` separate flashes in any
// one-period window that starts after a flash has faded.
let flashes = 0;
let wasLit = false;
const from = t0 + echo.fadeMs + 1;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
const lit = strength(from + ms, d) > 0.5;
if (lit && !wasLit) flashes++;
wasLit = lit;
}
assert.equal(flashes, pulse.count);
});
test('rim contacts fade out after the front wraps; outside the range never', () => {
const rim = R * 0.999;
assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2);
// Inside the ring pulses are born on, nothing is ever crossed: the
// subject and anything within 5 km never echo, at any moment.
const inner = R * pulse.startFraction;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
assert.equal(strength(ms, 0), 0, 'the subject itself');
assert.equal(strength(ms, inner - 1), 0);
}
assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring');
for (let ms = 0; ms < pulse.periodMs; ms += 50)
assert.equal(strength(ms, R + 1), 0);
assert.equal(strength(0, Number.NaN), 0);
assert.equal(strength(0, -1), 0);
});
test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => {
t.after(clearAwarenessEchoField);
const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo');
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
setAwarenessEchoField({ center, radiusM: R, pulse, echo });
assert.ok(getAwarenessEchoField());
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99);
assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99);
// Layers outside the echo list never light.
assert.equal(awarenessEchoAt('cctv', contact, t0), 0);
assert.equal(awarenessEchoAt('military-installations', contact, t0), 0);
// An aircraft 11 km up echoes with the ground point under it.
const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000);
assert.ok(
Math.abs(
awarenessEchoAt('flights', aloft, t0 + 300) -
awarenessEchoAt('flights', contact, t0 + 300),
) < 0.01,
);
// The field's centre is read in place, so a moved subject moves the echo:
// about 50 km farther, the contact lights at its new distance's moment.
Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center);
const moved = Cesium.Cartesian3.distance(center, contact);
assert.ok(Math.abs(moved - d) > 1000);
assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99);
assert.equal(awarenessEchoAt('flights', null, t0), 0);
clearAwarenessEchoField();
assert.equal(getAwarenessEchoField(), null);
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
});
test('echoes scale with the field level and rest with the pulses', (t) => {
t.after(clearAwarenessEchoField);
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
// ~100 km east of the subject, on the ground.
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
let level = 1;
setAwarenessEchoField({
center,
radiusM: R,
pulse,
echo,
level: () => level,
});
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
const full = awarenessEchoAt('flights', contact, t0);
assert.ok(full > 0.99);
level = 0.5;
assert.ok(
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
);
assert.equal(awarenessEchoActive(t0), true);
level = 0;
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
// At rest the overlay skips echoes for the whole frame.
assert.equal(awarenessEchoActive(t0), false);
clearAwarenessEchoField();
assert.equal(awarenessEchoActive(t0), false);
});
test('near the subject an echo is no brighter than the fading-in front', () => {
const fading = { ...pulse, fadeInFraction: 0.08 };
const at = (distanceM) =>
awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo);
// Born at 5 km, the front reaches full strength 8% of the way out.
const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction));
assert.equal(at(R * fading.startFraction), 0);
const halfway = (R * fading.startFraction + full) / 2;
assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`);
assert.ok(at(full + 1) > 0.99);
assert.ok(
at(R * 0.999) > 0.99,
'the rim, and so the ring flash, is unchanged',
);
});
+28 -1
View File
@@ -831,10 +831,37 @@ test('orbit-toggle controller stops globe motion before toggling', async (t) =>
return true;
},
},
_orbitIndicator: { classList: { toggle() {} } },
_locationControls: { showOrbit() {} },
});
assert.equal(toggled, true);
});
test('the voice orbit switch lights and clears the ORBIT indicator', async () => {
const { LocationNavigation } = await import('./ui/locationNavigation.js');
const shown = [];
let active = false;
const nav = Object.assign(Object.create(LocationNavigation.prototype), {
services: {},
_currentTarget: {},
_currentPoi: {},
orbitController: {
get active() {
return active;
},
toggle() {
active = !active;
return active;
},
stop() {
active = false;
},
},
_locationControls: { showOrbit: (value) => shown.push(value) },
});
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(false), { ok: true, orbiting: false });
assert.deepEqual(shown, [true, false]);
});
for (const owner of ['tracking', 'cockpit', 'close-up'])
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
const { createGevActionRunner } = await import('./voice/gevActions.js');
+1 -1
View File
@@ -353,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input.blur();',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
+6 -1
View File
@@ -1746,8 +1746,13 @@ export function flyRoute(
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
// A route hidden from Draw's list is not on screen, so it is not flown.
const routes = (annoList || []).filter(
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
(a) =>
a.type === 'route' &&
!a.hidden &&
Array.isArray(a.path) &&
a.path.length >= 2,
);
if (!routes.length) {
return {
+141 -84
View File
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
ctx.restore();
}
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
let rings = 0;
/**
* Screen-space ring and celestial direction overlay for the full-globe view.
* The overlay is presented to the celestial ring store, whose view renders it
* inside the viewer's container and hands back the two canvases this paints.
*/
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
* the keyhole helpers here stay free of Vue for the packages that use
* them.
*/
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
@@ -406,6 +414,10 @@ export class CelestialRing {
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
this._sunCanvas = null;
this._sunCtx = null;
this._moonCanvas = null;
this._moonCtx = null;
this._sunInertial = new Cesium.Cartesian3();
this._moonInertial = new Cesium.Cartesian3();
@@ -415,7 +427,7 @@ export class CelestialRing {
this._screenCenter = new Cesium.Cartesian2();
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._initView(store);
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
@@ -436,48 +448,68 @@ export class CelestialRing {
this.setEnabled(this.enabled);
}
/** Construct cached effect layers and Material Symbols celestial markers. */
_buildDOM() {
this._root = document.createElement('div');
this._root.id = 'celestial-ring-overlay';
this._root.className = 'celestial-ring-overlay';
this._root.setAttribute('aria-hidden', 'true');
this._ringOutline = document.createElement('div');
this._ringOutline.className = 'celestial-ring-outline';
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
/** Present the overlay and take the canvases its view hands back. */
_initView(store) {
const owner = {};
this._store = store;
this._owner = owner;
this._view = {
id: ++rings,
owner,
container: this.viewer.container,
enabled: this.enabled,
visible: false,
globeVisible: null,
ephemerisUpdates: null,
size: null,
outline: null,
sun: null,
moon: null,
sunMarker: null,
moonMarker: null,
};
this._viewKey = '';
store.publish(this._view);
store.connect({
attach: (from, kind, canvas) => {
if (from === owner && this._shown()) this._attach(kind, canvas);
},
});
}
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
/** Whether the store still shows this ring's overlay. */
_shown() {
return this._store.ring?.owner === this._owner;
}
this._sunMarker = document.createElement('span');
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
this._ringOutline,
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker,
/** Publish a change to the overlay, skipping one that changes nothing. */
_present(patch) {
if (!this._shown()) return;
const next = { ...this._view, ...patch };
const key = JSON.stringify(next, (name, value) =>
name === 'owner' || name === 'container' ? undefined : value,
);
this.viewer.container.appendChild(this._root);
if (key === this._viewKey) return;
this._view = next;
this._viewKey = key;
this._store.publish(next);
}
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
_attach(kind, canvas) {
const ctx =
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
// A new canvas starts blank, so the next frame repaints its layer.
if (kind === 'sun') {
this._sunCanvas = canvas;
this._sunCtx = ctx;
this._sunRenderKey = '';
} else if (kind === 'moon') {
this._moonCanvas = canvas;
this._moonCtx = ctx;
this._moonRenderKey = '';
} else return;
if (canvas && this.enabled) governorRequestRender('celestial-ring');
}
/** Enable or disable the user preference for the effect. */
@@ -491,12 +523,14 @@ export class CelestialRing {
// all. Camera motion covers every later repaint; the 60 s ephemeris
// timer requests its own. (perf wave 2 fix — owner playtest finding)
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
this._root.classList.toggle('disabled', !this.enabled);
if (!this.enabled) {
this.visible = false;
this._root.classList.remove('visible');
this._clear();
}
this._present({
enabled: this.enabled,
...(!this.enabled && { visible: false }),
});
}
/** Whether the current camera already frames the complete globe inside the keyhole. */
@@ -577,7 +611,7 @@ export class CelestialRing {
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
this._present({ outline: { ...this._view.outline, hidden: true } });
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
@@ -607,10 +641,9 @@ export class CelestialRing {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._present({ size: { width, height } });
this._renderScale = dpr;
}
@@ -665,8 +698,8 @@ export class CelestialRing {
);
}
/** Position one icon-library marker along the keyhole circumference. */
_positionMarker(marker, cx, cy, radius, angle, opacity) {
/** Where one icon-library marker sits along the keyhole circumference. */
_markerPosition(cx, cy, radius, angle, opacity) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
@@ -679,20 +712,26 @@ export class CelestialRing {
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
marker.style.opacity = String(opacity);
return {
left: cx + cos * markerRadius,
top: cy + sin * markerRadius,
opacity: String(opacity),
};
}
/** Update the cached effect geometry only when camera distance or viewport changes. */
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
const outlineKey = `${cx}:${cy}:${radius}`;
if (outlineKey !== this._outlineRenderKey) {
this._ringOutline.style.display = '';
this._ringOutline.style.left = `${cx - radius}px`;
this._ringOutline.style.top = `${cy - radius}px`;
this._ringOutline.style.width = `${radius * 2}px`;
this._ringOutline.style.height = `${radius * 2}px`;
this._present({
outline: {
hidden: false,
left: cx - radius,
top: cy - radius,
width: radius * 2,
height: radius * 2,
},
});
this._outlineRenderKey = outlineKey;
}
@@ -724,7 +763,11 @@ export class CelestialRing {
/** Per-frame camera projection with GPU-composited cached effect layers. */
_draw() {
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
// A ring the store no longer shows has handed its overlay to a later one.
if (!this.enabled || !this._shown()) return;
// Without its canvases (no view rendered them) the ring still tracks
// whether the globe is framed, so it turns itself off as before.
const painted = !!(this._sunCtx && this._moonCtx);
const now = performance.now();
if (now < this._nextDrawAt) return;
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
@@ -732,7 +775,7 @@ export class CelestialRing {
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return;
this._resize(width, height);
if (painted) this._resize(width, height);
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
@@ -740,9 +783,11 @@ export class CelestialRing {
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({
visible: nextVisible,
globeVisible: String(nextVisible),
ephemerisUpdates: String(this._ephemerisUpdateCount),
});
if (!nextVisible || !disc) {
this._clear();
this._debug = { enabled: true, visible: false, disc };
@@ -751,10 +796,14 @@ export class CelestialRing {
}
if (!wasVisible) this._ephemerisDirty = true;
if (!painted) {
this._debug = { enabled: true, visible: true, disc };
return;
}
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
@@ -779,10 +828,6 @@ export class CelestialRing {
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
this._moonCanvas.style.opacity = String(this._moonOpacity);
// Markers sit inside the illuminated band rather than straddling its outer
// stroke. Near conjunction (such as a new moon), keep the true bearings but
@@ -791,22 +836,30 @@ export class CelestialRing {
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
);
this._present({
sun: {
transform: `rotate(${this._sunAngle}rad)`,
opacity: String(this._sunOpacity),
},
moon: {
transform: `rotate(${this._moonAngle}rad)`,
opacity: String(this._moonOpacity),
},
sunMarker: this._markerPosition(
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
),
moonMarker: this._markerPosition(
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
),
});
this._debug = {
enabled: true,
visible: true,
@@ -831,7 +884,7 @@ export class CelestialRing {
: null;
}
/** Detach the render hook and remove all overlay DOM. */
/** Detach the render hook and withdraw the overlay. */
destroy() {
if (this._ephemerisTimer) {
clearInterval(this._ephemerisTimer);
@@ -839,6 +892,10 @@ export class CelestialRing {
}
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
this._root?.remove();
if (this._shown()) {
this._store.connect(null);
this._store.publish(null);
}
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
}
}
+251
View File
@@ -0,0 +1,251 @@
// src/celestialRingView.test.mjs — the full-globe ring renders in the
// viewer's container from what the ring owner presents, and the owner paints
// the canvases the view hands back.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
import * as Cesium from 'cesium';
import { CelestialRing } from './celestialRing.js';
// happy-dom has no 2D context; a stand-in takes every drawing call.
const context = new Proxy(
{},
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
);
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
function fakeViewer(container) {
return {
container,
camera: {
rightWC: new Cesium.Cartesian3(1, 0, 0),
upWC: new Cesium.Cartesian3(0, 1, 0),
},
scene: {
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
postRender: { addEventListener: () => () => {} },
requestRender() {},
},
};
}
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const store = useCelestialRingStore(pinia);
const app = createApp(CelestialRingView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const rings = [];
const create = (options = {}) => {
const ring = new CelestialRing(fakeViewer(container), {
enabled: false,
store,
...options,
});
rings.push(ring);
return ring;
};
t.after(() => {
for (const ring of rings) ring.destroy();
app.unmount();
});
const $ = (selector) => container.querySelector(selector);
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
return { container, store, create, $, settle };
}
test('the ring renders its overlay in the viewer container', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.equal(root.hasAttribute('data-globe-visible'), false);
assert.deepEqual(
[...root.children].map((child) => [child.tagName, child.className]),
[
['DIV', 'celestial-ring-outline'],
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
],
);
assert.equal(root.children[0].style.display, 'none');
assert.equal(root.children[3].textContent, 'light_mode');
assert.equal(root.children[4].textContent, 'dark_mode');
assert.ok(ring._sunCanvas === root.children[1]);
assert.ok(ring._moonCanvas === root.children[2]);
ring.setEnabled(true);
await settle();
assert.equal(root.className, 'celestial-ring-overlay');
ring.destroy();
await settle();
assert.equal($('#celestial-ring-overlay'), null);
assert.equal(ring._sunCanvas, null);
});
test('a drawn frame places the outline, layers and markers', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
ring._resize(400, 300);
ring._renderEffectLayers(200, 150, 120, 80);
ring._present({
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
});
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker] = root.children;
assert.equal(outline.style.display, '');
assert.equal(outline.style.left, '80px');
assert.equal(outline.style.width, '240px');
assert.equal(sun.style.width, '400px');
assert.equal(sun.style.transform, 'rotate(1rad)');
assert.equal(sun.style.opacity, '0.5');
assert.equal(moon.style.height, '300px');
assert.equal(sunMarker.style.left, '300px');
assert.equal(sunMarker.style.top, '150px');
ring.setEnabled(false);
await settle();
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(outline.style.display, 'none');
});
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
const { create, store, $, settle } = fixture(t);
const earlier = create();
await settle();
const later = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.ok(later._sunCanvas === root.children[1]);
earlier.setEnabled(true);
earlier.destroy();
await settle();
assert.ok(store.ring.owner === later._owner);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
});
// A globe framed well inside the keyhole of a 400 x 300 view.
const FRAMED = {
earthCenterX: 200,
earthCenterY: 150,
earthRadius: 40,
keyholeCenterX: 200,
keyholeCenterY: 150,
keyholeRadius: 140,
};
/** Run one rendered frame, past the frame-rate throttle. */
function frame(ring, disc) {
ring._projectedEarthDisc = () => disc;
// Fixed sun and moon directions; Cesium's own model would fetch its
// orientation data.
ring._updateEphemeris = () => {
if (ring._ephemerisDirty) {
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
ring._ephemerisDirty = false;
ring._ephemerisUpdateCount += 1;
}
return true;
};
ring._nextDrawAt = 0;
ring._draw();
}
test('a rendered frame publishes the layers and markers it painted', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
frame(ring, FRAMED);
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
const debug = ring.getDebugState();
assert.equal(debug.visible, true);
assert.equal(outline.style.display, '');
// The outline is the keyhole circle, a fixed inset inside its radius.
const diameter = parseFloat(outline.style.width);
assert.ok(diameter > 0 && diameter < 280);
assert.equal(outline.style.height, outline.style.width);
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
assert.equal(sun.style.width, '400px');
assert.equal(moon.style.height, '300px');
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
assert.equal(sun.style.opacity, String(debug.sunOpacity));
assert.equal(moon.style.opacity, String(debug.moonOpacity));
assert.ok(sunMarker.style.left.endsWith('px'));
assert.ok(moonMarker.style.top.endsWith('px'));
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
});
test('a frame without the globe framed turns the ring off', async (t) => {
const { create, $, settle } = fixture(t);
let disabled = 0;
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
await settle();
frame(ring, FRAMED);
frame(ring, null);
await settle();
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
});
test('without a rendered view the ring still tracks the globe and turns off', () => {
const pinia = createPinia();
let disabled = 0;
const ring = new CelestialRing(fakeViewer({}), {
enabled: true,
store: useCelestialRingStore(pinia),
onAutoDisable: () => (disabled += 1),
});
frame(ring, FRAMED);
assert.equal(ring.visible, true);
frame(ring, null);
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
ring.destroy();
});
test('a ring the store no longer shows stops drawing', async (t) => {
const { create, settle } = fixture(t);
let disabled = 0;
const earlier = create({
enabled: true,
onAutoDisable: () => (disabled += 1),
});
await settle();
create();
await settle();
frame(earlier, null);
assert.equal(earlier.enabled, true);
assert.equal(disabled, 0);
});
+122 -81
View File
@@ -2,7 +2,7 @@ import {
readShellSource,
shellMethod,
} from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
@@ -16,14 +16,20 @@ import {
readAircraftInfo,
_adoptTrackedEntity,
} from './ui/cockpitTrackingController.js';
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
import { cycleVisionMode } from './ui/cockpitInstruments.js';
import { createVisionState } from './ui/cockpitInstrumentState.js';
import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js';
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
import {
syncSignalLayout,
setContextCollapsed,
setSignalCollapsed,
} from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
publishBriefView,
setBriefAutoRotate,
} from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
@@ -46,7 +52,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
);
const ui = readShellSource();
@@ -149,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
cycleVisionMode.toString(),
/const modes = COCKPIT_VISION_MODES;/,
);
assert.match(
setVisionMode.toString(),
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
assert.deepEqual(
COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label),
['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'],
);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(
@@ -230,7 +235,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
@@ -246,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button',
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match(
body,
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/,
/if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
);
assert.equal(
(body.match(/this\.context\.hidden = true/g) || []).length,
(body.match(/this\.showContextCard\(false\)/g) || []).length,
1,
'the panel has exactly one hide path, and it is the no-snapshot standby case',
);
@@ -264,7 +269,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
// A culled subject holds last-known content behind the CONTACT LOST cue.
assert.match(body, /readout\.contactLost/);
assert.match(body, /CONTACT LOST/);
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
assert.match(body, /view\.state = 'lost'/);
assert.match(
body,
/if \(readout\.contactLost\) \{/,
@@ -272,14 +277,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
// PREVIOUS/NEXT must be written before any early return, so the operator can
// always step off the current contact — including a lost one.
const navIndex = body.indexOf('this.contextNext.disabled');
const navIndex = body.indexOf('view.canNext =');
const lostIndex = body.indexOf('if (readout.contactLost)');
assert.ok(
navIndex >= 0 && lostIndex > navIndex,
'nav state must be written before the CONTACT LOST return',
);
assert.equal(
(body.match(/this\.contextNext\.disabled/g) || []).length,
(body.match(/view\.canNext =/g) || []).length,
1,
'one owner for the NEXT enabled state, reached by every visible path',
);
@@ -471,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
);
assert.match(
radioBindings,
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
);
assert.match(
radioBindings,
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
);
assert.match(
css,
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
@@ -530,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
);
assert.match(
syncSignalLayout.toString(),
/setAttribute\('aria-hidden', String\(hiddenSibling\)\)/,
/primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/,
);
});
@@ -546,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
1,
'Parameters must have one DOM owner',
);
// The markup places Parameters below Detection; nothing re-homes it.
assert.match(
html,
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/,
);
assert.doesNotMatch(
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
/_sliderPanel/,
);
assert.match(
ui,
@@ -631,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
);
assert.match(
radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
);
assert.match(
ui,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
);
assert.match(
ui,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
'Utilities folds Contact away in Cockpit as Data Layers does',
);
assert.match(
setContextCollapsed.toString(),
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
@@ -676,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
for (const panelId of [
'data-panel',
'cctv-panel',
'utilities-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -699,7 +707,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
);
assert.match(
callback[1],
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
"entry must remember each panel's exact collapsed state before hiding it",
);
assert.match(
@@ -760,7 +768,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
);
assert.match(
collapse,
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
);
assert.match(
collapse,
@@ -798,35 +806,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.ok(resetStart >= 0);
assert.ok(contextRelease > resetStart);
assert.ok(satelliteRelease > contextRelease);
assert.match(
readShellElements.toString(),
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
);
const controls = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
'utf8',
);
assert.match(
ui,
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(
controls,
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
'both reset controls delegate to the same supplied reset action',
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
// Both reset buttons ask the one reset owner.
const globeActions = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
'utf8',
);
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
const cockpitView = readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8',
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
assert.match(
cockpitEnter.toString(),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/,
);
assert.match(
readRadioSource(
new URL('./ui/cockpitTrackingController.js', import.meta.url),
'utf8',
),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/,
/Object\.assign\(this\.presence, \{\s*active: true,/,
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
});
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
@@ -888,21 +890,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(radioBindings, /\n cycleRadio,\n/);
const panel = fs.readFileSync(
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
'utf8',
);
const cockpit = fs.readFileSync(
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
'utf8',
);
assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/);
assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/);
assert.match(
radioBindings,
/listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/,
cockpit,
/id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/,
);
assert.match(
radioBindings,
/listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
);
assert.match(
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
);
assert.match(
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
cockpit,
/id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/,
);
});
@@ -940,7 +945,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
assert.match(
signalLayout,
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
/publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/,
'Cockpit owns the utility strip anchor and republishes it every layout tick',
);
assert.match(
@@ -976,7 +981,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
);
assert.match(
signalLayout,
/cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/,
/maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/,
);
assert.match(
css,
@@ -1061,15 +1066,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.doesNotMatch(ui, /\['presets',/);
assert.match(
CockpitDisplayPortal.toString(),
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
/window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
);
// The portal publishes the slots; the Display panel teleports its groups.
assert.match(
CockpitDisplayPortal.prototype.setActive.toString(),
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
/this\.publish\(nextActive \? this\.slots : null\)/,
);
assert.match(
CockpitDisplayPortal.prototype.destroy.toString(),
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
/this\.stop\(\)[\s\S]*?this\.publish\(null\)/,
);
assert.doesNotMatch(
ui,
@@ -1091,7 +1097,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
);
assert.match(
ui,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
);
assert.match(
radioBindings,
@@ -1176,7 +1182,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
const match = html.match(
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit weather toggle markup is missing');
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
@@ -1188,7 +1194,7 @@ test('cockpit summary presents the focused item as Contact', () => {
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
assert.ok(match, 'cockpit Contact summary is missing');
assert.match(match[0], /aria-label="Contact cockpit summary"/);
assert.match(match[0], /class="cockpit-context-kicker">CONTACT</);
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
assert.match(match[0], /aria-label="Contact navigation"/);
assert.match(
match[0],
@@ -1200,9 +1206,13 @@ test('cockpit summary presents the focused item as Contact', () => {
);
assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
// The toggle renders the collapse the controller presents.
assert.match(
setContextCollapsed.toString(),
/expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
fs.readFileSync(
new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
'utf8',
),
/collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
);
});
@@ -1258,7 +1268,7 @@ test('Global Context standby describes both chooser modes', () => {
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
const match = html.match(
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit briefing cycle toggle is missing');
assert.match(match[0], /aria-label="CYCLE OFF"/);
@@ -1269,15 +1279,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', () => {
@@ -1368,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'no-tracked-aircraft'/);
});
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
assert.match(
ui,
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
'opening the second left panel keeps the fold the first recorded',
);
const { PanelChrome } = await import('./ui/panelChrome.js');
const previous = globalThis.document;
const panels = {};
globalThis.document = { getElementById: (id) => panels[id] ?? null };
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
try {
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed');
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
panels['data-panel'] = panel();
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
} finally {
globalThis.document = previous;
}
});
+7 -7
View File
@@ -1,10 +1,10 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from './data/headingDegrees.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
return ((value % 360) + 360) % 360;
}
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
export { normalizeHeading };
/**
* Three-digit heading readout, 000–359.
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
+1
View File
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
+264 -243
View File
@@ -1,187 +1,119 @@
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
// focus while they refresh and reorder, over happy-dom with the real view.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { afterEach } from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => {
globalThis.document = previousDocument;
});
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './ui/cockpitSignals.js';
import { useCockpitStore } from './ui/stores/cockpit.js';
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
function fixture() {
const document = { activeElement: null };
let focusLosses = 0;
let focusCalls = 0;
class Node {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.className = '';
this._text = '';
this.classList = {
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
/**
* A browser blurs an element it removes or moves. happy-dom only models the
* removal, so count both: any insert or removal of a node that holds focus
* is a loss, and a moved node gives its focus up as a browser's would.
*/
function trackFocus(t) {
const counts = { losses: 0, focusCalls: 0 };
const holdsFocus = (node) =>
node.isConnected && node.contains(document.activeElement);
const insertBefore = Node.prototype.insertBefore;
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
const lost = holdsFocus(node);
const result = insertBefore.call(this, node, anchor);
if (lost) {
counts.losses += 1;
document.activeElement.blur();
}
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
if (this.contains(document.activeElement)) {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove();
this._text = '';
this.append(...nodes);
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
}
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
controller.scheduleContextLayout = () => {};
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
return result;
});
const removeChild = Node.prototype.removeChild;
t.mock.method(Node.prototype, 'removeChild', function (node) {
if (holdsFocus(node)) counts.losses += 1;
return removeChild.call(this, node);
});
const focus = HTMLElement.prototype.focus;
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
assert.ok(this.isConnected, 'a focus destination must still be connected');
counts.focusCalls += 1;
return focus.apply(this, args);
});
return counts;
}
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitBriefing).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const button = () => {
const element = document.createElement('button');
element.type = 'button';
return document.body.appendChild(element);
};
const display = button();
const radio = button();
const outside = button();
const counts = trackFocus(t);
t.after(() => app.unmount());
const selected = [];
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
// The signal presenters need only this much of the controller.
const owner = {
destroyed: false,
brief: createBriefState(),
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => store.publishSignals(rows),
publishCockpitSignals,
scheduleContextLayout() {},
services: {
formatAwarenessLabel,
militaryAwarenessLayer: {
focusTarget: (...args) => selected.push(args),
},
},
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
// The controller shows the card on entering Cockpit.
document.getElementById('cockpit-signal-stream').hidden = false;
const list = document.getElementById('cockpit-signal-list');
list.addEventListener('click', (event) =>
handleSignalClick.call(owner, event),
);
const buttons = () => [...list.querySelectorAll('button')];
// Tab visits the enabled buttons no hidden ancestor removes from view.
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
const nodes = [...document.querySelectorAll('button')].filter(
(node) => !node.disabled && !node.closest('[hidden]'),
);
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
};
const pageTab = (index) =>
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
const signalTab = () => pageTab(0);
return {
controller,
document,
owner,
list,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
pageTab,
signalTab,
store,
losses: () => counts.losses,
focusCalls: () => counts.focusCalls,
};
}
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
const render = async (f, items) => {
f.owner.signalItems = items;
publishCockpitSignals.call(f.owner);
await nextTick();
};
const refresh = async (f, distanceM) => {
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
await nextTick();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
item('a'),
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
]);
const [action, status] = f.list.children;
assert.equal(action.className, 'nearby actionable');
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
const button = action.querySelector('button.cockpit-signal-target');
assert.equal(button.type, 'button');
assert.equal(button.dataset.signalLayer, 'flights');
assert.equal(button.dataset.signalId, 'a');
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
assert.equal(
button.querySelector('.cockpit-signal-target-label').textContent,
'Flight a',
);
assert.equal(
button.querySelector('.cockpit-signal-target-chevron').textContent,
'chevron_right',
);
assert.equal(
action.querySelector('div > span').textContent,
'COMMERCIAL FLIGHT · 2 KM',
);
assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button'), null);
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
const f = fixture(t);
await refresh(f);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
assert.equal(f.buttons()[1] === target, true);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
assert.match(f.list.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture();
render(f, [item('a')]);
test('signal content and accessible name update without replacing the active target', async (t) => {
const f = fixture(t);
await render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
await render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(f.buttons()[0] === target, true);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.match(f.list.textContent, /CURRENT/);
assert.equal(f.list.children[0].className, 'track actionable');
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture();
render(
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
const f = fixture(t);
await render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
['c', 'a', 'b', 'd'],
['b', 'a', 'd', 'c'],
]) {
render(
await render(
f,
order.map((id) => item(id)),
);
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
}
assert.equal(f.focusCalls(), calls);
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
pushCockpitSignal.call(
f.owner,
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
await nextTick();
assert.equal(f.list.children.length, 3);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
assert.equal(click.defaultPrevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture();
render(f, [item('a'), item('b')]);
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
await render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1);
assert.equal(f.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]'), null);
// Traversal carries on forward from the footer tab.
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(document.activeElement === f.pageTab(1), true);
while (document.activeElement !== f.display) f.tab();
await render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(
f.document.activeElement,
f.display,
document.activeElement === f.display,
true,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
assert.equal(document.activeElement === f.radio, true);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
test('a changed target cannot reuse a focused action for another flight', async (t) => {
const f = fixture(t);
await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
await render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(target === removed, false);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.click({ target, preventDefault() {} });
assert.equal(document.activeElement === f.signalTab(), true);
target.click();
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
});
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
const f = fixture();
render(f, [item('a'), item('a')]);
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
assert.notEqual(first, second);
assert.equal(first === second, false);
second.focus();
render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]);
assert.equal(f.document.activeElement, second);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
await render(f, [item('a'), item('a')]);
const again = f.buttons();
assert.equal(again[0] === first && again[1] === second, true);
assert.equal(document.activeElement === second, true);
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.match(f.list.textContent, /UNKNOWN/);
assert.equal(document.activeElement === f.signalTab(), true);
f.outside.focus();
render(f, []);
assert.equal(f.controller.signalList.children.length, 0);
render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first);
assert.equal(f.document.activeElement, f.outside);
await render(f, []);
assert.equal(f.list.children.length, 0);
await render(f, [item('a')]);
assert.equal(f.buttons()[0] === first, false);
assert.equal(document.activeElement === f.outside, true);
assert.equal(f.losses(), 0);
});
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
const f = fixture(t);
const ids = ['a', 'b', 'c', 'd', 'e'];
// A fixed pseudo-random sequence, so a failure reproduces.
let seed = 7;
const random = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const shuffle = (list) => {
const next = [...list];
for (let index = next.length - 1; index > 0; index -= 1) {
const swap = Math.floor(random() * (index + 1));
[next[index], next[swap]] = [next[swap], next[index]];
}
return next;
};
await render(
f,
ids.map((id) => item(id)),
);
for (let round = 0; round < 60; round += 1) {
const target = f.buttons()[round % ids.length];
target.focus();
const order = shuffle(ids);
await render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(document.activeElement === target, true);
}
assert.equal(f.losses(), 0);
});
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
const f = fixture(t);
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
await render(f, [item('a'), item('b')]);
f.buttons()[1].focus();
await render(f, [item('a')]);
assert.equal(document.activeElement === f.pageTab(2), true);
for (const index of [0, 1, 2]) f.pageTab(index).remove();
f.buttons()[0].focus();
await render(f, []);
assert.equal(
document.activeElement === document.getElementById('cockpit-signal-toggle'),
true,
);
});
+4
View File
@@ -243,6 +243,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
@@ -264,6 +266,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
+2 -2
View File
@@ -168,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface',
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
@@ -178,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
+38 -87
View File
@@ -1,17 +1,16 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
@@ -22,75 +21,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
assert.match(syncSource, /button\.tabIndex = 0/);
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
// The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
}
});
@@ -129,30 +65,45 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
// The Radio view never disables its Enable button; it marks it busy.
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
assert.ok(enable, 'Radio Enable button is missing');
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
// The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/);
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
// The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
+90
View File
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { geminiLiveProxy } from '../server/providers/gemini.js';
import { GEMINI_DEFAULT_PER_MIN } from '../server/providers/gemini/chat.js';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
@@ -168,6 +172,92 @@ test('the realtime token route throttles by default, and says so in a 429', asyn
);
});
function chatRequest(handler, input) {
const req = Readable.from([Buffer.from(JSON.stringify(input))]);
Object.assign(req, {
method: 'POST',
url: '/',
headers: {
host: 'localhost:4173',
origin: 'http://localhost:4173',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const headers = {};
const res = Object.assign(new EventEmitter(), {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
});
return new Promise((resolve, reject) => {
res.end = (body = '') => {
res.writableEnded = true;
resolve({ status: res.statusCode, headers, body: String(body) });
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('typed chat follows each provider throttle by default, before upstream', async (t) => {
for (const name of [
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
'GEV_RATELIMIT_GEMINI_PER_MIN',
'GEV_PREFER_CODEX_OAUTH',
])
env(t, name, undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEMINI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
upstreamCalls += 1;
return Response.json(
String(url).includes('openai')
? { choices: [{ message: { role: 'assistant', content: 'Hi' } }] }
: {
candidates: [
{ content: { role: 'model', parts: [{ text: 'Hi' }] } },
],
},
);
});
// The chat fallback must track the Gemini token route's own default.
const tokenDefault = readFileSync(
new URL('../server/providers/gemini/realtime.js', import.meta.url),
'utf8',
).match(/GEV_RATELIMIT_GEMINI_PER_MIN \?\? '(\d+)'/)?.[1];
assert.equal(Number(tokenDefault), GEMINI_DEFAULT_PER_MIN);
for (const [route, handler, cap, input] of [
[
'/api/openai/chat',
install(openAiRealtimeProxy({})).get('/api/openai/chat'),
OPENAI_DEFAULT_PER_MIN,
{ messages: [{ role: 'user', content: 'Hello' }] },
],
[
'/api/gemini/chat',
install(geminiLiveProxy({})).get('/api/gemini/chat'),
GEMINI_DEFAULT_PER_MIN,
{ contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
],
]) {
upstreamCalls = 0;
const statuses = [];
for (let i = 0; i < cap; i += 1)
statuses.push((await chatRequest(handler, input)).status);
assert.deepEqual(statuses, Array(cap).fill(200), route);
const overLimit = await chatRequest(handler, input);
assert.equal(overLimit.status, 429, route);
assert.equal(overLimit.headers['retry-after'], '60');
assert.equal(overLimit.body.includes('fixture-upstream-secret'), false);
assert.equal(upstreamCalls, cap, `${route} refuses before upstream`);
}
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
+8 -8
View File
@@ -2,7 +2,7 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { getLocale, t } from './i18n/index.js';
import { t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
@@ -14,19 +14,19 @@ export function configureCreditKeyboardAccess(root = document) {
const overlay = lightbox.parentElement;
// Cesium writes these two texts in English once, when it builds the credit
// display, and never rewrites them; English keeps Cesium's own text.
if (getLocale() !== 'en') {
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
}
// display, and never rewrites them; English is Cesium's own text. Called
// again after a locale change, this words them again.
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
expand.setAttribute('aria-haspopup', 'dialog');
expand.setAttribute('aria-controls', lightbox.id);
expand.setAttribute('aria-expanded', 'false');
if (!expand.hasAttribute?.('aria-expanded'))
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', t('Close data attribution'));
+13
View File
@@ -221,3 +221,16 @@ for (const [locale, link, title] of [
setLocale('en');
}
});
test('setting up again after a switch back to English restores the English texts', () => {
const f = textFixture();
setLocale('ru');
try {
configureCreditKeyboardAccess(f.root);
} finally {
setLocale('en');
}
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, 'Data attribution');
assert.equal(f.title.textContent, 'Data provided by:');
});
+9 -3
View File
@@ -480,11 +480,16 @@ export function setCyberSonarEnabled(
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
/**
* Whether the map-mode sweep should alter flight presentation. `hudActive`
* is the HUD's visibility from its owner, which a caller acting on it in the
* same turn passes; otherwise the rendered `#intel-hud` class answers.
*/
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
@@ -498,7 +503,7 @@ export function isCyberSonarActive({
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
@@ -508,12 +513,13 @@ export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode')
);
}
+167 -14
View File
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
}
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
function pointInRing(ring, lat, lon) {
export 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) {
@@ -150,17 +150,60 @@ export function polygonsContain(polygons, lat, lon) {
return false;
}
/**
* Whether polygons contain a point. A part whose outer ring crosses the
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
* longitude frame, with the point moved into that frame; every other part,
* including one genuinely wider than 180°, is tested as stored. Crossing is
* read from an edge jumping more than 180°, which assumes densely noded rings
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
* a non-crossing ring with a single edge over 180°, is not supported.
*/
export function containsAnchor(polygons, lat, lon) {
if (!Number.isFinite(lon)) return false;
for (const part of polygons || []) {
const outer = part?.[0];
if (!Array.isArray(outer) || !outer.length) continue;
const reference = outer[0][0];
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
const span = (ring, f) => {
let west = Infinity;
let east = -Infinity;
for (const [x] of ring) {
west = Math.min(west, f(x));
east = Math.max(east, f(x));
}
return east - west;
};
// An edge jumping more than 180° wraps the line; a polar ring whose
// edges run along ±180 collapses when reframed and is read as stored.
const jumps = outer.some(
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
);
const framedSpan = jumps ? span(outer, shift) : 0;
const crosses = framedSpan > 0 && framedSpan < 180;
const framed = crosses
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
: part;
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
}
return false;
}
/**
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
* the largest part for the box so units cut at the antimeridian (Alaska,
* Chukotka) get a box on the right side of the globe.
* Chukotka) get a box on the right side of the globe. An entry from another
* encoding supplies `decode()` returning the same `[outer, ...holes][]`.
*/
function geometryOf(entry) {
export function geometryOf(entry) {
if (entry.geometry) return entry.geometry;
const decimals = entry.feature.d ?? entry.decimals;
const polygons = entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const polygons = entry.decode
? entry.decode()
: entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
let west = Infinity;
let south = Infinity;
@@ -178,7 +221,7 @@ function geometryOf(entry) {
if (lat > north) north = lat;
}
}
const label = entry.feature.label
const label = Array.isArray(entry.feature.label)
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
: ringCentroid(polygons[0][0]);
entry.geometry = {
@@ -208,7 +251,7 @@ function ringCentroid(ring) {
}
/** Km from a point to a unit's box (0 inside it). */
function boxDistanceKm(bbox, lat, lon) {
export function boxDistanceKm(bbox, lat, lon) {
const [west, south, east, north] = bbox;
const mid = (west + east) / 2;
const x = lon - 360 * Math.round((lon - mid) / 360);
@@ -378,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([
['drc', 'democratic republic of the congo'],
]);
/**
* Country aliases that are another name for the very same unit. "Great
* Britain" and "Britain" are islands, "America" is also two continents and
* "Korea" two countries, so those find a country without naming it.
*/
const COUNTRY_IDENTITY_ALIASES = new Set([
'usa',
'us',
'u s',
'u s a',
'united states',
'uk',
'u k',
'russian federation',
'czechia',
'tanzania',
'drc',
]);
/**
* The unit an ask names in the matcher's own terms, when its words differ
* from the unit's name: "State of Texas" names Texas at admin level 1, and
* "USA" or "Country of USA" names United States of America. Null when the
* ask is just a name. Names are folded with `normalizeAdminName`.
* @param {string} text
* @returns {{name: string, level: 'country'|'admin1'}|null}
*/
export function adminQueryIdentity(text) {
const parsed = parseAdminQuery(text);
if (!parsed?.name) return null;
if (parsed.kind === 'state' && parsed.form === 'prefix')
return { name: parsed.name, level: 'admin1' };
if (parsed.kind !== 'country' && parsed.kind !== 'any') return null;
if (COUNTRY_IDENTITY_ALIASES.has(parsed.name))
return {
name: COUNTRY_TARGET_ALIASES.get(parsed.name),
level: 'country',
};
return parsed.kind === 'country'
? { name: parsed.name, level: 'country' }
: null;
}
const COUNTRY_ALIASES = new Map([
...COUNTRY_TARGET_ALIASES,
['england', 'united kingdom'],
@@ -399,8 +485,8 @@ const COUNTY_WORDS = [
];
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
const STATE_PREFIX_RE =
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
/^(free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (state|province|prefecture|territory)$/;
/**
* Split an ask into what it names and how.
@@ -461,14 +547,17 @@ export function parseAdminQuery(text) {
if (prefix)
return {
kind: 'state',
name: prefix[1],
name: prefix[2],
qualifiers,
postal: null,
countyWord: null,
full: head,
form: 'prefix',
adminType: prefix[1],
};
const suffix = STATE_SUFFIX_RE.exec(head);
// South Africa's canonical province name is literally "Free State"; do not
// strip its final word as though the ask explicitly named an admin type.
const suffix = head === 'free state' ? null : STATE_SUFFIX_RE.exec(head);
if (suffix)
return {
kind: 'state',
@@ -478,6 +567,7 @@ export function parseAdminQuery(text) {
countyWord: null,
full: head,
form: 'suffix',
adminType: suffix[2],
};
return {
kind: 'any',
@@ -489,6 +579,30 @@ export function parseAdminQuery(text) {
};
}
/** Explicit wording is stronger than a stale same-name geometry. */
function explicitStateTypeFits(entry, parsed) {
if (!parsed.adminType) return true;
const words = new Set(
normalizeAdminName(entry.feature?.type)
.split(/[^a-z]+/)
.filter(Boolean),
);
const accepted =
{
'free state': ['state'],
state: ['state'],
commonwealth: ['state'],
province: ['province'],
territory: ['territory'],
prefecture: ['prefecture'],
region: ['region'],
land: ['state'],
canton: ['canton'],
department: ['department', 'departamento'],
}[parsed.adminType] || [];
return accepted.some((word) => words.has(word));
}
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
* itself ("Washington, DC"). */
function qualifierMatches(entry, qualifier) {
@@ -505,7 +619,7 @@ function qualifierMatches(entry, qualifier) {
* Rank candidates: a unit containing `near` first, then the match tier,
* then distance from `near`, then larger area. Returns the ranked list.
*/
function rank(candidates, near) {
export function rank(candidates, near) {
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
const scored = candidates.map(({ entry, tier }) => {
const geometry = geometryOf(entry);
@@ -626,6 +740,10 @@ async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
list = list.filter((i) =>
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
);
// Preserve explicit identity at the selector seam. In particular,
// "Bagmati Province" must not select Natural Earth's historical NP-BA
// Bagmati Zone merely because the point and normalized name overlap.
list = list.filter((i) => explicitStateTypeFits(i.entry, parsed));
if (allowAmbiguous) return list;
// A bare name answers alone only for a prominent unit — a US state, a
// Canadian province, Bavaria — never a French département, an English
@@ -721,6 +839,35 @@ export async function findAdminArea(query, { near = null } = {}) {
return toResult(ranked[0], candidates.length);
}
/**
* Every prominent unit an ask's words could mean — countries, and states or
* provinces that would answer a bare name (see `stateCandidates`) — for a
* caller that asks the user which one ("Georgia", "Punjab") instead of
* letting the camera decide. Qualifiers narrow the list as in
* `findAdminArea`; minor units ("Kent", "Santa Barbara") are left out.
*
* @param {string} query
* @returns {Promise<AdminArea[]>} Largest first; empty when none.
*/
export async function findAdminCandidates(query) {
const parsed = parseAdminQuery(query);
if (!parsed?.name || parsed.kind === 'county') return [];
const prominent = ({ entry }) =>
entry.kind === 'country' ||
(entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
Boolean(entry.feature.cityState);
const candidates = [
...(await countryCandidates(parsed)),
...(parsed.kind === 'country'
? []
: await stateCandidates(parsed, { allowAmbiguous: true })),
].filter(prominent);
const seen = new Set();
return rank(candidates, null)
.filter(({ entry }) => !seen.has(entry) && seen.add(entry))
.map((scored) => toResult(scored, candidates.length));
}
/**
* Geocoder-confirmed lookup: the geocoder typed a country, state or
* county, so the unit must carry one of `names` AND contain the geocoded
@@ -738,6 +885,12 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
.map(parseAdminQuery)
.filter((p) => p?.name);
if (!parsedList.length) return null;
// If the ask explicitly names an admin type, do not let the geocoder's
// untyped alias reopen an incompatible historical same-name boundary.
const explicitStates = parsedList.filter(
(parsed) => parsed.kind === 'state' && parsed.adminType,
);
const stateParses = explicitStates.length ? explicitStates : parsedList;
const containing = (items) => {
const out = [];
for (const item of items)
@@ -782,7 +935,7 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
return toResult(rank(found, { lat, lon })[0], found.length);
}
const states = [];
for (const parsed of parsedList)
for (const parsed of stateParses)
states.push(
...(await stateCandidates(
{ ...parsed, qualifiers: [] },
+24
View File
@@ -7,6 +7,7 @@ import {
decodeRing,
findAdminArea,
findAdminAreaAt,
findAdminCandidates,
normalizeAdminName,
packLoads,
parseAdminQuery,
@@ -225,6 +226,29 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
assert.equal(newYork?.id, 'US-NY');
assert.equal(
(
await findAdminAreaAt(
['Ontario Province', 'Ontario'],
43.6532,
-79.3832,
'state',
)
)?.id,
'CA-ON',
);
// Natural Earth still carries the historical NP-BA Bagmati Zone. An ask
// for today's province must not silently select that incompatible geometry.
assert.equal(
await findAdminAreaAt(
['Bagmati Province', 'Bagmati'],
27.7172,
85.324,
'state',
),
null,
);
assert.deepEqual(await findAdminCandidates('Bagmati Province'), []);
// A county the geocoder typed, named without the county word.
const travis = await findAdminAreaAt(
['Travis', 'Travis County'],
+3 -2
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
+38 -18
View File
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import { layerFeedState } from './manager.js';
import {
presentSelectedVessel,
subscribeSelectedVessel,
} from '../hudReadouts.js';
test('open feed with vessels is healthy (null)', () => {
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
@@ -856,15 +860,6 @@ function makeTrailSpy() {
};
}
function makeClassList(...initial) {
const classes = new Set(initial);
return {
add(value) { classes.add(value); },
remove(value) { classes.delete(value); },
contains(value) { return classes.has(value); },
};
}
function makeInteractionHandler() {
return {
click: null,
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
};
const record = makeRecord();
const trail = makeTrailSpy();
presentSelectedVessel({ text: `AIS: ${record.name}` });
let vesselReadout;
const stopReadout = subscribeSelectedVessel(
(readout) => (vesselReadout = readout),
);
const hud = {
textContent: `AIS: ${record.name}`,
classList: makeClassList('active'),
get readout() {
return vesselReadout;
},
};
globalThis.window = windowTarget;
globalThis.document = {
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
getElementById: () => null,
};
_setVesselStateForTest({
records: [record],
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
reinstalledHandlers,
interactionHandlerFactory,
cleanup() {
stopReadout();
aisLiveVesselsLayer.destroy();
if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window;
@@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Clicking away is a deliberate deselect, not the vessel aging out of the
// feed. Consumers that keep a readout on screen (the Cockpit Contact
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
@@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Empty pick is a deliberate deselect — see the note above.
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
} finally {
@@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
try {
harness.keyTarget.dispatch({ key: 'Escape' });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
} finally {
harness.cleanup();
}
@@ -1322,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
assert.deepEqual(bare.details, []);
});
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
assert.equal(
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
'CONTAINER SHIP · 14.5KT · 0°',
);
});
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, { heading: null, course: 359.7 });
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
} finally {
harness.cleanup();
}
});
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
assert.equal(unnamed.title, 'MMSI 353136000');
+230 -47
View File
@@ -18,7 +18,11 @@
*
* Providers (injected — keeps the engine pure and node-testable):
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
* resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?}
* |{error:'region-timeout'}|{needsClarification, candidates}|null>
* (geometry = MultiPolygon coordinates; islands and holes all count)
* resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared,
* source, approximate}}|{ok:false, code, error}> (area/drawn/annotation)
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
*
* @module data/analystEngine
@@ -26,6 +30,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
@@ -67,6 +72,9 @@ const ANALYST_SCOPE_KINDS = Object.freeze([
'region',
'radius',
'anywhere',
'area',
'drawn',
'annotation',
]);
/**
@@ -104,9 +112,14 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (resolved?.prepared) {
return records.filter((r) =>
pointInPreparedArea(resolved.prepared, r.lat, r.lon),
);
}
if (scope.kind === 'region' && resolved?.ring) {
return records.filter(
(r) =>
@@ -320,6 +333,24 @@ function normalizeSpec(spec, layerKeys) {
return {
refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'),
};
if (
kind === 'area' &&
(typeof scope.areaId !== 'string' || !scope.areaId.trim())
)
return {
refusal: refusal(
'BAD_SCOPE',
'An area scope needs the areaId resolve_area returned.',
),
};
if (
kind === 'annotation' &&
scope.id !== undefined &&
(typeof scope.id !== 'string' || !scope.id.trim())
)
return {
refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'),
};
if (scope.center !== undefined && scope.center !== null) {
if (!validLat(scope.center.lat) || !validLon(scope.center.lon))
return {
@@ -456,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']);
/** Rows per scan slice before yielding to the page. */
const SCAN_CHUNK = 20_000;
/**
* A slice also ends after this long: a row inside a large area pays a ray
* cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows),
* so the row count alone would still make long tasks.
*/
const SCAN_BUDGET_MS = 8;
/** Rows between clock checks. */
const SCAN_CLOCK_EVERY = 256;
const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0));
const clockMs = () => globalThis.performance?.now?.() ?? Date.now();
/**
* Compile the filters into one predicate over (layerKey, row).
@@ -597,12 +637,19 @@ export async function selectAnalystRows(
let min = Infinity;
let max = -Infinity;
let seen = 0;
let sliceStart = clockMs();
for (const { key, rows } of groups) {
const kept = [];
for (const row of rows) {
if (++seen % SCAN_CHUNK === 0) {
seen += 1;
if (
seen % SCAN_CHUNK === 0 ||
(seen % SCAN_CLOCK_EVERY === 0 &&
clockMs() - sliceStart >= SCAN_BUDGET_MS)
) {
await yieldToPage();
if (!isCurrent()) return null;
sliceStart = clockMs();
}
if (inScope && !inScope(row)) continue;
if (!passes(key, row)) continue;
@@ -650,9 +697,8 @@ export function createAnalystEngine(providers) {
/**
* Run one query.
* @param {object} spec Query arguments (see the analyst_query tool).
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
* A query that is no longer current
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) =>
* Promise<object|void>}} [options] A query that is no longer current
* (cancelled or superseded while awaiting a boundary or between scan
* slices) returns CANCELLED and never replaces the follow-up memory.
* `beforeCommit` lets a caller finish required async projection work and
@@ -662,7 +708,7 @@ export function createAnalystEngine(providers) {
*/
async function query(
spec = {},
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
{ signal = null, isCurrent = () => true, beforeCommit = null } = {},
) {
const cancelled = () =>
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
@@ -673,6 +719,7 @@ export function createAnalystEngine(providers) {
{ coverage: { layersQueried: [], scope: 'none' } },
);
const followUp = Boolean(spec.followUp);
const inheritsRememberedScope = followUp && spec.scope == null;
// A follow-up re-filters the remembered set. Layers named explicitly
// must be that set: "those hospitals" after an aircraft answer is a new
// question, never a filter over the old aircraft.
@@ -815,25 +862,37 @@ export function createAnalystEngine(providers) {
}
// 2) Spatial scope
const rememberedScope =
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
const rememberedDistanceOf =
followUp && !spec.scope ? lastResult.distanceOf || null : null;
const rememberedPresentation = inheritsRememberedScope
? lastResult.presentation || null
: null;
const rememberedCoverageNote =
rememberedPresentation?.coverageNote ||
(inheritsRememberedScope ? lastResult.coverage?.note : null);
const rememberedDistanceOf = inheritsRememberedScope
? lastResult.distanceOf || null
: null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
let scopeNote =
rememberedPresentation?.coverageScope ||
(inheritsRememberedScope ? lastResult.coverage?.scope : null) ||
'anywhere';
let scopeSource =
rememberedPresentation?.coverageScopeSource ||
(inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) ||
null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
let scopeDetail = rememberedScope?.detail || scopeLabel;
let scopeLabel =
rememberedPresentation?.scopeLabel || 'anywhere in the loaded data';
let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel;
// A follow-up re-filters a set that was already scoped, so it only narrows
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
region = await providers.resolveRegionRing(scope.name, { signal });
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
@@ -849,7 +908,32 @@ export function createAnalystEngine(providers) {
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
};
}
if (!region?.ring) {
if (region?.needsClarification) {
return refusal(
'NEEDS_CLARIFICATION',
region.error ||
`"${scope.name}" names more than one place — ask which one.`,
{
needsClarification: true,
query: scope.name,
candidates: region.candidates || [],
// The way back once the user picks: count over that area.
then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.',
coverage: {
layersQueried,
scope: `region:${scope.name}:ambiguous`,
},
},
);
}
const prepared =
region?.prepared ||
(region?.geometry
? prepareArea(region.geometry)
: region?.ring
? prepareArea([[region.ring]])
: null);
if (!prepared) {
return {
ok: false,
code: 'REGION_UNRESOLVED',
@@ -857,11 +941,39 @@ export function createAnalystEngine(providers) {
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
};
}
resolvedScope = region;
resolvedScope = { ...region, prepared };
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
const regionSource = region.sourceLabel || region.source;
scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`;
} else if (
scope.kind === 'area' ||
scope.kind === 'drawn' ||
scope.kind === 'annotation'
) {
const found = providers.resolveAreaScope
? await providers.resolveAreaScope(scope)
: {
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'Area scopes are not available here.',
};
if (!isCurrent()) return cancelled();
if (!found?.ok || !found.area?.prepared) {
return refusal(
found?.code || 'AREA_UNKNOWN',
found?.error || 'That area is not available.',
{ coverage: { layersQueried, scope: `${scope.kind}:unresolved` } },
);
}
const area = found.area;
resolvedScope = { prepared: area.prepared, name: area.name };
scopeNote = `area:${area.areaId || scope.kind}`;
scopeLabel = `inside ${area.name}`;
const sourceLabel = area.sourceLabel || area.source;
scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`;
if (area.areaId) resolvedScope.areaId = area.areaId;
} else if (scope.kind === 'radius') {
// An explicit center always wins. Otherwise, when Contacts is active its
// SUBJECT is the centre the operator is actually reasoning about: the
@@ -906,25 +1018,21 @@ export function createAnalystEngine(providers) {
scopeLabel = 'in view';
scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`;
}
const inScope =
scope.kind === 'region' && resolvedScope?.ring
const inScope = resolvedScope?.prepared
? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon)
: (scope.kind === 'radius' || scope.kind === 'view') &&
resolvedScope?.center &&
Number.isFinite(resolvedScope.km)
? (row) =>
Number.isFinite(row.lat) &&
Number.isFinite(row.lon) &&
pointInRing(resolvedScope.ring, row.lat, row.lon)
: (scope.kind === 'radius' || scope.kind === 'view') &&
resolvedScope?.center &&
Number.isFinite(resolvedScope.km)
? (row) =>
Number.isFinite(row.lat) &&
Number.isFinite(row.lon) &&
haversineKm(
resolvedScope.center.lat,
resolvedScope.center.lon,
row.lat,
row.lon,
) <= resolvedScope.km
: null;
haversineKm(
resolvedScope.center.lat,
resolvedScope.center.lon,
row.lat,
row.lon,
) <= resolvedScope.km
: null;
// 3) Filter, count and rank in one pass, yielding between slices.
const selection = await selectAnalystRows(groups, {
@@ -992,15 +1100,25 @@ export function createAnalystEngine(providers) {
// For on-screen text; the model speaks the count and scopeLabel only.
display: {
scope: scopeDetail,
...(windows.length ? { window: windows.join('; ') } : {}),
...(caveats.length ? { caveat: caveats.join('; ') } : {}),
...(rememberedPresentation?.display?.window || windows.length
? {
window:
rememberedPresentation?.display?.window || windows.join('; '),
}
: {}),
...(rememberedPresentation?.display?.caveat || caveats.length
? {
caveat:
rememberedPresentation?.display?.caveat || caveats.join('; '),
}
: {}),
},
coverage: {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
@@ -1010,37 +1128,102 @@ export function createAnalystEngine(providers) {
// than implying a view-centred answer.
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: rememberedPresentation?.centeredOn
? { centeredOn: rememberedPresentation.centeredOn }
: {}),
...(rememberedPresentation?.window
? { window: structuredClone(rememberedPresentation.window) }
: {}),
...(resolvedScope?.areaId
? { areaId: resolvedScope.areaId }
: rememberedPresentation?.areaId
? { areaId: rememberedPresentation.areaId }
: {}),
};
const memoryOverride =
typeof beforeCommit === 'function'
? (await beforeCommit(result)) || null
: null;
if (!isCurrent()) return cancelled();
const committedCoverage = memoryOverride?.coverage || result.coverage;
lastResult = {
matched: memoryOverride?.matched || selection.matched,
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
layerKeys: [...(memoryOverride?.layerKeys || layers)],
coverage: committedCoverage,
coverage: memoryOverride?.coverage || result.coverage,
unanswered: [
...(memoryOverride?.unanswered === undefined
? unanswered
: memoryOverride.unanswered),
],
scopePresentation: {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
presentation: structuredClone(
memoryOverride?.presentation || {
scopeLabel: result.scopeLabel,
display: result.display,
coverageScope: result.coverage?.scope,
...(result.coverage?.scopeSource
? { coverageScopeSource: result.coverage.scopeSource }
: {}),
...(result.coverage?.note
? { coverageNote: result.coverage.note }
: {}),
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
...(result.window ? { window: result.window } : {}),
...(result.areaId ? { areaId: result.areaId } : {}),
},
),
};
return result;
}
return {
query,
/**
* Make another tool's result set "the last answer" (osm_query's places),
* so a follow-up filters exactly what the user just heard about.
* @param {string} layerKey Queryable layer the rows belong to.
* @param {Array<object>} rows Records in that layer's analyst shape.
* @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options]
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
* follow-up names the same scope instead of "anywhere". `truncated`
* says the set is only the first part of a larger one, so follow-ups
* stay incomplete (a count is a floor) rather than claiming the whole.
*/
remember(
layerKey,
rows,
{ scope = 'handoff', presentation = null, truncated = false } = {},
) {
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
const capped = truncated === true;
// A capped set's true size is unknown.
const total = capped ? null : rows.length;
lastResult = {
...(presentation
? { presentation: structuredClone(presentation) }
: {}),
matched: [{ key: layerKey, rows: rows.slice() }],
layerKeys: [layerKey],
coverage: {
layersQueried: [
{
layerKey,
status: rows.length ? 'ok' : 'empty',
returned: rows.length,
total,
truncated: capped,
},
],
records: {
returned: rows.length,
total,
truncated: capped,
},
scope,
followUp: false,
},
};
return true;
},
reset() {
lastResult = null;
},
+282 -5
View File
@@ -275,14 +275,18 @@ for (const [label, committedScope, scopeSource] of [
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
presentation: {
scopeLabel: 'the presented cohort',
display: { scope: 'the presented cohort window' },
coverageScope: committedScope,
...(scopeSource ? { coverageScopeSource: scopeSource } : {}),
},
};
},
},
@@ -436,6 +440,87 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(
followUp.items.map((item) => item.id),
['SWA1'],
);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(
followUp.items.map((item) => item.id),
['SWA1'],
);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
assert.equal(r.ok, false);
@@ -460,7 +545,7 @@ test('analyst: region lookup receives and obeys the owning action signal', async
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
resolveRegionRing: async (_name, { signal }) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
@@ -514,7 +599,7 @@ for (const [label, reason, trackAuthority] of [
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
resolveRegionRing: (name, { signal } = {}) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
@@ -720,7 +805,9 @@ test('unknown fields, operators, scopes and wrong-typed values are refused with
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation']);
const noId = await engine.query({ layers: ['flights'], scope: { kind: 'area' } });
assert.equal(noId.code, 'BAD_SCOPE', 'an area scope needs its areaId');
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
@@ -963,3 +1050,193 @@ test('analyst: a follow-up naming other layers than the last answer is refused',
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
// ── Area handles: region multipolygons and the area / drawn / annotation scopes ──
const box = (w, s, e, n) => [[w, s], [e, s], [e, n], [w, n], [w, s]];
function makeAreaEngine(overrides = {}) {
return createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
...overrides,
});
}
test('analyst: a region counts every part of a multipolygon and skips its holes', async () => {
// Main part around Austin with a hole over the airport ground traffic, and a
// separate island over Oregon: the old largest-ring shortcut dropped N123.
const engine = makeAreaEngine({
resolveRegionRing: async () => ({
name: 'Archipelago',
geometry: [
[box(-100, 28, -94, 33), box(-97.7, 30.1, -97.6, 30.25)],
[box(-123, 44, -121, 46)],
],
source: 'osm',
}),
});
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Archipelago' }, limit: 10 });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id).sort(), ['N123', 'RCH01']);
assert.equal(r.count, 2, 'SWA1 and GND1 sit in the hole');
assert.match(r.display.scope, /over Archipelago · osm/);
});
test('analyst: an ambiguous region asks instead of counting', async () => {
const engine = makeAreaEngine({
resolveRegionRing: async () => ({
ok: false,
needsClarification: true,
candidates: [{ candidateId: 'osm:r1', name: 'Georgia', level: 'country' }],
error: '"Georgia" matches 2 places — ask which one.',
}),
});
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Georgia' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'NEEDS_CLARIFICATION');
assert.equal(r.candidates.length, 1);
assert.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back');
assert.equal(engine.hasMemory(), false);
});
test('analyst: region lookup receives the caller lifetime signal', async () => {
const controller = new AbortController();
let received;
const engine = makeAreaEngine({
resolveRegionRing: async (_name, options) => {
received = options?.signal;
return { error: 'region-timeout' };
},
});
const result = await engine.query(
{
layers: ['flights'],
scope: { kind: 'region', name: 'Signal Region' },
},
{ signal: controller.signal },
);
assert.equal(received, controller.signal);
assert.equal(result.code, 'region-timeout');
});
test('analyst: an area scope counts inside the stored handle and names it', async () => {
const { prepareArea } = await import('./areaGeometry.js');
const prepared = prepareArea([[box(-99, 30, -97.5, 31)]]);
const seen = [];
const engine = makeAreaEngine({
resolveAreaScope: async (scope) => {
seen.push(scope);
if (scope.kind === 'area' && scope.areaId === 'osm:r42')
return { ok: true, area: { areaId: 'osm:r42', name: 'Hill Country', prepared, source: 'osm' } };
if (scope.kind === 'drawn')
return { ok: true, area: { areaId: 'anno:anno-7', name: 'the drawn area', prepared, source: 'drawn' } };
return { ok: false, code: 'AREA_UNKNOWN', error: 'No such area' };
},
});
const fires = await engine.query({ layers: ['local-firms'], scope: { kind: 'area', areaId: 'osm:r42' } });
assert.equal(fires.ok, true);
assert.equal(fires.count, 2);
assert.equal(fires.scopeLabel, 'inside Hill Country');
assert.equal(fires.areaId, 'osm:r42');
assert.equal(fires.coverage.scope, 'area:osm:r42');
const followUp = await engine.query({ followUp: true, limit: 1 });
assert.equal(followUp.scopeLabel, 'inside Hill Country');
assert.match(followUp.display.scope, /inside Hill Country/);
assert.equal(followUp.coverage.scope, 'area:osm:r42');
assert.equal(followUp.areaId, 'osm:r42');
const drawn = await engine.query({ layers: ['flights'], scope: { kind: 'drawn' } });
assert.equal(drawn.ok, true);
assert.equal(drawn.count, 2, 'SWA1 and GND1 sit inside the drawn box');
const missing = await engine.query({ layers: ['flights'], scope: { kind: 'area', areaId: 'osm:r0' } });
assert.equal(missing.ok, false);
assert.equal(missing.code, 'AREA_UNKNOWN');
assert.deepEqual(seen.map((s) => s.kind), ['area', 'drawn', 'area']);
});
test('analyst: an area scope superseded while resolving is cancelled and leaves no memory', async () => {
const { prepareArea } = await import('./areaGeometry.js');
let current = true;
const engine = makeAreaEngine({
resolveAreaScope: async () => {
current = false; // a newer turn arrived while the area was looked up
return { ok: true, area: { name: 'X', prepared: prepareArea([[box(-100, 28, -94, 33)]]) } };
},
});
const r = await engine.query(
{ layers: ['flights'], scope: { kind: 'annotation' } },
{ isCurrent: () => current },
);
assert.equal(r.code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
test('analyst: without an area provider the area scopes refuse plainly', async () => {
const r = await makeAreaEngine().query({ layers: ['flights'], scope: { kind: 'drawn' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'AREA_UNAVAILABLE');
});
test('a follow-up on handed-off places keeps the area they were found in', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
{ id: 'node/2', lat: 27.71, lon: 85.31, name: 'Teaching Hospital', kind: 'hospital', emergency: 'no' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
assert.equal(
engine.remember('osm-places', rows, {
presentation: { scopeLabel: 'inside Kathmandu', coverageScope: 'area:area-1', areaId: 'area-1' },
}),
true,
);
const result = await engine.query({
layers: ['osm-places'],
followUp: true,
filters: [{ field: 'emergency', op: 'eq', value: 'yes' }],
});
assert.equal(result.count, 1);
assert.equal(result.scopeLabel, 'inside Kathmandu');
assert.equal(result.coverage.scope, 'area:area-1');
});
test('a follow-up on a capped handed-off set is not complete', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
engine.remember('osm-places', rows, { truncated: true });
const result = await engine.query({ layers: ['osm-places'], followUp: true });
assert.equal(result.count, 1);
assert.equal(result.complete, false);
assert.equal(result.coverage.records.truncated, true);
assert.equal(result.coverage.records.total, null, 'the true size is unknown');
});
test('a slow scan yields on a time budget, not only every 20,000 rows', async () => {
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
// under a row-count budget alone.
const slow = () => {
const until = performance.now() + 0.05;
while (performance.now() < until);
return 5;
};
const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
// Count the scan's yields (other timers run only when it yields), not wall
// gaps, so a loaded machine cannot fail it.
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] });
clearInterval(timer);
assert.equal(result.count, 4_000);
assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`);
});
+397
View File
@@ -0,0 +1,397 @@
/**
* Area geometry for voice area handles: normalization, containment, size and
* display simplification over full MultiPolygons (islands and holes kept).
*
* Coordinates follow GeoJSON: a MultiPolygon is `Array<Polygon>`, a Polygon is
* `Array<Ring>` with the outer ring first and holes after it, a Ring is
* `Array<[lon, lat]>`.
*
* Counting uses the geometry exactly as resolved. Display simplification
* (`displayParts`) makes a separate copy for drawing and never feeds back into
* containment.
*
* Antimeridian: a ring whose consecutive longitudes jump by more than 180° is
* unwrapped into one continuous span (e.g. 170..190) when the jumps cancel
* out. Containment then tests the point at lon, lon + 360 and lon − 360. A ring
* that circles a pole (jumps that do not cancel) is used as given.
*
* Pure: no Cesium, DOM or network.
* @module data/areaGeometry
*/
const EARTH_RADIUS_KM = 6371;
const toRad = (degrees) => (degrees * Math.PI) / 180;
const validPair = (p) =>
Array.isArray(p) &&
Number.isFinite(p[0]) &&
Number.isFinite(p[1]) &&
Math.abs(p[0]) <= 540 &&
Math.abs(p[1]) <= 90;
const samePoint = (a, b, eps = 1e-9) =>
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
/** A ring as closed [lon, lat] pairs, or null with fewer than 3 distinct points. */
function cleanRing(ring) {
if (!Array.isArray(ring)) return null;
const out = [];
for (const p of ring) {
if (!validPair(p)) continue;
const pair = [Number(p[0]), Number(p[1])];
if (out.length && samePoint(out[out.length - 1], pair)) continue;
out.push(pair);
}
if (out.length && samePoint(out[0], out[out.length - 1])) out.pop();
if (out.length < 3) return null;
out.push([out[0][0], out[0][1]]);
return out;
}
/**
* GeoJSON Polygon/MultiPolygon geometry (or bare coordinates) → MultiPolygon
* coordinates with closed rings; null when nothing usable remains. Degenerate
* holes are dropped; a polygon whose outer ring is degenerate is dropped whole.
* @param {object|Array} geometry
* @returns {Array|null}
*/
export function normalizeMultiPolygon(geometry) {
let polygons;
if (Array.isArray(geometry)) {
// Bare coordinates: a ring, a polygon or a multipolygon, by nesting depth.
const depth = (value) => (Array.isArray(value) ? 1 + depth(value[0]) : 0);
const d = depth(geometry);
polygons = d === 2 ? [[geometry]] : d === 3 ? [geometry] : geometry;
} else if (geometry?.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry?.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
const out = [];
for (const polygon of Array.isArray(polygons) ? polygons : []) {
if (!Array.isArray(polygon)) continue;
const outer = cleanRing(polygon[0]);
if (!outer) continue;
const holes = polygon.slice(1).map(cleanRing).filter(Boolean);
out.push([outer, ...holes]);
}
return out.length ? out : null;
}
/**
* Longitudes made continuous across the antimeridian. Returns the ring as
* given when it has no jump, or when its jumps do not cancel (a ring that
* circles a pole).
*/
export function unwrapRing(ring) {
let shift = 0;
let jumped = false;
const out = [[ring[0][0], ring[0][1]]];
for (let i = 1; i < ring.length; i += 1) {
const delta = ring[i][0] - ring[i - 1][0];
if (delta > 180) {
shift -= 360;
jumped = true;
} else if (delta < -180) {
shift += 360;
jumped = true;
}
out.push([ring[i][0] + shift, ring[i][1]]);
}
if (!jumped) return ring;
return shift === 0 ? out : ring;
}
function ringBbox(ring) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const [lon, lat] of ring) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
/** Even-odd ray cast. */
function pointInRingXY(ring, lon, lat) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
return inside;
}
const wrap180 = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
const wrap360 = (lon) => {
const wrapped = lon % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
};
/** The shortest circular longitude interval containing every outer-ring vertex. */
function longitudeEnvelope(parts) {
const values = parts
.flatMap(({ outer }) => outer.map(([lon]) => wrap360(lon)))
.sort((a, b) => a - b);
if (values.length === 0) return null;
let gapIndex = values.length - 1;
let largestGap = values[0] + 360 - values.at(-1);
for (let i = 0; i < values.length - 1; i += 1) {
const gap = values[i + 1] - values[i];
if (gap > largestGap) {
largestGap = gap;
gapIndex = i;
}
}
const start = values[(gapIndex + 1) % values.length];
let end = values[gapIndex];
if (end < start) end += 360;
const span = end - start;
if (span >= 360 - 1e-9) return { west: -180, east: 180, crosses: false };
const west = start > 180 ? start - 360 : start;
const endOnCircle = end >= 360 ? end - 360 : end;
const east = endOnCircle > 180 ? endOnCircle - 360 : endOnCircle;
return { west, east, crosses: west > east };
}
/**
* Index a MultiPolygon for repeated containment tests: unwrapped rings with
* per-part bounding boxes, plus the overall bbox as [west, south, east, north]
* in −180..180 (west > east when it crosses the antimeridian).
* @param {Array} coordinates MultiPolygon coordinates.
* @returns {{parts: Array, bbox: number[], vertexCount: number, crossesAntimeridian: boolean}|null}
*/
export function prepareArea(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return null;
let vertexCount = 0;
let crosses = false;
const parts = multi.map((polygon) => {
const outer = unwrapRing(polygon[0]);
if (outer !== polygon[0]) crosses = true;
const holes = polygon.slice(1).map((ring) => {
let hole = unwrapRing(ring);
// Keep a hole in the same longitude frame as its outer ring.
const hb = ringBbox(hole);
const ob = ringBbox(outer);
const mid = (hb[0] + hb[2]) / 2;
if (mid < ob[0]) hole = hole.map(([x, y]) => [x + 360, y]);
else if (mid > ob[2]) hole = hole.map(([x, y]) => [x - 360, y]);
return hole;
});
for (const ring of polygon) vertexCount += ring.length;
return { outer, holes, bbox: ringBbox(outer) };
});
let south = Infinity;
let north = -Infinity;
for (const { bbox } of parts) {
south = Math.min(south, bbox[1]);
north = Math.max(north, bbox[3]);
}
const envelope = longitudeEnvelope(parts);
const bbox = [envelope.west, south, envelope.east, north];
crosses ||= envelope.crosses;
return { parts, bbox, vertexCount, crossesAntimeridian: crosses };
}
function inPart(part, lon, lat) {
const [w, s, e, n] = part.bbox;
if (lat < s || lat > n || lon < w || lon > e) return false;
if (!pointInRingXY(part.outer, lon, lat)) return false;
for (const hole of part.holes)
if (pointInRingXY(hole, lon, lat)) return false;
return true;
}
/**
* Whether a point lies inside the area: inside some part's outer ring and in
* none of that part's holes. Points exactly on an edge follow the ray-cast
* convention and may land either side.
* @param {object} prepared From prepareArea.
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
export function pointInPreparedArea(prepared, lat, lon) {
if (!prepared || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
for (const part of prepared.parts) {
if (inPart(part, lon, lat)) return true;
if (inPart(part, lon + 360, lat)) return true;
if (inPart(part, lon - 360, lat)) return true;
}
return false;
}
/** Spherical-excess ring area in km² (unsigned). */
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
for (let i = 0; i < n - 1; i += 1) {
const [lon1, lat1] = ring[i];
const [lon2, lat2] = ring[i + 1];
sum +=
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
}
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
/**
* Area of a MultiPolygon in km², holes subtracted.
* @param {Array} coordinates
* @returns {number}
*/
export function multiPolygonAreaKm2(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return 0;
let total = 0;
for (const polygon of multi) {
total += ringAreaKm2(unwrapRing(polygon[0]));
for (const hole of polygon.slice(1)) total -= ringAreaKm2(unwrapRing(hole));
}
return Math.max(0, total);
}
/** Counts of parts and holes. */
export function multiPolygonCounts(coordinates) {
const multi = normalizeMultiPolygon(coordinates) || [];
return {
parts: multi.length,
holes: multi.reduce((sum, polygon) => sum + polygon.length - 1, 0),
};
}
/** Mean of the largest part's outer ring vertices, wrapped to −180..180. */
export function areaAnchor(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return null;
let best = null;
let bestArea = -1;
for (const polygon of multi) {
const ring = unwrapRing(polygon[0]);
const area = ringAreaKm2(ring);
if (area > bestArea) {
bestArea = area;
best = ring;
}
}
let lon = 0;
let lat = 0;
const pts = best.slice(0, -1);
for (const [x, y] of pts) {
lon += x;
lat += y;
}
return { lon: wrap180(lon / pts.length), lat: lat / pts.length };
}
// ── Display copies ────────────────────────────────────────────
function dpRing(ring, tol) {
const n = ring.length;
if (n <= 5) return ring;
const keep = new Uint8Array(n);
keep[0] = 1;
keep[n - 1] = 1;
const stack = [[0, n - 1]];
while (stack.length) {
const [a, b] = stack.pop();
if (b - a < 2) continue;
const [ax, ay] = ring[a];
const vx = ring[b][0] - ax;
const vy = ring[b][1] - ay;
const c2 = vx * vx + vy * vy;
let worst = -1;
let worstD = tol;
for (let i = a + 1; i < b; i += 1) {
const wx = ring[i][0] - ax;
const wy = ring[i][1] - ay;
const t =
c2 === 0 ? 0 : Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2));
const d = Math.hypot(wx - t * vx, wy - t * vy);
if (d > worstD) {
worstD = d;
worst = i;
}
}
if (worst >= 0) {
keep[worst] = 1;
stack.push([a, worst], [worst, b]);
}
}
const out = [];
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(ring[i]);
return out.length >= 4 ? out : ring;
}
/**
* A simplified copy of the area for drawing only: the largest `maxParts`
* parts, each ring Douglas-Peucker simplified until the whole copy fits in
* `maxVertices`. Rings keep their antimeridian-unwrapped longitudes so a
* crossing part draws as one shape.
* @param {Array} coordinates MultiPolygon coordinates.
* @param {{maxVertices?: number, maxParts?: number}} [options]
* @returns {{parts: Array<{outer: Array, holes: Array}>, drawnParts: number, totalParts: number}}
*/
export function displayParts(
coordinates,
{ maxVertices = 6000, maxParts = 40 } = {},
) {
const multi = normalizeMultiPolygon(coordinates) || [];
const ranked = multi
.map((polygon) => ({
rings: polygon.map((ring) => unwrapRing(ring)),
area: ringAreaKm2(unwrapRing(polygon[0])),
}))
.sort((a, b) => b.area - a.area)
.slice(0, maxParts);
const bbox = ranked.length ? ringBbox(ranked[0].rings[0]) : [0, 0, 0, 0];
let tol = Math.max(
1e-6,
Math.max(bbox[2] - bbox[0], bbox[3] - bbox[1]) / 1500,
);
let parts = [];
for (let attempt = 0; attempt < 12; attempt += 1) {
parts = ranked.map(({ rings }) => ({
outer: dpRing(rings[0], tol),
holes: rings.slice(1).map((ring) => dpRing(ring, tol)),
}));
const total = parts.reduce(
(sum, p) =>
sum + p.outer.length + p.holes.reduce((h, r) => h + r.length, 0),
0,
);
if (total <= maxVertices) break;
tol *= 2;
}
return { parts, drawnParts: parts.length, totalParts: multi.length };
}
/**
* A circle as a closed ring (planar metres around the centre — accurate for
* the few-kilometre buffers it is used for).
* @returns {Array<[number, number]>}
*/
export function circleRing(lat, lon, radiusM, segments = 48) {
const mPerDegLat = 111_320;
const mPerDegLon = mPerDegLat * Math.max(0.01, Math.cos(toRad(lat)));
const ring = [];
for (let i = 0; i < segments; i += 1) {
const a = (i / segments) * Math.PI * 2;
ring.push([
lon + (Math.cos(a) * radiusM) / mPerDegLon,
lat + (Math.sin(a) * radiusM) / mPerDegLat,
]);
}
ring.push([ring[0][0], ring[0][1]]);
return ring;
}
+178
View File
@@ -0,0 +1,178 @@
// Area geometry: containment over full MultiPolygons (islands, holes, the
// antimeridian), OSM relation assembly and display copies that never touch the
// counting geometry.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
circleRing,
displayParts,
multiPolygonAreaKm2,
multiPolygonCounts,
normalizeMultiPolygon,
pointInPreparedArea,
prepareArea,
} from './areaGeometry.js';
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
test('a point on an island counts, not only on the largest part', () => {
// Mainland 0..10, island 20..22: the old largest-ring shortcut dropped the island.
const area = prepareArea([[square(0, 0, 10, 10)], [square(20, 0, 22, 2)]]);
assert.equal(pointInPreparedArea(area, 5, 5), true);
assert.equal(pointInPreparedArea(area, 1, 21), true);
assert.equal(pointInPreparedArea(area, 1, 15), false);
});
test('a point inside a hole is outside the area', () => {
const area = prepareArea([[square(0, 0, 10, 10), square(4, 4, 6, 6)]]);
assert.equal(pointInPreparedArea(area, 5, 5), false);
assert.equal(pointInPreparedArea(area, 2, 2), true);
// An island inside the hole (an enclave within an enclave) counts again.
const nested = prepareArea([
[square(0, 0, 10, 10), square(3, 3, 7, 7)],
[square(4, 4, 6, 6)],
]);
assert.equal(pointInPreparedArea(nested, 5, 5), true);
assert.equal(pointInPreparedArea(nested, 3.5, 3.5), false);
});
test('a ring crossing the antimeridian contains points on both sides', () => {
// 175E → 175W as one continuous ring (a drawn box over Fiji).
const ring = [
[175, -20],
[-175, -20],
[-175, -15],
[175, -15],
[175, -20],
];
const area = prepareArea([[ring]]);
assert.equal(area.crossesAntimeridian, true);
assert.equal(pointInPreparedArea(area, -17, 178), true);
assert.equal(pointInPreparedArea(area, -17, -178), true);
assert.equal(pointInPreparedArea(area, -17, 0), false);
assert.equal(pointInPreparedArea(area, -17, 170), false);
// bbox is west > east across the line.
assert.deepEqual(area.bbox, [175, -20, -175, -15]);
// Area of a 10°×5° box near 17°S, not the 350° long way round.
const km2 = multiPolygonAreaKm2([[ring]]);
assert.ok(km2 > 500_000 && km2 < 620_000, `area ${km2}`);
});
test('a multipolygon split at ±180 keeps a tight bbox', () => {
const area = prepareArea([
[square(177, -19, 180, -16)],
[square(-180, -19, -178, -16)],
]);
assert.equal(area.crossesAntimeridian, true);
assert.deepEqual(area.bbox, [177, -19, -178, -16]);
assert.equal(pointInPreparedArea(area, -17, -179), true);
assert.equal(pointInPreparedArea(area, -17, 179), true);
});
test('a crossing part and a separate island share one tight longitude frame', () => {
const crossing = [
[175, -20],
[-175, -20],
[-175, -15],
[175, -15],
[175, -20],
];
const area = prepareArea([[crossing], [square(-170, -19, -160, -16)]]);
assert.equal(area.crossesAntimeridian, true);
assert.deepEqual(area.bbox, [175, -20, -160, -15]);
assert.equal(pointInPreparedArea(area, -17, 178), true);
assert.equal(pointInPreparedArea(area, -17, -165), true);
assert.equal(pointInPreparedArea(area, -17, 0), false);
});
test('normalization closes rings and drops degenerate parts', () => {
const multi = normalizeMultiPolygon({
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
],
[
[0.2, 0.2],
[0.2, 0.2],
],
],
});
assert.equal(multi.length, 1);
assert.equal(multi[0].length, 1, 'degenerate hole dropped');
assert.deepEqual(multi[0][0][0], multi[0][0][3]);
assert.equal(
normalizeMultiPolygon({ type: 'LineString', coordinates: [] }),
null,
);
assert.equal(
normalizeMultiPolygon([
[
[0, 0],
[1, 1],
],
]),
null,
);
});
test('area subtracts holes and counts parts', () => {
const coords = [
[square(0, 0, 1, 1), square(0.25, 0.25, 0.75, 0.75)],
[square(5, 5, 6, 6)],
];
const whole = multiPolygonAreaKm2([[square(0, 0, 1, 1)]]);
const km2 = multiPolygonAreaKm2(coords);
assert.ok(km2 < whole * 2 && km2 > whole * 1.7, `${km2} vs ${whole}`);
assert.deepEqual(multiPolygonCounts(coords), { parts: 2, holes: 1 });
});
test('display copies are bounded and leave the counting geometry intact', () => {
const dense = [];
for (let i = 0; i < 20_000; i += 1) {
const a = (i / 20_000) * Math.PI * 2;
dense.push([
Math.cos(a) * (1 + 0.01 * Math.sin(a * 200)),
Math.sin(a) * (1 + 0.01 * Math.sin(a * 200)),
]);
}
dense.push(dense[0].slice());
const coords = [[dense]];
const snapshot = JSON.stringify(coords);
const shown = displayParts(coords, { maxVertices: 2000 });
const total = shown.parts.reduce((sum, p) => sum + p.outer.length, 0);
assert.ok(total <= 2000, `${total} vertices`);
assert.equal(JSON.stringify(coords), snapshot, 'input untouched');
// A point the simplified copy might shave off still counts on the original.
const area = prepareArea(coords);
assert.equal(pointInPreparedArea(area, 0, 0), true);
});
test('display copies keep the largest parts first', () => {
const coords = [
[square(0, 0, 1, 1)],
[square(10, 10, 15, 15)],
[square(20, 20, 20.1, 20.1)],
];
const shown = displayParts(coords, { maxParts: 2 });
assert.equal(shown.totalParts, 3);
assert.equal(shown.drawnParts, 2);
assert.equal(shown.parts[0].outer[0][0], 10);
});
test('a circle ring is closed and about the requested radius', () => {
const ring = circleRing(37.8, -122.39, 500);
assert.deepEqual(ring[0], ring[ring.length - 1]);
const km2 = multiPolygonAreaKm2([[ring]]);
assert.ok(Math.abs(km2 - Math.PI * 0.25) < 0.02, `${km2}`);
});
+668
View File
@@ -0,0 +1,668 @@
/**
* Named place → area handle, through the outline ladder that needs no public
* Overpass:
*
* 1. the area store (a query already resolved this session);
* 2. bundled Natural Earth physical/marine regions;
* 3. bundled countries and states/provinces by name — a name several
* prominent units share ("Georgia", "Punjab") asks which one, unless
* the view is inside exactly one of them;
* 4. the annotation outline ladder (`resolveNamedOutline`): bundled
* counties, US Census places and neighbourhood packs, then the guarded
* Nominatim outline route, then Overpass only when an operator
* configured it.
*
* Providers are injected, so the ladder runs under node:test:
* findNaturalRegion(query) → {name, polygons, kind}|null
* findAdminCandidates(query) → Array<AdminArea> (see data/adminBoundaries.js)
* resolveNamedOutline(query, {level, levelHint, signal}) →
* {ok:true, name, polygons, source, rung, approximate?, country?}
* |{ok:false, code, error}
* viewCenter() → {lat, lon}|null
*
* @module data/areaResolver
*/
import { AREA_LEVELS } from './areaStore.js';
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
import { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.js';
import {
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
} from '../voice/areaCandidateContract.js';
export {
AREA_CANDIDATE_ID_PATTERN,
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
} from '../voice/areaCandidateContract.js';
/** Most candidates a clarification offers. */
export const MAX_AREA_CANDIDATES = 4;
/** Candidates remembered for a follow-up `candidateId`. */
const CANDIDATE_MEMORY = 24;
/**
* A place name as a cache and matching key: case-folded and NFKC-normalized,
* punctuation dropped, letters, marks and digits in every script kept (東京
* and 大阪 are different keys). Empty when the text names nothing.
*/
export function normalizeAreaQuery(text) {
return String(text || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N} ]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^the /, '');
}
/** Words that name the kind of area rather than the area. */
const LEVEL_WORDS = [
[/\b(province|state|region|oblast|prefecture|canton|governorate)$/, 'admin1'],
[/\b(county|district|department)$/, 'admin2'],
[/\b(city|town|municipality|metropolitan city)$/, 'city'],
[/\b(neighbou?rhood|borough|suburb|quarter)$/, 'district'],
[/\b(country|nation)$/, 'country'],
];
/**
* Strip a trailing level word ("Bagmati province" → "bagmati", admin1). The
* word is kept as a hint, not a filter, since names often contain it.
*/
export function splitLevelWord(query) {
const norm = normalizeAreaQuery(query);
for (const [re, level] of LEVEL_WORDS) {
if (re.test(norm)) {
const bare = norm.replace(re, '').trim();
if (bare) return { bare, level };
}
}
return { bare: norm, level: null };
}
/** Area level of a bundled admin unit. */
const ADMIN_LEVEL = Object.freeze({
country: 'country',
state: 'admin1',
county: 'admin2',
natural: 'natural',
});
/**
* One of several same-name natural places as a clarification choice, located
* by its rough centre ("39 N 31 W") since it has no country.
*/
function naturalCandidate(place, kind) {
const { lat, lon } = place.center || {};
const locator = Number.isFinite(lat)
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
: '';
return {
kind: 'natural',
name: place.name,
country: '',
regionCode: locator,
polygons: place.polygons.map((ring) => [ring]),
source: 'natural-earth',
meta: { kind },
};
}
const ADMIN_NAME_LEVEL = Object.freeze({
country: 'country',
nation: 'country',
province: 'admin1',
state: 'admin1',
region: 'admin1',
oblast: 'admin1',
prefecture: 'admin1',
canton: 'admin1',
governorate: 'admin1',
county: 'admin2',
district: 'admin2',
department: 'admin2',
});
/**
* Drop accents from Latin letters only, for comparing names: speech often
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
* the name and is kept.
*/
export function foldLatinMarks(text) {
return String(text || '')
.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFC');
}
function explicitAdminName(text) {
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
const match = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
);
return {
normalized,
bare: foldLatinMarks(
match ? normalized.slice(0, -match[0].length).trim() : normalized,
),
word: match?.[1] || null,
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
};
}
/**
* Fail closed when a bundled unit's own name contradicts the requested unit.
* With `aliases`, an ask the administrative matcher reads as naming this very
* unit at its level ("State of Texas", "USA") also matches; the natural-region
* check leaves it off, since "Great Britain" is not the United Kingdom.
*/
function bundledAdminIdentityRefusal(
admin,
query,
wantedLevel,
{ aliases = true } = {},
) {
const source = explicitAdminName(admin?.name || '');
const requested = explicitAdminName(query);
const expectedLevel = wantedLevel || requested.level;
const emittedLevel = ADMIN_LEVEL[admin?.kind] || null;
if (!source.normalized || source.word === 'zone')
return {
ok: false,
code: 'AREA_IDENTITY_UNVERIFIED',
error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`,
};
const alias = aliases ? adminQueryIdentity(query) : null;
const named =
source.bare === requested.bare ||
Boolean(
alias &&
alias.level === emittedLevel &&
alias.name === normalizeAdminName(admin.name),
);
if (
!named ||
(source.level && expectedLevel && source.level !== expectedLevel) ||
(expectedLevel && emittedLevel !== expectedLevel)
)
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The bundled boundary is ${admin.name}, not the requested administrative area "${query}".`,
};
return null;
}
function stableCandidateHash(value) {
let hash = 0x811c9dc5;
for (const char of String(value)) {
hash ^= char.codePointAt(0);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function candidateIdOf(admin) {
const prefix = `ne:${admin.kind}:`;
// The region code keeps same-named units apart: "Washington" is both WA
// and DC, which would otherwise share one candidate and one stored area.
const slug = normalizeAreaQuery(
`${admin.country} ${admin.name} ${admin.regionCode || ''}`,
).replace(/ /g, '-');
const full = `${prefix}${slug}`;
if (full.length <= MAX_AREA_CANDIDATE_ID_LENGTH) return full;
const suffix = `-${stableCandidateHash(slug)}`;
let shortened = '';
for (const char of slug) {
if (
`${prefix}${shortened}${char}${suffix}`.length >
MAX_AREA_CANDIDATE_ID_LENGTH
)
break;
shortened += char;
}
return `${prefix}${shortened.replace(/-+$/u, '')}${suffix}`;
}
/** Short, speakable identity for a clarification choice. */
function describeCandidate(admin) {
return {
candidateId: candidateIdOf(admin),
name: admin.name,
level: ADMIN_LEVEL[admin.kind] || null,
// Tells same-named units apart when spoken: Washington WA vs DC.
...(admin.regionCode && admin.kind !== 'country'
? { region: String(admin.regionCode) }
: {}),
...(admin.country && admin.kind !== 'country'
? { country: admin.country }
: {}),
};
}
/**
* Distinct places among bundled candidates. Within one country, the country
* itself stands for its same-named state ("Mexico" is the country, not the
* State of Mexico); across countries each name is its own place.
*/
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
function viewCell(center) {
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
return 'none';
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
}
export function distinctAdminPlaces(candidates) {
const byCountry = new Map();
for (const admin of candidates) {
const list = byCountry.get(admin.country) || [];
list.push(admin);
byCountry.set(admin.country, list);
}
const out = [];
for (const list of byCountry.values()) {
const country = list.find((admin) => admin.kind === 'country');
out.push(...(country ? [country] : list));
}
return out;
}
/**
* Create a resolver bound to an area store and providers.
* @param {object} options
*/
export function createAreaResolver({
store,
findNaturalRegion = async () => null,
findAdminCandidates = async () => [],
resolveNamedOutline = async () => ({
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'Boundary lookup is unavailable here.',
}),
viewCenter = () => null,
onCredit = () => {},
now = () => Date.now(),
maxInFlight = 8,
} = {}) {
if (!store) throw new TypeError('An area store is required');
/** candidateId → bundled unit and original ask, for a clarification answered later. */
const candidateMemory = new Map();
/** query key → in-flight lookup, so a repeat joins the first. */
const inFlight = new Map();
const lifetime = new AbortController();
const inFlightLimit = Number.isFinite(maxInFlight)
? Math.max(1, Math.floor(maxInFlight))
: 8;
let activeLookups = 0;
let disposed = false;
const assertActive = () => lifetime.signal.throwIfAborted();
const remember = (admin, identity) => {
const id = candidateIdOf(admin);
candidateMemory.delete(id);
candidateMemory.set(id, { admin, identity });
while (candidateMemory.size > CANDIDATE_MEMORY)
candidateMemory.delete(candidateMemory.keys().next().value);
};
function storeAdmin(admin, confidence, identity) {
assertActive();
const refused = bundledAdminIdentityRefusal(
admin,
identity?.query,
identity?.wantedLevel,
);
if (refused) return refused;
const record = store.put({
geometry: admin.polygons,
name: admin.name,
level: ADMIN_LEVEL[admin.kind] || null,
source: admin.source,
sourceId: candidateIdOf(admin),
meta: {
...(admin.kind !== 'country' && admin.country
? { country: admin.country }
: {}),
rung: 'bundled',
},
});
if (!record)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `The boundary for ${admin.name} is not a usable polygon.`,
};
onCredit(admin.source);
return { ok: true, record, confidence, rung: 'bundled' };
}
async function lookup({ query, level, within }, { signal }) {
const { level: levelHint } = splitLevelWord(query);
const searchText = within ? `${query}, ${within}` : query;
let bundledRefusal = null;
// Offline first: a named natural region, unless an admin level or a
// country/state qualifier was given (the pack cannot check `within`).
if ((!level || level === 'natural') && !within) {
const ne = await findNaturalRegion(query, { signal }).catch(() => null);
signal?.throwIfAborted?.();
// A region named like a country or state ("India" the peninsula,
// "Punjab" the desert) never hides it; asked for by level, it answers.
// Only a unit that really has this name counts: "Great Britain" finds
// the United Kingdom by alias, which is not a Great Britain.
// A feature split into parts is one place with every part; distant
// namesakes (Flores in the Azores and Indonesia) are asked about.
const places = ne?.places?.length
? ne.places
: ne?.polygons?.length
? [{ name: ne.name, polygons: ne.polygons }]
: [];
const shadowsAdmin =
places.length > 0 &&
level !== 'natural' &&
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
(admin) =>
!bundledAdminIdentityRefusal(admin, query, null, {
aliases: false,
}),
);
signal?.throwIfAborted?.();
if (places.length > 1 && !shadowsAdmin) {
const offered = places
.slice(0, MAX_AREA_CANDIDATES)
.map((place) => naturalCandidate(place, ne.kind));
const identity = { query, wantedLevel: 'natural' };
offered.forEach((admin) => remember(admin, identity));
return {
ok: false,
needsClarification: true,
code: 'AMBIGUOUS_AREA',
candidates: offered.map(describeCandidate),
error: `"${query}" names ${offered.length} places — ask which one.`,
};
}
if (places.length === 1 && !shadowsAdmin) {
const record = store.put({
geometry: places[0].polygons.map((ring) => [ring]),
name: ne.name,
level: 'natural',
source: 'natural-earth',
sourceId: `ne:${normalizeAreaQuery(ne.name).replace(/ /g, '-')}`,
meta: { kind: ne.kind, rung: 'bundled' },
});
if (record) {
onCredit('natural-earth');
return { ok: true, record, confidence: 0.95, rung: 'bundled' };
}
}
}
signal?.throwIfAborted?.();
// Bundled countries and states by name. A name prominent units share is
// asked about, never guessed — except when the view is inside one.
const wanted = level || levelHint;
if (!wanted || wanted === 'country' || wanted === 'admin1') {
let admins = await findAdminCandidates(searchText, { signal }).catch(
() => [],
);
signal?.throwIfAborted?.();
if (wanted) admins = admins.filter((a) => ADMIN_LEVEL[a.kind] === wanted);
let places = distinctAdminPlaces(admins);
const identity = { query, wantedLevel: wanted };
const checked = places.map((admin) => ({
admin,
refused: bundledAdminIdentityRefusal(admin, query, wanted),
}));
places = checked
.filter(({ refused }) => !refused)
.map(({ admin }) => admin);
if (!places.length && checked.length) {
const refused = checked.find((entry) => entry.refused)?.refused;
// A retired or unverifiable unit is final. A different unit that
// merely shares the name ("Azad Kashmir" for "Kashmir") is not an
// answer, so the outline ladder still gets to look.
if (refused?.code !== 'AREA_IDENTITY_MISMATCH') return refused;
bundledRefusal = refused;
}
if (places.length === 1) {
return storeAdmin(places[0], 0.95, identity);
}
if (places.length > 1) {
const center = viewCenter();
const holding = center
? places.filter((admin) => {
const prepared = prepareArea(admin.polygons);
return (
prepared &&
pointInPreparedArea(prepared, center.lat, center.lon)
);
})
: [];
// Chosen by the view, so never remembered for the name: asked again
// over the other one, it answers the other one.
if (holding.length === 1)
return { ...storeAdmin(holding[0], 0.85, identity), byView: true };
const offered = places.slice(0, MAX_AREA_CANDIDATES);
offered.forEach((admin) => remember(admin, identity));
return {
ok: false,
needsClarification: true,
code: 'AMBIGUOUS_AREA',
candidates: offered.map(describeCandidate),
error: `"${query}" names ${offered.length} places — ask which one.`,
};
}
}
signal?.throwIfAborted?.();
// Everything else: the annotation outline ladder.
const outline = await resolveNamedOutline(searchText, {
level,
levelHint,
signal,
});
signal?.throwIfAborted?.();
if (!outline?.ok)
return (
bundledRefusal || {
ok: false,
code: outline?.code || 'AREA_NOT_FOUND',
error: outline?.error || `No boundary found for "${query}".`,
}
);
const record = store.put({
geometry: outline.polygons,
name: outline.name || query,
level: level || levelHint || outline.adminLevel || null,
source: outline.approximate ? 'approximate' : outline.source,
approximate: Boolean(outline.approximate),
meta: {
...(outline.country ? { country: outline.country } : {}),
...(outline.adminName ? { adminName: outline.adminName } : {}),
...(outline.adminLevel ? { adminLevel: outline.adminLevel } : {}),
rung: outline.rung || null,
...(outline.basis ? { basis: outline.basis } : {}),
},
});
if (!record)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `The boundary for ${query} is not a usable polygon.`,
};
return {
ok: true,
record,
confidence: outline.approximate ? 0.5 : 0.8,
rung: outline.rung || null,
};
}
/**
* Resolve a place to an area handle.
* @param {{query?: string, level?: string, within?: string, candidateId?: string}} args
* @param {{signal?: AbortSignal, budgetMs?: number}} [options] Past the
* budget the call answers AREA_TIMEOUT while the lookup continues and
* fills the caches, so asking again shortly is fast.
* @returns {Promise<object>}
*/
async function resolve(args = {}, { signal, budgetMs = 8000 } = {}) {
const started = now();
const level = AREA_LEVELS.includes(args.level) ? args.level : null;
const within = String(args.within || '').trim();
const candidateId = String(args.candidateId || '').trim();
const finish = (result, cached = false) => ({
...result,
cached,
ms: now() - started,
});
if (disposed)
return finish({
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Area resolver has been disposed.',
});
if (candidateId) {
if (!isAreaCandidateId(candidateId))
return finish({
ok: false,
code: 'BAD_CANDIDATE',
error:
'candidateId is malformed or too long. Resolve the name again.',
});
const remembered = candidateMemory.get(candidateId);
if (!remembered)
return finish({
ok: false,
code: 'UNKNOWN_CANDIDATE',
error: `Unknown candidateId "${candidateId}". Resolve the name again.`,
});
if (signal?.aborted)
return finish({
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'This request was superseded.',
});
return finish(storeAdmin(remembered.admin, 0.9, remembered.identity));
}
const query = String(args.query || '').trim();
if (!query)
return finish({
ok: false,
code: 'BAD_AREA',
error: 'Name a place to resolve.',
});
const nameKey = normalizeAreaQuery(query);
// The outline ladder and geocoder pick by the view ("Springfield" over
// Illinois), so an answer is reused or shared only within the same
// ~2° view cell. A name that normalizes to nothing is never cached.
const key = nameKey
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
: null;
const recalled = key ? store.recallQuery(key) : null;
if (recalled)
return finish(
{ ok: true, record: recalled, confidence: 0.95, rung: 'store' },
true,
);
let pending = key ? inFlight.get(key) : null;
if (!pending) {
if (activeLookups >= inFlightLimit)
return finish({
ok: false,
code: 'AREA_BUSY',
error:
'Too many area lookups are already running — try again shortly.',
});
// The shared lookup outlives any one caller's signal: a superseded turn
// must not cancel the answer a repeat is waiting on. The resolver's own
// lifetime still owns the provider work, so final teardown stops it.
activeLookups += 1;
pending = lookup({ query, level, within }, { signal: lifetime.signal })
.then((result) => {
assertActive();
if (result.ok && key && !result.byView)
store.rememberQuery(key, result.record.areaId);
return result;
})
.catch((error) =>
lifetime.signal.aborted
? {
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Area resolver has been disposed.',
}
: {
ok: false,
code: 'AREA_UNAVAILABLE',
error: error?.message || 'Boundary lookup failed.',
},
)
.finally(() => {
activeLookups -= 1;
if (key) inFlight.delete(key);
});
if (key) inFlight.set(key, pending);
}
let timer;
const racers = [pending];
if (Number.isFinite(budgetMs))
racers.push(
new Promise((resolveTimeout) => {
timer = setTimeout(
() =>
resolveTimeout({
ok: false,
code: 'AREA_TIMEOUT',
error: `Looking up "${query}" is taking a while — ask again in a moment.`,
}),
budgetMs,
);
}),
);
// Detached once this call settles: a caller's long-lived signal must not
// collect one listener per completed lookup.
let cancelled = null;
if (signal)
racers.push(
new Promise((resolveAbort) => {
const superseded = {
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Superseded.',
};
if (signal.aborted) return resolveAbort(superseded);
cancelled = () => resolveAbort(superseded);
signal.addEventListener?.('abort', cancelled, { once: true });
return undefined;
}),
);
try {
return finish(await Promise.race(racers));
} finally {
clearTimeout(timer);
if (cancelled) signal.removeEventListener?.('abort', cancelled);
}
}
function dispose() {
if (disposed) return;
disposed = true;
lifetime.abort();
inFlight.clear();
candidateMemory.clear();
}
return {
resolve,
dispose,
candidate: (id) => candidateMemory.get(id) || null,
};
}
+896
View File
@@ -0,0 +1,896 @@
// Area handles: the session store and the resolution ladder (bundled Natural
// Earth regions → bundled countries and states → the annotation outline
// ladder), with every rung mocked. Pins the clarification contract for
// ambiguous names and the fast cached path.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAreaStore, summarizeArea } from './areaStore.js';
import {
AREA_CANDIDATE_ID_PATTERN,
createAreaResolver,
distinctAdminPlaces,
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
splitLevelWord,
} from './areaResolver.js';
import { pointInPreparedArea } from './areaGeometry.js';
import { findAdminCandidates } from './adminBoundaries.js';
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
const admin = (kind, name, country, box, extra = {}) => ({
kind,
name,
country,
source: 'natural-earth',
polygons: [[square(...box)]],
...extra,
});
const GEORGIA_COUNTRY = admin(
'country',
'Georgia',
'Georgia',
[40, 41, 46.7, 43.6],
);
const GEORGIA_STATE = admin(
'state',
'Georgia',
'United States of America',
[-85.6, 30.4, -80.8, 35],
);
const PUNJAB_IN = admin('state', 'Punjab', 'India', [73.8, 29.5, 77, 32.5]);
const PUNJAB_PK = admin('state', 'Punjab', 'Pakistan', [69.3, 27.7, 73.7, 34]);
const TEXAS = admin(
'state',
'Texas',
'United States of America',
[-106, 26, -94, 36],
);
const BAGMATI = {
ok: true,
name: 'Bagmati Province',
polygons: [[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)]],
source: 'osm',
rung: 'nominatim',
adminName: 'Bagmati Province',
};
function makeResolver(overrides = {}) {
const store = createAreaStore();
const calls = { outline: [], natural: 0, admin: 0 };
const resolver = createAreaResolver({
store,
findNaturalRegion: async (q) => {
calls.natural += 1;
return /alps/i.test(q)
? {
name: 'Alps',
kind: 'natural',
polygons: [square(5, 44, 16, 48), square(17, 44, 18, 45)],
}
: null;
},
findAdminCandidates: async (q) => {
calls.admin += 1;
if (/^georgia$/i.test(q)) return [GEORGIA_STATE, GEORGIA_COUNTRY];
if (/^georgia, united states/i.test(q)) return [GEORGIA_STATE];
if (/^punjab$/i.test(q)) return [PUNJAB_PK, PUNJAB_IN];
if (/^punjab, pakistan/i.test(q)) return [PUNJAB_PK];
if (/^texas$/i.test(q)) return [TEXAS];
return [];
},
resolveNamedOutline: async (q, options) => {
calls.outline.push({ q, ...options, signal: undefined });
if (/bagmati/i.test(q)) return BAGMATI;
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `No boundary found for "${q}".`,
};
},
...overrides,
});
return { store, resolver, calls };
}
test('the store keeps geometry local and summarizes without coordinates', () => {
const store = createAreaStore({ maxEntries: 2 });
const a = store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'A',
source: 'drawn',
level: 'drawn',
});
const b = store.put({
geometry: [[square(2, 2, 3, 3)]],
name: 'B',
source: 'us-census',
sourceId: 'place:1',
});
assert.equal(b.areaId, 'place:1');
assert.equal(b.source, 'us-census');
store.get(a.areaId); // A is now the most recently used
store.put({ geometry: [[square(4, 4, 5, 5)]], name: 'C', source: 'osm' });
assert.equal(store.has('place:1'), false, 'least recently used evicted');
assert.ok(store.get(a.areaId));
const summary = summarizeArea(a);
assert.deepEqual(
Object.keys(summary).sort(),
[
'areaId',
'areaKm2',
'bbox',
'holes',
'level',
'name',
'parts',
'source',
].sort(),
);
assert.equal(JSON.stringify(summary).includes('geometry'), false);
assert.equal(
store.put({
geometry: [
[
[0, 0],
[1, 1],
],
],
name: 'bad',
}),
null,
);
});
test('query aliases have their own LRU bound even for one stable area', () => {
const store = createAreaStore({ maxEntries: 2, maxQueryEntries: 3 });
const area = store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'Stable',
source: 'osm',
sourceId: 'osm:stable',
});
for (let i = 0; i < 50; i += 1)
store.rememberQuery(`alias-${i}`, area.areaId);
assert.equal(store.recallQuery('alias-0'), null, 'old aliases are evicted');
assert.equal(store.recallQuery('alias-46'), null, 'the bound is independent');
assert.equal(store.recallQuery('alias-47')?.areaId, area.areaId);
assert.equal(store.recallQuery('alias-49')?.areaId, area.areaId);
assert.equal(store.size, 1, 'alias churn does not evict the area record');
});
test('a stable source keeps one handle when stored again', () => {
const store = createAreaStore();
for (let i = 0; i < 2; i += 1)
store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'X',
source: 'natural-earth',
sourceId: 'ne:state:x',
});
assert.equal(store.size, 1);
});
test('a named natural region resolves offline with every part', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'the Alps' });
assert.equal(result.ok, true);
assert.equal(result.record.source, 'natural-earth');
assert.equal(result.record.parts, 2);
assert.equal(calls.outline.length, 0, 'no lookup');
assert.ok(result.ms < 300);
});
test('a bundled state resolves by name with no lookup', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Texas' });
assert.equal(result.ok, true);
assert.equal(result.rung, 'bundled');
assert.equal(result.record.level, 'admin1');
assert.equal(result.record.source, 'natural-earth');
assert.equal(result.record.meta.country, 'United States of America');
assert.equal(calls.outline.length, 0);
});
test('Bagmati goes to the outline ladder and keeps its hole', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, true);
assert.equal(result.rung, 'nominatim');
assert.equal(result.record.source, 'osm');
assert.equal(result.record.level, 'admin1', 'the level word is kept');
assert.equal(result.record.holes, 1);
assert.equal(pointInPreparedArea(result.record.prepared, 27.2, 85), true);
assert.equal(
pointInPreparedArea(result.record.prepared, 27.65, 85.35),
false,
'inside the hole',
);
assert.equal(calls.outline[0].levelHint, 'admin1');
});
test('a legacy bundled zone is never stored as the requested current province', async () => {
const legacy = admin(
'state',
'Bagmati Zone',
'Nepal',
[84.645, 27.357, 86.055, 28.335],
);
const { store, resolver } = makeResolver({
findAdminCandidates: async () => [legacy],
});
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, false);
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(result.record, undefined);
assert.equal(store.size, 0);
const repeated = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(repeated.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(repeated.cached, false);
assert.equal(store.size, 0);
});
test('legacy zone identity cannot escape through view choice or candidate memory', async () => {
const nepal = admin(
'state',
'Bagmati Zone',
'Nepal',
[84.645, 27.357, 86.055, 28.335],
);
const namesake = admin(
'state',
'Bagmati Zone',
'Elsewhere',
[10, 10, 11, 11],
);
const { store, resolver } = makeResolver({
findAdminCandidates: async () => [nepal, namesake],
viewCenter: () => ({ lat: 27.7, lon: 85.3 }),
});
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, false);
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(result.needsClarification, undefined);
assert.equal(result.candidates, undefined);
assert.equal(store.size, 0);
assert.equal(
(await resolver.resolve({ candidateId: 'ne:state:nepal-bagmati-zone' }))
.code,
'UNKNOWN_CANDIDATE',
);
assert.equal(store.size, 0);
});
test('a repeat is answered from the store without a lookup', async () => {
const { resolver, calls } = makeResolver();
await resolver.resolve({ query: 'Bagmati' });
const again = await resolver.resolve({ query: 'bagmati' });
assert.equal(again.ok, true);
assert.equal(again.cached, true);
assert.equal(calls.outline.length, 1);
});
test('an ambiguous name asks instead of guessing, and the choice resolves', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
assert.equal(result.ok, false);
assert.equal(result.needsClarification, true);
assert.equal(result.candidates.length, 2);
assert.deepEqual(result.candidates[0], {
candidateId: 'ne:state:united-states-of-america-georgia',
name: 'Georgia',
level: 'admin1',
country: 'United States of America',
});
assert.equal(result.candidates[1].level, 'country');
assert.equal(calls.outline.length, 0, 'no lookup spent on a question');
const chosen = await resolver.resolve({
candidateId: result.candidates[0].candidateId,
});
assert.equal(chosen.ok, true);
assert.equal(chosen.record.meta.country, 'United States of America');
});
test('an already-aborted clarification choice does not persist page state', async () => {
const { store, resolver } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
const controller = new AbortController();
controller.abort();
const chosen = await resolver.resolve(
{ candidateId: result.candidates[0].candidateId },
{ signal: controller.signal },
);
assert.equal(chosen.code, 'CANCELLED');
assert.equal(chosen.cancelled, true);
assert.equal(store.size, 0);
});
test('candidate IDs accept every emitted choice and reject malformed or oversized input', async () => {
const { store, resolver } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
const grammar = new RegExp(AREA_CANDIDATE_ID_PATTERN, 'u');
for (const choice of result.candidates) {
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
assert.match(choice.candidateId, grammar);
assert.equal(isAreaCandidateId(choice.candidateId), true);
assert.equal(
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
true,
);
}
assert.equal(result.candidates[0].candidateId.length, 41);
store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'Not a candidate',
source: 'osm',
sourceId: 'area-1',
});
for (const bad of [
'area-1',
'ne:state:bad slug',
'ne:state:../../etc',
'ne:state:!!!',
'ne:state:a_b',
'ne:planet:earth',
`ne:state:${'a'.repeat(MAX_AREA_CANDIDATE_ID_LENGTH)}`,
]) {
const rejected = await resolver.resolve({ candidateId: bad });
assert.equal(rejected.code, 'BAD_CANDIDATE');
}
});
test('long bundled names produce bounded, distinct candidate handles', async () => {
const name = 'A'.repeat(70);
const candidates = [
admin('state', name, `B${'b'.repeat(70)}`, [0, 0, 1, 1]),
admin('state', name, `C${'c'.repeat(70)}`, [2, 2, 3, 3]),
];
const { resolver } = makeResolver({
findAdminCandidates: async () => candidates,
});
const result = await resolver.resolve({ query: name });
assert.equal(result.needsClarification, true);
assert.equal(
new Set(result.candidates.map((choice) => choice.candidateId)).size,
2,
);
for (const choice of result.candidates) {
assert.equal(isAreaCandidateId(choice.candidateId), true);
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
assert.equal(
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
true,
);
}
});
test('within or level settles an ambiguous name', async () => {
const { resolver } = makeResolver();
const state = await resolver.resolve({
query: 'Georgia',
within: 'United States',
});
assert.equal(state.ok, true);
assert.equal(state.record.level, 'admin1');
const country = await resolver.resolve({
query: 'Georgia',
level: 'country',
});
assert.equal(country.ok, true);
assert.equal(country.record.level, 'country');
const both = await resolver.resolve({ query: 'Punjab', level: 'admin1' });
assert.equal(both.needsClarification, true, 'both Punjabs are admin1');
const pk = await resolver.resolve({ query: 'Punjab', within: 'Pakistan' });
assert.equal(pk.record.meta.country, 'Pakistan');
});
test('the view settles an ambiguous name only when it is inside one of them', async () => {
const { resolver } = makeResolver({
viewCenter: () => ({ lat: 31, lon: 75.5 }),
});
const result = await resolver.resolve({ query: 'Punjab' });
assert.equal(result.ok, true);
assert.equal(result.record.meta.country, 'India');
const away = makeResolver({ viewCenter: () => ({ lat: 0, lon: 0 }) });
const asked = await away.resolver.resolve({ query: 'Punjab' });
assert.equal(asked.needsClarification, true);
// A choice the view made is not remembered for the name.
let view = { lat: 31, lon: 75.5 };
const moving = makeResolver({ viewCenter: () => view });
await moving.resolver.resolve({ query: 'Punjab' });
view = { lat: 31, lon: 71 };
const again = await moving.resolver.resolve({ query: 'Punjab' });
assert.equal(again.record.meta.country, 'Pakistan');
});
test('a qualifier skips the natural-region pack, which cannot check it', async () => {
const { resolver, calls } = makeResolver();
await resolver.resolve({ query: 'the Alps', within: 'Austria' });
assert.equal(calls.natural, 0);
assert.equal(calls.outline.length, 1);
});
test('the area just stored survives eviction even when every other area is pinned', () => {
const store = createAreaStore({ maxEntries: 2, isPinned: () => true });
for (let i = 0; i < 3; i += 1)
store.put({
geometry: [[square(i, 0, i + 0.5, 0.5)]],
name: `A${i}`,
source: 'drawn',
});
const last = store.put({
geometry: [[square(9, 0, 9.5, 0.5)]],
name: 'new',
source: 'drawn',
});
assert.ok(store.get(last.areaId), 'the new handle is usable');
});
test('a country stands for its same-named state; namesakes abroad are rivals', () => {
const mexico = admin('country', 'Mexico', 'Mexico', [-118, 14, -86, 33]);
const state = admin(
'state',
'State of Mexico',
'Mexico',
[-100, 18, -98, 20],
);
assert.deepEqual(distinctAdminPlaces([state, mexico]), [mexico]);
assert.equal(distinctAdminPlaces([PUNJAB_IN, PUNJAB_PK]).length, 2);
});
test('bundled candidates: shared prominent names list every unit, minor ones none', async () => {
const names = async (q) =>
(await findAdminCandidates(q)).map((a) => `${a.kind}:${a.country}`);
assert.deepEqual((await names('Punjab')).sort(), [
'state:India',
'state:Pakistan',
]);
assert.deepEqual((await names('Georgia')).sort(), [
'country:Georgia',
'state:United States of America',
]);
assert.deepEqual(await names('Punjab, India'), ['state:India']);
assert.deepEqual(await names('Kent'), [], 'minor units are the geocoder’s');
});
test('an outline ladder refusal passes through with its code', async () => {
const { resolver } = makeResolver();
const miss = await resolver.resolve({ query: 'Nowhere Province' });
assert.equal(miss.ok, false);
assert.equal(miss.code, 'AREA_NOT_FOUND');
const down = makeResolver({
resolveNamedOutline: async () => ({
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'No outline source can trace it here.',
}),
});
const failed = await down.resolver.resolve({ query: 'Kathmandu' });
assert.equal(failed.code, 'AREA_UNAVAILABLE');
});
test('an approximate outline is stored and labelled as such', async () => {
const { resolver } = makeResolver({
resolveNamedOutline: async () => ({
ok: true,
name: 'Mission',
polygons: [[square(0, 0, 0.01, 0.01)]],
source: 'osm',
rung: 'overpass',
approximate: true,
basis: 'buffer sized to the place',
}),
});
const result = await resolver.resolve({ query: 'the Mission' });
assert.equal(result.record.approximate, true);
assert.equal(result.record.source, 'approximate');
assert.equal(result.record.meta.basis, 'buffer sized to the place');
});
test('a slow lookup times out but keeps filling the cache', async () => {
let release;
const gate = new Promise((r) => {
release = r;
});
const { resolver } = makeResolver({
resolveNamedOutline: async () => {
await gate;
return BAGMATI;
},
});
const first = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
assert.equal(first.code, 'AREA_TIMEOUT');
release();
await new Promise((r) => setTimeout(r, 10));
const second = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
assert.equal(second.ok, true);
assert.equal(second.cached, true);
});
test('a cancelled caller returns at once without cancelling the shared lookup', async () => {
const { resolver } = makeResolver();
const controller = new AbortController();
controller.abort();
const result = await resolver.resolve(
{ query: 'Bagmati' },
{ signal: controller.signal },
);
assert.equal(result.code, 'CANCELLED');
await new Promise((r) => setTimeout(r, 5));
const again = await resolver.resolve({ query: 'Bagmati' });
assert.equal(again.ok, true);
});
test('distinct area lookups are hard-bounded while same-key callers still coalesce', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let calls = 0;
const { resolver } = makeResolver({
maxInFlight: 1,
resolveNamedOutline: async (query) => {
calls += 1;
await gate;
return { ...BAGMATI, name: query };
},
});
const first = resolver.resolve({ query: 'First Area' });
const joined = resolver.resolve({ query: 'First Area' });
const refused = await resolver.resolve({ query: 'Second Area' });
assert.equal(refused.code, 'AREA_BUSY');
release();
const [a, b] = await Promise.all([first, joined]);
assert.equal(calls, 1, 'the same key shares the one live provider lookup');
assert.equal(a.ok, true);
assert.equal(b.ok, true);
});
test("settled lookups leave no listener on the caller's signal", async () => {
const { resolver } = makeResolver();
const controller = new AbortController();
let added = 0;
let removed = 0;
const add = controller.signal.addEventListener.bind(controller.signal);
const remove = controller.signal.removeEventListener.bind(controller.signal);
controller.signal.addEventListener = (...args) => {
if (args[0] === 'abort') added += 1;
return add(...args);
};
controller.signal.removeEventListener = (...args) => {
if (args[0] === 'abort') removed += 1;
return remove(...args);
};
for (const query of ['Texas', 'Bagmati', 'Nowhere at all'])
await resolver.resolve({ query }, { signal: controller.signal });
assert.ok(added > 0, 'the lookups listened while running');
assert.equal(removed, added, 'every listener is detached once settled');
const aborted = AbortSignal.abort();
let attached = 0;
aborted.addEventListener = () => {
attached += 1;
};
const late = await resolver.resolve(
{ query: 'Punjab, Pakistan' },
{ signal: aborted },
);
assert.equal(late.code, 'CANCELLED');
assert.equal(attached, 0, 'an aborted signal gets no listener');
});
test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => {
let release;
let providerSignal;
const gate = new Promise((resolve) => {
release = resolve;
});
const { resolver, store } = makeResolver({
resolveNamedOutline: async (_query, { signal }) => {
providerSignal = signal;
await gate; // Deliberately ignores abort until it returns.
return BAGMATI;
},
});
const pending = resolver.resolve({ query: 'Late Area' });
await new Promise((resolve) => setTimeout(resolve, 0));
resolver.dispose();
assert.equal(providerSignal.aborted, true);
release();
const result = await pending;
assert.equal(result.ok, false);
assert.equal(store.size, 0, 'late provider data never reaches the store');
const after = await resolver.resolve({ query: 'Late Area' });
assert.equal(after.code, 'CANCELLED');
});
test('level words map to area levels', () => {
assert.deepEqual(splitLevelWord('Bagmati Province'), {
bare: 'bagmati',
level: 'admin1',
});
assert.deepEqual(splitLevelWord('Kathmandu'), {
bare: 'kathmandu',
level: null,
});
});
test('non-Latin names are distinct keys: 東京 then 大阪 looks up both', async () => {
const asked = [];
const { resolver } = makeResolver({
resolveNamedOutline: async (q) => {
asked.push(q);
return { ...BAGMATI, name: q };
},
});
const first = await resolver.resolve({ query: '東京' });
const second = await resolver.resolve({ query: '大阪' });
assert.notEqual(first.record.areaId, second.record.areaId);
assert.equal(second.cached, false, 'not the cached Tokyo');
assert.deepEqual(asked, ['東京', '大阪']);
// The same name with a different `within` is a different question.
await resolver.resolve({ query: '東京', within: '日本' });
assert.equal(asked.length, 3);
assert.equal((await resolver.resolve({ query: '東京' })).cached, true);
});
test('a name that normalizes to nothing is never cached or shared', async () => {
let calls = 0;
const { resolver } = makeResolver({
resolveNamedOutline: async () => {
calls += 1;
return BAGMATI;
},
});
await resolver.resolve({ query: '!!!' });
await resolver.resolve({ query: '???' });
assert.equal(calls, 2, 'two punctuation-only asks are two lookups');
});
test('a natural region never hides a country or state of the same name', async () => {
const desert = {
name: 'Punjab',
kind: 'natural',
polygons: [square(70, 28, 76, 33)],
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) => (/^punjab$/i.test(q) ? desert : null),
});
const bare = await resolver.resolve({ query: 'Punjab' });
assert.equal(bare.ok, false);
assert.equal(bare.code, 'AMBIGUOUS_AREA', 'the two states are asked about');
const natural = await resolver.resolve({ query: 'Punjab', level: 'natural' });
assert.equal(natural.ok, true);
assert.equal(natural.record.level, 'natural', 'asked for, the region wins');
});
test('same-named states in one country get their own candidate and area', async () => {
const wa = admin(
'state',
'Washington',
'United States of America',
[-124, 45.5, -117, 49],
{ regionCode: 'WA' },
);
const dc = admin(
'state',
'Washington',
'United States of America',
[-77.2, 38.8, -76.9, 39],
{ regionCode: 'DC' },
);
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^washington$/i.test(q) ? [wa, dc] : []),
});
const asked = await resolver.resolve({ query: 'Washington' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
const ids = asked.candidates.map((c) => c.candidateId);
assert.equal(new Set(ids).size, 2, `distinct ids: ${ids.join(', ')}`);
assert.deepEqual(
asked.candidates.map((c) => c.region),
['WA', 'DC'],
'the choices can be told apart',
);
const picked = await Promise.all(
ids.map((candidateId) => resolver.resolve({ candidateId })),
);
assert.ok(picked.every((p) => p.ok));
assert.notEqual(
picked[0].record.areaId,
picked[1].record.areaId,
'two areas, not one overwritten',
);
assert.equal(
pointInPreparedArea(picked[0].record.prepared, 47, -120),
true,
'Washington State',
);
assert.equal(
pointInPreparedArea(picked[1].record.prepared, 38.9, -77),
true,
'District of Columbia',
);
});
test('a bundled name mismatch falls through to the outline ladder', async () => {
const azad = admin('state', 'Azad Kashmir', 'Pakistan', [73, 33, 75, 35.5]);
const kashmir = {
ok: true,
name: 'Kashmir',
polygons: [[square(73, 32, 80, 37)]],
source: 'osm',
rung: 'nominatim',
};
let outlineOk = true;
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^kashmir$/i.test(q) ? [azad] : []),
resolveNamedOutline: async () =>
outlineOk
? kashmir
: { ok: false, code: 'AREA_NOT_FOUND', error: 'none' },
});
const found = await resolver.resolve({ query: 'Kashmir' });
assert.equal(found.ok, true, found.error);
assert.equal(found.record.name, 'Kashmir');
outlineOk = false;
const { resolver: offline } = makeResolver({
findAdminCandidates: async () => [azad],
resolveNamedOutline: async () => ({
ok: false,
code: 'AREA_NOT_FOUND',
error: 'none',
}),
});
const refused = await offline.resolve({ query: 'Kashmir' });
assert.equal(
refused.code,
'AREA_IDENTITY_MISMATCH',
'the bundled refusal is kept',
);
});
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
const springfield = (center) => ({
ok: true,
name:
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
polygons: [
[
square(
center.lon - 0.1,
center.lat - 0.1,
center.lon + 0.1,
center.lat + 0.1,
),
],
],
source: 'osm',
rung: 'nominatim',
});
const { resolver } = makeResolver({
viewCenter: () => view,
resolveNamedOutline: async () => springfield(view),
});
const illinois = await resolver.resolve({ query: 'Springfield' });
assert.equal(illinois.record.name, 'Springfield, Illinois');
const again = await resolver.resolve({ query: 'Springfield' });
assert.equal(again.cached, true, 'the same view still reuses it');
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
const massachusetts = await resolver.resolve({ query: 'Springfield' });
assert.equal(massachusetts.cached, false);
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
});
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
const cordobaAr = admin(
'state',
'Córdoba',
'Argentina',
[-65.8, -35, -61.8, -29.5],
);
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
const { resolver } = makeResolver({
findAdminCandidates: async (q) =>
/^quebec$/i.test(q)
? [quebec]
: /^cordoba$/i.test(q)
? [cordobaAr, cordobaEs]
: q === 'ปาย'
? [thai]
: [],
});
const qc = await resolver.resolve({ query: 'Quebec' });
assert.equal(qc.ok, true, qc.error);
assert.equal(qc.record.name, 'Québec');
const cordoba = await resolver.resolve({ query: 'Cordoba' });
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
assert.equal(cordoba.candidates.length, 2);
const tone = await resolver.resolve({ query: 'ปาย' });
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
});
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
const azores = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
areaKm2: 140,
center: { lat: 39.45, lon: -31.2 },
};
const indonesia = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(119.8, -8.9, 123, -8.1)],
areaKm2: 14000,
center: { lat: -8.5, lon: 121.4 },
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/^flores$/i.test(q)
? {
name: 'Flores',
kind: 'natural',
polygons: indonesia.polygons,
places: [indonesia, azores],
}
: null,
});
const asked = await resolver.resolve({ query: 'Flores' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
assert.equal(asked.candidates.length, 2);
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
const picked = await resolver.resolve({
candidateId: asked.candidates[1].candidateId,
});
assert.equal(picked.ok, true, picked.error);
assert.equal(picked.record.level, 'natural');
assert.equal(
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
true,
'the Azores Flores',
);
});
test('a natural feature in several parts is stored whole', async () => {
const north = square(33.7, 2.4, 44.2, 15.4);
const south = square(28.9, -15.6, 35.8, 2.4);
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/rift/i.test(q)
? {
name: 'Great Rift Valley',
kind: 'natural',
polygons: [north],
places: [
{
name: 'Great Rift Valley',
featurecla: 'Valley',
polygons: [north, south],
areaKm2: 2,
center: { lat: 0, lon: 36 },
},
],
}
: null,
});
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
assert.equal(rift.ok, true);
assert.equal(
pointInPreparedArea(rift.record.prepared, -10, 33),
true,
'the southern part counts',
);
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Session-scoped store of area handles. A voice turn refers to an area by its
* `areaId`; the geometry itself stays here and never goes to the model.
*
* Records are immutable once stored. A source with a stable identity (an OSM
* relation, a Natural Earth region, an annotation) keeps the same id when it
* is stored again, so repeating "outline Bagmati" does not mint new handles.
* Least recently used records are evicted past `maxEntries`, except those
* `isPinned` reports as still in use.
*
* Pure: no Cesium, DOM or network.
* @module data/areaStore
*/
import {
multiPolygonAreaKm2,
multiPolygonCounts,
normalizeMultiPolygon,
prepareArea,
} from './areaGeometry.js';
/** Where an area came from, as the model and the panel name it. */
export const AREA_SOURCES = Object.freeze([
'natural-earth',
'us-census',
'wof',
'datasf',
'osm',
'openfreemap',
'annotation',
'drawn',
'approximate',
]);
/** Levels a resolved area can have. */
export const AREA_LEVELS = Object.freeze([
'country',
'admin1',
'admin2',
'city',
'district',
'natural',
'site',
'drawn',
]);
const round = (value, digits) => {
const f = 10 ** digits;
return Math.round(value * f) / f;
};
/**
* Create an area store.
* @param {{maxEntries?: number, maxQueryEntries?: number, now?: () => number, isPinned?: (areaId: string) => boolean}} [options]
*/
export function createAreaStore({
maxEntries = 32,
maxQueryEntries = Math.max(32, maxEntries * 8),
now = () => Date.now(),
isPinned = () => false,
} = {}) {
/** Insertion order doubles as recency: get() re-inserts. */
const records = new Map();
/** query key → areaId, for cached resolutions. */
const queries = new Map();
let seq = 0;
function forgetRecordQueries(areaId) {
for (const [key, queried] of queries)
if (queried === areaId) queries.delete(key);
}
/**
* Drop least recently used records past the bound, skipping pinned ones
* (an area an outline on the map still refers to keeps its geometry for as
* long as the outline exists; the annotation cap bounds how many that is).
*/
function evict(keep) {
if (records.size <= maxEntries) return;
for (const id of [...records.keys()]) {
if (records.size <= maxEntries) break;
// The record just stored is always kept; pinned ones may exceed the bound.
if (id === keep || isPinned(id)) continue;
records.delete(id);
forgetRecordQueries(id);
}
}
/**
* Store an area. Returns the stored record, or null when the geometry is
* unusable.
* @param {object} input
* @param {object|Array} input.geometry GeoJSON Polygon/MultiPolygon or coordinates.
* @param {string} input.name
* @param {string} input.source One of AREA_SOURCES.
* @param {string} [input.sourceId] Stable identity, e.g. `ne:state:india-punjab`.
* @param {string} [input.level]
* @param {boolean} [input.approximate]
* @param {object} [input.meta] Extra facts (country, license, completeness).
* @returns {object|null}
*/
function put(input) {
const geometry = normalizeMultiPolygon(input?.geometry);
if (!geometry) return null;
const prepared = prepareArea(geometry);
if (!prepared) return null;
const areaId = input.sourceId
? String(input.sourceId)
: `area-${(seq += 1)}`;
const counts = multiPolygonCounts(geometry);
const record = Object.freeze({
areaId,
name: String(input.name || 'Unnamed area').slice(0, 160),
level: AREA_LEVELS.includes(input.level) ? input.level : null,
source: AREA_SOURCES.includes(input.source)
? input.source
: 'approximate',
approximate: Boolean(input.approximate),
geometry,
prepared,
bbox: prepared.bbox,
areaKm2: multiPolygonAreaKm2(geometry),
parts: counts.parts,
holes: counts.holes,
meta: Object.freeze({ ...(input.meta || {}) }),
storedAt: now(),
});
records.delete(areaId);
records.set(areaId, record);
evict(areaId);
return record;
}
/** The record for an id, marking it recently used; null when unknown or evicted. */
function get(areaId) {
const record = records.get(String(areaId ?? ''));
if (!record) return null;
records.delete(record.areaId);
records.set(record.areaId, record);
return record;
}
return {
put,
get,
has: (areaId) => records.has(String(areaId ?? '')),
/** Remember which area a query resolved to. */
rememberQuery(key, areaId) {
if (!key || !records.has(areaId)) return;
queries.delete(key);
queries.set(key, areaId);
while (queries.size > maxQueryEntries)
queries.delete(queries.keys().next().value);
},
/** The area a query resolved to earlier, if still stored. */
recallQuery(key) {
const id = queries.get(key);
const record = id ? get(id) : null;
if (!record) {
queries.delete(key);
return null;
}
queries.delete(key);
queries.set(key, id);
return record;
},
/** Newest first. */
list: () => [...records.values()].reverse(),
/** The most recently stored or used record matching `predicate`. */
latest(predicate = () => true) {
return [...records.values()].reverse().find(predicate) || null;
},
delete(areaId) {
const id = String(areaId ?? '');
const deleted = records.delete(id);
if (deleted) forgetRecordQueries(id);
return deleted;
},
clear() {
records.clear();
queries.clear();
},
get size() {
return records.size;
},
};
}
/**
* The model-facing summary of an area: identity, size and provenance, never
* coordinates beyond a rounded bbox.
* @param {object} record
* @returns {object}
*/
export function summarizeArea(record) {
if (!record) return null;
return {
areaId: record.areaId,
name: record.name,
level: record.level,
source: record.source,
...(record.meta.country ? { country: record.meta.country } : {}),
bbox: record.bbox.map((v) => round(v, 3)),
areaKm2:
record.areaKm2 >= 100
? Math.round(record.areaKm2)
: round(record.areaKm2, 2),
parts: record.parts,
holes: record.holes,
...(record.approximate ? { approximate: true } : {}),
};
}
+103 -53
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
import { t, msg, onLocaleChange } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
function placeCard(card, leader, anchor) {
/** Place a media card beside its map anchor, in the lane between the panels. */
export function placeCard(card, leader, anchor) {
const margin = 18;
const gap = 42;
const width = card.offsetWidth || 392;
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef
.querySelector('#scene-panel')
?.getBoundingClientRect?.();
const rightRail = documentRef
.querySelector('#right-context-rail')
?.getBoundingClientRect?.();
const commandDock = documentRef
.querySelector('#command-dock')
?.getBoundingClientRect?.();
if (leftPanel?.width > 0)
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
if (rightRail?.width > 0)
laneRight = Math.min(
laneRight,
rightRail.left - rootBounds.left - margin,
);
// The scene panel and the context rail swap sides in right-to-left
// languages: each narrows the lane from the side it sits on.
for (const selector of ['#scene-panel', '#right-context-rail']) {
const panel = documentRef
.querySelector(selector)
?.getBoundingClientRect?.();
if (!(panel?.width > 0)) continue;
const start = panel.left - rootBounds.left;
const end = panel.right - rootBounds.left;
if (start + end < rootBounds.width)
laneLeft = Math.max(laneLeft, end + margin);
else laneRight = Math.min(laneRight, start - margin);
}
if (commandDock?.height > 0)
laneBottom = Math.min(
laneBottom,
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
let record = null;
let warmRecord = null;
let generation = 0;
// Text the cards show, kept as the calls that word it so a locale change
// words it again. A node is forgotten once it has left the page.
const worded = new Map();
function forgetDetached() {
for (const node of worded.keys())
if (!node.isConnected) worded.delete(node);
}
function word(node, property, text) {
let properties = worded.get(node);
if (!properties) worded.set(node, (properties = new Map()));
properties.set(property, text);
writeWord(node, property, text());
}
function writeWord(node, property, value) {
if (property === 'textContent' || property === 'title')
node[property] = value;
else node.setAttribute(property, value);
}
const offLocale = onLocaleChange(() => {
forgetDetached();
for (const [node, properties] of worded)
for (const [property, text] of properties)
writeWord(node, property, text());
});
const leavingRecords = new Set();
const prefersReducedMotion = () =>
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
current.youtubeSession?.destroy();
current.card?.remove?.();
leavingRecords.delete(current);
forgetDetached();
}
const position = () => {
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.();
warmRecord = null;
forgetDetached();
}
function takeWarmFacebook(key, host) {
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
word(iframe, 'title', () =>
t('Preloading {provider} media', {
provider: providerLabel(source.provider),
}),
);
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = t('LOADING X POST…');
word(current.status, 'textContent', () => t('LOADING X POST…'));
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('X POST · PROVIDER CONTROLS'),
);
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
}),
);
position();
}
}
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('FACEBOOK · PROVIDER CONTROLS'),
);
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(
String(error?.message || msg('Facebook embed unavailable')),
),
}),
);
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
word(current.status, 'textContent', () =>
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
);
};
if (session.ready) session.onReady();
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
word(card, 'aria-label', () =>
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
leader.innerHTML =
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
player.className = 'bhote-embedded-callout-player ui-scroll';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
word(title, 'textContent', () =>
bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase(),
);
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
}),
);
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = t('OPEN ORIGINAL ↗');
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
word(iframe, 'title', () =>
t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
}),
);
}
position();
},
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
word(status, 'textContent', () =>
t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
}),
);
},
});
}
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
}
function destroy() {
offLocale();
worded.clear();
hide({ immediate: true });
for (const current of [...leavingRecords]) removeRecord(current);
clearWarm();
+24 -1
View File
@@ -6,6 +6,7 @@ import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
placeCard,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
assert.deepEqual(playerCalls, []);
media.destroy();
});
test('a media card stays between the panels when right-to-left swaps their sides', () => {
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
// Arabic: the scene panel sits on the right, the context rail on the left.
const panels = {
'#scene-panel': rect(1040, 1420),
'#right-context-rail': rect(20, 340),
'#command-dock': rect(500, 940, 820, 880),
};
const card = {
offsetWidth: 392,
offsetHeight: 360,
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
classList: { contains: () => false },
style: {},
};
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
const left = parseFloat(card.style.left);
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
});
+158 -201
View File
@@ -25,7 +25,9 @@ import {
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { applicationPinia } from '../ui/vueRuntime.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -449,18 +451,6 @@ function imageryEndpointText(entry) {
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
@@ -929,185 +919,70 @@ function setElementProperty(element, property, value) {
}
function setElementAttribute(element, attribute, value) {
if (!element || element.getAttribute?.(attribute) === value) return false;
element.setAttribute(attribute, value);
if (!element || element.attributes[attribute] === value) return false;
element.attributes[attribute] = value;
return true;
}
function collectPanelReferences(panel) {
/** One panel control's state, as the panel view renders it. */
function panelRecord(initial = {}) {
return {
textContent: '',
hidden: false,
disabled: false,
value: '',
...initial,
attributes: { ...initial.attributes },
};
}
/**
* The panel's controls as the layer writes them, starting from the panel's
* markup. The panel view renders what the layer publishes from them.
*/
function createPanelRecords(event) {
return Object.freeze({
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
observedStatus: panel.querySelector('.bhote-event-status.observed'),
beforeDate: panel.querySelector('[data-role="before-date"]'),
afterDate: panel.querySelector('[data-role="after-date"]'),
time: panel.querySelector('[data-role="time"]'),
caveat: panel.querySelector('[data-role="caveat"]'),
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
reportList: panel.querySelector('.bhote-event-report-list'),
playButton: panel.querySelector('[data-action="play"]'),
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
corridorButton: panel.querySelector('[data-action="corridor"]'),
previousButton: panel.querySelector('[data-action="previous-beat"]'),
nextButton: panel.querySelector('[data-action="next-beat"]'),
openSourceButton: panel.querySelector('[data-action="open-source"]'),
progressInput: panel.querySelector('[data-role="progress"]'),
splitInput: panel.querySelector('[data-role="split"]'),
splitReadout: panel.querySelector('[data-role="split-readout"]'),
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
beatIndex: panel.querySelector('[data-role="beat-index"]'),
beatTitle: panel.querySelector('[data-role="beat-title"]'),
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
root: { sceneBeat: false },
imagerySection: panelRecord(),
observedStatus: panelRecord(),
time: panelRecord({
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
}),
caveat: panelRecord({
textContent: bhoteEventText(event.reconstruction.caveat),
}),
imageryCloudNote: panelRecord(),
playButton: panelRecord({ textContent: t('▶ PLAY') }),
playSceneButton: panelRecord({
textContent: t('▶ PLAY SCENE'),
hidden: true,
}),
cinematicButton: panelRecord({
textContent: t('◉ CINEMATIC'),
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
corridorButton: panelRecord(),
previousButton: panelRecord({
attributes: { 'aria-label': t('Previous story beat') },
}),
nextButton: panelRecord({
attributes: { 'aria-label': t('Next story beat') },
}),
openSourceButton: panelRecord(),
progressInput: panelRecord({
value: '0',
attributes: { 'aria-label': t('Schematic downstream progression') },
}),
splitInput: panelRecord({ value: '50' }),
splitReadout: panelRecord({ textContent: '50 / 50' }),
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
beatIndex: panelRecord({ textContent: '01 / 06' }),
beatTitle: panelRecord({ textContent: t('CAUSE') }),
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
</div>
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
const label = document.createElement('span');
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
}
panel
.querySelector('[data-action="close"]')
.addEventListener('click', handlers.close);
panel
.querySelector('[data-action="play"]')
.addEventListener('click', handlers.play);
panel
.querySelector('[data-action="play-scene"]')
.addEventListener('click', handlers.playScene);
panel
.querySelector('[data-action="cinematic"]')
.addEventListener('click', handlers.toggleCinematic);
panel
.querySelector('[data-action="previous-beat"]')
.addEventListener('click', handlers.previousBeat);
panel
.querySelector('[data-action="next-beat"]')
.addEventListener('click', handlers.nextBeat);
panel
.querySelector('[data-action="story-replay"]')
.addEventListener('click', handlers.replayCinematic);
panel
.querySelector('[data-action="open-source"]')
.addEventListener('click', handlers.openActiveSource);
panel
.querySelector('[data-action="geolocation-map"]')
.addEventListener('click', handlers.openGeolocationMap);
panel
.querySelector('[data-action="corridor"]')
.addEventListener('click', handlers.focusCorridor);
panel
.querySelector('[data-role="split"]')
.addEventListener('input', (inputEvent) => {
handlers.setSplit(Number(inputEvent.target.value) / 100);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('input', (inputEvent) => {
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('change', (inputEvent) => {
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
final: true,
});
});
const rightRail = document.getElementById?.('right-context-rail');
rightRail?.classList.add('bhote-event-active');
(rightRail || document.body).appendChild(panel);
return { panel, refs };
}
export function createBhoteKoshiEventLayer({
overlayHost = DEFAULT_OVERLAY_HOST,
renderHost = DEFAULT_RENDER_HOST,
@@ -1119,14 +994,22 @@ export function createBhoteKoshiEventLayer({
evidenceFrameUpdater = updateEvidenceFrame,
videoFactory = DEFAULT_VIDEO_FACTORY,
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) {
let _viewer = null;
let _dataManager = null;
let _mapStackController = null;
let _event = null;
let _enabled = false;
let _panel = null;
// The panel's controls; null while the layer is off. Their state is
// published to the panel store, which the context rail renders.
let _panelRefs = null;
let _panelOwner = null;
let _panelPublished = '';
// The dates and field reports, which stay as they were when the panel
// opened, until a locale change words them again.
let _panelFixed = null;
let _offPanelLocale = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
@@ -2723,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
beforeTitle: () => imageryEndpointText(event.imagery.before),
afterTitle: () => imageryEndpointText(event.imagery.after),
ariaLabel: () => t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
t(
'A historical reference {before} percent, B post-event {after} percent',
@@ -2756,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
_lastCinematicProgress = _progress;
}
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
function publishPanel() {
if (!_panelRefs) return;
const { root, ...controls } = _panelRefs;
const refs = Object.fromEntries(
Object.entries(controls).map(([key, record]) => [
key,
{ ...record, attributes: { ...record.attributes } },
]),
);
const signature = JSON.stringify([root.sceneBeat, refs]);
if (signature === _panelPublished) return;
_panelPublished = signature;
_panelFixed ??= {
beforeDate: imageryEndpointLabel(_event.imagery.before),
afterDate: imageryEndpointLabel(_event.imagery.after),
reports: _event.fieldReports.map((report) => ({
label: bhoteEventText(report.label),
status: bhoteEventText(report.status),
})),
};
panelStore.publish({
owner: _panelOwner,
sceneBeat: root.sceneBeat,
refs,
..._panelFixed,
});
}
/**
* Word the panel again in the current locale: fresh records, written by
* the same syncs that keep them current.
*/
function relocalizePanel() {
if (!_panelRefs || !_event) return;
_panelRefs = createPanelRecords(_event);
_panelFixed = null;
writeCinematicButton();
syncPanel();
syncPresentationMode();
}
function syncCinematicButton() {
if (writeCinematicButton()) publishPanel();
}
/** Write the cinematic button's label and state; false without a panel. */
function writeCinematicButton() {
const button = _panelRefs?.cinematicButton;
if (!button) return;
if (!button) return false;
setElementProperty(
button,
'textContent',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
return true;
}
function claimCinematicCamera() {
@@ -3029,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
);
publishPanel();
if (_sceneSeekFrame != null) return;
const schedule =
globalThis.requestAnimationFrame ||
@@ -3302,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
'disabled',
!observation?.media?.sourceUrl,
);
syncCinematicButton();
writeCinematicButton();
setElementProperty(
_panelRefs.splitInput,
'value',
@@ -3314,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`${beforePercent} / ${100 - beforePercent}`,
);
publishPanel();
}
function syncPresentationMode() {
@@ -3330,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
const evidenceAvailable =
!sceneDirected ||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
if (_panelRefs?.playSceneButton)
_panelRefs.playSceneButton.hidden = !sceneDirected;
if (_panelRefs?.imagerySection)
@@ -3364,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
}
if (_panelRefs?.openSourceButton)
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
publishPanel();
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
}
@@ -3561,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
createSplitControl(event);
const panelView = createPanel(event, {
_panelRefs = createPanelRecords(event);
_panelOwner = {};
_panelPublished = '';
_panelFixed = null;
const owner = _panelOwner;
const controls = {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
origin: 'user',
@@ -3578,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
commitProgress,
setSplit,
focusCorridor,
});
_panel = panelView.panel;
_panelRefs = panelView.refs;
openReport: (index) => {
const url = event.fieldReports[index]?.url;
if (url) openExternal(url);
},
};
// Only the panel this enable published may act.
panelStore.connect(
Object.fromEntries(
Object.entries(controls).map(([name, control]) => [
name,
(...args) => (_panelOwner === owner ? control(...args) : undefined),
]),
),
);
publishPanel();
_offPanelLocale?.();
_offPanelLocale = onLocaleChange(relocalizePanel);
syncPresentationMode();
overlayHost.setVisible(
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
@@ -3673,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
_surgePoint = null;
_lastSurgePosition = null;
_lastSurgeVisible = null;
_panel?.remove();
document
.getElementById?.('right-context-rail')
?.classList.remove('bhote-event-active');
_panel = null;
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
panelStore.connect(null);
panelStore.publish(null);
}
_offPanelLocale?.();
_offPanelLocale = null;
_panelOwner = null;
_panelRefs = null;
_panelPublished = '';
_panelFixed = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
+125 -44
View File
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import {
BHOTE_KOSHI_LAYER_ID,
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
/** The event's divider as the imagery split store holds it, or undefined. */
const eventSplit = () =>
useImagerySplitStore(applicationPinia()).splits.findLast(
(split) => split.view.id === 'bhote-koshi-split-line',
);
// The panel controls the layer presents, by the selector the panel view gives each.
const PANEL_REFS = {
'[data-role="imagery-comparison"]': 'imagerySection',
'[data-role="time"]': 'time',
'[data-role="caveat"]': 'caveat',
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
'[data-role="progress"]': 'progressInput',
'[data-role="split"]': 'splitInput',
'[data-role="timeline-label"]': 'timelineLabel',
'[data-role="beat-index"]': 'beatIndex',
'[data-role="beat-title"]': 'beatTitle',
'[data-role="beat-meta"]': 'beatMeta',
'[data-action="play"]': 'playButton',
'[data-action="play-scene"]': 'playSceneButton',
'[data-action="cinematic"]': 'cinematicButton',
'[data-action="story-replay"]': 'storyReplayButton',
'[data-action="corridor"]': 'corridorButton',
'[data-action="previous-beat"]': 'previousButton',
'[data-action="next-beat"]': 'nextButton',
'[data-action="open-source"]': 'openSourceButton',
};
// What each control calls, as the panel view wires it.
const PANEL_EVENTS = {
playButton: { click: 'play' },
playSceneButton: { click: 'playScene' },
cinematicButton: { click: 'toggleCinematic' },
storyReplayButton: { click: 'replayCinematic' },
corridorButton: { click: 'focusCorridor' },
previousButton: { click: 'previousBeat' },
nextButton: { click: 'nextBeat' },
openSourceButton: { click: 'openActiveSource' },
progressInput: {
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
},
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
};
/**
* The event panel as the layer presents it to the panel store, or undefined
* while it is off. Controls read the latest presentation, and dispatching an
* event calls the connected controls as the panel view does.
*/
function eventPanel() {
const store = useBhoteKoshiPanelStore(applicationPinia());
if (!store.panel) return undefined;
const control = (key) => {
let typed = null;
const record = () => store.panel.refs[key];
return {
get textContent() { return record().textContent; },
get hidden() { return record().hidden; },
get disabled() { return record().disabled; },
get value() { return typed ?? record().value; },
set value(value) { typed = value; },
getAttribute: (name) => record().attributes[name] ?? null,
dispatch(type) {
const value = typed ?? record().value;
typed = null;
const handler = PANEL_EVENTS[key]?.[type];
return typeof handler === 'function'
? handler(store.controls, value)
: store.controls[handler]?.();
},
};
};
const named = (name) => ({ dispatch: () => store.controls[name]() });
return {
querySelector(selector) {
if (selector === '.bhote-event-report-list') {
return {
children: store.panel.reports.map((_, index) => ({
dispatch: () => store.controls.openReport(index),
})),
};
}
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
if (selector === '[data-action="close"]') return named('close');
return control(PANEL_REFS[selector]);
},
};
}
class FakeElement {
constructor(tagName = 'div') {
this.tagName = tagName;
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { body, removedProperties } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const clearedTimeouts = [];
globalThis.window = {
setTimeout: () => 71,
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.imagery.length, 2);
assert.equal(viewer._test.dataSources.length, 1);
assert.equal(body.children.length, 2);
assert.ok(eventPanel());
assert.ok(eventSplit());
activeMap = 'operator-choice';
generation += 1;
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.deepEqual(mapCalls, ['esri-imagery']);
assert.equal(viewer._test.removedImagery.length, 2);
assert.equal(viewer._test.removedDataSources.length, 1);
assert.ok(body.children.every((element) => element.removed));
assert.equal(eventPanel(), undefined);
assert.equal(eventSplit(), undefined);
assert.equal(viewer.scene.splitPosition, 0.37);
assert.deepEqual(clearedTimeouts, [71]);
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
try {
await layer.enable(viewer, { origin: 'scene' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
panel.querySelector('[data-action="play"]').dispatch('click');
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const { body } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const timers = [];
const openedUrls = [];
const cancelledFrames = [];
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.deepEqual(timers, []);
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.ok(panel);
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
assert.ok(splitLine);
assert.equal(splitLine.hidden, true);
assert.ok(eventSplit());
assert.equal(eventSplit().view.hidden, true);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
assert.match(panel.innerHTML, /data-action="open-source"/);
const fullStory = panel.querySelector('[data-action="story-replay"]');
assert.equal(fullStory.textContent, '↺ FULL STORY');
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
beatId: 'timure-cluster',
sceneControls: { imageryComparison: true },
}, { origin: 'scene' });
assert.equal(splitLine.hidden, false);
assert.equal(eventSplit().view.hidden, false);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
const splitHandle = splitLine.children.find(
(element) => element.className === 'bhote-koshi-split-handle',
);
assert.ok(splitHandle);
assert.equal(splitHandle.textContent, '↔');
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
splitHandle.dispatch('pointerdown', {
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
assert.equal(eventSplit().view.valueNow, 62);
const splitHandle = eventSplit().controls;
splitHandle.pointerdown({
button: 0,
clientX: 250,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointermove', {
splitHandle.pointermove({
clientX: 750,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointerup', { pointerId: 4 });
splitHandle.pointerup({ pointerId: 4 });
assert.equal(viewer.scene.splitPosition, 0.75);
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
splitHandle.dispatch('keydown', {
assert.equal(eventSplit().view.valueNow, 75);
splitHandle.keydown({
key: 'ArrowLeft',
shiftKey: true,
preventDefault() {},
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
split: 0.62,
sceneControls: {},
}, { origin: 'scene' });
assert.equal(splitLine.hidden, true);
assert.equal(eventSplit().view.hidden, true);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
assert.equal(viewer.scene.splitPosition, 0.62);
assert.equal(overlayVisibility.at(-1).visible, false);
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(layer.getCinematicState().active, false);
assert.equal(viewer._test.cameraLooks.length, 0);
assert.deepEqual(mapCalls, []);
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
assert.ok(eventPanel());
assert.equal(viewer.scene.splitPosition, 0.62);
for (const beatId of [
'debris-dammed-lake',
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
videos[0].dispatch('loadeddata');
assert.equal(layer.getCinematicState().mediaReady, true);
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
assert.ok(video.pauseCalls >= 1);
assert.ok(video.cancelledFrameHandles.length >= 1);
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
assert.ok(eventPanel());
await layer.disable();
} finally {
globalThis.document = originalDocument;
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
const panel = eventPanel();
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
const entries = publications[0].entries;
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
}
});
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const cinematic = panel.querySelector('[data-action="cinematic"]');
const progress = panel.querySelector('[data-role="progress"]');
cinematic.dispatch('click');
progress.value = '500';
progress.dispatch('input');
const queryCount = panel._queryCount;
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
sum + element._writeCount
), 0);
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
const published = panelStore.panel;
const cameraLookCount = viewer._test.cameraLooks.length;
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
assert.equal(
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
writeCount,
'unchanged frames do not rewrite panel state',
);
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
assert.equal(
viewer._test.cameraLooks.length,
cameraLookCount,
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
assert.equal(layer.getCinematicState().active, true);
assert.equal(layer.getPlaybackState().playing, true);
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
animationCallbacks[0](91001);
assert.equal(layer.getPlaybackState().progress, 1);
+5 -2
View File
@@ -12,7 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
import { t, msg, getLocale, isolate } from '../i18n/index.js';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -67,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
// Isolated: the card draws left to right after its "11 · " counter, so in
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
// DANDAGAUN») must keep its own right-to-left order.
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
}
const INCIDENT = Object.freeze({
+212
View File
@@ -0,0 +1,212 @@
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
// Koshi reconstruction panel the event layer presents and sends its clicks
// and slider moves back.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import ContextView from '../ui/views/ContextView.vue';
const record = (initial = {}) => ({
textContent: '',
hidden: false,
disabled: false,
value: '',
attributes: {},
...initial,
});
function presentation(patch = {}) {
return {
owner: {},
sceneBeat: false,
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
afterDate: { name: 'Post-event', date: '' },
reports: [
{ label: 'Report A', status: 'Verified' },
{ label: 'Report B', status: 'Unverified' },
],
refs: {
imagerySection: record(),
observedStatus: record(),
time: record({ textContent: '~0:00 / 2:00' }),
caveat: record({ textContent: 'Schematic only.' }),
imageryCloudNote: record(),
playButton: record({ textContent: '▶ PLAY' }),
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
cinematicButton: record({
textContent: '◉ CINEMATIC',
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
corridorButton: record(),
previousButton: record({
disabled: true,
attributes: { 'aria-label': 'Previous story beat' },
}),
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
openSourceButton: record(),
progressInput: record({
value: '0',
attributes: { 'aria-label': 'Schematic downstream progression' },
}),
splitInput: record({ value: '50' }),
splitReadout: record({ textContent: '50 / 50' }),
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
beatIndex: record({ textContent: '01 / 06' }),
beatTitle: record({ textContent: 'CAUSE' }),
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
},
...patch,
};
}
function fixture(t) {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(ContextView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useBhoteKoshiPanelStore(pinia);
const calls = [];
store.connect(
new Proxy(
{},
{
get:
(_, name) =>
(...args) =>
calls.push([name, ...args]),
},
),
);
const rail = document.getElementById('right-context-rail');
const $ = (selector) => rail.querySelector(selector);
return { store, calls, rail, $ };
}
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
const { store, rail, $ } = fixture(t);
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
store.publish(presentation());
await nextTick();
const panel = $('#bhote-koshi-event-panel');
assert.equal(panel.tagName, 'ASIDE');
assert.ok(rail.lastElementChild === panel);
assert.ok(rail.classList.contains('ui-scroll'));
assert.ok(rail.classList.contains('bhote-event-active'));
assert.equal(
panel.getAttribute('aria-label'),
'Bhote Koshi flood reconstruction controls',
);
assert.match(
panel.textContent,
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
);
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
assert.equal(
$('[data-role="before-date"]').textContent,
'Historical reference · 2021-05-01',
);
assert.equal(
$('[data-role="before-date"] .bhote-event-date').textContent,
'2021-05-01',
);
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
assert.equal($('[data-action="play-scene"]').hidden, true);
assert.ok($('[data-action="open-source"]'));
assert.equal($('[data-action="witness"]'), null);
assert.doesNotMatch(panel.innerHTML, /witness/i);
assert.equal($('[data-action="previous-beat"]').disabled, true);
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
assert.equal(
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
'false',
);
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
assert.equal($('[data-role="progress"]').value, '0');
assert.equal($('[data-role="split"]').value, '50');
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
assert.deepEqual(
[...$('.bhote-event-report-list').children].map(
(button) => button.textContent,
),
['Report AVerified', 'Report BUnverified'],
);
store.publish(null);
await nextTick();
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
assert.ok(rail.classList.contains('ui-scroll'));
});
test('a later presentation updates the controls in place', async (t) => {
const { store, $ } = fixture(t);
store.publish(presentation());
await nextTick();
const play = $('[data-action="play"]');
const next = presentation({ sceneBeat: true });
next.refs.playButton = record({
textContent: '▶ PLAY SHOT',
disabled: true,
attributes: { 'aria-label': 'Play Global context' },
});
next.refs.progressInput = record({ value: '250', hidden: true });
next.refs.imagerySection = record({ hidden: true });
store.publish(next);
await nextTick();
assert.ok($('[data-action="play"]') === play);
assert.equal(play.textContent, '▶ PLAY SHOT');
assert.equal(play.disabled, true);
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
assert.ok(
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
);
assert.equal($('[data-role="progress"]').value, '250');
assert.equal($('[data-role="progress"]').hidden, true);
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
});
test('buttons, sliders and reports call the connected controls', async (t) => {
const { store, calls, $ } = fixture(t);
store.publish(presentation());
await nextTick();
for (const action of [
'close',
'play',
'cinematic',
'story-replay',
'open-source',
'corridor',
'next-beat',
'geolocation-map',
])
$(`[data-action="${action}"]`).click();
$('.bhote-event-report-list').children[1].click();
const progress = $('[data-role="progress"]');
progress.value = '500';
progress.dispatchEvent(new Event('input'));
progress.dispatchEvent(new Event('change'));
const split = $('[data-role="split"]');
split.value = '25';
split.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
['close'],
['play'],
['toggleCinematic'],
['replayCinematic'],
['openActiveSource'],
['focusCorridor'],
['nextBeat'],
['openGeolocationMap'],
['openReport', 1],
['previewProgress', 0.5],
['commitProgress', 0.5, { final: true }],
['setSplit', 0.25],
]);
});
+162
View File
@@ -0,0 +1,162 @@
// Outline names are drawn by the DOM annotation overlay, never as Cesium
// text labels: the boundary data modules, the outline resolver and the
// annotation renderers must not import or construct Cesium label primitives,
// and drawing a bundled outline must create none at runtime.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
/** Pure data modules: no Cesium at all. */
const DATA_MODULES = [
'./adminBoundaries.js',
'./placeBoundaries.js',
'./neighborhoodPolygons.js',
'./local_data/us_census_places/files.js',
'./local_data/wof_neighborhoods/files.js',
];
/** Modules that use Cesium for outlines but must not draw text with it. */
const CESIUM_MODULES = [
'../annotations/resolver.js',
'../annotations/worldAnnotationRenderer.js',
'../annotations/hybridAnnotationRenderer.js',
];
/** The DOM overlay: callout elements, never Cesium label primitives. */
const OVERLAY_MODULES = ['../annotations/screenAnnotationRenderer.js'];
const LABEL_PRIMITIVES = [
/\bLabelCollection\b/,
/\bLabelGraphics\b/,
/\bLabelStyle\b/,
/\bLabelVisualizer\b/,
];
/** Entity label options, inline or assigned later. */
const ENTITY_LABELS = [
// `label: { text: … }` or `label: new …`
/\blabel\s*:\s*\{[^{}]*\btext\s*:/s,
/\blabel\s*:\s*new\b/,
// `label: labelOptions` (anything but `null`)
/\blabel\s*:\s*(?!null\b)[A-Za-z_$][\w$]*\s*[,}\n]/,
// `entity.label = …`
/\.label\s*=(?!=)/,
];
test('boundary data modules never import Cesium', () => {
for (const path of DATA_MODULES) {
const source = read(path);
assert.doesNotMatch(source, /from\s+['"]cesium['"]/, path);
assert.doesNotMatch(source, /import\(\s*['"]cesium['"]/, path);
}
});
test('outline modules and renderers construct no Cesium labels', () => {
for (const path of [...DATA_MODULES, ...CESIUM_MODULES, ...OVERLAY_MODULES])
for (const pattern of LABEL_PRIMITIVES)
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
for (const path of [...DATA_MODULES, ...CESIUM_MODULES])
for (const pattern of ENTITY_LABELS)
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
});
test('the static check catches a Cesium label however it is written', () => {
const offending = [
'viewer.scene.primitives.add(new Cesium.LabelCollection());',
"viewer.entities.add({ position, label: { text: 'Austin' } });",
'const graphics = new LabelGraphics({ text });',
'viewer.entities.add({ position, label: labelOptions });',
'entity.label = new Cesium.LabelGraphics(options);',
'entity.label = options;',
];
const all = [...LABEL_PRIMITIVES, ...ENTITY_LABELS];
for (const source of offending)
assert.ok(
all.some((pattern) => pattern.test(source)),
source,
);
// Data, not Cesium labels: a result's label point, a stripped proxy label.
for (const source of [
'label: geometry.label,',
'liveProxy(anno, { label: null });',
'if (entity.label === x) {}',
])
assert.ok(!ENTITY_LABELS.some((p) => p.test(source)), source);
});
test('drawing bundled place and neighborhood outlines creates no Cesium label', async (t) => {
// Cesium's material setup checks for these browser image types.
for (const name of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
if (name in globalThis) continue;
globalThis[name] = class {};
t.after(() => delete globalThis[name]);
}
const featureSource = {};
for (const method of FEATURE_SOURCE_METHODS)
featureSource[method] = async () => null;
const { resolveAnnotationTarget } = createAnnotationResolver({
featureSource,
});
const primitives = [];
const dataSources = [];
const viewer = {
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-97.7431,
30.2672,
3000,
),
},
scene: { primitives: { add: (p) => primitives.push(p) } },
dataSources: {
add: (ds) => dataSources.push(ds),
remove() {},
},
};
const renderer = createWorldAnnotationRenderer(viewer);
for (const [target, lon, lat] of [
['Austin', -97.7431, 30.2672],
['Notting Hill', -0.2, 51.511],
]) {
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
lon,
lat,
3000,
);
const resolved = await resolveAnnotationTarget({
// The resolver reads only the camera (no scene to pick from).
viewer: { camera: viewer.camera },
target,
footprint: true,
deferFootprint: true,
});
assert.equal(resolved.source, 'bundled', target);
renderer.add({
id: target,
type: 'area',
label: resolved.label,
ring: resolved.ring,
polygons: resolved.polygons,
footprintKind: resolved.footprintKind,
synthesized: resolved.synthesized,
anchor: { lon: resolved.lon, lat: resolved.lat, height: 0 },
color: 'primary',
});
}
assert.equal(primitives.length, 0, 'no primitive collections');
const entities = dataSources.flatMap((ds) => ds.entities.values);
assert.ok(entities.length >= 2, 'the outlines were drawn');
for (const entity of entities) assert.equal(entity.label, undefined);
renderer.destroy();
});
-27
View File
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
.replace(/\(\s+/g, '(');
}
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as Cesium from 'cesium';
import cctvLayer, {
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
@@ -83,16 +80,6 @@ import {
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'ui',
'cctvPresentation.js',
),
'utf8',
);
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
@@ -1302,20 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
const renderer = CCTV_PRESENTATION_SOURCE.match(
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
);
assert.ok(renderer, '_renderCctvState is missing');
assert.match(
renderer[0],
// Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
houstonCameraName,
houstonCameraToSource,
loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_IMAGE_ORIGIN,
HOUSTON_IMAGE_URL,
HOUSTON_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyHoustonCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const DOWNTOWN_ID = '1002';
const GALLERIA_ID = '2001';
/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */
const row = (overrides = {}) => ({
location: '10 EAST @ SAN JACINTO',
id: DOWNTOWN_ID,
lat: '29.76824',
lng: '-95.355711',
dir: 'South',
fc: '6',
fp: '300',
url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')",
...overrides,
});
test('a Houston row maps to a source on the pinned HTTPS frame host', () => {
const source = houstonCameraToSource(row());
assert.equal(source.id, `houston-${DOWNTOWN_ID}`);
assert.equal(source.name, '10 EAST @ SAN JACINTO');
assert.equal(source.city, 'Houston');
assert.equal(source.cityId, 'houston');
assert.equal(source.provider, 'Houston TranStar');
assert.equal(source.lat, 29.76824);
assert.equal(source.lon, -95.355711);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'houston-transtar');
assert.equal(
source.license,
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
);
assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN));
// dir South = the camera sits south of I-10; the nearest lanes run east.
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, 90);
});
test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => {
const source = houstonCameraToSource(
row({ url: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('zero ids, off-city geometry and unusable ids are dropped', () => {
assert.equal(houstonCameraToSource(row({ id: '0' })), null);
assert.equal(houstonCameraToSource(row({ id: '00' })), null);
assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(houstonCameraToSource(row({ id: '12' })), null);
assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null);
// Dallas: a plausible lat/lon, but not a Houston camera.
assert.equal(
houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })),
null,
);
assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null);
assert.equal(houstonCameraToSource(null), null);
// 3-digit and 5-digit snapshot ids stay.
assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102');
assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711');
});
test('dir is the side of the road: it gives a low-confidence travel heading', () => {
// TranStar's map reads dir as "Traffic closest to the camera is traveling":
// South -> Eastbound, North -> Westbound, West -> Southbound, East -> Northbound.
const expected = { South: 90, North: 270, West: 180, East: 0, south: 90 };
for (const [dir, heading] of Object.entries(expected)) {
const source = houstonCameraToSource(row({ dir }));
assert.equal(source.headingDeg, heading, `dir ${dir}`);
assert.equal(source.headingConfidence, 'low', `dir ${dir}`);
assert.equal(source.pitchDeg, -18, `dir ${dir}`);
assert.equal(source.fovDeg, 44, `dir ${dir}`);
assert.equal(source.rangeM, 145, `dir ${dir}`);
assert.equal(source.mountHeightM, 8, `dir ${dir}`);
}
const none = houstonCameraToSource(row({ dir: 'None' }));
assert.equal(none.headingConfidence, 'low');
assert.ok(Number.isFinite(none.headingDeg));
assert.equal(
none.headingDeg,
houstonCameraToSource(row({ dir: '' })).headingDeg,
);
for (const dir of ['Northeast', 'constructor', '__proto__', undefined]) {
assert.equal(
houstonCameraToSource(row({ dir })).headingDeg,
none.headingDeg,
`dir ${dir} falls back to the id hash`,
);
}
});
test('a nameless row still gets a label', () => {
assert.equal(
houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'),
'45 GULF @ WEST DALLAS',
);
assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102');
assert.equal(houstonCameraName({}, ''), 'Houston Camera');
});
test('the unselected label is the interchange, trimmed to the code width', () => {
assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO');
const long = houstonCameraToSource(
row({
location:
'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.headers.Accept, init.redirect]);
return Response.json({
cameras: [
row(),
row({ location: '10 EAST @ SAN JACINTO (duplicate)' }),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'North',
}),
row({ url: 'https://evil.example/x.jpg', id: '0' }),
],
});
});
const cameras = await loadHoustonSourcesFromOpenData();
assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'nearest-to-anchor first: Uptown sits closer to its pin than downtown does',
);
assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.js' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify({ cameras: [row()] });
return new Response(body, {
headers: {
'Content-Type': 'application/javascript',
'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
// Valid JSON with a usable camera, so only the byte cap can refuse it.
const oversized = JSON.stringify({
cameras: [row()],
padding: 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024),
});
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/javascript' } },
);
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_HOUSTON_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES;
else process.env.CCTV_HOUSTON_MAX_SOURCES = saved;
});
process.env.CCTV_HOUSTON_MAX_SOURCES = '8';
const rows = [
...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) =>
row({
id: String(3000 + i),
lat: String(lat),
lng: '-94.80',
location: `Galveston ${i}`,
dir: 'East',
}),
),
...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) =>
row({
id: String(4000 + i),
lat: String(lat),
lng: '-95.3698',
location: `Downtown ${i}`,
dir: 'North',
}),
),
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ cameras: rows }),
);
const cameras = await loadHoustonSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.name.slice(0, 8)),
[
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Galvesto',
'Galvesto',
],
'downtown fills first; the Galveston anchor still keeps a share',
);
});
/**
* Serve the Houston catalog to the Houston endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.restoreAll();
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 === HOUSTON_CAMERAS_URL) {
return Response.json({
cameras: [
row(),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'West',
}),
],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Houston lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HOUSTON_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(HOUSTON_CAMERAS_URL),
'the catalog refresh invokes the Houston loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston').map((s) => s.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'Houston cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
for (const value of [undefined, '0']) {
if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED;
else process.env.CCTV_HOUSTON_ENABLED = value;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(HOUSTON_CAMERAS_URL),
false,
'the pack reaches TranStar only after an explicit opt-in',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston'),
[],
);
}
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
});
test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => {
assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown
assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston
assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH
assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas
assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyHoustonCoordinate(0, 0), false);
assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false);
});
+2
View File
@@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'ontario 511': 3 * 60 * 1000,
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
txdot: 3 * 60 * 1000,
// TranStar says snapshots update about every 3 minutes.
'houston transtar': 3 * 60 * 1000,
// Digitraffic publishes a new weathercam frame on each station's
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
+1
View File
@@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
assert.equal(
+54 -5
View File
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
const OSM_CREDIT = {
export 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>)',
};
const OPENMAPTILES_CREDIT = {
export 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>',
};
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'openseamap',
html:
'Sea marks overlay: ' +
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'opensnowmap',
html:
'Ski pistes overlay: ' +
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'nasa-gibs',
html:
@@ -167,6 +181,13 @@ export const DATA_CREDITS = [
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'faa-sua',
html:
'Prohibited & restricted airspace: ' +
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
' (prohibited and restricted zones; informational only)',
},
OSM_CREDIT,
OPENMAPTILES_CREDIT,
{
@@ -297,6 +318,14 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'houston-cctv',
html:
'CCTV cameras &amp; frames (Houston, when enabled): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>. ' +
"Copying, distributing or framing its images needs TxDOT's written permission under the " +
'<a href="https://traffic.houstontranstar.org/disclaimer.aspx" target="_blank" rel="noopener">TranStar Content Usage terms</a>',
},
{
key: 'hk-cctv',
html:
@@ -538,15 +567,35 @@ export const NATURAL_EARTH_CREDIT = {
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
};
/** Registered when the first bundled US county outline resolves (public
* domain — credited as a courtesy). */
/** Registered when the first bundled US county or place outline resolves
* (public domain — credited as a courtesy). */
export const US_CENSUS_CREDIT = {
key: 'us-census-counties',
html:
'US county boundaries from the ' +
'US county and place boundaries from the ' +
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
};
/** Registered when the first bundled Who's On First neighborhood outline
* resolves. The link leads to the pack's per-source credits and licences and
* says how the boundaries were modified. */
export const WOF_CREDIT = {
key: 'wof-neighborhoods',
html:
'Neighborhood boundaries: ' +
'<a href="https://whosonfirst.org/docs/licenses/" target="_blank" rel="noopener">Who\'s On First</a> and contributors, simplified (' +
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
};
/** Registered when a voice place search (a configured Overpass) first shows places. */
export const OSM_PLACES_CREDIT = {
key: 'osm-places',
html:
'Places found by voice: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
};
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
+12
View File
@@ -129,6 +129,18 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /observe posted signage/);
});
test("the Houston camera credit names TranStar's terms and the permission they require", () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'houston-cctv');
assert.ok(credit, 'Houston TranStar camera frames must be credited');
assert.match(credit.html, /\(Houston, when enabled\)/);
assert.ok(
credit.html.includes(
'href="https://traffic.houstontranstar.org/disclaimer.aspx"',
),
);
assert.match(credit.html, /TxDOT's written permission/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
+45
View File
@@ -53,6 +53,12 @@ import {
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
import {
awarenessEchoActive,
awarenessEchoBracket,
awarenessEchoColor,
getAwarenessEchoField,
} from '../awarenessEcho.js';
/**
* @module detection
@@ -103,6 +109,8 @@ const SPARSE_ZONE_FRACTION = 0.5;
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4;
/** Finer bands for Global Context echo brackets, whose fade is animated. */
const ECHO_ALPHA_STEPS = 8;
/** Stable per-layer candidate safety cap; independent of density and camera bearing. */
const LAYER_CANDIDATE_CAP = 2600;
const LAYER_WEIGHTS = Object.freeze({
@@ -1335,6 +1343,11 @@ function _drawOverlay(frame) {
const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
// Global Context sonar echoes: a second bracket, banded by strength, around
// each flight or vessel a pulse front has just crossed.
// Resting pulses light nothing, so the per-contact echo work is skipped.
const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null;
const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null;
const pathFor = (map, color, alpha) => {
const band = Math.max(
1,
@@ -1437,6 +1450,24 @@ function _drawOverlay(frame) {
halfH,
);
visibleCount++;
if (echoPaths) {
// Starts on the contact's bracket and spreads outward as it fades.
const echo = awarenessEchoBracket(
obj._layerId,
obj.position,
now,
bracketAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
appendCornerBracket(
(echoPaths[echo.band] ??= new Path2D()),
sx,
sy,
halfW + echo.spreadPx,
halfH + echo.spreadPx,
);
}
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
@@ -1576,6 +1607,20 @@ function _drawOverlay(frame) {
_ctx.stroke(entry.path);
}
}
if (echoPaths) {
_ctx.lineWidth = echoField.echo.lineWidth;
_ctx.strokeStyle = awarenessEchoColor(
_theme.line,
_theme === THEME_MAP._default,
);
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
if (!echoPaths[band]) continue;
_ctx.globalAlpha =
fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity;
_ctx.stroke(echoPaths[band]);
}
_ctx.lineWidth = bracketWidth;
}
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
const fadingCount = countFadingRenderEntries(renderEntries);
+107
View File
@@ -0,0 +1,107 @@
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
// a contact's own detection bracket: a second bracket that starts on it and
// spreads outward as it fades, in the detection style's own colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoBracket,
awarenessEchoColor,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
const STEPS = 8;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = {
fadeMs: 1400,
layerIds: ['flights', 'military', 'ais-live-vessels'],
spreadPx: 8,
color: 'rgb(98, 181, 255)',
};
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const field = { center, radiusM: 250_000, pulse, echo };
/** The moment pulse 0's front reaches the ground under `position`. */
function arrivalAt(position) {
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
const d = Cesium.Cartesian3.distance(center, ground);
return (
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 3
);
}
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
t.after(clearAwarenessEchoField);
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const arrival = arrivalAt(position);
const at = (nowMs, bracketAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS);
// The moment the front arrives: on the bracket, strongest band.
const fresh = at(arrival);
assert.ok(fresh);
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
assert.equal(fresh.band, STEPS);
// Half way through the fade: dimmer and spread outward, but not past
// the field's spread.
const half = at(arrival + echo.fadeMs / 2);
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
assert.ok(half.spreadPx < echo.spreadPx);
assert.ok(half.band < STEPS && half.band >= 1);
// The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries
// over, and the echo is never drawn brighter than its bracket: a bracket
// too dim for even one band draws no echo.
assert.equal(at(arrival, 0.25).band, 2);
assert.equal(at(arrival, 0.05), null);
for (let alpha = 0.05; alpha <= 1; alpha += 0.05) {
const echoed = at(arrival + echo.fadeMs / 3, alpha);
if (echoed)
assert.ok(
echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9,
`band ${echoed.band} for a bracket at ${alpha}`,
);
}
// Faded out, or between fronts: no echo bracket.
assert.equal(at(arrival + echo.fadeMs + 5), null);
});
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
t.after(clearAwarenessEchoField);
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
clearAwarenessEchoField();
for (let now = 0; now < pulse.periodMs; now += 25)
assert.equal(
awarenessEchoBracket('flights', position, now, 1, STEPS),
null,
);
setAwarenessEchoField(field);
for (let now = 0; now < pulse.periodMs; now += 25)
for (const layerId of ['cctv', 'satellites', 'traffic'])
assert.equal(
awarenessEchoBracket(layerId, position, now, 1, STEPS),
null,
);
});
test('echoes take the detection style colour except on the default style', (t) => {
t.after(clearAwarenessEchoField);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
null,
);
setAwarenessEchoField(field);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
echo.color,
);
// CRT, NVG and FLIR keep their monochrome palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
const { line } = DETECTION_THEME_MAP[style];
assert.equal(awarenessEchoColor(line, false), line, style);
}
});
+233 -3
View File
@@ -24,6 +24,10 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
@@ -50,8 +54,9 @@ class MockEvent {
}
class MockPath2D {
moveTo() {}
lineTo() {}
constructor() { this.points = []; }
moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
roundRect() {}
arcTo() {}
closePath() {}
@@ -98,7 +103,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -627,6 +632,231 @@ test('surveillance military tier reaches the dedicated render target as shipped
}
});
test('Global Context echo brackets reach the canvas in the detection style colour', () => {
const env = installEnvironment();
// Fronts every millisecond with a long fade, so the contact is lit on
// whatever frame paints; a distinctive width picks the echo stroke out.
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const echoStrokes = () =>
env.detectionCtx.calls
.filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth)
.map(([, , style]) => style);
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
assert.ok(echoStrokes().length > 0, 'the echo bracket is painted');
assert.ok(echoStrokes().every((style) => style === echo.color));
// CRT, NVG and FLIR keep their own palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
env.detectionCtx.calls.length = 0;
setDetectionStyle(style);
settleFrame(env);
const strokes = echoStrokes();
assert.ok(strokes.length > 0, style);
assert.ok(
strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line),
`${style} echoes take the style line colour, got ${strokes}`,
);
}
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => {
const env = installEnvironment();
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
// Fronts every millisecond keep the echo at full strength; a level of 0.5
// halves it, so it sits 4 px out and draws at half the bracket's alpha.
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 0.5,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
assert.ok(echoStroke, 'the echo bracket is painted');
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
assert.ok(bracketStroke, 'the contact bracket is painted');
const extent = (path) => {
const xs = path.points.map(([x]) => x);
const ys = path.points.map(([, y]) => y);
return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 };
};
const echoBox = extent(echoStroke[1]);
const bracketBox = extent(bracketStroke[1]);
assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3,
`echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`);
assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3);
const echoAlpha = echoStroke[4];
const bracketAlpha = bracketStroke[4];
assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`);
assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => {
const env = installEnvironment();
// The cyber theme with its HUD open turns the contact sonar on; its sweep
// dims brackets it is not passing over.
env.document.documentElement = {
// The lowest sweep floor, so dimmed brackets drop a whole alpha band.
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (name) => name === 'active' };
env.document.body.appendChild(hud);
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const contact = {
id: 'military',
getDetectableObjects: () => [{
position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752),
sourceId: 'sonar-pin',
id: 'SONAR',
metric: 'FL300',
type: 'AIR',
tier: 'military',
}],
};
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 1,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contact], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
let dimmed = 0;
let compared = 0;
for (let frame = 0; frame < 40; frame++) {
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
if (!bracketStroke) continue;
const bracketAlpha = bracketStroke[4];
if (bracketAlpha < 0.99) dimmed++;
if (!echoStroke) continue;
compared++;
assert.ok(
echoStroke[4] <= bracketAlpha + 1e-9,
`echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`,
);
}
assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame');
assert.ok(compared > 0, 'an echo was painted');
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => {
const env = installEnvironment();
// Six echo-eligible flights on screen; the identity camera maps x/y
// straight to device coordinates.
const flights = Array.from({ length: 6 }, (_, index) => ({
position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752),
sourceId: `flight-${index}`,
id: `ECHO${index}`,
metric: `FL${300 + index}`,
type: 'AIR',
}));
const layer = { id: 'flights', getDetectableObjects: () => flights };
let levelCalls = 0;
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo: {
layerIds: ['flights'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
},
level: () => {
levelCalls++;
return 0;
},
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [layer], () => {});
setMode('DENSE');
settleFrame(env);
levelCalls = 0;
settleFrame(env);
const painted = getDetectionDiagnostics().visibleCount;
assert.equal(painted, flights.length, 'every flight is on screen');
// One check per painted frame, however many contacts are on screen.
assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live');
assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`);
assert.ok(
!env.detectionCtx.calls.some(
([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)',
),
'no echoes while the pulses rest',
);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
// Opts into the telemetry banner: the held-frame assertions count banner paints.
const env = installEnvironment({ search: '?detectDebug=1' });
+2 -2
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -19,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0);
+2
View File
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
assert.equal(routeDistanceText(9960), '10\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilometre decimals for a distance: one below 10 km, none from there on.
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
* @param {number} meters
* @returns {0|1}
*/
export function kilometreDigits(meters) {
return Math.round(meters / 100) < 100 ? 1 : 0;
}
/**
* The unit and precision a distance prints with: metres below 1 km, then
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
* except below `exactBelowM`, where they stay whole. Each cut is taken after
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
* distance that is negative or not finite. The value is already rounded to
* `digits`, so toFixed and Intl render it the same way.
*/
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
if (!Number.isFinite(meters) || meters < 0) return null;
const step = meters < exactBelowM ? 1 : metreStep;
const shownMeters = Math.round(meters / step) * step;
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
const digits = kilometreDigits(meters);
const value =
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
return { value, unit: 'km', digits };
}
/**
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {string|null}
*/
export function formatDistanceText(meters, options) {
const parts = distanceParts(meters, options);
if (!parts) return null;
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
distanceParts,
formatDistanceText,
kilometreDigits,
} from './distanceFormat.js';
test('a distance switches to kilometres where its metres round to 1,000', () => {
assert.equal(formatDistanceText(999.4), '999 m');
assert.equal(formatDistanceText(999.5), '1.0 km');
assert.equal(formatDistanceText(999.6), '1.0 km');
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
});
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
assert.equal(kilometreDigits(9949), 1);
assert.equal(kilometreDigits(9950), 0);
assert.equal(formatDistanceText(9949), '9.9 km');
assert.equal(formatDistanceText(9950), '10 km');
assert.equal(formatDistanceText(9960), '10 km');
assert.equal(formatDistanceText(99950), '100 km');
});
test('metres below exactBelowM stay whole while the rest round to the step', () => {
const options = { metreStep: 10, exactBelowM: 100 };
assert.equal(formatDistanceText(8, options), '8 m');
assert.equal(formatDistanceText(99.4, options), '99 m');
assert.equal(formatDistanceText(846, options), '850 m');
assert.deepEqual(distanceParts(846, options), {
value: 850,
unit: 'm',
digits: 0,
});
});
test('a negative or non-finite distance has no parts', () => {
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
assert.equal(distanceParts(value), null);
assert.equal(formatDistanceText(value), null);
}
assert.equal(formatDistanceText(0), '0 m');
});
test('kilometres come back rounded, so toFixed and Intl agree', () => {
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
assert.equal(formatDistanceText(1450), '1.5 km');
assert.equal(formatDistanceText(1150), '1.2 km');
});
+292
View File
@@ -0,0 +1,292 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js';
const feature = (id = 310, type = 'P') => ({
properties: {
NAME: `${type}-${id}`,
TYPE_CODE: type,
OBJECTID: id,
UPPER_DESC: 'TNI',
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-77, 38],
[-76, 38],
[-76, 39],
[-77, 38],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = droneAirspaceProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/drone-airspace');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local', headers = {}) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.status ??= this.statusCode;
this.body = JSON.parse(body);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(calls.length)],
exceededTransferLimit: calls.length < 3,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 3);
assert.equal(calls.length, 3);
assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')");
assert.equal(calls[0].searchParams.get('f'), 'geojson');
assert.equal(calls[0].searchParams.has('resultOffset'), false);
assert.equal(calls[1].searchParams.get('resultOffset'), '1');
});
}
/**
* Serve one good snapshot, then let the upstream answer `bad` after the TTL:
* the bad answer must not replace it, so the client gets the last good rows
* marked stale, and a cold proxy answers 502 rather than caching the bad one.
*/
async function assertRejectedSnapshot(bad) {
let now = 1000;
let broken = false;
let calls = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
calls += 1;
return broken ? bad(calls) : Response.json({ features: [feature(7)] });
},
});
const first = await request();
assert.equal(first.status, 200);
broken = true;
now += 3_600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(
stale.body.rows.map((row) => row.stableId),
['7'],
);
calls = 0;
const cold = install({ fetchImpl: async () => bad(++calls) });
const res = await cold();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
}
test('an empty upstream snapshot keeps the last good one', () =>
assertRejectedSnapshot(() => Response.json({ features: [] })));
test('a page whose every feature is invalid keeps the last good one', () =>
assertRejectedSnapshot(() =>
Response.json({
features: [
{
...feature(8),
properties: { ...feature(8).properties, TYPE_CODE: 'W' },
},
{ ...feature(9), geometry: null },
],
}),
));
test('paging that exhausts its page limit still flagged keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json({
features: [feature(100 + call)],
exceededTransferLimit: true,
}),
));
test('a flagged page that adds no features keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json(
call % 2
? { features: [feature(100 + call)], exceededTransferLimit: true }
: { features: [], properties: { exceededTransferLimit: true } },
),
));
test('a cached snapshot is reused until TTL, then refreshed', async () => {
let now = 1000;
let fetches = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature(fetches)] });
},
});
const first = await request();
now += 3_599_000;
const cached = await request();
now += 2000;
const refreshed = await request();
assert.equal(first.body.rows[0].stableId, '1');
assert.equal(cached.body.rows[0].stableId, '1');
assert.equal(refreshed.body.rows[0].stableId, '2');
assert.equal(fetches, 2);
});
test('a failed refresh serves the last good snapshot as stale', async () => {
let now = 1000;
let fail = false;
const request = install({
now: () => now,
fetchImpl: async () => {
if (fail) return new Response('nope', { status: 503 });
return Response.json({ features: [feature(7)] });
},
});
const first = await request();
fail = true;
now += 3_600_001;
const stale = await request();
assert.equal(first.status, 200);
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows[0].stableId, '7');
});
test('method, unknown routes and rate limits are rejected', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/nope')).status, 404);
const limited = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
let last;
for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9');
assert.equal(last.status, 429);
});
test('a cold upstream failure is 502, not an empty snapshot', async () => {
const request = install({
fetchImpl: async () => new Response('nope', { status: 503 }),
});
const res = await request();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
});
test('a stale snapshot is served for at most a day, then refused', async () => {
let now = 1_000_000_000;
let up = true;
const request = install({
now: () => now,
fetchImpl: async () => {
if (!up) throw new Error('FAA unreachable');
return Response.json({ features: [feature(7)] });
},
});
const fresh = await request();
assert.equal(fresh.status, 200);
up = false;
now += 23 * 60 * 60 * 1000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
now += 2 * 60 * 60 * 1000;
assert.equal((await request()).status, 502, 'a day-old snapshot is refused');
});
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
let fetches = 0;
const request = install({
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature()] });
},
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, origin: 'https://evil.example' },
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refusals than the 60/min per-client quota, all from one peer.
for (let i = 0; i < 64; i++) {
const headers = refused[i % refused.length];
const res = await request('/', 'GET', '10.0.0.5', headers);
assert.equal(res.status, 403, JSON.stringify(headers));
}
assert.equal(fetches, 0, 'refusals cost no upstream call');
const app = await request('/', 'GET', '10.0.0.5', {
host,
origin: `http://${host}`,
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200, 'the app keeps its full quota');
assert.equal(fetches, 1);
});
test('the app served behind a reverse proxy still gets the zones', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
const host = 'gev.example.com';
const res = await request('/', 'GET', '127.0.0.1', {
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
'x-forwarded-proto': 'https',
});
assert.equal(res.status, 200);
assert.equal(res.body.rows.length, 1);
});
+25
View File
@@ -14,6 +14,7 @@ import {
FIRMS_OVERLAY_SOURCE_ID,
} from './firmsLabels.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
const NOW = Date.UTC(2026, 6, 17, 4, 0);
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
assert.equal(card.accent, accentForSeverity('orange'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
const title = (count) =>
buildCellCard(
{
cell: { count, maxFrp: 10, newestAcqMs: 0 },
position: { x: 0, y: 0, z: 0 },
},
NOW,
).title;
const previous = getLocale();
try {
assert.equal(title(1), '1 FIRE');
setLocale('ar');
assert.deepEqual(
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
['حريقان (2)', '3 حرائق', '14 حريقًا'],
);
setLocale('ru');
assert.equal(title(14), '14 ПОЖАРОВ');
} finally {
setLocale(previous);
}
});
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
// ground floor is warm — and the cached per-fire position must re-anchor when
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
+6 -1
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
@@ -258,7 +259,8 @@ test('the IPv4 retry answering an invalid reason phrase rejects instead of crash
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port on `localhost` refuses both addresses.
// disabling the retry. A closed port refuses both loopback addresses; resolve
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
@@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection',
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
+11
View File
@@ -41,3 +41,14 @@
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
Modified, test-only OpenStreetMap data; same ODbL attribution and database
share-alike terms as the OpenFreeMap fixtures above.
- `ofm-outlines-austin-14-3743-{6745,6746}.pbf` — OpenFreeMap / OpenMapTiles
z14 tiles from
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
retrieved 2026-09-28. Trimmed to the `transportation_name` features for
Congress Avenue, South Congress Avenue and East 11th Street, and the
`building` features within about 150 m of the Texas Capitol and the
`landcover` park polygon around it, preserving
original geometry and dictionaries. Used by the street and building outline
tests. Same OpenFreeMap / OpenMapTiles / OpenStreetMap attribution and ODbL
1.0 share-alike terms as the fixtures above. Test-only.
@@ -0,0 +1,175 @@
¬
transportation_name\œÝ”�——–———"> Ú €BŠ-3oxÇ:ÑNƒøçÀË‚£~—ˆ�ô© ~§Ð‡b—èó:©
ref_lengthsubclassrefclassnamename_int
name:latinname_dename_enname:es route_1_refroute_2_networkroute_1_network route_2_refnetworklayer"0"
junction"
233"
motorway"
232"0"
234B"
234A"
South Interstate 35"
secondary_construction"
The Circle"
footway"
path"$
"Lady Bird Lake Hike and Bike Trail"
cycleway"
Waller Creek Greenbelt Trail"&
$Ann & Roy Butler Hike and Bike Trail"
North Interstate 35"
path_construction"
Woodland Trail"
Harper's Branch Connection"
steps"
Snake Path"
Sendero del Serpiente"
Nellie Street Crossing"
Woodland Avenue"
Alameda Drive"
minor"
Forest Avenue"
South 2nd Street"
Post Road"
Taylor Street"
Pecan Grove Road"
East Avenue"
Fletcher Street"
Lockhart Drive"
Alta Vista Avenue"
Retama Street"
Haywood Avenue"
Ramona Street"
Ravine Drive"
Betty Jo Drive"
Spence Street"
Rebel Road"
Bickler Road"
Clifton Street"
Columbus Street"
Reagan Terrace"
Daniel Drive"
East Mary Street"
Lindell Avenue"
Drake Avenue"
Mariposa Drive"
Travis Heights Boulevard"
Christopher Street"
Music Lane"
Flores Street"
Sunset Lane"
Mission Ridge"
Oak Heights Drive"
Bonniview Street"
Algarita Avenue"
Edgecliff Terrace"
Melissa Lane"
Sunny Lane"
Le Grande Avenue"
Avondale Road"
Milam Place"
Fairmount Avenue"
River Street"
Garden Street"
Rosedale Terrace"
Nash Hernandez Senior Road"
West Monroe Street"
Gillespie Place"
Newning Avenue"
West James Street"
College Avenue"
Sylvan Drive"
Leland Street"
Bonham Terrace"
Sunny Vale Street"
Bartlett Street"
Ebony Street"
Lupine Lane"
Terrace Drive"
Newton Street"
Parkinson Drive"
Inglewood Street"
Manlove Street"
Academy Drive"
Clermont Avenue"
Copeland Street"
South 3rd Street"
Crockett Street"
East Gibson Street"
West Gibson Street"
Summit Street"
Chelsea Lane"
Willow Street"
San Marcos Street"
East Side Drive"
Lone Oak Drive"
East Elizabeth Street"
West Elizabeth Street"
Lambie Street"
Wilson Street"
Eva Street"
Roma Street"
Waller Street"
Elmhurst Drive"
Kenwood Avenue"
Brackenridge Street"
Hillside Avenue"
East Milton Street"
West Milton Street"
Fairlawn Lane"
Euclid Avenue"
Rutherford Place"
Cummings Street"
Nickerson Street"
Nellie Street"
Park Lane"
Bellaire Drive"
West Johanna Street"
Post Oak Street"
Rainey Street"
Taylor Gaines Street"
Harwood Place"
Brooklyn Street"
Jewell Street"
LBJ Way"
Bierce Street"
Festival Beach Road"
Art Dilly Drive"
Haskell Street"
Holly Street"
tertiary"
West Mary Street"
West Live Oak Street"
East Live Oak Street"
East Monroe Street"
Bouldin Avenue"
East Annie Street"
West Annie Street"
Dawson Road"
secondary"
South 1st Street"
West Oltorf Street"
East Riverside Drive"
primary"
South Congress Avenue"
Barton Springs Road"
35"
Purple Heart Trail"
US:US"
US:I"
290"
us-interstate"0"
Skyline Views"
Blunn Creek Connection"
Waterfront Promenade"
Hartman Bridge"0(€ x
+4 -2
View File
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
roadType,
level,
closure,
flow,
};
const key = cellKey(
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
@@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) {
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
/**
* Nearest direction-compatible segment within 35 m of a projected point.
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
* `radiusFor(segment)` may tighten the radius per segment (never widen it);
* 0 leaves the segment out.
*/
function nearest(px, py, bearing, radiusFor = null) {
const cx = Math.floor(px / CELL_SIZE_M);
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
candidate = true; // within radius, bearing not yet checked
if (radiusFor) {
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
if (d2 > radius * radius) continue;
if (!(radius > 0) || d2 > radius * radius) continue;
}
const diff = bearingDiffDeg(seg.bearing, bearing);
if (
+9 -8
View File
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
* it in the active language. Numbers stay numbers in `values`: the UI formats
* them in that language's numerals.
*/
export class GeoImportError extends Error {
constructor(message, template = message, values = {}) {
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
if (over === 'links')
return new GeoImportError(
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
{ limit: MAX_KML_LINKS },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
{ limit: MAX_IMPORT_POSITIONS, features },
);
return new GeoImportError(
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
{ limit: MAX_IMPORT_POSITIONS },
);
}
@@ -1466,8 +1467,8 @@ export async function loadGeoFile(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
count: entityCount,
limit: MAX_IMPORT_ENTITIES,
},
);
}
+19
View File
@@ -0,0 +1,19 @@
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
// Only a negative remainder takes the extra turn, so a positive bearing
// keeps its precision (0.49999999999999 must not become 360.5).
const remainder = value % 360;
const wrapped = remainder < 0 ? remainder + 360 : remainder;
return wrapped === 360 ? 0 : wrapped;
}
/**
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
* and negative or multi-turn values wrap. Null when the value is not finite,
* so each caller keeps its own placeholder.
*/
export function roundHeadingDegrees(value) {
if (!Number.isFinite(value)) return null;
return Math.round(normalizeHeading(value)) % 360;
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
assert.equal(normalizeHeading(370), 10);
assert.equal(normalizeHeading(-10), 350);
assert.equal(normalizeHeading(720), 0);
assert.equal(normalizeHeading(Number.NaN), 0);
assert.equal(normalizeHeading(Infinity), 0);
});
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
assert.equal(roundHeadingDegrees(359.4), 359);
assert.equal(roundHeadingDegrees(359.5), 0);
assert.equal(roundHeadingDegrees(359.6), 0);
assert.equal(roundHeadingDegrees(360), 0);
assert.equal(roundHeadingDegrees(0), 0);
assert.equal(roundHeadingDegrees(194.2), 194);
});
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
assert.equal(roundHeadingDegrees(-0.6), 359);
assert.equal(roundHeadingDegrees(-1), 359);
assert.equal(roundHeadingDegrees(719.6), 0);
assert.equal(roundHeadingDegrees(-360.6), 359);
});
test('roundHeadingDegrees returns null for a value that is not finite', () => {
for (const value of [
Number.NaN,
Infinity,
-Infinity,
null,
undefined,
'12',
]) {
assert.equal(roundHeadingDegrees(value), null);
}
});
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
assert.equal(normalizeHeading(-1e-15) < 360, true);
});
+68 -3
View File
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
]);
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
partial: 5,
nominal: 6,
off: 7,
});
export { layerFeedState } from './feedState.js';
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
if (overall === 'partial')
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
lastUpdate: snap.lastUpdate,
};
}
/**
* The feed state and up to two layer names of a provenance envelope that is
* not nominal, or null; the worst layers come first, so the names match the
* state. Values stay English (`state` is a feed-state id, `names` are
* `layer.name`); the HUD translates them for display.
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
* @returns {{ state: string, names: string[] }|null}
*/
export function provenanceTag(envelope) {
const overall = envelope?.overall;
if (!overall || overall === 'nominal' || overall === 'off') return null;
const names = (envelope.layers || [])
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.sort(
(a, b) =>
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: overall, names };
}
const upperCase = (word) => word.toUpperCase();
/**
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
* it; the layer names trail so they never push out the text.
* @param {string} text
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
* @param {(state: string) => string} [stateLabel]
* @param {(name: string) => string} [layerLabel]
* @returns {string}
*/
export function provenanceLine(
text,
tag,
stateLabel = upperCase,
layerLabel = upperCase,
) {
if (!tag) return text;
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
.filter(Boolean)
.join(' | ');
}
/**
* Require a matching feedState for warning states: a view that is not
* nominal falls back on a missing or different state.
* @param {{ feedState?: string|null }|null} response The proxy's reply.
* @param {{ overall?: string|null }|null} provenance The context's envelope.
* @returns {boolean}
*/
export function hudSummaryMatchesProvenance(response, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return response?.feedState === state;
}
+52 -1
View File
@@ -11,6 +11,7 @@ import {
layerFeedState,
layerSnapshot,
layerSnapshots,
provenanceTag,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
[
'nominal',
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
assert.equal(worstFeedState([]), null);
});
test('partial is a feed-state of its own: above nominal, below every fault', () => {
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
const partial = layer('flights', {
partial: true,
count: 4,
lastUpdate: NOW,
});
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
const overall = (layers) =>
feedProvenanceEnvelope(layers, { now: NOW }).overall;
assert.equal(overall([partial]), 'partial');
assert.equal(overall([partial, nominal]), 'partial');
assert.equal(overall([nominal, partial]), 'partial');
assert.equal(overall([partial, stale]), 'stale');
assert.equal(overall([stale, partial]), 'stale');
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
assert.match(note, /coverage is incomplete/);
assert.doesNotMatch(note, /do not describe this as live/);
assert.equal(
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
true,
);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
@@ -258,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
test('the warning tag names the layers in the reported state first', () => {
const tag = provenanceTag({
overall: 'unavailable',
layers: [
{ name: 'Live Flights', feedState: 'partial' },
{ name: 'Live AIS Vessels', feedState: 'partial' },
{ name: 'CCTV', feedState: 'unavailable' },
],
});
assert.deepEqual(tag, {
state: 'unavailable',
names: ['CCTV', 'Live Flights'],
});
});
+7
View File
@@ -598,6 +598,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'cctv',
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({
id: 'drone-airspace',
token: '8',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
@@ -666,6 +671,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
token: '0',
+43 -20
View File
@@ -1,6 +1,6 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.equal(REGISTERED_LAYER_IDS.length, 35);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,20 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '6');
assert.equal(nextLayerStateToken(), '9');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '6',
bravo: '7',
alpha: '9',
bravo: '00',
}),
'8',
'01',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '6',
next: '7',
future: '9',
next: '00',
}),
true,
);
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 6/,
/next free token 9/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
),
/next free token 7/,
/next free token 00/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -734,18 +732,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity',
);
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
);
assert.match(
html,
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
);
assert.match(
ui,
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
/this\._models3dPressedShown = this\._models3dEnabled;/,
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
);
const panel = await readFile(
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
'utf8',
);
assert.match(
panel,
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
);
assert.match(
html,
@@ -2687,6 +2694,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
assert.deepEqual(decoded.enabledLayerIds, [
'drone-airspace',
'fire-perimeters',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token,
'8',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+31 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -137,6 +137,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
'drone-airspace': '8',
},
);
assert.throws(
@@ -162,9 +165,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
.future,
'6',
'9',
);
assert.throws(
() =>
@@ -182,14 +185,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '6']] },
{ rows: [...reservationRows, ['retired-layer', '9']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
assert.equal(nextLayerStateToken(published), '00');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '7',
newcomer: '00',
}),
true,
);
@@ -197,15 +200,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '6',
competing: '9',
}),
/next free token 7/,
/next free token 00/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '6',
newcomer: '9',
}),
/changed or removed: retired-layer/,
);
@@ -213,8 +216,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -235,8 +238,8 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
[...reservationRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -244,10 +247,10 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
@@ -255,16 +258,16 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 7/);
assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -272,20 +275,20 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /33 published, 1 new/);
assert.match(corrected.stdout, /36 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -337,7 +340,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'6789'].map((digit) => [
const priorDigits = [...'9'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -374,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 75);
assert.equal(params.get('l').length, 81);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -392,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 33);
assert.equal(Object.keys(published).length, 36);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
Loaded 100 of 1249 files, more files were not shown because too many files have changed in this diff. Show more