Merge main into feat/shipwrecks

Main added the News layer at the same places: both layers are kept in the
layer names, panel order (News stays last of Events), standalone sources,
voice manifest, credits, DATA_SOURCES and the CURRENT-STATE table. News took
share-link token 00 first, so Shipwrecks moves to 01 (ledger, registry,
CURRENT-STATE and the token tests). The catalog now has 40 layers and the
share-link registry 38. Shipwreck strings are added to the new Hebrew
catalog; the other catalogs keep both sides' strings.
This commit is contained in:
Sameh Khamis committed 2026-11-10 14:03:39 -08:00
commit 8ff22f1a12
466 files changed
+57748 -1865

No files matched your search

+144 -1
View File
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
: shape;
}
/** The kind of shape a row action is offered for: `area` or `line`. */
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
const lonLat = (point) =>
Array.isArray(point)
? [Number(point[0]), Number(point[1])]
: [Number(point?.lon), Number(point?.lat)];
const finitePairs = (points) =>
(Array.isArray(points) ? points : [])
.map(lonLat)
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
const wrapLon = (lon) =>
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
/** Whether consecutive positions step across the antimeridian once wrapped. */
const crossesAntimeridian = (points) =>
points.some(
(point, index) =>
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
);
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
const inRange = (points) => {
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
return crossesAntimeridian(wrapped) ? null : wrapped;
};
const closed = (ring) => {
if (ring.length < 3) return ring;
const [first, last] = [ring[0], ring[ring.length - 1]];
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
};
/**
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
* handle has several parts), a line a `LineString`. A pin, a shape too small
* to be one, or one that crosses the antimeridian has none, so it is offered
* no action rather than a shape that wraps the wrong way round the world.
*/
export function drawMarkGeometry(mark) {
if (mark?.type === 'route') {
const path = inRange(finitePairs(mark.path));
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
}
if (mark?.type !== 'area') return null;
const ringOf = (points) => {
const ring = inRange(closed(finitePairs(points)));
return ring?.length >= 4 ? ring : null;
};
if (Array.isArray(mark.polygons) && mark.polygons.length) {
const polygons = [];
for (const rings of mark.polygons) {
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
// A part that cannot be given whole is no part: never a hole dropped.
if (!parts.length || parts.some((ring) => !ring)) return null;
polygons.push(parts);
}
return polygons.length > 1
? { type: 'MultiPolygon', coordinates: polygons }
: { type: 'Polygon', coordinates: polygons[0] };
}
const ring = ringOf(mark.ring);
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
}
/**
* The actions an application adds to Draw's rows, checked once: each has an
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
* as the row shows it). Malformed or repeated ids are dropped.
*/
export function drawMarkActions(actions = []) {
const kept = new Map();
for (const action of Array.isArray(actions) ? actions : []) {
const wellFormed =
typeof action?.id === 'string' &&
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
typeof action.icon === 'string' &&
typeof action.label === 'function' &&
typeof action.run === 'function' &&
Array.isArray(action.shapes);
if (!wellFormed || kept.has(action.id)) continue;
kept.set(
action.id,
Object.freeze({
id: action.id,
icon: action.icon,
label: action.label,
shapes: Object.freeze(
action.shapes.filter((shape) =>
Object.values(ACTION_SHAPES).includes(shape),
),
),
accepts:
typeof action.accepts === 'function' ? action.accepts : () => true,
run: action.run,
}),
);
}
return Object.freeze([...kept.values()]);
}
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
export function drawMarkActionsFor(mark, actions = []) {
const shape = ACTION_SHAPES[mark?.type];
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
const geometry = drawMarkGeometry(mark);
if (!geometry) return { shape, geometry, actions: [] };
return {
shape,
geometry,
actions: actions.filter((action) => {
if (!action.shapes.includes(shape)) return false;
try {
return action.accepts(geometry, shape) === true;
} catch {
return false;
}
}),
};
}
/** An action's words for a row; an application's label that throws shows its id. */
function actionLabel(action, title) {
try {
const text = action.label(title);
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
} catch {
return action.id;
}
}
/**
* 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()`.
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
*/
export function presentDrawMarks(marks = []) {
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
const offered = drawMarkActionsFor(mark, actions).actions;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
busy: notes.get(mark.id)?.busy === true,
note: offered.length ? notes.get(mark.id)?.text || '' : '',
actions: offered.map((action) => ({
id: action.id,
icon: action.icon,
label: actionLabel(action, title),
})),
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
+219
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
drawMarkGeometry,
presentDrawMarks,
} from './drawMarks.js';
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
}),
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
}).coordinates[0].at(-1),
[0, 0],
'an open ring is closed',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
],
}),
null,
);
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
{ lon: 1, lat: 2, height: 9 },
{ lon: 3, lat: 4, height: 0 },
],
}),
{
type: 'LineString',
coordinates: [
[1, 2],
[3, 4],
],
},
);
assert.equal(
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
null,
);
const square = (x) => [
[x, 0],
[x + 1, 0],
[x + 1, 1],
[x, 0],
];
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: square(0),
polygons: [[square(0)], [square(5)]],
}),
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
'an area handle with several parts keeps them all',
);
assert.equal(
drawMarkGeometry({
type: 'area',
polygons: [
[
square(0),
[
[0.2, 0.2],
[0.3, 0.2],
],
],
],
}),
null,
'a hole that cannot be given whole is never dropped silently',
);
});
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
[370, 1],
[371, 1],
],
}).coordinates,
[
[10, 1],
[11, 1],
],
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[180, 0],
[179, 0],
[179, 1],
],
}).coordinates[0][0],
[180, 0],
'the antimeridian itself stays put',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[179.9, 0],
[-179.9, 0],
[-179.9, 1],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[179.5, 0],
[180.5, 0],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[0, 91],
[1, 0],
],
}),
null,
);
});
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
const run = () => {};
const label = () => 'x';
const actions = drawMarkActions([
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
{
id: 'tag',
icon: 'sell',
label,
run,
shapes: ['area', 'line', 'pin'],
},
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
null,
]);
assert.deepEqual(
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
);
assert.deepEqual(drawMarkActions('nope'), []);
const area = {
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
};
const offer = drawMarkActionsFor(area, actions);
assert.equal(offer.shape, 'area');
assert.deepEqual(
offer.actions.map((a) => a.id),
['tag'],
);
const refusing = drawMarkActions([
{
id: 'measure',
icon: 'a',
label,
run,
shapes: ['area'],
accepts: () => {
throw new Error('no');
},
},
]);
assert.deepEqual(
drawMarkActionsFor(area, refusing).actions,
[],
'an accepts that throws offers nothing',
);
assert.deepEqual(
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
.actions,
[],
);
});
+90 -5
View File
@@ -50,9 +50,28 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The longest note a row action leaves under its row, in characters. */
const NOTE_MAX = 200;
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
function noteText(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (trimmed.length <= NOTE_MAX) return trimmed;
const cut = trimmed.slice(0, NOTE_MAX - 1);
const space = cut.lastIndexOf(' ');
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
* 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
* `markActions` are the application's own buttons on an area's or a line's
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
@@ -76,7 +98,12 @@ export function initDrawTool({
navigation = null,
publish = () => {},
connect = () => () => {},
markActions = [],
}) {
const rowActions = drawMarkActions(markActions);
// What a row action said last on each drawn shape: `{ run, busy, text }`.
const notes = new Map();
let noteRun = 0;
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
@@ -115,14 +142,19 @@ export function initDrawTool({
() => null,
);
const present = () =>
const present = () => {
const marks = annotations.list?.() || [];
// A note goes with its shape.
for (const id of notes.keys())
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
marks: presentDrawMarks(marks, rowActions, notes),
});
};
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -450,6 +482,56 @@ export function initDrawTool({
return annotations.frame?.(id) ?? false;
}
/**
* Run the application's row action `actionId` on drawn shape `id`, if the
* shape is still on the board and still offered it. Returns whether it ran.
* What the action returns, or resolves to, is the row's note (a measurement,
* "saved"): the row is busy until it settles, a newer press or the shape's
* removal makes a late answer moot, and a failure clears the note, so an
* action words its own refusals and resolves with them.
*/
function runMarkAction(id, actionId) {
const mark = (annotations.list?.() || []).find(
(entry) => entry.id === id && entry.manual === true,
);
const offer = drawMarkActionsFor(mark, rowActions);
const action = offer.actions.find((entry) => entry.id === actionId);
if (!action || notes.get(mark.id)?.busy) return false;
const run = ++noteRun;
const settle = (text) => {
if (destroyed || notes.get(mark.id)?.run !== run) return;
notes.set(mark.id, {
run,
busy: false,
text: noteText(text),
});
present();
};
const fail = (error) => {
console.warn(`[draw] ${actionId} failed`, error);
settle('');
};
notes.set(mark.id, { run, busy: true, text: '' });
let answer;
try {
answer = action.run({
id: mark.id,
shape: offer.shape,
name: mark.name || '',
title: mark.name || drawMarkFallbackName(mark),
geometry: offer.geometry,
});
} catch (error) {
fail(error);
return false;
}
if (typeof answer?.then === 'function') {
present();
Promise.resolve(answer).then(settle, fail);
} else settle(answer);
return true;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -478,6 +560,7 @@ export function initDrawTool({
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
runDrawMarkAction: runMarkAction,
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
@@ -500,13 +583,15 @@ export function initDrawTool({
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
marks: () =>
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
runMarkAction,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
+129 -1
View File
@@ -223,7 +223,7 @@ function fakeAnnotations() {
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) {
function harness({ navigation = null, markActions = [] } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
viewer,
annotations,
navigation,
markActions,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
}
});
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
const ran = [];
const h = harness({
markActions: [
{
id: 'tag',
icon: 'sell',
label: (name) => `Tag ${name}`,
shapes: ['area', 'line'],
run: (shape) => ran.push(['tag', shape]),
},
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
accepts: (geometry) => geometry.type === 'Polygon',
run: (shape) => ran.push(['measure', shape]),
},
],
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
[
['r1', [
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
]],
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
['p1', []],
],
);
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual(ran, [
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
]);
} finally {
h.restore();
}
});
test('an action that throws is reported and leaves the row alone', async () => {
const h = harness({
markActions: [
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
],
});
const warn = console.warn;
const warned = [];
console.warn = (...args) => warned.push(args[0]);
try {
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
h.annotations.changed();
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
assert.deepEqual(warned, ['[draw] boom failed']);
} finally {
console.warn = warn;
h.restore();
}
});
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
const pending = [];
const h = harness({
markActions: [
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
},
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
],
});
const warn = console.warn;
console.warn = () => {};
try {
const board = h.annotations;
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
board.marks.push(area('r1'), area('r2'));
board.changed();
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
h.act('runDrawMarkAction', 'r1', 'long');
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
pending[0].resolve('412 km², 38 km of edge');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
h.act('runDrawMarkAction', 'r1', 'measure');
pending[1].reject(new Error('refused'));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
h.act('runDrawMarkAction', 'r2', 'measure');
board.marks.splice(1, 1);
board.changed();
pending[2].resolve('late');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(row('r2'), undefined);
board.marks.push(area('r2'));
board.changed();
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
} finally {
console.warn = warn;
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+4 -1
View File
@@ -1,5 +1,6 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
@@ -31,6 +32,7 @@ import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationShipwrecks } from './layers/shipwrecks.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationNews } from './layers/news.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
@@ -79,7 +81,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
@@ -140,6 +142,7 @@ export function createApplicationCatalog({
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationNews({ source: sources.news }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 39);
assert.equal(first.layers.length, 40);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'),
@@ -68,6 +68,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('news'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -161,7 +162,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, 39);
assert.equal(catalog.layers.length, 40);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+5 -1
View File
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { createNewsLayer } from '../../layers/news/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire the news layer to the application overlay host. */
export function createApplicationNews(options) {
return createNewsLayer({ overlayHost, ...options });
}
+4
View File
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { worldTimeFor } from '../../time/worldClock.js';
const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
now: () => new Date(imageryTime()),
});
}
+8
View File
@@ -15,6 +15,8 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
@@ -59,6 +61,12 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
+1
View File
@@ -51,6 +51,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
shipwrecks: contract(['shipwrecks'], ['getSnapshot']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']),
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
+30 -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, 39);
assert.equal(actual.layers.length, 40);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -150,6 +150,35 @@ test('GhostMaps obeys source omission, replacement and explicit removal', async
assert.equal(fetches, 0);
});
test('News is unavailable without a source and the standalone default reads only same-origin /api/news', async (t) => {
const missing = catalog({}, t);
assert.equal(missing.getSourceAvailability('news')?.available, false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: missing.getSourceAvailability },
);
manager.register(missing.get('news'));
assert.equal(await manager.setEnabled('news', true), false);
assert.equal(manager.layers.get('news').initialized, false);
assert.equal(createStandaloneLayerSources({ news: null }).news, null);
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return new Response(
JSON.stringify({
error: 'news_not_configured',
unavailable: true,
retryable: false,
}),
{ status: 503 },
);
});
const standalone = createStandaloneLayerSources().news;
await assert.rejects(standalone.getHotspots(), { unavailable: true });
assert.deepEqual(urls, ['/api/news/hotspots']);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
+22 -1
View File
@@ -1,6 +1,11 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import {
claimPointer,
isPointerFree,
releasePointer,
} from '../data/inputOwnership.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -35,6 +40,7 @@ export function createApplicationTools({
startChrome,
onSceneDirector,
sceneDataPacks,
drawMarkActions = [],
signal,
defer,
}) {
@@ -80,12 +86,15 @@ export function createApplicationTools({
// while a session is open, so its teardown belongs to the application
// 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.
// `drawMarkActions` are an application's own buttons on a drawn area's or
// line's row (annotations/drawMarks.js); the standalone shell adds none.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
markActions: drawMarkActions,
});
defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
@@ -241,5 +250,17 @@ export function createApplicationTools({
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
// An application's own tool takes the pointer as Draw does, and its own
// click handlers yield while a tool holds it, as the layers' do
// (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
pointer: {
claim: claimPointer,
release: releasePointer,
isFree: isPointerFree,
},
};
}
+43
View File
@@ -7,6 +7,48 @@ import {
isFreeGlobeCamera,
} from '../globeInView.js';
/**
* At the farthest a hand can zoom out, the globe is this share of the
* screen's height across. Cesium sets no limit, so zooming on left the globe
* a speck among the stars.
*/
const MIN_GLOBE_SHARE = 0.3;
/**
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
* vertical field of view. It follows the screen's shape, as the app's own
* whole-globe view does, so that view always lies within it.
* @param {number} fovy Vertical field of view, radians.
*/
export function zoomOutLimit(fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
return radius / Math.sin(angular) - radius;
}
/**
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
* field of view follows the screen's shape. The limit never sits below the
* camera: one already past it (a flight, a screen turned) stops there rather
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
* @returns {() => void} Stop limiting.
*/
export function limitZoomOut(viewer) {
const { scene, camera } = viewer;
return scene.preRender.addEventListener(() => {
const controller = scene.screenSpaceCameraController;
const fovy = camera.frustum.fovy;
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
return;
}
controller.maximumZoomDistance = Math.max(
zoomOutLimit(fovy),
camera.positionCartographic?.height ?? 0,
);
});
}
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -372,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
limitZoomOut(viewer);
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
+73
View File
@@ -0,0 +1,73 @@
// A hand zooming out stops while the globe is still a fair share of the
// screen, on any screen's shape, rather than leaving it a speck.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { limitZoomOut, zoomOutLimit } from './viewer.js';
// The share of the screen's height the globe spans from `altitude`.
function globeShare(altitude, fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.asin(radius / (radius + altitude));
return Math.tan(angular) / Math.tan(fovy / 2);
}
test("zooming out stops with the globe 30% of the screen's height across", () => {
for (const fovy of [Math.PI / 3, 0.4, 0.2]) {
const limit = zoomOutLimit(fovy);
assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`);
}
});
test('a wide screen, whose view is narrower, may zoom out farther', () => {
assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3));
assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens');
});
function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) {
const scene = {
mode: Cesium.SceneMode.SCENE3D,
preRender: new Cesium.Event(),
screenSpaceCameraController: { maximumZoomDistance: Infinity },
};
const camera = {
frustum: { fovy },
positionCartographic: { height },
};
return { scene, camera };
}
test('the limit follows the screen, and holds a camera already past it', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
const stop = limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3));
// A wider screen narrows the view, and the globe may go farther.
viewer.camera.frustum.fovy = 0.4;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4));
// Flown past it, the next zoom out stops there instead of jumping in.
viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
stop();
viewer.camera.positionCartographic.height = 0;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
});
test('flat maps keep zooming out unlimited', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.ok(Number.isFinite(controller.maximumZoomDistance));
viewer.scene.mode = Cesium.SceneMode.SCENE2D;
viewer.camera.frustum = {};
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, Infinity);
});
+3 -2
View File
@@ -42,7 +42,7 @@ function ordered(source, needles, label) {
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();',
'this.viewer.trackedEntity = undefined;',
@@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
);
ordered(transaction, [
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
// Aircraft-only: a selected vessel must not stand in for a voice target.
"if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));",
], 'Cockpit entry transaction');
// No direct camera control: every mutation goes through a layer tracker or
// the cockpit controller, both of which stamp.
+25 -1
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
import { worldClock, worldTimeFor } from './time/worldClock.js';
// The ring shows the sun and moon at the time the sky is drawn.
const skyTime = worldTimeFor('sky');
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -28,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
*/
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
// How far the world time moves before the sun and moon move with it: a
// minute moves the sun a quarter of a degree.
const EPHEMERIS_WORLD_STEP_MS = 60_000;
const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
@@ -445,6 +452,20 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
// A jump in the world time moves the sun and moon at once.
this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate();
// Played through, the sky moves on each minute of world time it passes.
this._ephemerisWorldMs = null;
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
if (
this._ephemerisWorldMs !== null &&
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
)
return;
this._ephemerisWorldMs = timeMs;
this._ephemerisDirty = true;
});
this.setEnabled(this.enabled);
}
@@ -801,7 +822,8 @@ export class CelestialRing {
return;
}
const time = Cesium.JulianDate.now();
this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
@@ -890,6 +912,8 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
+16 -1
View File
@@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
return value === '1';
}
/**
* Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit:
* the pass fades out above 24 km (weatherAltitudeFactors), so a satellite
* would pay for it and see nothing. The saved preference is left as it is
* for the next aircraft or bridge; cloud cover from orbit is the Weather
* layer's.
* @param {string|null|undefined} layerId
*/
export function cockpitCloudsShowFor(layerId) {
return layerId !== 'satellites';
}
/**
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
@@ -207,10 +219,12 @@ class CockpitCloudEffectsController {
this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize();
this.orbit = false;
this.onCockpitMode = (event) => {
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId);
if (active && this.enabled) this.start();
else this.stop();
};
@@ -344,7 +358,8 @@ class CockpitCloudEffectsController {
this.destroyed ||
!this.enabled ||
this.frame !== null ||
this.suspended
this.suspended ||
this.orbit
)
return;
if (!this.gl) {
+8
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
cockpitCloudRenderSize,
cockpitCloudsShowFor,
cockpitWeatherRefreshDue,
cockpitWeatherEnabledFromStoredValue,
} from './cockpitCloudEffects.js';
@@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i
assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true);
});
test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => {
assert.equal(cockpitCloudsShowFor('satellites'), false);
for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) {
assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId));
}
});
test('cockpit weather refreshes after time or meaningful movement', () => {
const anchor = { latitude: 30, longitude: -97 };
assert.equal(cockpitWeatherRefreshDue({
+244
View File
@@ -0,0 +1,244 @@
/**
* Bridge look-around policy: how the arrow keys turn the bridge camera away
* from the bow and back, and how a turned view is described. Yaw is relative
* to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no
* Cesium and no translated text; the Cockpit words the readout.
*/
/** One arrow press turns the view this far. */
export const LOOK_STEP_DEG = 5;
/** One Shift+arrow press turns the view this far: two reach either beam. */
export const LOOK_COARSE_STEP_DEG = 45;
/** The view pitches no further than straight up or straight down. */
export const LOOK_PITCH_LIMIT_DEG = 90;
/**
* The eased view's time constant. Three of them (about 180 ms) close 95% of
* a step, so a press glides rather than snaps and a held key turns smoothly.
*/
export const LOOK_EASE_TAU_MS = 60;
/** Closer than this the eased view lands on its target. */
const LOOK_SETTLE_DEG = 0.05;
/** Arrow key → the axis it turns and the direction it turns it. */
const LOOK_ARROWS = Object.freeze({
ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }),
ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }),
ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }),
ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }),
});
/**
* Wrap a yaw to (-180, 180]: negative to port, positive to starboard.
* @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead.
* @returns {number}
*/
export function wrapLookYaw(deg) {
if (!Number.isFinite(deg)) return 0;
const wrapped = (((deg % 360) + 360) % 360) - 360;
const yaw = wrapped <= -180 ? wrapped + 360 : wrapped;
return yaw === 0 ? 0 : yaw;
}
/**
* Clamp a view pitch to [-90, 90].
* @param {number} deg Pitch in degrees; a non-finite pitch reads as level.
* @returns {number}
*/
export function clampLookPitch(deg) {
if (!Number.isFinite(deg)) return 0;
const pitch = Math.min(
LOOK_PITCH_LIMIT_DEG,
Math.max(-LOOK_PITCH_LIMIT_DEG, deg),
);
return pitch === 0 ? 0 : pitch;
}
/**
* The step an arrow key asks for, or null for any other key.
* @param {string} key `KeyboardEvent.key`.
* @param {boolean} [coarse] Shift is held: step 45° instead of 5°.
* @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null}
*/
export function lookKeyStep(key, coarse = false) {
const arrow = LOOK_ARROWS[key];
if (!arrow) return null;
return {
...arrow,
stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG,
};
}
/**
* Move one value to the next multiple of `stepDeg` in the direction `sign`.
* A value off the grid (the bridge's -2° rest pitch) lands on it first, so
* the readout shows round angles.
*/
function nextOnGrid(value, sign, stepDeg) {
const cells = value / stepDeg;
const next =
sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1;
return next * stepDeg;
}
/**
* Apply one key step to a look target.
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
* {@link lookKeyStep}.
* @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped
* to (-180, 180], pitch clamped to [-90, 90].
*/
export function stepLook(target, step) {
const yawDeg = wrapLookYaw(target?.yawDeg ?? 0);
const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0);
if (!step) return { yawDeg, pitchDeg };
if (step.axis === 'yaw')
return {
yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)),
pitchDeg,
};
return {
yawDeg,
pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)),
};
}
/**
* Advance the eased view toward its target. Yaw takes the shorter way
* round; with `immediate` (reduced motion) the view jumps to the target.
* @param {{yawDeg: number, pitchDeg: number}} current Eased view now.
* @param {{yawDeg: number, pitchDeg: number}} target Look target.
* @param {number} dtMs Milliseconds since the previous step.
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeLook(current, target, dtMs, { immediate = false } = {}) {
const goalYaw = wrapLookYaw(target?.yawDeg ?? 0);
const goalPitch = clampLookPitch(target?.pitchDeg ?? 0);
if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch };
const fraction =
Number.isFinite(dtMs) && dtMs > 0
? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS)
: 0;
const fromYaw = wrapLookYaw(current.yawDeg);
const yawError = wrapLookYaw(goalYaw - fromYaw);
const pitchError = goalPitch - clampLookPitch(current.pitchDeg);
const yawDeg =
Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG
? goalYaw
: wrapLookYaw(fromYaw + yawError * fraction);
const pitchDeg =
Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG
? goalPitch
: clampLookPitch(current.pitchDeg + pitchError * fraction);
return { yawDeg, pitchDeg };
}
/**
* Whether a look differs from the rest view (ahead at `restPitchDeg`).
* @param {{yawDeg: number, pitchDeg: number}} look
* @param {number} restPitchDeg The camera profile's pitch.
* @returns {boolean}
*/
export function lookIsOffset(look, restPitchDeg) {
return (
wrapLookYaw(look?.yawDeg ?? 0) !== 0 ||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
clampLookPitch(restPitchDeg)
);
}
/**
* The parts of a look readout, for the Cockpit to word. A part is null when
* that axis rests: yaw ahead, or pitch at the rest pitch.
* @param {{yawDeg: number, pitchDeg: number}} look Look target.
* @param {number} restPitchDeg The camera profile's pitch.
* @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number,
* elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is
* the angle off the bow (0–180) and `angleDeg` the angle off level (0–90),
* both rounded to whole degrees.
*/
export function lookReadoutParts(look, restPitchDeg) {
const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0));
const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg));
const pitchOffset =
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
clampLookPitch(restPitchDeg);
let side = null;
if (Math.abs(yaw) >= 180) side = 'astern';
else if (yaw > 0) side = 'starboard';
else if (yaw < 0) side = 'port';
let elevation = null;
if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level';
return {
side,
bearingDeg: Math.abs(yaw),
elevation,
angleDeg: Math.abs(pitch),
};
}
/**
* The orbital view's off-nadir limit on each axis. The orbital look reuses
* the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts
* the view east (positive) or west, pitch tilts it north (positive) or
* south. Single 5° steps only; the bridge keeps its own limits.
*/
export const ORBITAL_LOOK_LIMIT_DEG = 45;
/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */
function clampOrbitalAxis(deg) {
if (!Number.isFinite(deg)) return 0;
const value = Math.min(
ORBITAL_LOOK_LIMIT_DEG,
Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg),
);
return value === 0 ? 0 : value;
}
/**
* Clamp an orbital look to the off-nadir limit on both axes.
* @param {{yawDeg: number, pitchDeg: number}} look East and north tilt.
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function clampOrbitalLook(look) {
return {
yawDeg: clampOrbitalAxis(look?.yawDeg),
pitchDeg: clampOrbitalAxis(look?.pitchDeg),
};
}
/**
* Apply one arrow step to an orbital look: the bridge's stepping on the
* same 5° grid, then the off-nadir clamp.
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
* {@link lookKeyStep}.
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function stepOrbitalLook(target, step) {
return clampOrbitalLook(stepLook(clampOrbitalLook(target), step));
}
/**
* The parts of an off-nadir readout, for the Cockpit to word: the north or
* south tilt and the east or west tilt, each null at rest, in whole degrees.
* @param {{yawDeg: number, pitchDeg: number}} look Orbital look target.
* @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number,
* eastWest: 'E'|'W'|null, eastWestDeg: number}}
*/
export function orbitalLookReadoutParts(look) {
const { yawDeg, pitchDeg } = clampOrbitalLook(look);
const north = Math.round(pitchDeg);
const east = Math.round(yawDeg);
return {
northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null,
northSouthDeg: Math.abs(north),
eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null,
eastWestDeg: Math.abs(east),
};
}
+195
View File
@@ -0,0 +1,195 @@
// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps,
// yaw wrapping, pitch limits, easing and the readout's parts.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LOOK_COARSE_STEP_DEG,
LOOK_STEP_DEG,
ORBITAL_LOOK_LIMIT_DEG,
clampLookPitch,
clampOrbitalLook,
easeLook,
lookIsOffset,
lookKeyStep,
lookReadoutParts,
orbitalLookReadoutParts,
stepLook,
stepOrbitalLook,
wrapLookYaw,
} from './cockpitLook.js';
const REST = { yawDeg: 0, pitchDeg: -2 };
/** Press one key `count` times from `start`. */
function press(start, key, count = 1, coarse = false) {
let look = start;
for (let index = 0; index < count; index += 1)
look = stepLook(look, lookKeyStep(key, coarse));
return look;
}
test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => {
assert.equal(wrapLookYaw(0), 0);
assert.equal(wrapLookYaw(-0), 0);
assert.equal(wrapLookYaw(180), 180);
assert.equal(wrapLookYaw(-180), 180);
assert.equal(wrapLookYaw(185), -175);
assert.equal(wrapLookYaw(-185), 175);
assert.equal(wrapLookYaw(720 + 45), 45);
assert.equal(wrapLookYaw(NaN), 0);
assert.equal(clampLookPitch(120), 90);
assert.equal(clampLookPitch(-120), -90);
assert.equal(clampLookPitch(-0), 0);
assert.equal(clampLookPitch(Infinity), 0);
});
test('only the arrows step, 5° plain and 45° with Shift', () => {
assert.deepEqual(lookKeyStep('ArrowLeft'), {
axis: 'yaw',
sign: -1,
stepDeg: LOOK_STEP_DEG,
});
assert.deepEqual(lookKeyStep('ArrowUp', true), {
axis: 'pitch',
sign: 1,
stepDeg: LOOK_COARSE_STEP_DEG,
});
assert.equal(lookKeyStep('ArrowDown').sign, -1);
assert.equal(lookKeyStep('ArrowRight').sign, 1);
for (const key of ['Home', '0', 'a', 'Escape', undefined])
assert.equal(lookKeyStep(key), null);
});
test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => {
assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 });
assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 });
assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90);
assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90);
// A coarse step from off the 45° grid lands on it.
assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45);
// Past astern the yaw wraps to the other side.
assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180);
assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135);
assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175);
});
test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => {
assert.equal(press(REST, 'ArrowUp').pitchDeg, 0);
assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10);
assert.equal(press(REST, 'ArrowDown').pitchDeg, -5);
assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90);
assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90);
assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90);
assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90);
assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw');
});
test('the eased view closes most of a step in about 180 ms', () => {
const target = { yawDeg: 90, pitchDeg: -2 };
let view = { ...REST };
view = easeLook(view, target, 180);
assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`);
for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50);
assert.deepEqual(view, target, 'it lands exactly on the target');
assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion');
});
test('the eased yaw takes the short way across astern', () => {
const view = easeLook(
{ yawDeg: 170, pitchDeg: 0 },
{ yawDeg: -170, pitchDeg: 0 },
30,
);
assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`);
});
test('reduced motion jumps straight to the target', () => {
const target = { yawDeg: -45, pitchDeg: 30 };
assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target);
});
test('the readout names the side, the bearing and the elevation', () => {
assert.equal(lookIsOffset(REST, -2), false);
assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true);
assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true);
assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), {
side: 'starboard',
bearingDeg: 45,
elevation: 'up',
angleDeg: 10,
});
assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), {
side: 'port',
bearingDeg: 90,
elevation: null,
angleDeg: 2,
});
assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), {
side: null,
bearingDeg: 0,
elevation: 'down',
angleDeg: 30,
});
assert.equal(
lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation,
'level',
);
assert.equal(
lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side,
'astern',
);
});
test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => {
let look = { yawDeg: 0, pitchDeg: 0 };
for (let index = 0; index < 3; index += 1)
look = stepOrbitalLook(look, lookKeyStep('ArrowUp'));
assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 });
look = stepOrbitalLook(look, lookKeyStep('ArrowRight'));
assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 });
for (let index = 0; index < 20; index += 1) {
look = stepOrbitalLook(look, lookKeyStep('ArrowDown'));
look = stepOrbitalLook(look, lookKeyStep('ArrowLeft'));
}
assert.deepEqual(look, {
yawDeg: -ORBITAL_LOOK_LIMIT_DEG,
pitchDeg: -ORBITAL_LOOK_LIMIT_DEG,
});
assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), {
yawDeg: 45,
pitchDeg: 0,
});
assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), {
yawDeg: 45,
pitchDeg: -45,
});
});
test('the orbital readout names north or south and east or west', () => {
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), {
northSouth: 'N',
northSouthDeg: 10,
eastWest: 'E',
eastWestDeg: 15,
});
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), {
northSouth: null,
northSouthDeg: 0,
eastWest: 'W',
eastWestDeg: 5,
});
assert.equal(
orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth,
'S',
);
});
test('the bridge look keeps its own limits beside the orbital clamp', () => {
const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp'));
assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 });
assert.equal(
stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true))
.yawDeg,
90,
);
});
+6 -7
View File
@@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
assert.doesNotMatch(html, /id="cockpit-exit"/);
assert.match(
html,
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
);
const topCenterActions = html.match(
/<nav id="top-center-actions"[\s\S]*?<\/nav>/,
@@ -163,7 +163,8 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(
cockpitEnter.toString(),
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
// Orbit alone opens optical (src/ui/cockpitOrbitalView.test.mjs).
/this\.setVisionMode\(\s*satellite \? 'optical' : this\.getInitialVisionMode\(\)\s*,?\s*\)/,
'Cockpit must enter on the mode matching the active map preset',
);
assert.match(
@@ -232,14 +233,12 @@ 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', () => {
test('open utilities own the first Escape and Cockpit leaves C alone', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
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\(\)/,
);
// C is the app-wide CCTV shortcut; Cockpit never claims it.
assert.doesNotMatch(keydown, /=== 'c'/);
});
test('the Contact panel never hides itself out from under its own NEXT button', () => {
+40
View File
@@ -176,6 +176,46 @@ export function compassDivisions(heading) {
);
}
/**
* A heading tape that scrolls continuously: the 30-degree divisions within
* `reach` divisions of `heading`, each placed by its offset from the exact
* heading in division slots, so the centre caret reads the heading itself
* rather than the nearest division. `nearest` marks the division closest to
* the caret.
* @param {number} heading Degrees true.
* @param {number} [reach] Divisions each side of the caret.
* @returns {Array<{heading: number, slot: number, nearest: boolean}>}
*/
export function scrollingCompassDivisions(heading, reach = 4) {
if (!Number.isFinite(heading)) return [];
const exact = normalizeHeading(heading);
const centre = Math.round(exact / 30) * 30;
const divisions = [];
for (let step = -reach; step <= reach; step += 1) {
const division = normalizeHeading(centre + step * 30);
divisions.push({
heading: division,
slot: Number((relativeBearing(division, exact) / 30).toFixed(4)),
nearest: step === 0,
});
}
return divisions;
}
/**
* Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape:
* its offset from the caret (the tape's exact heading) in division slots of
* 30 degrees, or null when it lies more than three divisions out.
* @param {number} heading Degrees true.
* @param {number} tapeHeading The heading under the tape's caret.
* @returns {?number}
*/
export function compassMarkerSlot(heading, tapeHeading) {
if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null;
const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4));
return Math.abs(slot) <= 3 ? slot : null;
}
/** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading);
+42
View File
@@ -11,6 +11,8 @@ import {
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
compassDivisions,
compassMarkerSlot,
scrollingCompassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
@@ -151,6 +153,46 @@ test('compass divisions remain ordered across north', () => {
assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]);
});
test('compass marks sit in division slots off the caret, or off the tape', () => {
// Measured from the exact heading under the caret, not the nearest division.
assert.equal(compassMarkerSlot(350, 350), 0);
assert.equal(compassMarkerSlot(35, 350), 1.5);
assert.equal(compassMarkerSlot(260, 350), -3);
assert.equal(compassMarkerSlot(80, 350), 3);
assert.equal(compassMarkerSlot(90, 350), null);
assert.equal(compassMarkerSlot(180, 350), null);
assert.equal(compassMarkerSlot(Number.NaN, 0), null);
});
test('a scrolling tape puts the exact heading under the caret', () => {
// At 100° the divisions sit a third of a slot off their snapped places,
// so the bow mark (also 100°) lines up with the caret, not 10° beside it.
const tape = scrollingCompassDivisions(100);
assert.deepEqual(
tape.map(({ heading }) => heading),
[330, 0, 30, 60, 90, 120, 150, 180, 210],
);
const nearest = tape.find((division) => division.nearest);
assert.equal(nearest.heading, 90);
assert.equal(nearest.slot, -0.3333);
assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667);
assert.equal(compassMarkerSlot(100, 100), 0);
assert.equal(compassMarkerSlot(90, 100), nearest.slot);
// Across north the slots stay continuous.
assert.deepEqual(
scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [
heading,
slot,
]),
[
[330, -0.8333],
[0, 0.1667],
[30, 1.1667],
],
);
assert.deepEqual(scrollingCompassDivisions(Number.NaN), []);
});
test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(270), 'W');
assert.equal(formatCompassDivision(30), '030');
+808
View File
@@ -0,0 +1,808 @@
/**
* Camera policy for the orbital (satellite) view: the eye rides the tracked
* satellite and looks straight down at the Earth's centre. Satellites publish
* no attitude, so the picture is turned to the ground track (track-up; see
* nadirOrientation's `headingDeg`), or north-up for a satellite with none
* (geostationary). This module holds the zoom steps,
* the field of view, the nadir orientation, the ground footprint and the zoom
* transition curves. No DOM, Cesium or i18n dependencies: vectors are plain
* `{x, y, z}` objects in Earth-fixed (ECEF) metres, so the policy is
* unit-testable and the Cockpit camera copies them into its own scratch.
*/
/** Mean Earth radius used for footprint and framing geometry, metres. */
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
/**
* The whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
* imaging satellites with a known sensor, each up to the step its sensor's
* swath allows (`orbitalMaxZoom`).
*/
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
/** The tightest step for a satellite without sensor data. */
export const ORBITAL_BASE_MAX_ZOOM = 8;
/** The zoom an orbital view opens at. */
export const ORBITAL_DEFAULT_ZOOM = 1;
/**
* Vertical field of view at 1x for low orbits. From the ISS (~420 km) a 60°
* frame spans roughly 490 km of ground top to bottom: a regional picture
* with the curvature just out of frame, which 8x narrows to ~60 km.
*/
export const ORBITAL_BASE_FOV_LEO_RAD = (60 * Math.PI) / 180;
/**
* How much wider than the Earth's disk a high orbit's 1x frame is. A fixed
* 60° frame from GEO (~35,786 km) would show a 17° Earth floating in black;
* above about 8,000 km the base frame instead fits the whole disk with this
* margin, so 1x is "the whole Earth" and the steps zoom into it.
*/
export const ORBITAL_DISK_MARGIN = 1.15;
/** Duration of one zoom step's field-of-view change, ms. */
export const ORBITAL_ZOOM_TRANSITION_MS = 400;
/** How long the reticle shows its lock tick after a step settles, ms. */
export const ORBITAL_LOCK_TICK_MS = 220;
/** Peak focus-pull blur at the middle of a zoom step, CSS pixels. */
export const ORBITAL_MAX_BLUR_PX = 6;
/**
* Longest a zoom step holds its focus pull for the new field of view's
* imagery, ms. The camera moves at orbital speed, so the tiles may never
* all arrive; the hold gives them a start, then the reticle locks anyway.
*/
export const ORBITAL_SETTLE_MAX_MS = 2500;
/** The soft blur held while a step waits for its imagery, CSS pixels. */
export const ORBITAL_SETTLE_BLUR_PX = 2.5;
/** Relative slack so a zoom computed in floating point still meets its step. */
const ZOOM_EPSILON = 1e-6;
/**
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
* A zoom between steps (the SENSOR step) moves to the step above it.
* @param {number} zoom Current zoom.
* @param {number} [maxZoom] The satellite's tightest step.
* @returns {number}
*/
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const next = ORBITAL_ZOOM_LADDER.find(
(step) => step > current * (1 + ZOOM_EPSILON) && step <= maxZoom,
);
return next ?? current;
}
/**
* The next wider ladder step; 1x stays where it is. A zoom between steps
* moves to the step below it.
* @param {number} zoom Current zoom.
* @returns {number}
*/
export function previousOrbitalZoom(zoom) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const wider = ORBITAL_ZOOM_LADDER.filter(
(step) => step < current * (1 - ZOOM_EPSILON),
);
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
}
/**
* The ground width the view spans at a zoom: what the SWATH readout shows.
* @param {number} altitudeM Satellite altitude, metres.
* @param {number} zoom Magnification.
* @param {number} aspect Canvas width / height.
* @returns {number|null} Kilometres, or null for an unusable altitude.
*/
export function viewWidthKm(altitudeM, zoom, aspect) {
return footprintWidthKm(
altitudeM,
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
aspect,
);
}
/**
* A satellite's tightest zoom step at its live altitude: the tightest ladder
* step whose view is still at least as wide as its sensor's narrowest
* standard swath, so the view never shows finer ground than the sensor
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
* @param {number} altitudeM Live altitude, metres.
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
* @param {number} aspect Canvas width / height.
* @returns {number} One of ORBITAL_ZOOM_LADDER.
*/
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
return ORBITAL_BASE_MAX_ZOOM;
let max = ORBITAL_BASE_MAX_ZOOM;
for (const step of ORBITAL_ZOOM_LADDER) {
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
const width = viewWidthKm(altitudeM, step, aspect);
if (!Number.isFinite(width)) break;
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
max = step;
}
return max;
}
/**
* The zoom at which the view is exactly as wide as the sensor's swath, for
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
* than the 1x view, or narrower than the tightest allowed step).
* @param {number} altitudeM Live altitude, metres.
* @param {number|null|undefined} sensorSwathKm
* @param {number} aspect Canvas width / height.
* @param {number} maxZoom The satellite's tightest step.
* @returns {number|null}
*/
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
const widest = viewWidthKm(altitudeM, 1, aspect);
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
// The width falls monotonically with zoom: bisect in log space.
let low = 0;
let high = Math.log2(maxZoom);
for (let i = 0; i < 48; i += 1) {
const mid = (low + high) / 2;
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
else high = mid;
}
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
}
/**
* How much of the view's width a sensor swath covers, for the strip drawn
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
* @param {number|null|undefined} sensorSwathKm
* @param {number|null|undefined} viewKm The view's current ground width.
* @returns {number|null} Null without both widths.
*/
export function sensorStripFraction(sensorSwathKm, viewKm) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
return Math.min(1, sensorSwathKm / viewKm);
}
/**
* Angular radius of the Earth's disk seen from an altitude, radians.
* @param {number} altitudeM Height above the mean sphere, metres.
* @returns {number} Half-angle from nadir to the limb (π/2 at the surface).
*/
export function earthAngularRadiusRad(altitudeM) {
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
return Math.asin(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + h));
}
/**
* Vertical field of view at 1x for an altitude, radians.
*
* Low orbits get the fixed 60° regional frame. Higher orbits, where 60° would
* leave the Earth small in a black frame, get the Earth's disk plus
* ORBITAL_DISK_MARGIN. The two meet near 8,100 km, so the base never jumps
* as an eccentric orbit climbs: GPS (~20,200 km) opens at ~32°, GEO at ~20°.
* @param {number} altitudeM Satellite altitude, metres.
* @returns {number} Vertical field of view, radians.
*/
export function orbitalBaseFovRad(altitudeM) {
const diskFov = 2 * earthAngularRadiusRad(altitudeM) * ORBITAL_DISK_MARGIN;
return Math.min(ORBITAL_BASE_FOV_LEO_RAD, diskFov);
}
/**
* Field of view at an optical zoom: magnification divides the tangent of
* the half-angle, as a lens's focal length does.
* @param {number} baseFovRad Field of view at 1x, radians.
* @param {number} zoom Magnification (1 leaves the base unchanged).
* @returns {number} Field of view, radians.
*/
export function fovForZoom(baseFovRad, zoom) {
const magnification = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
return 2 * Math.atan(Math.tan(baseFovRad / 2) / magnification);
}
/**
* Cesium's `PerspectiveFrustum.fov` is horizontal on a landscape canvas and
* vertical on a portrait one; convert a vertical field of view to it.
* @param {number} verticalFovRad Vertical field of view, radians.
* @param {number} aspect Canvas width / height.
* @returns {number} The value to assign to `frustum.fov`, radians.
*/
export function cesiumFrustumFovRad(verticalFovRad, aspect) {
if (!(aspect > 1)) return verticalFovRad;
return 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspect);
}
/**
* Ground width covered across the frame, along a great circle through the
* sub-satellite point, km. A ray past the limb ends at the horizon, so a
* frame wider than the disk reports the visible cap's width.
* @param {number} altitudeM Satellite altitude, metres.
* @param {number} fovRad Vertical field of view, radians.
* @param {number} [aspect=1] Frame width / height; the width is measured
* across the wider horizontal extent.
* @returns {number|null} Width in kilometres, or null without a usable input.
*/
export function footprintWidthKm(altitudeM, fovRad, aspect = 1) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return null;
if (!Number.isFinite(fovRad) || fovRad <= 0) return null;
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
const halfAngle = Math.atan(Math.tan(fovRad / 2) * ratio);
const R = ORBITAL_EARTH_RADIUS_M;
const sinGround = ((R + altitudeM) / R) * Math.sin(halfAngle);
// Central angle from nadir to where the ray meets the sphere, or the limb.
const central =
sinGround >= 1
? Math.acos(R / (R + altitudeM))
: Math.asin(sinGround) - halfAngle;
return (2 * R * central) / 1000;
}
function length(v) {
return Math.hypot(v.x, v.y, v.z);
}
function scale(v, s) {
return { x: v.x * s, y: v.y * s, z: v.z * s };
}
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
};
}
/** `v` with its component along unit `d` removed, or null when degenerate. */
function perpendicularUnit(v, d) {
const along = dot(v, d);
const p = {
x: v.x - along * d.x,
y: v.y - along * d.y,
z: v.z - along * d.z,
};
const n = length(p);
return n > 1e-6 ? scale(p, 1 / n) : null;
}
const NORTH_AXIS = Object.freeze({ x: 0, y: 0, z: 1 });
/**
* Camera orientation looking from an Earth-fixed position at the Earth's
* centre, with north at the top of the frame.
*
* `up` is the Earth's rotation axis projected perpendicular to the view, which
* is local north at the sub-satellite point. Directly over a pole north is
* undefined; the previous frame's `up` is kept when given (so a polar pass
* does not spin the picture), otherwise the limit along the prime meridian
* is used: over the north pole the top of the frame faces 180°E, over the
* south pole 0°E.
*
* With `headingDeg` the frame turns so that bearing (degrees clockwise from
* north) is at the top: the ground track, for a view that scrolls along it.
* @param {{x: number, y: number, z: number}} position ECEF position, metres.
* @param {{previousUp?: {x: number, y: number, z: number}|null,
* headingDeg?: number|null}} [options] `previousUp` is a previous frame's
* `north`, not its turned `up`.
* @returns {{direction: {x: number, y: number, z: number},
* up: {x: number, y: number, z: number},
* right: {x: number, y: number, z: number},
* north: {x: number, y: number, z: number}}|null} Unit vectors (right =
* direction × up, as Cesium's camera keeps them), or null at the centre.
*/
export function nadirOrientation(
position,
{ previousUp = null, headingDeg = null } = {},
) {
if (!position) return null;
const r = length(position);
if (!Number.isFinite(r) || r < 1) return null;
const direction = scale(position, -1 / r);
let up = perpendicularUnit(NORTH_AXIS, direction);
if (!up && previousUp && Number.isFinite(length(previousUp)))
up = perpendicularUnit(previousUp, direction);
if (!up) {
// Looking down -z (north pole): north at longitude 0 points along -x.
up = perpendicularUnit(
{ x: direction.z < 0 ? -1 : 1, y: 0, z: 0 },
direction,
);
}
const north = up;
if (Number.isFinite(headingDeg) && headingDeg % 360 !== 0) {
// Looking down, right = direction × north is east: the bearing turns
// north toward it.
const east = cross(direction, north);
const θ = (headingDeg * Math.PI) / 180;
up = {
x: north.x * Math.cos(θ) + east.x * Math.sin(θ),
y: north.y * Math.cos(θ) + east.y * Math.sin(θ),
z: north.z * Math.cos(θ) + east.z * Math.sin(θ),
};
}
return { direction, up, right: cross(direction, up), north };
}
/**
* Ease-in-out progress of a zoom step (cubic), clamped to 0..1.
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, reducedMotion?: boolean}} [options]
* Reduced motion cuts straight to the end.
* @returns {number} Eased progress, 0..1.
*/
export function zoomTransitionProgress(
elapsedMs,
{ durationMs = ORBITAL_ZOOM_TRANSITION_MS, reducedMotion = false } = {},
) {
if (reducedMotion || !(durationMs > 0)) return 1;
const linear = Math.min(1, Math.max(0, (elapsedMs || 0) / durationMs));
return linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
}
/**
* Focus-pull blur for a zoom step: zero at both ends, peaking mid-step.
* @param {number} progress Step progress, 0..1 (linear or eased).
* @param {{maxBlurPx?: number, reducedMotion?: boolean}} [options]
* Reduced motion never blurs.
* @returns {number} Blur radius, CSS pixels.
*/
export function zoomTransitionBlurPx(
progress,
{ maxBlurPx = ORBITAL_MAX_BLUR_PX, reducedMotion = false } = {},
) {
if (reducedMotion || !Number.isFinite(progress)) return 0;
const p = Math.min(1, Math.max(0, progress));
return Math.max(0, maxBlurPx) * Math.sin(Math.PI * p);
}
/**
* Field of view part-way through a zoom step. Interpolates the tangent of
* the half-angle geometrically, so each moment of the step magnifies by the
* same ratio and 1x→2x feels like 4x→8x.
* @param {number} fromFovRad Field of view the step starts at.
* @param {number} toFovRad Field of view the step ends at.
* @param {number} progress Eased progress, 0..1.
* @returns {number} Field of view, radians.
*/
export function interpolateZoomFov(fromFovRad, toFovRad, progress) {
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const a = Math.log(Math.tan(fromFovRad / 2));
const b = Math.log(Math.tan(toFovRad / 2));
return 2 * Math.atan(Math.exp(a + (b - a) * p));
}
/**
* The HUD's transition state at a moment of a zoom step, in the store shape
* `{ active, blurPx, lockTick }`: blur while the field of view moves, then a
* brief reticle lock tick once it settles. Reduced motion skips the blur and
* the tick (the cut is instant).
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, lockTickMs?: number, maxBlurPx?: number,
* reducedMotion?: boolean}} [options]
* @returns {{active: boolean, progress: number, blurPx: number,
* lockTick: boolean}}
*/
export function zoomTransitionState(
elapsedMs,
{
durationMs = ORBITAL_ZOOM_TRANSITION_MS,
lockTickMs = ORBITAL_LOCK_TICK_MS,
maxBlurPx = ORBITAL_MAX_BLUR_PX,
reducedMotion = false,
} = {},
) {
const progress = zoomTransitionProgress(elapsedMs, {
durationMs,
reducedMotion,
});
if (reducedMotion)
return { active: false, progress: 1, blurPx: 0, lockTick: false };
const elapsed = Math.max(0, elapsedMs || 0);
const linear = durationMs > 0 ? Math.min(1, elapsed / durationMs) : 1;
return {
active: elapsed < durationMs,
progress,
blurPx: zoomTransitionBlurPx(linear, { maxBlurPx }),
lockTick: elapsed >= durationMs && elapsed < durationMs + lockTickMs,
};
}
/**
* Whether a zoom step whose field of view has landed still holds its focus
* pull: until the imagery has loaded, at most `maxHoldMs`.
* @param {number} sinceLandedMs Time since the field of view stopped moving.
* @param {boolean} tilesLoaded Whether the scene's imagery has loaded.
* @param {{maxHoldMs?: number}} [options]
*/
export function zoomSettleHolding(
sinceLandedMs,
tilesLoaded,
{ maxHoldMs = ORBITAL_SETTLE_MAX_MS } = {},
) {
return !tilesLoaded && Math.max(0, sinceLandedMs || 0) < maxHoldMs;
}
/**
* The total angle between a tilted view and nadir, degrees:
* acos(cos north · cos east), as `offNadirOrientation` tilts.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number}
*/
export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const cosine = Math.min(1, Math.max(-1, Math.cos(n) * Math.cos(e)));
return (Math.acos(cosine) * 180) / Math.PI;
}
/**
* Whether the view's centre, tilted off nadir, still meets the Earth: its
* angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius
* from the satellite, asin(R / (R + altitude)).
* @param {number} altitudeM
* @param {{northDeg?: number, eastDeg?: number}} [offset]
* @returns {boolean}
*/
export function offNadirCentreMeetsEarth(altitudeM, offset = {}) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false;
const R = ORBITAL_EARTH_RADIUS_M;
const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI;
return offNadirAngleDeg(offset) < limbDeg;
}
/**
* Roughly how much wider the ground across the frame is at the centre of a
* tilted view than at nadir, on a flat Earth: tilting across the frame (east,
* the frame's right) stretches its width by 1/cos², tilting along it (north,
* the frame's top) by 1/cos. 1 at nadir; an approximation, so the HUD marks
* the width it scales with ≈.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number} At least 1.
*/
export function offNadirSwathScale({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const along = Math.abs(Math.cos(n));
const across = Math.cos(e) ** 2;
if (!(along > 1e-6) || !(across > 1e-6)) return 1;
return Math.max(1, 1 / (along * across));
}
/**
* Tilt a nadir orientation off nadir: first toward north (a rotation about
* the nadir frame's right axis), then toward east (about the tilted up
* axis). The base frame is always the nadir one, so a tilt never drifts with
* the orbit; the angle from nadir is acos(cos north · cos east).
* @param {{direction: object, up: object, right: object}} nadir From
* {@link nadirOrientation}.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees;
* negative is south or west.
* @returns {{direction: object, up: object, right: object}} Unit vectors,
* right = direction × up.
*/
export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
if (n === 0 && e === 0) return nadir;
const { direction: d, up: u, right: r } = nadir;
const combine = (a, ka, b, kb) => ({
x: a.x * ka + b.x * kb,
y: a.y * ka + b.y * kb,
z: a.z * ka + b.z * kb,
});
// Toward north: the view swings onto the up axis, up swings off it.
const d1 = combine(d, Math.cos(n), u, Math.sin(n));
const u1 = combine(u, Math.cos(n), d, -Math.sin(n));
// Toward east: the view swings onto the right axis.
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
return { direction, up: u1, right: cross(direction, u1) };
}
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
/**
* After a step, the rest of that gesture is absorbed for this long, so one
* physical notch (which some browsers deliver as several events) is one step.
*/
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
/**
* A pause this long ends a scroll gesture: a partial sum left from an earlier
* gesture never adds to a later one.
*/
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
/** `WheelEvent.deltaMode` line and page units, in pixels. */
const WHEEL_LINE_PX = 16;
const WHEEL_PAGE_PX = 800;
/**
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
* so they are summed until they reach a step; a change of direction, or a
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
* viewer (negative deltaY) zooms in.
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
* accumulator Previous state.
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
* @param {number} nowMs
* @returns {{accumulator: {sum: number, lastStepMs: number,
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
* (1 = zoom in).
*/
export function wheelZoomStep(accumulator, wheel, nowMs) {
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
const previous =
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
? { ...held, sum: 0 }
: held;
const unit =
wheel?.deltaMode === 1
? WHEEL_LINE_PX
: wheel?.deltaMode === 2
? WHEEL_PAGE_PX
: 1;
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
if (!delta) return { accumulator: held, step: 0 };
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
return {
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
step: 0,
};
const sum =
Math.sign(previous.sum) === -Math.sign(delta)
? delta
: previous.sum + delta;
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
return {
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
step: sum < 0 ? 1 : -1,
};
}
/** From this zoom the view pre-loads imagery ahead along the ground track. */
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
/** How often the pre-load looks ahead, ms. */
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
/**
* Where across the track it looks, as fractions of the view's width: the
* centre line, a third out and the frame's edges each side, so a row of
* tiles a fifth of the view wide or more spans the frame.
*/
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
0,
-1 / 3,
1 / 3,
-1 / 2,
1 / 2,
]);
/**
* The points ahead, regrouped by their place across the track: every lead's
* centre point first, then each lead's next pair out, so the tile cap trims
* the frame's edges before it trims the far leads.
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
* lead by lead.
* @param {number} [across] Points per lead.
* @returns {Array<{lat: number, lon: number}>}
*/
export function centreLinesFirst(
points,
across = ORBITAL_PREFETCH_ACROSS.length,
) {
const out = [];
for (let k = 0; k < across; k += 1)
for (let i = k; i < points.length; i += across) out.push(points[i]);
return out;
}
/** At most this many tile requests per look-ahead. */
export const ORBITAL_PREFETCH_MAX_TILES = 12;
const toRadians = (deg) => (deg * Math.PI) / 180;
const toDegrees = (rad) => (rad * 180) / Math.PI;
/**
* The point `distanceM` from a point along an initial bearing, on a sphere.
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
*/
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
const lat1 = toRadians(latDeg);
const theta = toRadians(bearingDeg);
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(delta) +
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
);
const lon2 =
toRadians(lonDeg) +
Math.atan2(
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
);
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
return { lat: toDegrees(lat2), lon };
}
/**
* Points the view will pass over: for each lead time, the sub-satellite
* point that far along the ground track (orbital speed scaled down to the
* ground), and points across the track at `across` fractions of the view's
* width. Nearest lead first. Empty without a track, speed or width.
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
* velocityMps: number, altitudeM: number}} info Satellite subject.
* @param {number} viewKm The view's ground width, km.
* @returns {Array<{lat: number, lon: number}>}
*/
export function groundTrackAhead(
info,
viewKm,
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
) {
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
info || {};
if (
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
Number.isFinite,
) ||
velocityMps <= 0 ||
altitudeM <= 0 ||
!(viewKm > 0)
)
return [];
const R = ORBITAL_EARTH_RADIUS_M;
const groundMps = (velocityMps * R) / (R + altitudeM);
const points = [];
for (const seconds of leadS) {
const centre = destinationPoint(
latitude,
longitude,
groundTrackDeg,
groundMps * seconds,
);
for (const fraction of across) {
points.push(
fraction === 0
? centre
: destinationPoint(
centre.lat,
centre.lon,
groundTrackDeg + (fraction > 0 ? 90 : -90),
Math.abs(fraction) * viewKm * 1000,
),
);
}
}
return points;
}
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
const WGS84_MAXIMUM_RADIUS_M = 6378137;
/**
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
* its level-zero estimate (TerrainProvider's
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
* level.
* @param {number} level
* @returns {number}
*/
export function ellipsoidTerrainLevelErrorM(level) {
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
}
/** The deepest terrain level considered. */
const MAX_TERRAIN_LEVEL = 30;
/**
* The imagery level Cesium draws under the view, and so the one to pre-load.
* Cesium refines the terrain tile under the view while its geometric error
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
* screenSpaceError: error over the ground a pixel covers), then drapes the
* imagery level whose texel spacing is nearest that error (ImageryLayer's
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
* is left out. Clamped to the provider's levels; null without a usable input.
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
* @param {{metresPerPixel: number, latitudeDeg: number,
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
* maximumLevel?: number, maximumScreenSpaceError?: number,
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
* is the ground one CSS pixel covers at the view's centre; `geographic`
* marks a scheme in the geographic projection, whose texels do not narrow
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
* provider's getLevelMaximumGeometricError.
* @returns {number|null}
*/
export function imageryLevelForView(
tilingScheme,
{
metresPerPixel,
latitudeDeg,
geographic = false,
tileWidthPx = 256,
minimumLevel = 0,
maximumLevel = 30,
maximumScreenSpaceError = 2,
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
},
) {
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
let terrainLevel = 0;
while (
terrainLevel < MAX_TERRAIN_LEVEL &&
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
)
terrainLevel += 1;
const errorM = terrainLevelErrorM(terrainLevel);
if (!(errorM > 0)) return null;
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
const latitudeFactor = geographic
? 1
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
const levelZeroTexelM =
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
((tileWidthPx || 256) * rootTiles);
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
if (!Number.isFinite(level)) return null;
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
return Math.min(top, Math.max(bottom, level));
}
/**
* The tiles at `level` holding `points`, in order, without repeats or tiles
* `skip` reports as already asked for, at most `maxTiles`.
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
* @param {Array<{lat: number, lon: number}>} points Degrees.
* @param {number} level
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
* @returns {Array<{x: number, y: number, level: number, key: string}>}
*/
export function tilesForPoints(
tilingScheme,
points,
level,
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
) {
const tiles = [];
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
if (!Number.isInteger(level) || level < 0) return tiles;
const seen = new Set();
for (const point of points) {
if (tiles.length >= maxTiles) break;
const xy = tilingScheme.positionToTileXY(
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
level,
);
if (!xy) continue;
const key = `${level}/${xy.x}/${xy.y}`;
if (seen.has(key) || skip(key)) continue;
seen.add(key);
tiles.push({ x: xy.x, y: xy.y, level, key });
}
return tiles;
}
+618
View File
@@ -0,0 +1,618 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ORBITAL_ZOOM_LADDER,
ORBITAL_BASE_FOV_LEO_RAD,
ORBITAL_EARTH_RADIUS_M,
ORBITAL_SETTLE_MAX_MS,
ORBITAL_WHEEL_COOLDOWN_MS,
cesiumFrustumFovRad,
earthAngularRadiusRad,
footprintWidthKm,
fovForZoom,
interpolateZoomFov,
nadirOrientation,
nextOrbitalZoom,
offNadirAngleDeg,
offNadirCentreMeetsEarth,
centreLinesFirst,
offNadirSwathScale,
orbitalMaxZoom,
sensorStripFraction,
sensorSwathZoom,
viewWidthKm,
wheelZoomStep,
ORBITAL_WHEEL_GESTURE_GAP_MS,
offNadirOrientation,
orbitalBaseFovRad,
previousOrbitalZoom,
zoomSettleHolding,
zoomTransitionBlurPx,
zoomTransitionProgress,
zoomTransitionState,
ORBITAL_PREFETCH_ACROSS,
ORBITAL_PREFETCH_LEAD_S,
ORBITAL_PREFETCH_MAX_TILES,
destinationPoint,
groundTrackAhead,
imageryLevelForView,
tilesForPoints,
} from './cockpitOrbital.js';
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
const RAD = Math.PI / 180;
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
const norm = (v) => Math.hypot(v.x, v.y, v.z);
/** ECEF position on the mean sphere at a latitude, longitude and altitude. */
function ecef(latDeg, lonDeg, altitudeM) {
const r = ORBITAL_EARTH_RADIUS_M + altitudeM;
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: r * Math.cos(lat) * Math.cos(lon),
y: r * Math.cos(lat) * Math.sin(lon),
z: r * Math.sin(lat),
};
}
/** Local north unit vector at a latitude and longitude. */
function localNorth(latDeg, lonDeg) {
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: -Math.sin(lat) * Math.cos(lon),
y: -Math.sin(lat) * Math.sin(lon),
z: Math.cos(lat),
};
}
function assertOrthonormal(orientation) {
const { direction, up, right } = orientation;
for (const v of [direction, up, right])
assert.ok(Math.abs(norm(v) - 1) < 1e-9);
assert.ok(Math.abs(dot(direction, up)) < 1e-9);
assert.ok(Math.abs(dot(direction, right)) < 1e-9);
assert.ok(Math.abs(dot(up, right)) < 1e-9);
}
test('the nadir view points at the Earth centre with north up', () => {
for (const [lat, lon, alt] of [
[0, 0, 420e3],
[51.6, -97.3, 420e3],
[-33.9, 151.2, 20.2e6],
[0.05, 75, 35.786e6],
]) {
const position = ecef(lat, lon, alt);
const orientation = nadirOrientation(position);
const toCentre = {
x: -position.x / norm(position),
y: -position.y / norm(position),
z: -position.z / norm(position),
};
assert.ok(dot(orientation.direction, toCentre) > 1 - 1e-12);
assert.ok(dot(orientation.up, localNorth(lat, lon)) > 1 - 1e-9);
assertOrthonormal(orientation);
// North up puts east on the right of a picture looking down.
const east = { x: -Math.sin(lon * RAD), y: Math.cos(lon * RAD), z: 0 };
assert.ok(dot(orientation.right, east) > 1 - 1e-9);
}
});
test('over a pole the orientation stays valid and keeps the previous up', () => {
for (const z of [1, -1]) {
const position = { x: 0, y: 0, z: z * (ORBITAL_EARTH_RADIUS_M + 800e3) };
const fallback = nadirOrientation(position);
assertOrthonormal(fallback);
assert.equal(fallback.direction.z, -z);
// The prime-meridian limit of north.
assert.ok(Math.abs(fallback.up.x - -z) < 1e-9);
const kept = nadirOrientation(position, {
previousUp: { x: 0, y: 1, z: 0 },
});
assertOrthonormal(kept);
assert.ok(kept.up.y > 1 - 1e-9);
}
// Just off the pole, north still points over it.
const nearPole = nadirOrientation(ecef(89.9999, 30, 500e3));
assertOrthonormal(nearPole);
assert.ok(dot(nearPole.up, localNorth(89.9999, 30)) > 1 - 1e-6);
});
test('a degenerate position has no orientation', () => {
assert.equal(nadirOrientation(null), null);
assert.equal(nadirOrientation({ x: 0, y: 0, z: 0 }), null);
assert.equal(nadirOrientation({ x: NaN, y: 0, z: 0 }), null);
});
test('each zoom step divides the tangent of the half-angle', () => {
const base = ORBITAL_BASE_FOV_LEO_RAD;
for (const zoom of ORBITAL_ZOOM_LADDER) {
const fov = fovForZoom(base, zoom);
assert.ok(Math.abs(Math.tan(fov / 2) - Math.tan(base / 2) / zoom) < 1e-12);
}
assert.ok(
Math.abs(
Math.tan(fovForZoom(base, 4) / 2) / Math.tan(fovForZoom(base, 2) / 2) -
0.5,
) < 1e-12,
);
assert.equal(fovForZoom(base, 1), base);
assert.equal(fovForZoom(base, 0), base);
});
test('zoom steps clamp at both ends without wrapping', () => {
assert.equal(nextOrbitalZoom(1), 2);
assert.equal(nextOrbitalZoom(4), 8);
assert.equal(nextOrbitalZoom(8), 8);
assert.equal(previousOrbitalZoom(8), 4);
assert.equal(previousOrbitalZoom(2), 1);
assert.equal(previousOrbitalZoom(1), 1);
assert.equal(nextOrbitalZoom(undefined), 2);
});
test('the base frame is regional in low orbit and fits the disk from GEO', () => {
assert.equal(orbitalBaseFovRad(420e3), ORBITAL_BASE_FOV_LEO_RAD);
const geo = orbitalBaseFovRad(35.786e6);
const disk = 2 * earthAngularRadiusRad(35.786e6);
assert.ok(geo > disk && geo < ORBITAL_BASE_FOV_LEO_RAD);
// Continuous across the crossover: no jump as an orbit climbs.
let previous = orbitalBaseFovRad(1e6);
for (let alt = 1e6; alt <= 40e6; alt += 50e3) {
const fov = orbitalBaseFovRad(alt);
assert.ok(fov <= previous + 1e-12);
assert.ok(previous - fov < 0.01);
previous = fov;
}
});
test('the footprint shrinks with zoom and stops at the limb', () => {
const alt = 420e3;
const widths = [1, 2, 4, 8].map((zoom) =>
footprintWidthKm(alt, fovForZoom(orbitalBaseFovRad(alt), zoom)),
);
for (let i = 1; i < widths.length; i++) assert.ok(widths[i] < widths[i - 1]);
assert.ok(widths[0] > 400 && widths[0] < 600, `1x ${widths[0]} km`);
assert.ok(widths[3] > 40 && widths[3] < 80, `8x ${widths[3]} km`);
// A wider frame covers more ground across.
assert.ok(
footprintWidthKm(alt, ORBITAL_BASE_FOV_LEO_RAD, 16 / 9) > widths[0],
);
// From GEO a frame wider than the disk spans the visible cap only.
const cap =
(2 *
ORBITAL_EARTH_RADIUS_M *
Math.acos(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + 35.786e6))) /
1000;
assert.ok(Math.abs(footprintWidthKm(35.786e6, 40 * RAD) - cap) < 1e-6);
assert.equal(footprintWidthKm(0, 1), null);
assert.equal(footprintWidthKm(420e3, NaN), null);
});
test('Cesium frustum fov widens to the horizontal on a landscape canvas', () => {
const vertical = 30 * RAD;
assert.equal(cesiumFrustumFovRad(vertical, 0.75), vertical);
const horizontal = cesiumFrustumFovRad(vertical, 2);
assert.ok(
Math.abs(Math.tan(horizontal / 2) - 2 * Math.tan(vertical / 2)) < 1e-12,
);
});
test('zoom transition curves stay bounded and settle', () => {
assert.equal(zoomTransitionProgress(0), 0);
assert.equal(zoomTransitionProgress(400), 1);
assert.equal(zoomTransitionProgress(9999), 1);
assert.equal(zoomTransitionProgress(-50), 0);
assert.equal(zoomTransitionProgress(0, { reducedMotion: true }), 1);
let previous = 0;
for (let ms = 0; ms <= 500; ms += 10) {
const p = zoomTransitionProgress(ms);
assert.ok(p >= previous && p >= 0 && p <= 1);
previous = p;
const blur = zoomTransitionBlurPx(ms / 400);
assert.ok(blur >= 0 && blur <= 6);
}
assert.equal(zoomTransitionBlurPx(0), 0);
assert.ok(zoomTransitionBlurPx(1) < 1e-9);
assert.ok(Math.abs(zoomTransitionBlurPx(0.5) - 6) < 1e-9);
assert.equal(zoomTransitionBlurPx(0.5, { reducedMotion: true }), 0);
const from = fovForZoom(1, 1);
const to = fovForZoom(1, 2);
assert.equal(interpolateZoomFov(from, to, 0).toFixed(12), from.toFixed(12));
assert.equal(interpolateZoomFov(from, to, 1).toFixed(12), to.toFixed(12));
const mid = interpolateZoomFov(from, to, 0.5);
assert.ok(mid < from && mid > to);
});
test('the transition state blurs, then ticks the lock, then rests', () => {
const moving = zoomTransitionState(200);
assert.equal(moving.active, true);
assert.ok(moving.blurPx > 5);
assert.equal(moving.lockTick, false);
const settled = zoomTransitionState(450);
assert.equal(settled.active, false);
assert.equal(settled.lockTick, true);
assert.equal(settled.progress, 1);
assert.ok(settled.blurPx < 1e-9);
const rest = zoomTransitionState(2000);
assert.equal(rest.lockTick, false);
assert.deepEqual(zoomTransitionState(100, { reducedMotion: true }), {
active: false,
progress: 1,
blurPx: 0,
lockTick: false,
});
});
test('an off-nadir tilt turns the view by its angle from the nadir frame', () => {
const nadir = nadirOrientation(ecef(30, -97, 420_000));
assert.equal(offNadirOrientation(nadir), nadir, 'no tilt is nadir itself');
const angle = (v) => Math.acos(Math.min(1, dot(v, nadir.direction))) / RAD;
const north = offNadirOrientation(nadir, { northDeg: 10 });
assert.ok(Math.abs(angle(north.direction) - 10) < 1e-9);
assert.ok(dot(north.direction, nadir.up) > 0, 'toward north');
assert.ok(Math.abs(dot(north.direction, nadir.right)) < 1e-12);
const west = offNadirOrientation(nadir, { eastDeg: -20 });
assert.ok(Math.abs(angle(west.direction) - 20) < 1e-9);
assert.ok(dot(west.direction, nadir.right) < 0, 'toward west');
const both = offNadirOrientation(nadir, { northDeg: 45, eastDeg: 45 });
const expected = Math.acos(Math.cos(45 * RAD) ** 2) / RAD;
assert.ok(Math.abs(angle(both.direction) - expected) < 1e-9);
for (const view of [north, west, both]) {
assert.ok(Math.abs(norm(view.direction) - 1) < 1e-12);
assert.ok(Math.abs(norm(view.up) - 1) < 1e-12);
assert.ok(Math.abs(dot(view.direction, view.up)) < 1e-12);
}
});
test('a landed zoom step holds for its imagery, but never past the cap', () => {
assert.equal(zoomSettleHolding(0, false), true, 'still loading');
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS - 1, false), true);
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS, false), false, 'cap');
assert.equal(zoomSettleHolding(0, true), false, 'loaded: lock at once');
assert.equal(zoomSettleHolding(Number.NaN, false), true);
});
const WIDE = 16 / 9;
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
assert.equal(nextOrbitalZoom(8, 64), 16);
assert.equal(nextOrbitalZoom(32, 64), 64);
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
assert.equal(previousOrbitalZoom(128), 64);
assert.equal(previousOrbitalZoom(1), 1);
});
test('a sensor caps the zoom at its swath for the live altitude', () => {
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
// ICEYE spotlight, 5 km at 570 km.
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
// No sensor, or an unusable swath.
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
// A swath exactly as wide as the 16x view allows 16x.
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
});
test('the SENSOR step frames the swath when it lies in range', () => {
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
assert.ok(landsat > 4 && landsat < 8, String(landsat));
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
assert.equal(worldview, null, 'narrower than the 64x cap allows');
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
});
test('the sensor strip spans its share of the view', () => {
assert.equal(sensorStripFraction(13, 130), 0.1);
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
assert.equal(sensorStripFraction(null, 131), null);
assert.equal(sensorStripFraction(13.1, null), null);
});
test('the wheel steps once per notch and sums a trackpad swipe', () => {
// A mouse notch away from the viewer zooms in.
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
assert.equal(r.step, 1);
// The rest of that notch, inside the cooldown, is absorbed.
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
assert.equal(r.step, 0);
// A notch toward the viewer after the cooldown zooms out.
r = wheelZoomStep(
r.accumulator,
{ deltaY: 120, deltaMode: 0 },
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
);
assert.equal(r.step, -1);
// A trackpad's small deltas add up before they step.
let pad = null;
const steps = [];
for (let i = 0; i < 4; i += 1) {
const out = wheelZoomStep(
pad,
{ deltaY: -30, deltaMode: 0 },
5000 + i * 16,
);
pad = out.accumulator;
steps.push(out.step);
}
assert.deepEqual(steps, [0, 0, 0, 1]);
// Turning back starts the sum over.
const turned = wheelZoomStep(
{ sum: -90, lastStepMs: -Infinity },
{ deltaY: 30, deltaMode: 0 },
9000,
);
assert.equal(turned.step, 0);
assert.equal(turned.accumulator.sum, 30);
// Line-mode wheels (Firefox): three lines make a notch.
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
});
test('a heading turns the nadir frame, keeping north to fall back on', () => {
// Over the equator at 0°E: north is +z, east is +y.
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
const northUp = nadirOrientation(position);
const east = nadirOrientation(position, { headingDeg: 90 });
const close = (a, b) =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.y - b.y) < 1e-9 &&
Math.abs(a.z - b.z) < 1e-9;
assert.ok(close(northUp.up, { x: 0, y: 0, z: 1 }));
assert.ok(close(east.up, { x: 0, y: 1, z: 0 }), 'east at the top');
assert.ok(close(east.north, northUp.up), 'north kept for the next frame');
assert.ok(
close(nadirOrientation(position, { headingDeg: 0 }).up, northUp.up),
);
});
test('a partial scroll does not carry over into a later gesture', () => {
// 90 px: short of a step.
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
assert.equal(r.step, 0);
// Still the same gesture: 10 px more completes the step.
const same = wheelZoomStep(
r.accumulator,
{ deltaY: -10, deltaMode: 0 },
1100,
);
assert.equal(same.step, 1);
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
r = wheelZoomStep(
r.accumulator,
{ deltaY: -10, deltaMode: 0 },
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
);
assert.equal(r.step, 0);
});
test('the total off-nadir angle combines both tilts', () => {
assert.equal(offNadirAngleDeg(), 0);
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
// FWD 15° and RIGHT 10° read OFF-NADIR 18.0°.
const total = offNadirAngleDeg({ northDeg: 15, eastDeg: 10 });
assert.equal(total.toFixed(1), '18.0');
assert.equal(offNadirAngleDeg({ northDeg: -15, eastDeg: -10 }), total);
});
test("a tilted view's width grows faster across the frame than along it", () => {
assert.equal(offNadirSwathScale(), 1, 'nadir');
const cos = (deg) => Math.cos((deg * Math.PI) / 180);
assert.ok(
Math.abs(offNadirSwathScale({ northDeg: 30 }) - 1 / cos(30)) < 1e-9,
);
assert.ok(
Math.abs(offNadirSwathScale({ eastDeg: 30 }) - 1 / cos(30) ** 2) < 1e-9,
);
let last = 1;
for (const deg of [5, 15, 25, 35, 45]) {
const scale = offNadirSwathScale({ northDeg: deg, eastDeg: deg });
assert.ok(scale > last, `${deg}°`);
last = scale;
}
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
});
// ── imagery pre-load ahead of the ground track ─────────────────────────
const PASS = Object.freeze({
latitude: 30,
longitude: -97,
groundTrackDeg: 45,
velocityMps: 7_600,
altitudeM: 500_000,
});
test('destinationPoint walks a great circle along a bearing', () => {
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
assert.ok(Math.abs(east.lat) < 1e-9);
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
const north = destinationPoint(10, 20, 0, 50_000);
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
});
test('the points ahead run along the ground track at ground speed', () => {
const points = groundTrackAhead(PASS, 20);
const across = ORBITAL_PREFETCH_ACROSS.length;
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
const start = { lat: PASS.latitude, lon: PASS.longitude };
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
const centre = points[i * across];
const km = greatCircleKm(start, centre);
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
// North-east of the start, on a 45° track.
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
// The side points sit their fraction of the view off the centre line.
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
});
});
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
assert.deepEqual(groundTrackAhead(PASS, null), []);
});
test('the tile level is the one Cesium draws for the view', () => {
const mercator = new Cesium.WebMercatorTilingScheme();
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
// one-pixel texel would have asked for level 14.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
13,
);
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
14,
);
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
// texel spans 1.4 px.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
14,
);
// At 60° a Mercator texel covers half the ground: one level coarser.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
12,
);
// A geographic scheme starts from two root tiles and keeps its texels at
// every latitude.
const geographic = new Cesium.GeographicTilingScheme();
for (const latitudeDeg of [0, 60])
assert.equal(
imageryLevelForView(geographic, {
metresPerPixel: 10,
latitudeDeg,
geographic: true,
}),
12,
`${latitudeDeg}°`,
);
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
// and a looser screen-space error are honoured.
const worldTerrain = (level) =>
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 10,
latitudeDeg: 0,
terrainLevelErrorM: worldTerrain,
}),
13,
);
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 10,
latitudeDeg: 0,
maximumScreenSpaceError: 4,
}),
12,
);
// Clamped to the provider's levels.
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 5,
latitudeDeg: 0,
maximumLevel: 12,
}),
12,
);
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
null,
);
});
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
const mercator = new Cesium.WebMercatorTilingScheme();
const points = groundTrackAhead(PASS, 20);
const tiles = tilesForPoints(mercator, points, 13);
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
// Each tile holds its point: the first is the centre line 5 s ahead.
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
assert.ok(
Cesium.Rectangle.contains(
first,
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
),
);
// Ahead along a north-east track: rows north (smaller y), columns east.
const here = mercator.positionToTileXY(
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
13,
);
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
// Bounded, and a tile already asked for is skipped.
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
const asked = new Set(
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
(tile) => tile.key,
),
);
assert.deepEqual(
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
[],
);
assert.deepEqual(tilesForPoints({}, points, 13), []);
});
test('a view tilted past the limb no longer meets the Earth', () => {
// GEO: the Earth spans about 8.7 degrees either side of nadir.
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true);
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false);
// LEO: a 30 degree tilt still lands on the ground.
assert.equal(
offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }),
true,
);
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
});
test('the centre line of every lead comes before the frame edges', () => {
const points = groundTrackAhead(PASS, 20);
const across = ORBITAL_PREFETCH_ACROSS.length;
const ordered = centreLinesFirst(points);
assert.equal(ordered.length, points.length);
const leads = ORBITAL_PREFETCH_LEAD_S.length;
assert.deepEqual(
ordered.slice(0, leads),
points.filter((_, i) => i % across === 0),
);
// The 12-tile cap at a level of small tiles still reaches the far lead.
const mercator = new Cesium.WebMercatorTilingScheme();
const tiles = tilesForPoints(mercator, ordered, 17);
const far = mercator.positionToTileXY(
Cesium.Cartographic.fromDegrees(
ordered[leads - 1].lon,
ordered[leads - 1].lat,
),
17,
);
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Ground focus for the orbital view: the look that keeps a point on the
* ground at the centre of the picture as the satellite moves, whether that
* look is within the off-nadir reach, and which mapped points the view can
* show or be turned to. The look is the orbital view's own
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
import {
LOOK_EASE_TAU_MS,
ORBITAL_LOOK_LIMIT_DEG,
easeLook,
} from './cockpitLook.js';
/**
* How long a focus takes to swing the view onto its target, or back to
* nadir when it ends: the look's own easing curve, on a slower clock. Three
* of them (about 0.75 s) close 95% of the turn.
*/
export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
/** How often the orbital Live Signals rows are rebuilt, ms. */
export const ORBITAL_REACH_REFRESH_MS = 1000;
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
const WGS84_A2 = 6378137 ** 2;
const WGS84_B2 = 6356752.314245179 ** 2;
/** Slack on the reach limit so a target held exactly at 45° stays. */
const REACH_EPSILON_DEG = 1e-6;
const toDegrees = (rad) => (rad * 180) / Math.PI;
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* The look that centres `target` in the picture, in the nadir frame's tilt
* (north first, then east; see `offNadirOrientation`): east is the angle off
* the frame's top-bottom plane, north the angle along it. Also the single
* angle between nadir and the line of sight, the image's off-nadir angle,
* and whether the target faces the satellite (above its horizon).
* @param {{direction: object, up: object, right: object}} nadir From
* `nadirOrientation`.
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
* visible: boolean}|null} Null without usable positions.
*/
export function focusLook(nadir, satellite, target) {
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const range = Math.hypot(line.x, line.y, line.z);
if (!(range > 1)) return null;
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
const along = dot(v, nadir.direction);
const up = dot(v, nadir.up);
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
// The target's own zenith, the WGS84 surface normal there: the satellite
// is above its horizon when the line from the target to it points up.
const normal = {
x: target.x / WGS84_A2,
y: target.y / WGS84_A2,
z: target.z / WGS84_B2,
};
const visible =
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
const yawDeg = toDegrees(Math.asin(right));
const pitchDeg = toDegrees(Math.atan2(up, along));
return {
yawDeg: yawDeg === 0 ? 0 : yawDeg,
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
visible,
};
}
/**
* Whether a focus look is within the off-nadir reach: the target faces the
* satellite and each axis is within the arrows' ±45° clamp.
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
* @returns {boolean}
*/
export function focusInReach(look) {
if (!look || look.visible === false) return false;
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
return (
Number.isFinite(look.yawDeg) &&
Number.isFinite(look.pitchDeg) &&
Math.abs(look.yawDeg) <= limit &&
Math.abs(look.pitchDeg) <= limit
);
}
/**
* Ease what is left of the turn onto a focus target toward nothing: the
* look's easing (`easeLook`) on the focus's slower clock. The view is the
* live target's look plus this remainder, so once it lands the view holds
* the target exactly, without the lag of chasing it. Reduced motion cuts.
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
* @param {number} dtMs Milliseconds since the previous frame.
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeFocusRemainder(
remainder,
dtMs,
{ immediate = false } = {},
) {
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
immediate,
});
}
/**
* A frame's time on the focus's clock, for `easeLook`: its fixed time
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
* @param {number} dtMs
* @returns {number}
*/
export function focusEaseDtMs(dtMs) {
return Number.isFinite(dtMs) && dtMs > 0
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
: 0;
}
/**
* The mapped points the view can be turned to: those facing the satellite
* and within the off-nadir reach, nearest to nadir first.
* @param {Iterable<{key: string, position: {x: number, y: number,
* z: number}}>} candidates
* @param {{nadir: object, satellite: object}} frame
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
*/
export function pointsInReach(candidates, { nadir, satellite }) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return entries;
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!focusInReach(look)) continue;
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
}
return entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
}
+227
View File
@@ -0,0 +1,227 @@
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
// orbital view: the look that centres a target, its off-nadir angle, the
// reach, the easing onto and off a target, and the reach list.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
ORBITAL_FOCUS_EASE_TAU_MS,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
pointsInReach,
} from './cockpitOrbitalFocus.js';
import {
ORBITAL_EARTH_RADIUS_M,
nadirOrientation,
offNadirAngleDeg,
offNadirOrientation,
} from './cockpitOrbital.js';
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
const near = (actual, expected, label, tolerance = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} not within ${tolerance} of ${expected}`,
);
const unit = (v) => {
const n = Math.hypot(v.x, v.y, v.z);
return { x: v.x / n, y: v.y / n, z: v.z / n };
};
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
const angleBetweenDeg = (a, b) => {
const u = unit(a);
const v = unit(b);
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
return (Math.acos(cosine) * 180) / Math.PI;
};
/** A point on a sphere of radius `radiusM`, ECEF. */
const onSphere = (latDeg, lonDeg, radiusM) => {
const lat = (latDeg * Math.PI) / 180;
const lon = (lonDeg * Math.PI) / 180;
return {
x: radiusM * Math.cos(lat) * Math.cos(lon),
y: radiusM * Math.cos(lat) * Math.sin(lon),
z: radiusM * Math.sin(lat),
};
};
/** A point on the WGS84 ellipsoid, as the globe draws it. */
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
const ISS_ALT_M = 420_000;
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
geodeticToEcef(latDeg, lonDeg, altitudeM);
test('the focus look turns the nadir frame onto the target exactly', () => {
const satellite = satelliteAt(30, -97);
for (const heading of [null, 0, 51.6, 200]) {
const nadir = nadirOrientation(satellite, { headingDeg: heading });
for (const [lat, lon] of [
[31.5, -96],
[28.2, -99.4],
[30, -97],
[32.9, -97.1],
]) {
const target = geodeticToEcef(lat, lon, 300);
const look = focusLook(nadir, satellite, target);
const view = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
near(
angleBetweenDeg(view.direction, sub(target, satellite)),
0,
`centred at ${lat},${lon} heading ${heading}`,
1e-5,
);
// The single angle the readout gives is the tilt's total angle.
near(
look.offNadirDeg,
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
'one off-nadir angle',
1e-9,
);
assert.equal(look.visible, true);
}
}
});
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
// On a sphere: a target `central` radians from the sub-satellite point is
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
const R = ORBITAL_EARTH_RADIUS_M;
const satellite = onSphere(10, 20, R + ISS_ALT_M);
const nadir = nadirOrientation(satellite);
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
const target = onSphere(10 + centralDeg, 20, R);
const γ = (centralDeg * Math.PI) / 180;
const expected =
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
Math.PI;
const look = focusLook(nadir, satellite, target);
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
}
// Straight below reads zero, a real angle and not a missing one.
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
near(below.offNadirDeg, 0, 'straight below', 1e-9);
near(below.yawDeg, 0, 'no east', 1e-9);
near(below.pitchDeg, 0, 'no north', 1e-9);
assert.equal(focusInReach(below), true);
});
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
assert.equal(focusInReach(null), false);
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
// From geostationary height the far side of the Earth lies within a few
// degrees of nadir, but it faces away.
const satellite = satelliteAt(0, 0, 35_786_000);
const nadir = nadirOrientation(satellite);
const farSide = geodeticToEcef(0, 100, 0);
const look = focusLook(nadir, satellite, farSide);
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
assert.equal(look.visible, false);
assert.equal(focusInReach(look), false);
// A point near the sub-satellite point faces it.
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
});
test('a target slides out of reach as the satellite passes it', () => {
const target = geodeticToEcef(0, 10, 0);
const inReach = [];
for (let lon = 10; lon >= -5; lon -= 1) {
const satellite = satelliteAt(0, lon);
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
}
assert.equal(inReach[0], true, 'overhead');
assert.equal(inReach.at(-1), false, '15° of longitude behind');
// Once out, it stays out: the reach ends once, not on and off.
const firstOut = inReach.indexOf(false);
assert.ok(firstOut > 0);
assert.ok(inReach.slice(firstOut).every((value) => value === false));
});
test('the turn onto a target eases on the slower focus clock and lands', () => {
assert.equal(
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
LOOK_EASE_TAU_MS,
'one focus time constant is one look time constant',
);
assert.equal(focusEaseDtMs(Number.NaN), 0);
assert.equal(focusEaseDtMs(-5), 0);
let remainder = { yawDeg: -30, pitchDeg: 20 };
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
let frames = 0;
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
const before = remainder;
remainder = easeFocusRemainder(remainder, 16);
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
assert.ok(size < previous, 'always closer');
// No axis moves more than a tenth of the way in a frame, but for the
// last few hundredths of a degree it lands on: a turn, not a cut.
for (const axis of ['yawDeg', 'pitchDeg']) {
const step = Math.abs(before[axis] - remainder[axis]);
assert.ok(
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
`frame ${frames} ${axis}`,
);
}
previous = size;
frames += 1;
}
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
// About three time constants close 95%; landing takes a little longer.
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.deepEqual(
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
{ yawDeg: 0, pitchDeg: 0 },
'reduced motion cuts',
);
});
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const candidates = [];
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
// 45° tilt, the rest beyond it.
for (let i = 0; i < 80; i += 1)
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
const entries = pointsInReach(candidates, { nadir, satellite });
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
const angles = entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
assert.equal(entries[0].key, 'east-0');
assert.equal(entries[0].offNadirDeg, 0);
// Only those within the 45° tilt are listed.
assert.ok(entries.length > 0 && entries.length < 80);
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
});
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
const target = geodeticToEcef(45, 0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const satelliteAtElevation = (elevationDeg) => {
const e = Cesium.Math.toRadians(elevationDeg);
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
};
const below = satelliteAtElevation(-0.1);
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
const above = satelliteAtElevation(0.1);
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
});
+74
View File
@@ -0,0 +1,74 @@
/**
* The camera's hop from the satellite the orbital view rides to another.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
/** The most nearby satellites asked for the orbital rows, nearest first. */
export const ORBITAL_SATELLITES_MAX = 8;
/** How long the camera takes to hop to another satellite, ms. */
export const ORBITAL_HOP_MS = 1200;
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* Where the camera is part-way through a hop: along the great circle
* between the two positions (never through the Earth), its distance from the
* centre interpolated linearly. The circle is the one through both ends, so
* the path moves smoothly as the destination does, however far round it
* lies; only exactly opposite ends, which no single circle joins, turn about
* a fixed axis square to them.
* @param {{x: number, y: number, z: number}} from
* @param {{x: number, y: number, z: number}} to
* @param {number} progress Eased, 0..1.
* @returns {{x: number, y: number, z: number}|null}
*/
export function hopPosition(from, to, progress) {
if (!finiteVector(from) || !finiteVector(to)) return null;
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const ra = Math.hypot(from.x, from.y, from.z);
const rb = Math.hypot(to.x, to.y, to.z);
if (!(ra > 0) || !(rb > 0)) return null;
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
// The direction the circle leaves `a` in: `b` less its part along `a`.
let c = {
x: b.x - cosine * a.x,
y: b.y - cosine * a.y,
z: b.z - cosine * a.z,
};
let rc = Math.hypot(c.x, c.y, c.z);
const r = ra + (rb - ra) * p;
const omega = Math.atan2(rc, cosine);
if (!(rc > 1e-12)) {
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
// Opposite ends: set off square to `a`, northward (or, near a pole,
// toward the prime meridian).
const pole =
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
c = {
x: pole.x - along * a.x,
y: pole.y - along * a.y,
z: pole.z - along * a.z,
};
rc = Math.hypot(c.x, c.y, c.z);
}
const angle = p * omega;
const cos = Math.cos(angle);
const sin = Math.sin(angle) / rc;
return {
x: (a.x * cos + c.x * sin) * r,
y: (a.y * cos + c.y * sin) * r,
z: (a.z * cos + c.z * sin) * r,
};
}
+73
View File
@@ -0,0 +1,73 @@
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
// satellite to another.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { hopPosition } from './cockpitOrbitalSatellites.js';
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
test('the hop follows the great circle and lands on the satellite', () => {
const from = at(0, 0, 400_000);
const to = at(0, 90, 800_000);
const start = hopPosition(from, to, 0);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
// Half-way it is above the Earth, between the two heights, not on the
// chord through it.
const mid = hopPosition(from, to, 0.5);
const radius = Math.hypot(mid.x, mid.y, mid.z);
const ra = Cesium.Cartesian3.magnitude(from);
const rb = Cesium.Cartesian3.magnitude(to);
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
assert.equal(hopPosition(null, to, 0.5), null);
});
test('a hop to the opposite side goes round the Earth, not through it', () => {
const from = at(0, 0, 400_000);
const to = at(0, 180, 400_000);
const radius = Cesium.Cartesian3.magnitude(from);
for (const p of [0.25, 0.5, 0.75]) {
const point = hopPosition(from, to, p);
assert.ok(
Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3,
`p=${p}`,
);
}
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
});
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
const r = 6_778_137;
const from = { x: r, y: 0, z: 0 };
const near = (delta) => ({
x: -r * Math.cos(delta),
y: r * Math.sin(delta),
z: 0,
});
const mid = (delta) => hopPosition(from, near(delta), 0.5);
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
assert.ok(
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
'no jump between neighbours',
);
for (const delta of [1e-3, 1e-6, 1e-9]) {
const to = near(delta);
const end = hopPosition(from, to, 1);
assert.ok(
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
`lands at ${delta}`,
);
const half = mid(delta);
assert.ok(
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
`round at ${delta}`,
);
}
});
+166
View File
@@ -0,0 +1,166 @@
/**
* Bridge radar policy: the range steps, the range rings and the heading-up
* projection of nearby AIS vessels onto the radar's range circle. Pure: no
* DOM, no Cesium and no translated text; the Cockpit words the labels.
*/
import {
bearingBetweenCoordinates,
normalizeHeading,
relativeBearing,
} from './cockpitMath.js';
import { greatCircleMeters } from './geoDistance.js';
/** Metres in one international nautical mile. */
export const METRES_PER_NM = 1852;
/** The radar's range steps in nautical miles, shortest first. */
export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]);
/** The range the radar opens on. */
export const DEFAULT_RADAR_RANGE_NM = 3;
/** At most this many contacts are drawn, nearest first. */
export const RADAR_MAX_CONTACTS = 60;
/** Range rings divide the range into this many equal steps. */
export const RADAR_RING_COUNT = 4;
/**
* Normalize a range to one of the radar's steps.
* @param {number} rangeNm Requested range in nautical miles.
* @returns {number} The range when it is a step, else the default range.
*/
export function radarRangeNm(rangeNm) {
return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM;
}
/**
* The range after this one; the longest range wraps to the shortest.
* @param {number} rangeNm Current range in nautical miles.
* @returns {number} The next range step.
*/
export function nextRadarRange(rangeNm) {
const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm));
return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length];
}
/**
* Range ring distances at each quarter of the range, innermost first.
* @param {number} rangeNm Range in nautical miles.
* @returns {number[]} Ring distances in nautical miles; the last is the range.
*/
export function radarRingsNm(rangeNm) {
const range = radarRangeNm(rangeNm);
return Array.from(
{ length: RADAR_RING_COUNT },
(_, index) => (range * (index + 1)) / RADAR_RING_COUNT,
);
}
/**
* The search radius for a range, in metres.
* @param {number} rangeNm Range in nautical miles.
* @returns {number}
*/
export function radarRangeM(rangeNm) {
return radarRangeNm(rangeNm) * METRES_PER_NM;
}
const round4 = (value) => Math.round(value * 1e4) / 1e4;
/**
* Project vessel contacts onto a heading-up radar.
*
* Positions are fractions of the range circle: x to the right (starboard),
* y up (ahead), both in [-1, 1]. Contacts outside the range, or without a
* position, are left out; the rest come nearest first, at most `maxCount`.
* @param {{latitude: number, longitude: number, headingDeg: number}} own
* Own ship position and the heading the radar is drawn up from.
* @param {Array<{id: string, name?: string, latitude: number,
* longitude: number, course?: number|null, color?: string,
* stale?: boolean}>} contacts Nearby vessels.
* @param {{rangeNm?: number, maxCount?: number}} [options]
* @returns {Array<{id: string, name: string, x: number, y: number,
* distanceNm: number, relativeBearingDeg: number,
* relativeCourseDeg: number|null, color: string, stale: boolean}>}
* relativeBearingDeg and relativeCourseDeg are clockwise from the bow in
* [-180, 180).
*/
export function projectRadarContacts(own, contacts, options = {}) {
const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM);
const cap =
Number.isFinite(options.maxCount) && options.maxCount > 0
? Math.floor(options.maxCount)
: RADAR_MAX_CONTACTS;
const lat = Number(own?.latitude);
const lon = Number(own?.longitude);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Array.isArray(contacts)
)
return [];
const heading = normalizeHeading(own.headingDeg ?? 0);
const projected = [];
for (const contact of contacts) {
const toLat = Number(contact?.latitude);
const toLon = Number(contact?.longitude);
const distanceNm =
greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) /
METRES_PER_NM;
if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue;
const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon);
const relativeBearingDeg =
bearing === null ? 0 : relativeBearing(bearing, heading);
const radians = (relativeBearingDeg * Math.PI) / 180;
const fraction = distanceNm / rangeNm;
const course =
contact.course === null || contact.course === undefined
? NaN
: Number(contact.course);
projected.push({
id: String(contact.id),
name: String(contact.name || contact.id),
x: round4(Math.sin(radians) * fraction),
y: round4(Math.cos(radians) * fraction),
distanceNm,
relativeBearingDeg,
relativeCourseDeg: Number.isFinite(course)
? relativeBearing(course, heading)
: null,
color: contact.color || '',
stale: contact.stale === true,
});
}
projected.sort((a, b) => a.distanceNm - b.distanceNm);
return projected.slice(0, cap);
}
/**
* The radar presentation without its worded labels.
* @param {{latitude: number, longitude: number, headingDeg: number,
* courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground.
* @param {Array<object>} contacts See {@link projectRadarContacts}.
* @param {number} rangeNm Range in nautical miles.
* @returns {{rangeNm: number, ringsNm: number[], headingDeg: number,
* ownRelativeCourseDeg: number|null, contacts: Array<object>}}
*/
export function radarPicture(own, contacts, rangeNm) {
const range = radarRangeNm(rangeNm);
const headingDeg = normalizeHeading(own?.headingDeg ?? 0);
const course =
own?.courseDeg === null || own?.courseDeg === undefined
? NaN
: Number(own.courseDeg);
return {
rangeNm: range,
ringsNm: radarRingsNm(range),
headingDeg,
ownRelativeCourseDeg: Number.isFinite(course)
? relativeBearing(course, headingDeg)
: null,
contacts: projectRadarContacts({ ...own, headingDeg }, contacts, {
rangeNm: range,
}),
};
}
+140
View File
@@ -0,0 +1,140 @@
// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and
// heading-up contact projection.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_RADAR_RANGE_NM,
METRES_PER_NM,
RADAR_MAX_CONTACTS,
RADAR_RANGES_NM,
nextRadarRange,
projectRadarContacts,
radarPicture,
radarRangeM,
radarRingsNm,
} from './cockpitRadar.js';
const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 });
/** One nautical mile of latitude, in degrees. */
const NM_LAT = 1 / 60;
/** One nautical mile of longitude at 54.5°N, in degrees. */
const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180));
function contact(id, northNm, eastNm, extra = {}) {
return {
id,
name: id,
latitude: OWN.latitude + northNm * NM_LAT,
longitude: OWN.longitude + eastNm * NM_LON,
course: null,
color: '#fff',
stale: false,
...extra,
};
}
test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => {
assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]);
assert.equal(DEFAULT_RADAR_RANGE_NM, 3);
assert.equal(nextRadarRange(3), 6);
assert.equal(nextRadarRange(6), 12);
assert.equal(nextRadarRange(12), 24);
assert.equal(nextRadarRange(24), 3);
assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default');
assert.equal(radarRangeM(6), 6 * METRES_PER_NM);
});
test('range rings sit at each quarter of the range', () => {
assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]);
assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]);
});
test('a contact dead ahead is straight up the scope', () => {
const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], {
rangeNm: 6,
});
assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`);
assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`);
assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02);
assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5);
});
test('heading-up turns the picture with the bow', () => {
// Heading east: a contact to the north is on the port beam, one to the
// south on the starboard beam, one to the east dead ahead.
const own = { ...OWN, headingDeg: 90 };
const byId = Object.fromEntries(
projectRadarContacts(own, [
contact('N', 2, 0),
contact('S', -2, 0),
contact('E', 0, 2),
]).map((entry) => [entry.id, entry]),
);
assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port');
assert.ok(
byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02,
'south is to starboard',
);
assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead');
assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1);
assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1);
});
test('a contact to starboard is right of the centre', () => {
const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]);
assert.ok(starboard.x > 0.2, `x ${starboard.x}`);
assert.ok(Math.abs(starboard.y) < 0.01);
});
test('course is drawn relative to the bow and stays null when unknown', () => {
const own = { ...OWN, headingDeg: 90 };
const [crossing, drifting] = projectRadarContacts(own, [
contact('A', 1, 0, { course: 180 }),
contact('B', 2, 0, { course: null }),
]);
assert.equal(crossing.relativeCourseDeg, 90);
assert.equal(drifting.relativeCourseDeg, null);
});
test('contacts beyond the range are left out', () => {
const contacts = projectRadarContacts(
OWN,
[contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)],
{ rangeNm: 6 },
);
assert.deepEqual(
contacts.map((entry) => entry.id),
['in'],
);
assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1));
});
test('contacts come nearest first, capped', () => {
const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) =>
contact(`V${index}`, 0, 5.5 - index * 0.07),
);
const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 });
assert.equal(contacts.length, RADAR_MAX_CONTACTS);
for (let index = 1; index < contacts.length; index += 1)
assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm);
assert.equal(
projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length,
3,
'a smaller cap is honoured',
);
});
test('a picture carries the range, rings, heading and own course over ground', () => {
const picture = radarPicture(
{ ...OWN, headingDeg: 350, courseDeg: 10 },
[contact('A', 1, 0)],
12,
);
assert.equal(picture.rangeNm, 12);
assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]);
assert.equal(picture.headingDeg, 350);
assert.equal(picture.ownRelativeCourseDeg, 20);
assert.equal(picture.contacts.length, 1);
assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°');
assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null);
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Camera policy for first-person subjects other than aircraft: where a
* vessel's bridge eye sits and how its reported fix advances between feed
* refreshes. No DOM or i18n dependencies, so the policy is unit-testable.
*/
import { projectGroundArcLatLon } from './data/motionModel.js';
/** Bridge cameras look along the horizon; a ship's eye is already high. */
export const VESSEL_BRIDGE_PITCH_DEG = -2;
/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */
export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0;
/** Clearance over the shared ground floor for a camera that floats on water. */
export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2;
/** Eye height used when the vessel layer knows no height for the type. */
export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12;
/**
* A reported fix older than this stops advancing: the vessel holds where the
* last projection left it instead of sailing on through a feed outage.
*/
export const VESSEL_DEAD_RECKON_MAX_S = 180;
/**
* The vessel layer floats every ship on the geoid (mean sea level), but a
* river or lake can sit well above it: the Seine through Paris is ~27 m up,
* which put a bridge eye under the water. The bridge reads the rendered
* surface around its ship instead: probes on a ring this far out, plus one
* at the ship itself.
*/
export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30;
/** Probes on the ring around the ship. */
export const VESSEL_WATERLINE_PROBE_COUNT = 8;
/** How often the bridge reads the water under its ship again, ms. */
export const VESSEL_WATERLINE_REFRESH_MS = 5000;
/** A surface less than this above sea level is the sea itself, metres. */
export const VESSEL_WATERLINE_MIN_RISE_M = 2;
/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */
export const VESSEL_WATERLINE_MAX_RISE_M = 4000;
/**
* Where to read the surface around a ship: the ship's own position, then a
* ring of probes around it.
* @param {number} latitude
* @param {number} longitude
* @returns {Array<{lat: number, lon: number}>}
*/
export function waterlineProbePoints(
latitude,
longitude,
radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M,
count = VESSEL_WATERLINE_PROBE_COUNT,
) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
const points = [{ lat: latitude, lon: longitude }];
const metresPerDegLat = 111_320;
const metresPerDegLon =
metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180));
for (let i = 0; i < count; i += 1) {
const bearing = (2 * Math.PI * i) / count;
points.push({
lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat,
lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon,
});
}
return points;
}
/**
* The water surface a bridge floats on, ellipsoidal metres. Quays, bridges,
* moored boats and the ship's own model all stand above the water, so the
* lowest probe is the water. A lowest probe barely above sea level is the
* sea; one implausibly high is a bad sample. Either way the geoid stands.
* @param {number} seaSurfaceM The geoid sea surface the layer floats ships on.
* @param {Array<number|null|undefined>} sampleHeightsM Rendered-surface probes.
* @returns {number}
*/
export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) {
const heights = (sampleHeightsM || []).filter(Number.isFinite);
if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM;
const lowest = Math.min(...heights);
const rise = lowest - seaSurfaceM;
return rise >= VESSEL_WATERLINE_MIN_RISE_M &&
rise <= VESSEL_WATERLINE_MAX_RISE_M
? lowest
: seaSurfaceM;
}
/**
* Camera placement for a vessel subject, in the shape the Cockpit camera
* reads for every subject kind.
* @param {{eyeHeightM?: number|null}} info Vessel subject info.
* @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}}
*/
export function vesselCameraProfile(info) {
return {
forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M,
upM: Number.isFinite(info?.eyeHeightM)
? info.eyeHeightM
: VESSEL_DEFAULT_EYE_HEIGHT_M,
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
};
}
/**
* How old a vessel's fix is now, seconds, or null without a fix time.
*
* The fix time is on the AIS feed's clock and `nowMs` on this client's, so
* comparing them directly would turn a client clock running minutes fast
* into minutes of age (every bridge projecting its full limit and reading
* STALE FEED). With the time this client first received the fix (client
* clock) and the newest fix in that refresh (feed clock), the age is the
* time since the fix arrived plus how far it trailed that newest fix; each
* part is read on one clock. A fix the feed keeps re-serving keeps its first
* receipt, so it ages into STALE FEED. The age leaves out the newest fix's
* own delay before the refresh, typically seconds: a feed that was already
* stalled when the bridge first saw it ages from then. Without them it falls
* back to the clock difference, never negative.
* @param {{fixEpochMs: number|null, receivedAtMs?: number|null,
* feedEpochMs?: number|null}} fix
* @param {number} nowMs Wall-clock time, ms.
* @returns {number|null}
*/
export function vesselFixAgeSec(fix, nowMs) {
const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {};
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null;
if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs))
return (
Math.max(0, (nowMs - receivedAtMs) / 1000) +
Math.max(0, (feedEpochMs - fixEpochMs) / 1000)
);
return Math.max(0, (nowMs - fixEpochMs) / 1000);
}
/**
* Advance a vessel's last reported fix along its course over ground.
*
* The vessel layer redraws a ship only when the feed refreshes, so its icon
* stands still between fixes. A camera converging on that still point would
* sail backwards against its own motion. Projecting the fix forward gives the
* Cockpit anchor a moving target, and the bounded age keeps a silent feed from
* carrying the ship away indefinitely.
* @param {{latitude: number, longitude: number, courseDeg: number|null,
* speedMps: number|null, fixEpochMs: number|null,
* receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last
* report; its age is `vesselFixAgeSec`.
* @param {number} nowMs Wall-clock time, ms.
* @returns {{latitude: number, longitude: number, ageSec: number|null}}
*/
export function deadReckonVesselFix(fix, nowMs) {
const { latitude, longitude, courseDeg, speedMps } = fix || {};
const ageSec = vesselFixAgeSec(fix, nowMs);
if (ageSec === null) return { latitude, longitude, ageSec: null };
const { lat, lon } = projectGroundArcLatLon(
latitude,
longitude,
courseDeg,
speedMps,
0,
Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S),
);
return { latitude: lat, longitude: lon, ageSec };
}
+137
View File
@@ -0,0 +1,137 @@
// src/cockpitSubjects.test.mjs — the vessel camera profile and dead
// reckoning of AIS fixes between feed refreshes.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
VESSEL_BRIDGE_MIN_CLEARANCE_M,
VESSEL_BRIDGE_PITCH_DEG,
VESSEL_DEAD_RECKON_MAX_S,
VESSEL_DEFAULT_EYE_HEIGHT_M,
VESSEL_WATERLINE_PROBE_RADIUS_M,
deadReckonVesselFix,
vesselFixAgeSec,
vesselCameraProfile,
vesselWaterlineM,
waterlineProbePoints,
} from './cockpitSubjects.js';
const EARTH_M_PER_DEG_LAT = 111_195;
test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => {
assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), {
forwardM: 0,
upM: 30,
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
});
});
test('a vessel with no known eye height uses the default', () => {
for (const info of [{}, { eyeHeightM: null }, null]) {
assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M);
}
});
test('a fix advances along course over ground for its age', () => {
const fixEpochMs = 1_000_000;
const advanced = deadReckonVesselFix(
{
latitude: 0,
longitude: 0,
courseDeg: 0,
speedMps: 10,
fixEpochMs,
},
fixEpochMs + 60_000,
);
assert.equal(advanced.ageSec, 60);
// Sixty seconds north at 10 m/s.
assert.ok(
Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2,
`expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`,
);
assert.ok(Math.abs(advanced.longitude) < 1e-9);
});
test('projection stops at the dead-reckoning limit', () => {
const fixEpochMs = 0;
const at = (ageSec) =>
deadReckonVesselFix(
{ latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs },
fixEpochMs + ageSec * 1000,
);
const limit = at(VESSEL_DEAD_RECKON_MAX_S);
const later = at(VESSEL_DEAD_RECKON_MAX_S * 4);
assert.equal(later.longitude, limit.longitude);
assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4);
});
test('a fix without time, course or speed stays where it was reported', () => {
const fix = {
latitude: 54.3,
longitude: 10.1,
courseDeg: null,
speedMps: 6,
fixEpochMs: 0,
};
assert.deepEqual(deadReckonVesselFix(fix, 30_000), {
latitude: 54.3,
longitude: 10.1,
ageSec: 30,
});
assert.deepEqual(
deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000),
{ latitude: 54.3, longitude: 10.1, ageSec: null },
);
});
test('waterline probes ring the ship at the probe radius', () => {
const points = waterlineProbePoints(48.859, 2.293);
assert.equal(points.length, 9);
assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 });
for (const point of points.slice(1)) {
const northM = (point.lat - 48.859) * 111_320;
const eastM =
(point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180);
assert.ok(
Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) <
0.01,
);
}
assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []);
});
test('the waterline is the lowest probe when it stands clear of the sea', () => {
// The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m.
assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2);
// Swell and tile noise are the sea itself.
assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6);
// Under the sea surface (a dock, a bad tile) never sinks the ship.
assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6);
// Nothing sampled, or implausibly high.
assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6);
assert.equal(vesselWaterlineM(44.6, [9000]), 44.6);
});
test("a fix's age is read on each clock, so a fast client clock adds none", () => {
const fixEpochMs = 1_800_000_000_000;
// The refresh's newest fix is 10 s newer than this one; the client, its
// clock five minutes fast, received the refresh 5 s ago.
const receivedAtMs = fixEpochMs + 12_000 + 300_000;
const fix = {
latitude: 0,
longitude: 0,
courseDeg: 90,
speedMps: 5,
fixEpochMs,
receivedAtMs,
feedEpochMs: fixEpochMs + 10_000,
};
assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15);
assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15);
// Without receive data the clocks are compared, never below zero.
const bare = { fixEpochMs };
assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60);
assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0);
assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null);
});
+2 -1
View File
@@ -65,7 +65,8 @@ export function enterCockpitWithTracking({
);
}
}
if (!entryError) entered = Boolean(cockpitView.enter());
// This transaction selects aircraft; a selected vessel must not stand in.
if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));
} catch (error) {
entryThrew = true;
entryError = error instanceof Error ? error : new Error(String(error));
+36
View File
@@ -445,6 +445,42 @@ export function cockpitEntryAllowed({
);
}
/**
* Whether the selected vessel's bridge view can be entered. Like aircraft
* Cockpit it needs only its own layer, Live AIS Vessels, with a vessel
* selected. A Context mode change in flight still owns the camera, so entry
* waits it out.
* @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state
* @returns {boolean}
*/
export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
return Boolean(vesselsEnabled) && !contextModeChanging;
}
/**
* Whether the tracked satellite's orbital view can be entered. Like the
* bridge it needs only its own layer, Satellites, with a satellite tracked.
* A Context mode change in flight still owns the camera, so entry waits it
* out. A subject the satellites layer marks as not enterable (debris:
* rocket bodies, fragments, dead satellites; `satelliteClassEnterable` in
* src/layers/satellites/classes.js) can be tracked but is never ridden: it is
* no mission and has no view to offer.
* @param {{satellitesEnabled: boolean, contextModeChanging: boolean,
* subjectEnterable?: boolean}} state
* @returns {boolean}
*/
export function orbitEntryAllowed({
satellitesEnabled,
contextModeChanging,
subjectEnterable = true,
}) {
return (
Boolean(satellitesEnabled) &&
!contextModeChanging &&
subjectEnterable !== false
);
}
/**
* Internal context-mode id → the word the voice tools accept for it.
*
+26
View File
@@ -1,6 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
bridgeEntryAllowed,
orbitEntryAllowed,
cockpitEntryAllowed,
contextAllowedLayerIds,
contextLayerEnableBlockReason,
@@ -426,6 +428,30 @@ test("cockpit entry needs only the tracked aircraft's own layer", () => {
subjectLayerEnabled: false,
contextModeChanging: false,
}), false, 'there is no aircraft to ride');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'ais-live-vessels',
subjectLayerEnabled: true,
contextModeChanging: false,
}), false, "a vessel is the bridge gate's subject, not Cockpit's");
});
test('bridge entry needs only Live AIS Vessels', () => {
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true);
assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false);
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false);
});
test('orbital entry needs only Satellites', () => {
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: false }), true);
assert.equal(orbitEntryAllowed({ satellitesEnabled: false, contextModeChanging: false }), false);
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: true }), false);
});
test('debris can be tracked but never ridden in orbit', () => {
const on = { satellitesEnabled: true, contextModeChanging: false };
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: false }), false);
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: true }), true);
assert.equal(orbitEntryAllowed(on), true, 'no subject read yet');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+41 -17
View File
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a
// conservative floor rather than a guess.
// conservative floor rather than a guess. The world timeline's lift raises
// the dock, its trays and the credit together, so it moves no gap either.
const VETTED_VARS = new Set([
'--dock-pinned-stack-height',
'--dock-lower-pinned-height',
'--dock-location-pinned-height',
'--dock-presets-pinned-height',
'--world-timeline-lift',
]);
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 66, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
liftPx = lift;
try {
check(lift);
} finally {
liftPx = 0;
}
}
}
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor
}
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = [];
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
}
}
}
}
});
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
});
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
);
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
}
eachLift((lift) => {
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
});
}
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
});
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
'calc(2vh + 5rem)',
`calc(2vh + 5rem + ${lift})`,
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
);
});
+55
View File
@@ -989,6 +989,22 @@ test('vessel interaction wire: trail pick does not deselect', () => {
}
});
test('vessel interaction wire: a click on open water keeps the ship the bridge rides', () => {
const harness = installWireHarness({ id: 'gev-trailing-contact' });
try {
aisLiveVesselsLayer.setCockpitSubject(harness.record.mmsi);
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
// Released, the same click deselects as it always has.
aisLiveVesselsLayer.setCockpitSubject(null);
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
} finally {
harness.cleanup();
}
});
test('vessel interaction wire: only the gev-trail: namespace receives the trail no-op', () => {
const harness = installWireHarness({ id: 'gev-trailing-contact' });
try {
@@ -1786,3 +1802,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
test('detection objects name the vessel card their callout gives way to', () => {
const vessel = (mmsi, name) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
const keyed = vessel('200', 'GOLDEN BEAR');
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
_setVesselStateForTest({
viewer: {},
records: [keyed, unkeyed],
billboardCollection: { show: true, remove() {} },
});
try {
const objects = Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
assert.equal(
objects['NO MMSI'].cardEntryId,
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
'the same identity the vessel card is published under',
);
} finally {
_setVesselStateForTest({ enabled: false });
}
});
+21
View File
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
h.restore();
}
});
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
const record = normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: {
'surveillance:type': 'ALPR',
operator: ' Şehir\n\u0000 PD ',
manufacturer: '😀'.repeat(200),
'camera:type': null,
'surveillance:zone': '\t traffic zone ',
},
});
assert.equal(record.operator, 'Şehir PD');
assert.equal(record.manufacturer.length, 180);
assert.equal(record.manufacturer, '😀'.repeat(90));
assert.equal(record.cameraType, null);
assert.equal(record.zone, 'traffic zone');
});
+78 -5
View File
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'planespotters',
html:
'Aircraft photos: ' +
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
'· each photo is © its photographer, credited beside it',
},
{
key: 'aisstream',
html:
@@ -143,10 +150,23 @@ export const DATA_CREDITS = [
{
key: 'celestrak',
html:
'Satellites (TLEs): CelesTrak ' +
'Satellites (TLEs and SATCAT): CelesTrak ' +
'(<a href="https://celestrak.org" target="_blank" rel="noopener">celestrak.org</a>), ' +
'Dr. T.S. Kelso',
},
{
key: 'ucs-satellite-database',
html:
'Satellite operators and purposes: ' +
'<a href="https://www.ucsusa.org/satellite_database" target="_blank" rel="noopener">UCS Satellite Database</a> ' +
'(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists',
},
{
key: 'imaging-sensors',
html:
'Imaging sensor swaths: operator and agency specifications, ' +
'cited per platform',
},
{
key: 'launch-library-2',
html:
@@ -553,6 +573,30 @@ export const DATA_CREDITS = [
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
'Shown as published; reports are S2 assessments, not verified fact.',
},
{
key: 'news',
html:
'News: every report is credited to its own publisher and links to the original article; ' +
'pictures are the publishers’ own, shown from their sites with the publisher named. ' +
'Places, categories, severity and English headlines are derived automatically, by ' +
'bundled rules, the news provider or the configured news server, and can be wrong.',
},
{
// GDELT's terms ask for a citation and a link.
key: 'gdelt-news',
html:
'News, Conflicts without a news server: events and page titles from the ' +
'<a href="https://www.gdeltproject.org/" target="_blank" rel="noopener">GDELT Project</a> ' +
'(GDELT 2.0 events and GKG; places and categories are machine-coded)',
},
{
// World News API's free plan asks for a visible "World News API" link to
// its site; always listed, so a free-plan key is never shown without it.
key: 'worldnewsapi',
html:
'News, Local without a news server: headlines from ' +
'<a href="https://worldnewsapi.com/" target="_blank" rel="noopener">World News API</a>',
},
{
key: 'shipwrecks',
html:
@@ -627,6 +671,15 @@ export const TOMTOM_CREDIT = {
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
};
/** Registered when the News layer shows a Liveuamap report (only with
* LIVEUAMAP_API_KEY set and no news server). */
export const LIVEUAMAP_CREDIT = {
key: 'liveuamap',
html:
'News, Conflicts: mapped reports from ' +
'<a href="https://liveuamap.com/" target="_blank" rel="noopener">Liveuamap</a>',
};
/** Registered when the first Natural Earth region or state/province outline
* resolves (public domain — no attribution required; credited as a courtesy). */
export const NATURAL_EARTH_CREDIT = {
@@ -668,20 +721,40 @@ export const OSM_PLACES_CREDIT = {
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
*
* Two optional registry fields serve licences that ask for more than a name:
* `sourceUrl` links the attribution to the publisher (Warsaw asks for a link
* to its API), and `creditDated` adds the UTC date of the data, from the feed
* header or else the fetch time (Warsaw and Poznań ask for the download or
* update date). A dated credit is keyed by its date, so a new day registers a
* new line instead of keeping yesterday's.
* @param {{ id: string, attribution: string, license: string, licenseUrl: string,
* sourceUrl?: string, creditDated?: boolean }} feed
* @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot]
* `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms.
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
export function transitFeedCredit(feed, snapshot = null) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const source = feed.sourceUrl
? `<a href="${escape(feed.sourceUrl)}" target="_blank" rel="noopener">${escape(feed.attribution)}</a>`
: escape(feed.attribution);
const dataMs = Number.isFinite(snapshot?.feedTimestamp)
? snapshot.feedTimestamp * 1000
: snapshot?.fetchedAt;
const date =
feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0
? new Date(dataMs).toISOString().slice(0, 10)
: null;
return {
key: `transit-${feed.id}`,
key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
html:
`Transit (${escape(feed.attribution)}): ` +
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
+78 -1
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
import {
DATA_CREDITS,
LIVEUAMAP_CREDIT,
NYC_DOT_DISCLAIMER,
transitFeedCredit,
} from './dataCredits.js';
import { getTransitFeed } from './transitFeeds.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -38,6 +44,36 @@ test('adsbdb is credited and carries its published route-data restriction', () =
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
test('Planespotters is credited with a plain followable link', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters');
assert.ok(credit, 'the tracked aircraft photos come from Planespotters');
assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/);
assert.doesNotMatch(credit.html, /nofollow/);
});
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
// UCS database manual, section 3: references name the version (the Excel
// file's name) and link www.ucsusa.org/satellite_database.
const credit = DATA_CREDITS.find(
(entry) => entry.key === 'ucs-satellite-database',
);
assert.ok(credit, 'the satellite classes and operators come from UCS');
assert.match(credit.html, /UCS-Satellite-Database 5-1-2023/);
assert.match(
credit.html,
/href="https:\/\/www\.ucsusa\.org\/satellite_database"/,
);
});
test('the imaging sensor swaths credit operator and agency specifications', () => {
// Each sensor-pack entry cites its operator's or agency's own pages;
// no aggregator database is credited.
const credit = DATA_CREDITS.find((entry) => entry.key === 'imaging-sensors');
assert.ok(credit, 'the orbital zoom caps come from the sensor pack');
assert.match(credit.html, /operator and agency specifications/);
assert.doesNotMatch(credit.html, /OSCAR|wmo\.int/);
});
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
const osm = DATA_CREDITS.filter((entry) =>
entry.html.includes('openstreetmap.org/copyright'),
@@ -154,6 +190,26 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
);
});
test('the News layer credits its built-in sources', () => {
const credit = (key) => DATA_CREDITS.find((entry) => entry.key === key);
// GDELT's terms ask for a citation and a link.
assert.match(
credit('gdelt-news').html,
/href="https:\/\/www\.gdeltproject\.org\/"[^>]*>GDELT Project</,
);
// World News API's free plan asks for this exact link.
assert.match(
credit('worldnewsapi').html,
/<a href="https:\/\/worldnewsapi\.com\/"[^>]*>World News API<\/a>/,
);
assert.match(LIVEUAMAP_CREDIT.html, />Liveuamap</);
assert.ok(
!DATA_CREDITS.some((entry) => entry.key === LIVEUAMAP_CREDIT.key),
'Liveuamap is credited only once its key is in use',
);
assert.doesNotMatch(credit('news').html, /No single provider/);
});
test('the PR #1506 camera packs are credited with their sources and terms', () => {
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
@@ -167,3 +223,24 @@ test('the PR #1506 camera packs are credited with their sources and terms', () =
/href="https:\/\/www\.river\.go\.jp\//,
);
});
test('Warsaw and Poznań credits carry the data date their licences ask for', () => {
const warsaw = getTransitFeed('ztm-warszawa');
// 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds).
const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 });
assert.match(credit.html, /, data of 2026-11-09\)/);
assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/);
assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09');
// Without a feed timestamp the fetch time is the download date.
const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), {
feedTimestamp: null,
fetchedAt: Date.UTC(2026, 10, 10, 12),
});
assert.match(poznan.html, /data of 2026-11-10/);
// Undated feeds keep one credit line for the session.
const mbta = transitFeedCredit(getTransitFeed('mbta'), {
feedTimestamp: 1_794_204_000,
});
assert.equal(mbta.key, 'transit-mbta');
assert.doesNotMatch(mbta.html, /data of/);
});
+24 -3
View File
@@ -21,7 +21,10 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
getQueuedOverlayEntryAlpha,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
const SPARSE_ZONE_FRACTION = 0.5;
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
/** 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. */
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
.filter((row) => row.paintAlpha > 0.001)
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
return result;
}
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
paintAlpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
_calloutCount++;
}
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
paintDetectionCallout(ctx, row, row.alpha);
// The callout would show through its contact's own card: fade it by the
// opacity that card paints with this frame.
const cardAlpha =
row.cardEntryId === ''
? 0
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
row.paintAlpha =
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
}
ctx.globalAlpha = 1;
}
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
+179
View File
@@ -20,9 +20,16 @@ import {
} from './detection.js';
import {
destroyWorldOverlay,
getOverlayPaintRect,
initWorldOverlay,
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import {
getKeyholeFadeTuning,
getKeyholeGeometry,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
}
});
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
function shipWithCard(mmsi, position) {
return {
position,
sourceId: mmsi,
id: `SHIP-${mmsi}`,
metric: '6 kn',
type: 'SEA',
cardSourceId: 'ais-live-vessels',
cardEntryId: `vessel:${mmsi}`,
};
}
/** The production ambient vessel card (not protected: it goes through the arbiter). */
function ambientVesselCard(mmsi, position) {
return applyVesselOverlayPolicy({
id: `vessel:${mmsi}`,
position,
title: `CARD-${mmsi}`,
details: ['CARGO · 6KT · 80°'],
gapPx: 10,
});
}
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
// Default keyhole tuning: 7% feather, 1% outside opacity.
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
const width = 1600;
const height = 900;
const keyhole = getKeyholeGeometry(width, height);
const env = installEnvironment({ width, height, dpr: 1 });
// Just outside the keyhole's left rim: the card sits above or below the
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
const ship = shipWithCard('200', position);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
for (let i = 0; i < 4; i++) settleFrame(env);
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
assert.ok(rect, 'the host paints the card (at the outside opacity)');
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
const callouts = getDetectionDiagnostics().calloutRects;
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
const ship = shipWithCard('200', at(0.1, 0.1));
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
const frame = (ms) => {
env.ctx.calls.length = 0;
env.advance(ms);
env.postRender.raise();
};
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
for (let i = 0; i < 6; i++) frame(250);
frame(16);
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
// The vessel layer republishes its screen-grid cohort after a camera move:
// this vessel's card is replaced by another one, the vessel stays detectable.
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
frame(16);
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
assert.ok(painted('CARD-200'), 'and its text still paints');
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
for (let i = 0; i < 4; i++) frame(250);
assert.ok(!painted('CARD-200'), 'the card has faded out');
assert.ok(painted('SHIP-200'), 'and the callout is back');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a vessel whose ambient card loses the collision keeps its callout', () => {
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
// Four vessels a few pixels apart: their cards collide (a card goes only
// above or below its anchor), so the arbiter paints only some of them.
const ships = [0, 1, 2, 3].map((i) =>
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
setOverlayEntries(
'ais-live-vessels',
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
);
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
env.ctx.calls.length = 0;
settleFrame(env);
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
assert.ok(
winners.length >= 1 && winners.length < ships.length,
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
);
for (const ship of ships) {
const carded = winners.includes(ship);
assert.equal(
painted(ship.id),
!carded,
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
);
}
assert.equal(
getDetectionDiagnostics().calloutRects.length,
ships.length - winners.length,
'only the callouts that painted are reported',
);
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout fades out while its card fades in instead of vanishing at once', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
const alphas = [];
for (let i = 0; i < 30; i++) {
env.advance(16);
env.postRender.raise();
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
}
assert.ok(
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
);
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
// Satellites sit over the high-albedo lit Earth disc far more often than
// aircraft do, so the space tier carries the heavier backing. Losing the
+11
View File
@@ -0,0 +1,11 @@
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
export function normalizeDisplayText(value, maxLength = 180) {
if (value == null || typeof value === 'object') return '';
return String(value)
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
.replace(/[\uD800-\uDBFF]$/, '')
.trim();
}
+4 -3
View File
@@ -120,9 +120,10 @@ test('fire detection key survives a refetch that renumbers the index', () => {
});
test('two satellites over the same pixel at the same time stay distinct', () => {
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
// overlap, so position and time alone are NOT unique. Collapsing them let a
// restored selection land on the other satellite's record.
// The proxy merges NOAA-20 + NOAA-21 without dedup and their orbits overlap,
// so position and time alone are NOT unique. Collapsing them let a restored
// selection land on the other satellite's record. Archived Suomi NPP (`N`)
// rows must stay distinct too.
const base = makeFire({ satellite: 'N20' });
const twin = makeFire({ satellite: 'N21' });
const suomi = makeFire({ satellite: 'N' });
+14 -5
View File
@@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
// Read the list from production so these tests follow it.
const listed = config.match(/const SOURCES = \[([^\]]*)\];/);
assert.ok(listed, 'FIRMS source list exists');
const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]);
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
@@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0, 0][index], ok: true,
source, count: [200_000, 1, 0][index], ok: true,
})));
});
@@ -63,7 +66,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 3);
assert.equal(result.fires.length, SOURCES.length - 1);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
@@ -77,7 +80,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 3);
assert.equal(result.fires.length, SOURCES.length - 1);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
@@ -107,7 +110,7 @@ function wrappedAddressSelectionFailure() {
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
@@ -307,3 +310,9 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () =>
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
test('FIRMS requests only the satellites NASA still delivers', () => {
// NASA stopped delivering Suomi NPP on 2026-11-02; asking for it only spends
// a MAP_KEY transaction.
assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT'));
});
+2
View File
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
flightsLayer.testing._driveFleetModelHandoffForTest;
export const _ensureFleetModelForTest =
flightsLayer.testing._ensureFleetModelForTest;
export const _resetEnrichmentForTest =
flightsLayer.testing._resetEnrichmentForTest;
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
export default flightsLayer;
+293 -2
View File
@@ -1,7 +1,7 @@
// src/data/flights.test.mjs
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
// Pure function — no viewer/DOM needed; imported directly.
import { test } from 'node:test';
import { after, afterEach, before, test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import flightsLayer, {
@@ -14,21 +14,53 @@ import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
_floorGroundedDisplayPositionForTest,
_clearDisplayFloorStateForTest,
_resetEnrichmentForTest,
mapAnalystRecord,
} from './flights.js';
import { setMilitaryLayerActive } from './militaryRegistry.js';
import {
isMilitaryLayerActive,
registerMilitaryIcaos,
setMilitaryLayerActive,
} from './militaryRegistry.js';
import {
GROUND_FLOOR_LIFT_M,
reportMeshFloorCell,
reportValidatedMeshFloorCell,
setMeshFloorPreferred,
_clearMeshFloorCellsForTest,
} from './groundFloor.js';
import { resolveEllipsoidalGround } from './terrainHeights.js';
import {
_setTrackedOverlayHostForTest,
destroyTrackedReadout,
initTrackedReadout,
} from './trackedReadout.js';
// Unit tests never reach the network. A test that tracks a plane queues its
// enrichment (type, route, photo), so every test ends on an empty queue, and a
// fetch of any absolute URL fails the test it happened in. A test that needs
// answers installs its own fetch and restores this one.
const nodeFetch = globalThis.fetch;
const networkRequests = [];
before(() => {
globalThis.fetch = async (url) => {
const href = String(url?.url ?? url);
if (/^https?:\/\//i.test(href)) networkRequests.push(href);
throw new TypeError(`Failed to parse URL from ${href}`);
};
});
afterEach(() => {
_resetEnrichmentForTest();
assert.deepEqual(
networkRequests.splice(0),
[],
'no test reaches the network',
);
});
after(() => {
globalThis.fetch = nodeFetch;
});
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
assert.equal(
@@ -402,6 +434,232 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
}
});
test('a tracked plane whose photo lookup failed asks again on the first poll after the cool-down', async (t) => {
const icao24 = 'b2c3d4';
const realNow = Date.now.bind(Date);
let offsetMs = 0;
t.mock.method(Date, 'now', () => realNow() + offsetMs);
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
icao24,
entity,
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
color: Cesium.Color.WHITE,
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
meta: {
callsign: 'UAL9',
altitude: 9_000,
renderAltitudeM: 9_050,
velocity: 180,
true_track: 80,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.2,
rawLon: -97.7,
},
});
let callsign = 'UAL9 ';
const photoRequests = [];
const routeRequests = [];
const guardFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
url = String(url);
if (url.startsWith('https://api.planespotters.net/')) {
photoRequests.push(url);
return { ok: false, status: 503, json: async () => ({}) };
}
if (url.startsWith('/api/adsbdb/route/UAL')) routeRequests.push(url);
if (!url.startsWith('/api/flights'))
return { ok: true, status: 200, json: async () => ({ found: false }) };
const nowSec = Math.floor(Date.now() / 1000);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
time: nowSec,
states: [
[
icao24,
callsign,
'United States',
nowSec,
nowSec,
-97.6,
30.3,
10_668,
false,
250,
95,
5,
null,
10_700,
null,
null,
null,
5,
],
],
}),
};
};
// Lookups drip out one per 200 ms. Wait for what must arrive (with a
// generous deadline, so a loaded machine cannot fail it), then a little
// longer so anything that must NOT arrive would have.
const until = async (ready) => {
const deadline = realNow() + 5_000;
while (!ready() && realNow() < deadline)
await new Promise((done) => setTimeout(done, 20));
await new Promise((done) => setTimeout(done, 450));
};
try {
await flightsLayer.update(viewer);
await until(() => photoRequests.length >= 1 && routeRequests.length >= 1);
assert.equal(photoRequests.length, 1, 'the first poll asks for the photo');
assert.deepEqual(routeRequests, ['/api/adsbdb/route/UAL9']);
// Well inside the 60 s cool-down, and well past it below, so real time
// spent between polls cannot move either side across the line.
offsetMs += 30_000;
callsign = 'UAL10 ';
await flightsLayer.update(viewer);
await until(() => routeRequests.length >= 2);
assert.equal(photoRequests.length, 1, 'not within the cool-down');
assert.deepEqual(
routeRequests,
['/api/adsbdb/route/UAL9', '/api/adsbdb/route/UAL10'],
'the route follows the callsign the poll just recorded',
);
offsetMs += 60_000;
await flightsLayer.update(viewer);
await until(() => photoRequests.length >= 2);
assert.deepEqual(
photoRequests,
[
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
],
'asked again on the first poll after the cool-down',
);
} finally {
globalThis.fetch = guardFetch;
// The moved clock dated this layer's dispatch and cool-downs into the
// future; reset before the real clock comes back.
_resetEnrichmentForTest();
t.mock.restoreAll();
}
});
test('a known-military aircraft tracked in the flights layer is never looked up and shows no photo', async () => {
const icao24 = 'ae5e01';
// The registry has no way to forget one hex (only a whole-registry reset,
// which would also drop its source), so the test uses a hex no other test
// reads and restores the layer-active flag it changes.
const militaryWasActive = isMilitaryLayerActive();
registerMilitaryIcaos([icao24]);
setMilitaryLayerActive(false);
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
icao24,
entity: { gevLabelModel: { title: 'OLD', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
color: Cesium.Color.WHITE,
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
meta: {
callsign: 'RCH123',
altitude: 9_000,
renderAltitudeM: 9_050,
velocity: 180,
true_track: 80,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.2,
rawLon: -97.7,
// Even a photo already on the record is never shown.
photo: {
src: 'https://t.plnspttrs.net/1/ae5e01_280.jpg',
width: 280,
height: 186,
photographer: 'Jane Doe',
link: 'https://www.planespotters.net/photo/1/x',
},
},
});
const photoRequests = [];
const routeRequests = [];
const guardFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
url = String(url);
if (url.startsWith('https://api.planespotters.net/')) {
photoRequests.push(url);
return { ok: false, status: 503, json: async () => ({}) };
}
if (url.startsWith('/api/adsbdb/route/')) routeRequests.push(url);
if (!url.startsWith('/api/flights'))
return { ok: true, status: 200, json: async () => ({ found: false }) };
const nowSec = Math.floor(Date.now() / 1000);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
time: nowSec,
states: [
[
icao24,
'RCH123 ',
'United States',
nowSec,
nowSec,
-97.6,
30.3,
10_668,
false,
250,
95,
5,
null,
10_700,
null,
null,
null,
5,
],
],
}),
};
};
try {
await flightsLayer.update(viewer);
// A photo lookup would go out ahead of the route; wait for the route.
const deadline = Date.now() + 5_000;
while (!routeRequests.length && Date.now() < deadline)
await new Promise((done) => setTimeout(done, 20));
assert.deepEqual(routeRequests, ['/api/adsbdb/route/RCH123']);
assert.deepEqual(photoRequests, [], 'no photo lookup for a military hex');
assert.equal(flightsLayer.getTrackedInfo()?.icao24, icao24);
assert.equal(flightsLayer.getTrackedInfo()?.photo, null);
assert.equal(flightsLayer.getTrackedSubject()?.photo, null);
} finally {
globalThis.fetch = guardFetch;
setMilitaryLayerActive(militaryWasActive);
}
});
test('real civil track path creates no native label and publishes every cached host line', async () => {
const icao24 = 'civ001';
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
@@ -1181,6 +1439,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte
assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6);
});
test('display floor: reads the reading nearest the contact, not the cell centre', async () => {
_clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(true);
const realFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
try {
await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]);
} finally {
globalThis.fetch = realFetch;
}
// A hangar roof over the cell's centre; the apron where the contact is.
assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true);
assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true);
const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100);
const out = _floorGroundedDisplayPositionForTest(
{ onGround: true },
pos,
false,
);
const c = _floorCarto(out);
assert.ok(
Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05,
`height ${c.height} should be the apron's 121 + lift`,
);
_clearMeshFloorCellsForTest();
_clearDisplayFloorStateForTest();
});
test('display floor: an AIRBORNE contact over the same cell is never lifted', () => {
_clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest();
+1 -1
View File
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
*/
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
if (!isCalibratedAllocationRuntime()) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
}
const result = spawnSync(
process.execPath,
+1
View File
@@ -22,6 +22,7 @@ export const {
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
meshFloorPriorReady,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
+25
View File
@@ -0,0 +1,25 @@
/**
* @file The bundled imaging-sensor pack: swath and resolution for the main
* Earth-observation platforms, matched to satellites by NORAD number or name.
*
* Data: `local_data/imaging_sensors/imaging_sensors.json`; sources, licences
* and curation in that folder's README. Loaded once, on demand, by the
* satellites layer; a failed load is retried after a cooldown rather than
* remembered for the session.
*
* @module data/imagingSensors
*/
import { indexImagingSensors } from '../layers/satellites/sensors.js';
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
const PACK_URL = new URL(
'./local_data/imaging_sensors/imaging_sensors.json',
import.meta.url,
);
/** The pack, loaded and indexed once. */
export const loadImagingSensors = createRetryableLoader(async () =>
indexImagingSensors(await loadBundledJson(PACK_URL)),
);
+13 -1
View File
@@ -2,6 +2,7 @@
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import { normalizeSatelliteClassMask } from './satelliteClassMask.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -416,6 +417,16 @@ const OPTION_GROUPS = Object.freeze({
dense: 'd',
}),
integerOption('selectedSatTrackingId', 't', null),
// Every class but Starlink shows by default (satellites/classes.js).
Object.freeze({
key: 'classes',
token: 'k',
defaultValue: 'sitpd',
normalize: normalizeSatelliteClassMask,
encode: (value) => value,
decode: (value) =>
typeof value === 'string' ? normalizeSatelliteClassMask(value) : null,
}),
]),
cctv: Object.freeze([
enumOption('coverageMode', 'c', 'on', ['off', 'on', 'viewshed'], {
@@ -682,6 +693,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'i',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'news', token: '00', disposition: 'enabled-only' }),
Object.freeze({
id: 'radio',
token: 'r',
@@ -711,7 +723,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'shipwrecks', token: '00', disposition: 'enabled-only' }),
Object.freeze({ id: 'shipwrecks', token: '01', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
+120 -17
View File
@@ -203,8 +203,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, 37);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 37);
assert.equal(REGISTERED_LAYER_IDS.length, 38);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -243,22 +243,23 @@ 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(), '01');
// The allocation rules below start from the ledger as it stood before
// shipwrecks took 00.
const preShipwrecks = { ...LAYER_STATE_TOKEN_RESERVATIONS };
delete preShipwrecks.shipwrecks;
assert.equal(nextLayerStateToken(preShipwrecks), '00');
// News holds 00 and Shipwrecks 01, the first pairs: the next token is 02.
assert.equal(nextLayerStateToken(), '02');
// The allocation rules below read the ledger before either took a pair.
const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS };
delete beforeNews.news;
delete beforeNews.shipwrecks;
assert.equal(nextLayerStateToken(beforeNews), '00');
assert.equal(
nextLayerStateToken({
...preShipwrecks,
...beforeNews,
alpha: '9',
bravo: '00',
}),
'01',
);
const digitsExhausted = {
...preShipwrecks,
...beforeNews,
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -309,8 +310,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true,
);
assert.equal(
validateLayerStateAllocations(preShipwrecks, {
...preShipwrecks,
validateLayerStateAllocations(beforeNews, {
...beforeNews,
future: '00',
next: '01',
}),
@@ -318,8 +319,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
);
assert.throws(
() =>
validateLayerStateAllocations(preShipwrecks, {
...preShipwrecks,
validateLayerStateAllocations(beforeNews, {
...beforeNews,
future: '01',
}),
/next free token 00/,
@@ -346,8 +347,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...preShipwrecks, merged: '9' },
{ ...preShipwrecks, merged: '9', competing: '9' },
{ ...beforeNews, merged: '9' },
{ ...beforeNews, merged: '9', competing: '9' },
),
/next free token 00/,
);
@@ -505,7 +506,7 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
});
test('malformed enabled-layer lists reject the entire payload', () => {
for (const value of ['.c', 'c.', 'c..e', 'c.c', '01', 'c.01']) {
for (const value of ['.c', 'c.', 'c..e', 'c.c', 'zz', 'c.zz']) {
assert.equal(
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
null,
@@ -975,6 +976,41 @@ test('a direct Context selection promotes its owned tracker layer before persist
coordinator.destroy();
});
test('a satellite class choice is saved, shared and restored', async () => {
const storage = memoryStorage();
const manager = productionManager();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
await coordinator.start();
await manager.setEnabled('satellites', true, { origin: 'user' });
assert.equal(
coordinator.getDurableState().options.satellites.classes,
'sitpd',
'every class but Starlink by default',
);
manager.setLayerParams(
'satellites',
{ classes: { debris: false } },
{ origin: 'user' },
);
const durable = coordinator.getDurableState();
assert.equal(durable.options.satellites.classes, 'sitp');
assert.equal(
new URLSearchParams(encode(durable)).get('lo')?.includes('s.k.sitp'),
true,
);
coordinator.destroy();
// A reload hands the layer its letters back (the layer decodes them:
// src/data/satelliteClass.test.mjs).
const reloaded = productionManager();
const next = new LayerStateCoordinator(reloaded, shareSink(), { storage });
await next.start();
assert.equal(reloaded.getLayerParams('satellites')?.classes, 'sitp');
next.destroy();
});
test('explicit Flight to Satellite replacement keeps the new Satellite ID durable', async () => {
const manager = productionManager();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
@@ -3126,3 +3162,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co
confidence: 'nominal',
});
});
// An application built without a layer (a smaller catalog) receives a link or
// saved state from a build that has it: the layer is neither restored nor
// stored nor carried into the links this application shares.
function managerWithout(withheld) {
const manager = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS)
if (!withheld.includes(id)) manager.register(fakeLayer(id));
manager.finalizeRegistrations(
LAYER_STATE_REGISTRY.filter(({ id }) => !withheld.includes(id)),
);
return manager;
}
test('layers the application did not register are never restored, stored or shared', async () => {
const manager = managerWithout(['earthquakes']);
const storage = memoryStorage();
const share = shareSink();
const coordinator = new LayerStateCoordinator(manager, share, { storage });
const incoming = createDefaultLayerState();
incoming.enabledLayerIds = ['earthquakes', 'traffic'];
await coordinator.start({ shareLayerState: incoming });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']);
assert.deepEqual(share.provider().enabledLayerIds, ['traffic']);
assert.equal(manager.layers.has('earthquakes'), false);
await manager.setEnabled('cctv', true, { origin: 'user' });
const [[, written]] = storage.writes;
assert.deepEqual(parseStoredLayerState(written).enabledLayerIds, [
'cctv',
'traffic',
]);
assert.deepEqual(share.provider().enabledLayerIds, ['cctv', 'traffic']);
await coordinator.applyShareLayerState({ shareLayerState: incoming });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']);
coordinator.destroy();
const saved = createDefaultLayerState();
saved.enabledLayerIds = ['earthquakes', 'traffic'];
const local = new LayerStateCoordinator(
managerWithout(['earthquakes']),
shareSink(),
{ storage: memoryStorage(serializeStoredLayerState(saved)) },
);
await local.start();
assert.equal(local.source, 'local');
assert.deepEqual(local.getDurableState().enabledLayerIds, ['traffic']);
local.destroy();
});
test('an application with every layer registered keeps every requested layer', async () => {
const coordinator = new LayerStateCoordinator(
productionManager(),
shareSink(),
{ storage: memoryStorage() },
);
const incoming = createDefaultLayerState();
incoming.enabledLayerIds = ['earthquakes', 'traffic'];
await coordinator.start({ shareLayerState: incoming });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'traffic',
]);
coordinator.destroy();
});
+24 -9
View File
@@ -106,9 +106,7 @@ export class LayerStateCoordinator {
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
let selected = shareLayerState ? this._normalize(shareLayerState) : null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
@@ -124,7 +122,7 @@ export class LayerStateCoordinator {
/* best effort */
}
if (stored) {
selected = stored;
selected = this._normalize(stored);
this._source = 'local';
}
} else {
@@ -163,9 +161,7 @@ export class LayerStateCoordinator {
for (const controller of this._restoreControllers.values())
controller.abort('superseded-by-share-navigation');
this._revokePendingTrackingWatch('superseded-by-share-navigation');
const selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
const selected = shareLayerState ? this._normalize(shareLayerState) : null;
this._source = selected ? 'share' : 'legacy-share';
this._shareCreatedAtMs =
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
@@ -182,6 +178,25 @@ export class LayerStateCoordinator {
return this._source;
}
/**
* Canonical layer state holding only the layers this application
* registered. An application built without a layer never restores, stores
* or shares it, even when a link or saved state from a build that has it
* names it. Filtering before normalizing lets normalization drop options
* that depend on a layer that is not here (a tracked selection).
*/
_normalize(candidate) {
const input = candidate && typeof candidate === 'object' ? candidate : {};
return normalizeLayerState({
...input,
enabledLayerIds: Array.isArray(input.enabledLayerIds)
? input.enabledLayerIds.filter((id) =>
this.dataManager.layers.has(String(id)),
)
: [],
});
}
getDurableState() {
return cloneLayerState(this._durableState);
}
@@ -345,7 +360,7 @@ export class LayerStateCoordinator {
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
this._durableState = this._normalize(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
@@ -478,7 +493,7 @@ export class LayerStateCoordinator {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
this._durableState = this._normalize({
...this._durableState,
options: {
...this._durableState.options,
+107 -91
View File
@@ -97,28 +97,34 @@ function withPublishedBase(
}
}
// Rows as they stood before the last digit, 9, was taken (shipwrecks then
// took 00).
const preWildlifeRows = reservationRows.filter(
([id]) => id !== 'wildlife-sightings' && id !== 'shipwrecks',
// The ledger before Wildlife Sightings took the last digit and News and
// Shipwrecks the first pairs, for the allocation rules written against it.
const beforeLastDigitRows = reservationRows.filter(
([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id),
);
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
// The codec's own imports, beside it.
for (const name of ['idSwitches.js', 'satelliteClassMask.js'])
writeFileSync(
path.join(cwd, 'src/data', name),
readFileSync(new URL(`./${name}`, import.meta.url)),
);
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
if (!rows.some(([id]) => id === 'wildlife-sightings'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
'',
);
if (!rows.some(([id]) => id === 'news'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'news',[\s\S]*?\}\),\n/,
'',
);
if (!rows.some(([id]) => id === 'shipwrecks'))
source = source.replace(
/ Object\.freeze\(\{ id: 'shipwrecks',[^\n]*\n/,
/ Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/,
'',
);
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
@@ -144,7 +150,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, 37);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -157,7 +163,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'ski-pistes': '7',
'drone-airspace': '8',
'wildlife-sightings': '9',
shipwrecks: '00',
news: '00',
shipwrecks: '01',
},
);
assert.throws(
@@ -183,9 +190,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '01']])
parseLayerStateTokenReservations([...reservationRows, ['future', '02']])
.future,
'01',
'02',
);
assert.throws(
() =>
@@ -203,7 +210,7 @@ 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: [...preWildlifeRows, ['retired-layer', '9']] },
{ rows: [...beforeLastDigitRows, ['retired-layer', '9']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
@@ -235,80 +242,86 @@ test('published retired digits and competing PRs advance the merge-time allocati
});
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
withPublishedBase(
{ rows: [...beforeLastDigitRows, ['pr-a', '9']] },
(cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
assert.match(
staleRebase.stderr,
/Duplicate layer-state token reservation/,
);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...preWildlifeRows, ['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: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /36 published, 1 new/);
});
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...beforeLastDigitRows, ['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: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /36 published, 1 new/);
},
);
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...preWildlifeRows,
...beforeLastDigitRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
@@ -367,7 +380,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
token,
}));
const fixtureRows = [
...preWildlifeRows,
...beforeLastDigitRows,
...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]),
];
@@ -376,13 +389,16 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal(
nextLayerStateToken(
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
parseLayerStateTokenReservations([
...beforeLastDigitRows,
...priorDigits,
]),
),
'00',
);
assert.equal(
validateLayerStateAllocations(
parseLayerStateTokenReservations(preWildlifeRows),
parseLayerStateTokenReservations(beforeLastDigitRows),
codec.LAYER_STATE_TOKEN_RESERVATIONS,
),
true,
@@ -407,19 +423,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
withPublishedBase(
{
rows: [...reservationRows, ['retired-layer', '01']],
rows: [...reservationRows, ['retired-layer', '02']],
source:
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "01" });\n',
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n',
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 38);
assert.equal(published['retired-layer'], '01');
assert.equal(Object.keys(published).length, 39);
assert.equal(published['retired-layer'], '02');
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '01',
newcomer: '00',
}),
/changed or removed/,
);
+2 -1
View File
@@ -18,13 +18,14 @@
["military", "m"],
["military-awareness", "g"],
["military-installations", "i"],
["news", "00"],
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["sea-marks", "6"],
["shipwrecks", "00"],
["shipwrecks", "01"],
["ski-pistes", "7"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
+17 -1
View File
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
/**
* Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps).
* @param {string} [layerId] - The layer whose row changed.
*/
refreshLayerStats() {
refreshLayerStats(layerId) {
this._dropRecoveredRefreshError(layerId);
this._publishActivity({ type: 'status' });
}
/**
* Drop a tick failure the layer's stats no longer report (the camera left
* that feed) instead of showing it until the next tick.
*/
_dropRecoveredRefreshError(layerId) {
const entry = layerId ? this.layers.get(layerId) : null;
if (!entry?.managerRefreshError || entry.refreshing) return;
const failure = refreshFailureFromStats(
this._moduleStats(entry),
entry.module.name || layerId,
);
if (!failure) entry.managerRefreshError = null;
}
_publishActivity(change) {
for (const callback of this._activityListeners) {
try {
@@ -0,0 +1,63 @@
# Imaging sensor pack
Swath widths and ground resolutions for 46 Earth-observation platforms and
constellations, matched to satellites by NORAD catalog number or by their
CelesTrak name. The orbital view draws a satellite's sensor swath to scale and
lets it zoom past 8x only as far as that swath (`src/layers/satellites/sensors.js`,
`src/data/imagingSensors.js`, `orbitalMaxZoom` in `src/cockpitOrbital.js`).
| File | Content | Entries |
|------|---------|---------|
| `imaging_sensors.json` | Platform, match rule, operator, instruments, altitude, imaging modes (swath km, best ground resolution m), narrowest standard swath, sources, unverified fields | 46 |
**Compiled:** 2026-11-04, by hand from the pages cited per entry (`sources`).
Each match pattern was checked against CelesTrak's active list that day.
Altitude is the nominal value from a cited source; where none was found it is
the median altitude of the matching satellites from CelesTrak GP data, noted in
`unverified`. The app zooms on the live altitude, not this one.
**Narrowest standard swath** is the narrowest continuous standard imaging swath
or scene width (cross-track); for a geostationary imager, the narrower side of
its smallest standard scan area. Some listed modes are left out of it:
- Sampling modes: SAR wave vignettes, and Himawari's Landmark Area scans
(1000 × 500 km), so Himawari's narrowest is the 1000 km Target Area.
- Generations the operator has retired: PlanetScope's Dove Classic and Dove-R
(about 25 km scenes, imagery until April 2022 per Planet) stay in `modes`,
while its narrowest is the SuperDove's 32.5 km. The `^FLOCK ` name rule
would also match those older Doves.
**Unverified values.** A value not confirmed on an opened page is named in the
entry's `unverified` list. `swathConfirmed` is false when the narrowest swath is
missing or unverified; such a platform keeps the base 1–8x zoom steps:
- Landsat 9 (swath inferred from Landsat 8), ResourceSat-2 (LISS-3 swath),
SuperView Neo-1 (12 km from search snippets only), BlackSky and Meteosat
MTG FCI (no swath found).
## Sources and licences
The values are individual specification facts, each cited from the
operator's or agency's official pages listed per entry in `sources`. No text or
images are copied. The main sources:
- **Operator and agency pages and spec sheets** (NASA, USGS, NOAA/NESDIS,
ESA SentiWiki and Earth Online, EUMETSAT, JAXA, ISRO/NRSC, INPE, CSA, DLR,
JMA, ASI, KARI, China's Ministry of Natural Resources (LASAC), Planet,
Vantor (formerly Maxar) datasheet pages, L3Harris, China Siwei, BlackSky,
Airbus and KOMPSAT-5 via the Disasters Charter, ICEYE, Capella, Umbra,
Satellogic, Chang Guang).
- **Academic papers and proceedings** (Sensors papers on Gaofen-3, KARI's
ACRS 2012 paper on KOMPSAT-3, CAST's IAC-16 abstract on Gaofen-2).
- **eoPortal** (ESA's mission directory, an institutional source): a swath
read there counts as confirmed. It is cited beside the operator's and
agency's pages, and alone for values they do not give (the wider modes of
Gaofen-3 and COSMO-SkyMed Second Generation, and a few other entries).
Gaofen-1 takes eoPortal's 69 km PMS and 830 km WFV swaths by maintainer
choice; a 2019 Chinese lecture and a Sensors paper give 60 and 800 km.
- **CEOS MIM Database** (https://database.eohandbook.com): CC BY 4.0 since
7 September 2026, with the credit "CEOS MIM Database,
https://database.eohandbook.com". No entry relies on it yet; credit it if
values are taken from it later.
Listed in DATA_SOURCES.md and in the app's Data attribution popover.
@@ -0,0 +1 @@
{"version":1,"generated":"2026-11-04","entries":[{"id":"landsat-8-9","label":"Landsat 8 / Landsat 9","match":{"norad":[39084,49260],"nameRegex":"^LANDSAT [89]$"},"operator":"NASA / USGS","sensor":"OLI / OLI-2 / TIRS / TIRS-2","type":"multispectral / thermal","altitudeKm":705,"modes":[{"name":"OLI / OLI-2 · Standard scene","swathKm":185,"gsdM":15},{"name":"TIRS / TIRS-2 · Standard scene","swathKm":185,"gsdM":100}],"narrowestSwathKm":185,"swathConfirmed":false,"sources":["https://www.usgs.gov/landsat-missions/landsat-8","https://www.usgs.gov/landsat-missions/landsat-9"],"unverified":["Landsat 9 swath (inferred from Landsat 8)"]},{"id":"sentinel-2","label":"Sentinel-2A/B/C","match":{"norad":[40697,42063,60989],"nameRegex":"^SENTINEL-2[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"MSI","type":"multispectral","altitudeKm":786,"modes":[{"name":"MSI · Nominal","swathKm":290,"gsdM":10}],"narrowestSwathKm":290,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s2-mission"],"unverified":[]},{"id":"sentinel-1","label":"Sentinel-1A/C/D","match":{"norad":[39634,62261,66315],"nameRegex":"^SENTINEL-1[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"C-SAR","type":"SAR","altitudeKm":693,"modes":[{"name":"C-SAR · Stripmap (SM)","swathKm":80,"gsdM":5},{"name":"C-SAR · Interferometric Wide (IW)","swathKm":250,"gsdM":5},{"name":"C-SAR · Extra Wide (EW)","swathKm":400,"gsdM":20},{"name":"C-SAR · Wave (WV)","swathKm":null,"gsdM":5}],"narrowestSwathKm":80,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s1-mission"],"unverified":[]},{"id":"sentinel-3","label":"Sentinel-3A/B/C","match":{"norad":[41335,43437,100690],"nameRegex":"^SENTINEL-3[A-D]$"},"operator":"ESA / EUMETSAT (Copernicus)","sensor":"OLCI / SLSTR","type":"multispectral / thermal","altitudeKm":814.5,"modes":[{"name":"OLCI · Full resolution","swathKm":1270,"gsdM":300},{"name":"SLSTR · Nadir view","swathKm":1400,"gsdM":500},{"name":"SLSTR · Oblique view","swathKm":740,"gsdM":500}],"narrowestSwathKm":740,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s3-mission"],"unverified":[]},{"id":"terra-aqua-modis","label":"Terra / Aqua","match":{"norad":[25994,27424],"nameRegex":"^(TERRA|AQUA)$"},"operator":"NASA","sensor":"MODIS","type":"multispectral","altitudeKm":705,"modes":[{"name":"MODIS · Cross-track scan","swathKm":2330,"gsdM":250}],"narrowestSwathKm":2330,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/modis/"],"unverified":["Aqua altitude (assumed same as Terra)"]},{"id":"viirs","label":"Suomi NPP / NOAA-20 / NOAA-21","match":{"norad":[37849,43013,54234],"nameRegex":"^(SUOMI NPP|NOAA 2[01] \\(JPSS-[12]\\))$"},"operator":"NASA / NOAA (JPSS)","sensor":"VIIRS","type":"multispectral","altitudeKm":830,"modes":[{"name":"VIIRS · Cross-track scan","swathKm":3000,"gsdM":375}],"narrowestSwathKm":3000,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/viirs/","https://www.earthdata.nasa.gov/data/catalog/laads-vj102img-2.1"],"unverified":[]},{"id":"goes-abi","label":"GOES-16/17/18/19 (GOES-R series)","match":{"norad":[41866,43226,51850,60133],"nameRegex":"^GOES 1[6-9]$"},"operator":"NOAA / NASA","sensor":"ABI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"ABI · Full disk","swathKm":null,"gsdM":500},{"name":"ABI · CONUS","swathKm":3000,"gsdM":500},{"name":"ABI · Mesoscale sector","swathKm":1000,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.nesdis.noaa.gov/advanced-baseline-imager-scan-mode-information"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"himawari-ahi","label":"Himawari-8 / Himawari-9","match":{"norad":[40267,41836],"nameRegex":"^HIMAWARI-[89]$"},"operator":"JMA","sensor":"AHI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"AHI · Full Disk","swathKm":11000,"gsdM":500},{"name":"AHI · Japan Area","swathKm":2400,"gsdM":500},{"name":"AHI · Target Area","swathKm":1000,"gsdM":500},{"name":"AHI · Landmark Area","swathKm":500,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/spsg_ahi.html"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"mtg-fci","label":"Meteosat Third Generation imager (Meteosat-12 / MTG-I2)","match":{"norad":[54743,100530],"nameRegex":"^(METEOSAT-12|MTG-I\\d)"},"operator":"EUMETSAT","sensor":"FCI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"FCI · Full disc","swathKm":null,"gsdM":1000},{"name":"FCI · Rapid scan (Europe, 1/4 disc)","swathKm":null,"gsdM":500}],"narrowestSwathKm":null,"swathConfirmed":false,"sources":["https://www.esa.int/Applications/Observing_the_Earth/Meteorological_missions/Meteosat/About_Meteosat_Third_Generation","https://www-cdn.eumetsat.int/files/2020-04/pdf_mtg_goesr_ws_1.pdf"],"unverified":["narrowestSwathKm","altitudeKm (standard GEO value)"]},{"id":"gaofen-1","label":"GaoLine truncated
+66 -3
View File
@@ -1,8 +1,11 @@
# Natural Earth packs
Offline named-region polygons for the voice-annotation resolver
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
range geometry with no network dependency.
Offline polygons with no network dependency. The voice-annotation resolver
(`src/data/naturalEarthRegions.js`) reads the named regions, so "outline the
Alps" resolves to the real range geometry; area annotations read the
countries, states and provinces (`src/data/adminBoundaries.js`); and the News
layer reads the countries and the disputed areas
(`server/providers/news/areas.js`).
| File | Source dataset | Features |
|------|----------------|----------|
@@ -10,6 +13,8 @@ range geometry with no network dependency.
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
| `disputed_areas.json` | `ne_10m_admin_0_disputed_areas` (disputed, breakaway and UN-patrolled areas; a hand-picked set, see below) | 11 of 99 |
| `populated_places.json` | `ne_10m_populated_places_simple` (cities and towns, as points) | 7,342 |
**Source:** Natural Earth 10m physical vectors, via the canonical
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
@@ -103,3 +108,61 @@ Includes England, Scotland, Wales and Northern Ireland from
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
## Disputed areas (`disputed_areas.json`)
The News layer's areas that no one country's name may stand for
(`server/providers/news/areas.js`): a place inside one is named with the
area ("Crimea"), and Local there reads no country's outlets.
**Source:** `geojson/ne_10m_admin_0_disputed_areas.geojson` at the same pinned
commit (SHA-256
`9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5`), fetched
2026-11-05. Public domain, as above.
**Selection:** a hand-picked 11 of the source's 99 features. No rule picks
them; this is the list:
| Area | Natural Earth's note (`NOTE_BRK`) |
|------|-----------------------------------|
| Golan Heights | Admin. By Israel; Claimed by Syria |
| UNDOF Zone | UN Patrolled Zone in Syria |
| Transnistria | Self admin.; Claimed by Moldova |
| Abyei | Admin. by Sudan; Claimed by South Sudan |
| Crimea | Admin. by Russia; Claimed by Ukraine |
| Shebaa Farms | Admin. By Israel; Claimed by Lebanon |
| Bir Tawil | Between Egypt and Sudan |
| Halayib Triangle | Admin. by Egypt; Claimed by Sudan |
| East Jerusalem | Admin. By Israel; Claimed by Palestine |
| Abu Musa I. | Admin. by Iran; Claimed by UAE |
| Doumera Island | Admin. By Eritrea; Claimed by Djibouti |
The source names three of these by a country ("Moldova", "Sudan", "Iran");
the pack names them as the area. The 2014 Donetsk and Luhansk lines are left
out as out of date. Every other feature is left out too, so a place there is
named by its country in `countries.json`.
**Curation:** `kind` (`held`, `self`, `un`, `unclaimed`), `heldBy` and
`claimedBy` are read from `NOTE_BRK`, and `note` keeps it as published. Every
part is kept; each ring is Douglas-Peucker simplified at 0.003°, coordinates
are rounded to 3 decimals and rings are stored open. Provenance is in the
pack's `meta`. Result: 14,504 bytes.
## Populated places (`populated_places.json`)
The orbital view's "near <city>" (`src/data/placeBelow.js`): from 8x it
names the most prominent place within half the view's width.
**Source:** `geojson/ne_10m_populated_places_simple.geojson` at the pinned
commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`, the file
the admin-1 build already reads), fetched 2026-11-04. Public domain, as above.
**Regenerate:** `node scripts/build-admin-packs.mjs --only places`.
**Schema:** `meta` (source, URL, hash, license, curation) and `places[]`, each
`[name, latitude, longitude, scalerank, rank_max]`: `scalerank` 0 (most
prominent) to 10, `rank_max` the population class (0 to 14, higher is
larger). Sorted by `scalerank`, then `rank_max` descending, then name.
**Curation:** every place kept; all other attributes dropped; coordinates
rounded to 3 decimals (~110 m). Result: 229,461 bytes (98,444 with gzip -9).
@@ -0,0 +1 @@
{"meta":{"title":"Natural Earth admin-0 disputed areas, for the News layer","source":"ne_10m_admin_0_disputed_areas","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_disputed_areas.geojson","sha256":"9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5","fetched":"2026-11-05","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","curation":"A hand-picked 11 of the source's 99 features: Golan Heights, UNDOF Zone, Transnistria, Abyei, Crimea, Shebaa Farms, Bir Tawil, Halayib Triangle, East Jerusalem, Abu Musa I. and Doumera Island. The source names three of them by a country (Moldova, Sudan, Iran); they are named here as the area. kind, heldBy and claimedBy are read from NOTE_BRK; note is NOTE_BRK as published. The 2014 Donetsk and Luhansk lines are left out as out of date. Every part kept; each ring Douglas-Peucker simplified at 0.003 degrees, coordinates rounded to 3 decimals, rings stored open."},"areas":[{"name":"Golan Heights","kind":"held","heldBy":"IL","claimedBy":"SY","note":"Admin. By Israel; Claimed by Syria","rings":[[[35.821,33.407],[35.81,33.36],[35.786,33.343],[35.764,33.334],[35.802,33.312],[35.769,33.273],[35.804,33.248],[35.808,33.202],[35.83,33.19],[35.833,33.161],[35.811,33.127],[35.811,33.112],[35.849,33.099],[35.846,33.085],[35.859,32.99],[35.888,32.945],[35.85,32.896],[35.838,32.866],[35.842,32.854],[35.834,32.828],[35.784,32.778],[35.758,32.744],[35.74,32.741],[35.685,32.711],[35.636,32.679],[35.612,32.682],[35.595,32.707],[35.6,32.717],[35.634,32.752],[35.635,32.769],[35.641,32.772],[35.635,32.794],[35.641,32.82],[35.638,32.832],[35.641,32.865],[35.638,32.875],[35.602,32.909],[35.615,32.921],[35.62,32.984],[35.588,33.018],[35.621,33.05],[35.634,33.052],[35.637,33.077],[35.656,33.125],[35.672,33.15],[35.661,33.213],[35.66,33.236],[35.666,33.247],[35.635,33.249],[35.608,33.239],[35.605,33.244],[35.618,33.256],[35.62,33.27],[35.631,33.275],[35.654,33.262],[35.712,33.307],[35.718,33.317],[35.716,33.327],[35.744,33.331],[35.769,33.343],[35.786,33.358],[35.806,33.391]]]},{"name":"UNDOF Zone","kind":"un","note":"UN Patrolled Zone in Syria","rings":[[[35.758,32.744],[35.784,32.778],[35.834,32.828],[35.842,32.854],[35.838,32.866],[35.85,32.896],[35.888,32.945],[35.859,32.99],[35.846,33.085],[35.849,33.099],[35.811,33.112],[35.811,33.127],[35.833,33.161],[35.83,33.19],[35.808,33.202],[35.804,33.248],[35.769,33.273],[35.802,33.312],[35.764,33.334],[35.786,33.343],[35.81,33.36],[35.821,33.407],[35.87,33.431],[35.873,33.416],[35.87,33.407],[35.845,33.389],[35.83,33.365],[35.83,33.351],[35.843,33.322],[35.845,33.304],[35.84,33.295],[35.819,33.284],[35.82,33.281],[35.851,33.276],[35.866,33.226],[35.889,33.199],[35.882,33.189],[35.885,33.179],[35.9,33.18],[35.911,33.166],[35.911,33.135],[35.892,33.124],[35.897,33.117],[35.903,33.117],[35.903,33.11],[35.868,33.107],[35.865,33.082],[35.877,32.993],[35.898,32.986],[35.924,32.968],[35.912,32.947],[35.89,32.929],[35.861,32.88],[35.843,32.822],[35.815,32.787],[35.791,32.774],[35.77,32.748],[35.764,32.747]]]},{"name":"Transnistria","kind":"self","claimedBy":"MD","note":"Self admin.; Claimed by Moldova","rings":[[[29.899,46.553],[29.868,46.58],[29.84,46.582],[29.831,46.588],[29.824,46.601],[29.776,46.58],[29.728,46.594],[29.748,46.617],[29.776,46.629],[29.759,46.658],[29.749,46.665],[29.728,46.67],[29.703,46.665],[29.696,46.676],[29.667,46.676],[29.69,46.707],[29.698,46.728],[29.665,46.801],[29.652,46.813],[29.62,46.815],[29.611,46.827],[29.619,46.832],[29.615,46.841],[29.59,46.824],[29.563,46.787],[29.549,46.793],[29.543,46.793],[29.531,46.783],[29.512,46.787],[29.498,46.801],[29.502,46.821],[29.495,46.828],[29.494,46.862],[29.477,46.889],[29.471,46.941],[29.46,46.953],[29.436,46.957],[29.376,46.935],[29.35,46.937],[29.316,46.958],[29.296,46.992],[29.325,46.988],[29.371,47.003],[29.378,47.012],[29.374,47.011],[29.364,47.033],[29.381,47.029],[29.383,47.042],[29.378,47.071],[29.372,47.078],[29.33,47.108],[29.296,47.122],[29.291,47.14],[29.237,47.162],[29.227,47.149],[29.221,47.149],[29.213,47.158],[29.188,47.162],[29.179,47.156],[29.18,47.145],[29.194,47.124],[29.19,47.111],[29.157,47.113],[29.148,47.096],[29.125,47.074],[29.099,47.092],[29.07,47.101],[29.095,47.135],[29.112,47.15],[29.145,47.163],[29.164,47.192],[29.179,47.197],[29.164,47.231],[29.094,47.31],[29.08,47.332],[29.079,47.348],[29.089,47.351],[29.123,47.329],[29.14,47.329],[29.154,47.336],[29.165,47.348],[29.139,47.376],[29.083,47.406],[29.091,47.437],[29.108,47.454],[29.111,47.464],[29.104,47.478],[29.093,47.487],[29.049,47.506],[29.034,47.518],[29.025,47.533],[29.016,47.582],[28.993,47.605],[28.987,47.622],[28.996,47.651],[28.988,47.661],[28.981,47.687],[28.976,47.743],[28.981,47.765],[29.018,47.827],[29.019,47.853],[28.981,47.875],[28.861,47.899],[28.829,47.917],[28.831,47.941],[28.813,47.965],[28.785,47.983],[28.754,47.979],[28.719,47.999],[28.698,48.022],[28.682,48.031]Line truncated
@@ -0,0 +1 @@
{"meta":{"title":"Natural Earth populated places","source":"ne_10m_populated_places_simple","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","sha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]","decimals":3,"curation":{"decimals":3},"script":"scripts/build-admin-packs.mjs"},"places":[["Beijing",39.931,116.386,0,14],["Cairo",30.052,31.248,0,14],["Istanbul",41.107,29.008,0,14],["Kolkata",22.497,88.323,0,14],["Los Angeles",33.992,-118.182,0,14],["Mexico City",19.444,-99.133,0,14],["Moscow",55.754,37.614,0,14],["Mumbai",19.019,72.855,0,14],["New York",40.752,-73.982,0,14],["Rio de Janeiro",-22.923,-43.227,0,14],["São Paulo",-23.557,-46.627,0,14],["Shanghai",31.218,121.435,0,14],["Tokyo",35.687,139.749,0,14],["Bogota",4.598,-74.085,0,13],["Hong Kong",22.307,114.183,0,13],["Jakarta",-6.172,106.827,0,13],["Lagos",6.445,3.39,0,13],["London",51.502,-0.119,0,13],["Paris",48.869,2.331,0,13],["Santiago",-33.448,-70.669,0,13],["Singapore",1.295,103.854,0,13],["Cape Town",-33.918,18.433,0,12],["Nairobi",-1.281,36.815,0,12],["Riyadh",24.643,46.771,0,12],["Rome",41.898,12.481,0,12],["Sydney",-33.918,151.183,0,12],["Washington, D.C.",38.901,-77.011,0,12],["Buenos Aires",-34.601,-58.399,1,14],["Manila",14.606,120.98,1,14],["Ōsaka",34.752,135.458,1,14],["Baghdad",33.341,44.392,1,13],["Bangkok",13.752,100.515,1,13],["Bengaluru",12.972,77.558,1,13],["Chicago",41.832,-87.752,1,13],["Kinshasa",-4.328,15.313,1,13],["Lima",-12.046,-77.052,1,13],["Madrid",40.402,-3.685,1,13],["Miami",25.79,-80.226,1,13],["Seoul",37.568,126.998,1,13],["Taipei",25.036,121.568,1,13],["Tehran",35.674,51.422,1,13],["Toronto",43.702,-79.422,1,13],["Addis Ababa",9.035,38.698,1,12],["Amsterdam",52.352,4.915,1,12],["Athens",37.985,23.731,1,12],["Atlanta",33.832,-84.402,1,12],["Auckland",-36.848,174.763,1,12],["Berlin",52.524,13.4,1,12],["Caracas",10.503,-66.919,1,12],["Casablanca",33.602,-7.618,1,12],["Chengdu",30.672,104.068,1,12],["Dakar",14.718,-17.475,1,12],["Denver",39.741,-104.986,1,12],["Dubai",25.232,55.278,1,12],["Geneva",46.21,6.14,1,12],["Houston",29.822,-95.342,1,12],["Johannesburg",-26.168,28.028,1,12],["Kabul",34.519,69.181,1,12],["Kyiv",50.435,30.515,1,12],["Melbourne",-37.818,144.973,1,12],["Monterrey",25.672,-100.332,1,12],["San Francisco",37.769,-122.417,1,12],["Stockholm",59.353,18.095,1,12],["Tashkent",41.314,69.293,1,12],["Ürümqi",43.807,87.573,1,12],["Vancouver",49.275,-123.124,1,12],["Vienna",48.202,16.365,1,12],["New Delhi",28.6,77.2,1,10],["Delhi",28.672,77.228,2,14],["Dhaka",23.725,90.407,2,14],["Karachi",24.872,66.988,2,14],["Ahmedabad",23.032,72.578,2,13],["Chennai",13.092,80.278,2,13],["Chongqing",29.567,106.593,2,13],["Guangzhou",23.147,113.323,2,13],["Ho Chi Minh City",10.782,106.693,2,13],["Hyderabad",17.402,78.478,2,13],["Lahore",31.562,74.348,2,13],["Luanda",-8.836,13.232,2,13],["Philadelphia",40.002,-75.172,2,13],["Shenzhen",22.554,114.12,2,13],["Tianjin",39.132,117.198,2,13],["Wuhan",30.582,114.268,2,13],["Abidjan",5.322,-4.042,2,12],["Accra",5.552,-0.219,2,12],["Aleppo",36.232,37.168,2,12],["Alexandria",31.202,29.948,2,12],["Algiers",36.765,3.049,2,12],["Ankara",39.929,32.862,2,12],["Bandung",-6.948,107.568,2,12],["Baotou",40.654,109.82,2,12],["Barcelona",41.385,2.181,2,12],["Belém",-1.448,-48.482,2,12],["Boston",42.332,-71.072,2,12],["Brasília",-15.781,-47.918,2,12],["Brussels",50.835,4.331,2,12],["Bucharest",44.435,26.098,2,12],["Budapest",47.502,19.081,2,12],["Cali",3.402,-76.502,2,12],["Changchun",43.867,125.338,2,12],["Changsha",28.202,112.968,2,12],["Chattogram",22.332,91.798,2,12],["Curitiba",-25.418,-49.322,2,12],["Dallas",32.822,-96.842,2,12],["Damascus",33.502,36.298,2,12],["Dar es Salaam",-6.798,39.266,2,12],["Detroit",42.332,-83.082,2,12],["Dongguan",23.051,113.743,2,12],["Dublin",53.335,-6.251,2,12],["Durban",-29.863,30.978,2,12],["Edmonton",53.552,-113.502,2,12],["Fortaleza",-3.748,-38.582,2,12],["Frankfurt",50.1,8.675,2,12],["Fuzhou",26.082,119.298,2,12],["Goiânia",-16.718,-49.302,2,12],["Guadalajara",20.672,-103.332,2,12],["Guayaquil",-2.218,-79.922,2,12],["Guiyang",26.582,106.718,2,12],["Hamburg",53.552,9.998,2,12],["Hangzhou",30.252,120.168,2,12],["Hanoi",21.035,105.848,2,12],["Harbin",45.752,126.648,2,12],["Havana",23.134,-82.366,2,12],["Helsinki",60.178,24.932,2,12],["Hiroshima",34.39,132.441,2,12],["Jaipur",26.923,75.808,2,12],["Jeddah",21.519,39.217,2,12],["Jinan",36.677,116.993,2,12],["Kano",12.002,8.518,2,12],["Kanpur",26.462,80.318,2,12],["Khartoum",15.59,32.532,2,12],["København",55.681,12.562,2,12],["Kuala Lumpur",3.169,101.698,2,12],["Kunming",25.072,102.678,2,12],["Kuwait City",29.372,4Line truncated
+18
View File
@@ -0,0 +1,18 @@
# Owner flags
`owner_flags.png` is one sheet of every owner country a satellite can be
marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`),
drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code
order, 16 to a row, each followed by 2 transparent pixels so a scaled flag
never smooths into its neighbour. `src/ui/ownerFlag.js` loads it once at startup and draws
each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`.
**Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons)
7.5.0 (a devDependency, used only to build the sheet).
**License:** flag-icons is MIT licensed (© Panayiotis Lipiridis). Country
flags themselves are not copyrightable designs in most jurisdictions.
**Regenerate:** `node scripts/build-owner-flags.mjs` (after adding an owner
country, or updating flag-icons). With the same flag-icons and sharp versions
a rerun reproduces both files byte for byte.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,50 @@
# UCS Satellite Database pack
Offline operator, owner-country and purpose records for active satellites,
keyed by NORAD catalog number. The satellites layer joins them to CelesTrak's
positions to file each satellite under a class (stations, imaging, telecom,
probes) and to name its operator and owner country
(`src/data/ucsSatellites.js`, `src/layers/satellites/classes.js`).
| File | Source dataset | Records |
|------|----------------|---------|
| `ucs_satellites.json` | UCS Satellite Database, release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01) | 3,564 |
**Source:** Union of Concerned Scientists, https://www.ucsusa.org/satellite_database.
The same release is mirrored on Hugging Face as
`juliensimon/ucs-satellite-database` (identical rows). UCS has paused updates
since this release, so satellites launched after May 2023 are not in it; the
layer classifies those from CelesTrak SATCAT, CelesTrak groups and name
patterns instead.
**License:** free, unrestricted use. Section 3 of the UCS database manual:
"The Database is free and its use is unrestricted. We request that its use be
acknowledged and referenced in written materials. References should include
the version of the Database that was used, which is indicated by the name of
the Excel file, and a link to or URL for the webpage
www.ucsusa.org/satellite_database."
**Acknowledgement:** "Satellite operators and purposes: UCS Satellite Database
(UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database".
It is also listed in the app's Data attribution popover and in
DATA_SOURCES.md.
**Build:** `python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"`
(openpyxl). The file's `meta` header records the curation.
**Curation:**
- Kept columns: Operator/Owner, Country of Operator/Owner and Purpose, the
ones the layer reads. Everything else (detailed purpose, users, orbits,
masses, contractors, launch data, sources) is left out.
- Repeated text is stored once in `strings`; each record is
`[noradId, operator, operatorCountry, purpose]` with string-table indexes,
`0` meaning empty.
- The 3,996 Starlink rows are dropped: every Starlink object is classed and
credited to SpaceX by name, so they would only add size.
- Rows without a readable, positive NORAD number are dropped and counted apart
from duplicate NORAD numbers (the first row of a duplicate is kept); both
counts are 0 in this release.
- Owner countries stay as UCS writes them ("USA", "USA/Japan", "ESA",
"Multinational"); `src/layers/satellites/classes.js` normalises them, together with
CelesTrak SATCAT owner codes, in one table.
@@ -0,0 +1 @@
{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","TaLine truncated
-10
View File
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
this._presentation.refresh();
}
/**
* Repaint the toggle panel now. For layers whose data arrives outside their
* manager tick (camera-driven loads such as Transit's proximity polls), so a
* row shows its count when the data lands instead of at the next interval.
* One DOM pass; skipped while the document is hidden.
*/
refreshLayerStats() {
this._refreshTogglePanel();
}
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
+25
View File
@@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
assert.deepEqual(changes, ['status']);
});
test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
let stats = { count: 0, error: 'Feed unavailable' };
lifecycle.register({
id: 'feed',
name: 'Feed',
getStats: () => stats,
async update() {},
});
const entry = lifecycle.layers.get('feed');
entry.enabled = true;
entry.initialized = true;
entry.managerRefreshError = 'Feed unavailable';
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, 'Feed unavailable');
stats = { count: 0 };
lifecycle.refreshLayerStats();
assert.equal(entry.managerRefreshError, 'Feed unavailable');
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, null);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
+178 -1
View File
@@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
import { resolveEllipsoidalGround } from './terrainHeights.js';
import {
cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred,
_clearMeshFloorCellsForTest,
_clearMeshFloorCellsForTest, reportValidatedMeshFloorCell,
} from './groundFloor.js';
/** Distinct site per test — terrainHeights' cache is module-global and warm
@@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', ()
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
test('sampler: the probe goes where the point is, not to the cell centre', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const asked = [];
const scene = fakeScene((carto) => {
asked.push(Cesium.Math.toDegrees(carto.longitude));
return 122.0;
});
const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 };
sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(asked.length, 1);
assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`);
});
test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A viaduct deck 9 m up along one line of the cell, the street elsewhere.
const deckLat = cell.lat + 0.0003;
let calls = 0;
const scene = fakeScene((carto) => {
calls += 1;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
const street = { lat: cell.lat - 0.0003, lon: cell.lon };
sampleMeshFloorCells(scene, [onDeck], opts);
sampleMeshFloorCells(scene, [street], opts);
assert.equal(calls, 2);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck');
assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street');
assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0);
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts);
assert.equal(calls, 2, 'a point next to one already probed is not probed again');
});
test('sampler: a roof over the cell centre does not lift a bus on the street', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A roof 22 m up covering the cell's centre; the street 30 m west of it.
const scene = fakeScene((carto) =>
Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0);
const bus = { lat: cell.lat, lon: cell.lon - 0.0003 };
sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0);
});
test('sampler: a second reading at the same spot keeps the lower one', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true);
assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0);
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it');
});
test('sampler: a cell with a reading is probed a few times at most', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
let calls = 0;
const scene = fakeScene(() => {
calls += 1;
return 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 20; i++)
sampleMeshFloorCells(
scene,
[{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }],
opts,
);
assert.equal(calls, 8);
});
test('sampler: a cell with no accepted reading is probed again on every call', async () => {
reset();
const cell = nextSite();
let calls = 0;
const scene = fakeScene(() => {
calls += 1;
return 138.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts);
assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried');
await seedDem(cell, 120.0);
sampleMeshFloorCells(scene, [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm');
});
test('sampler: a spot whose reading is refused does not use up the cell', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A tower 260 m up where the first vehicle waits; the street elsewhere and
// a deck 9 m up along one line of the cell.
const towerLon = cell.lon + 0.0004;
const deckLat = cell.lat + 0.0003;
const scene = fakeScene((carto) => {
if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time');
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts);
assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed');
});
test('ground floor: a cell keeps at most sixteen readings', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
for (let i = 0; i < 40; i++) {
const lat = cell.lat - 0.00045 + (i % 8) * 0.0001;
const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002;
reportValidatedMeshFloorCell(lat, lon, 121.0 + i);
}
// Readings 0..15 are kept; reading 39 (at the far corner) is not, so that
// spot answers from the nearest kept one.
assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0);
assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15);
});
test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => {
reset();
const cell = nextSite();
const deckLat = cell.lat + 0.0003;
let calls = 0;
const scene = fakeScene((carto) => {
calls += 1;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept');
await seedDem(cell, 120.0);
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(calls, 3);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
});
test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => {
const { createGroundFloor } = await import('../services/groundFloor.js');
const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js');
let prior = 120.0;
const terrain = {
cachedRealEllipsoidalGround: () => prior,
resolveEllipsoidalGround: async () => {},
};
const groundFloor = createGroundFloor({ terrain });
const sampler = createMeshFloorSampler({ groundFloor });
const cell = { lat: 41.5, lon: -97.66 };
const deckLat = cell.lat + 0.0003;
const scene = fakeScene((carto) =>
Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0);
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
prior = null; // the cell's DEM entry is evicted; the street reading stays
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0);
prior = 120.0;
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
});
+87 -1
View File
@@ -425,7 +425,7 @@ test('a dependency the user adopts stays on when Contacts turns off', async () =
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
"the adopted layer is the user's now; the rest are still released",
);
} finally {
runtime.restore();
@@ -1118,6 +1118,92 @@ test('production military tracked-subject label falls back to registration befor
}
});
test('the tracked airliner’s photo reaches the Contacts panel when it lands after selection', async () => {
const id = 'a1f2c3';
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
const sourceViewer = { camera: { positionCartographic: null }, scene: {} };
const runtime = installAwarenessRuntime();
const restores = [];
const meta = {
callsign: 'AAL100',
altitude: 10_668,
renderAltitudeM: 10_700,
velocity: 250,
true_track: 95,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
photo: null,
};
try {
_setTrackedFlightRefreshStateForTest({
icao24: id,
entity: null,
billboard: { position, color: Cesium.Color.WHITE, show: true },
billboardCollection: { show: true, remove() {} },
viewer: sourceViewer,
meta,
});
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
replaceMethod(
militaryFlightsLayer,
'getTrackedSubject',
() => null,
restores,
);
replaceMethod(flightsLayer, 'trackById', () => true, restores);
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
assert.equal(runtime.panel().view?.kind, 'results');
assert.equal(runtime.panel().view.photo, null, 'no photo before it lands');
// An unchanged refresh stays quiet.
const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(runtime.panel().publishes, settledPublishes);
// The photo lands while the plane holds still: render-worthy on its own.
meta.photo = {
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
width: 280,
height: 186,
photographer: 'Jane Doe',
link: 'https://www.planespotters.net/photo/1/n100aa',
};
await militaryAwarenessLayer.update();
assert.deepEqual(runtime.panel().view.photo, {
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
width: 280,
height: 186,
link: 'https://www.planespotters.net/photo/1/n100aa',
credit: 'Photo © Jane Doe',
});
// And it repaints once, not on every refresh.
const shownPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(runtime.panel().publishes, shownPublishes);
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedFlightRefreshStateForTest({
icao24: id,
entity: null,
billboard: { position, show: true },
billboardCollection: { show: false, remove() {} },
viewer: sourceViewer,
meta: {},
tracked: false,
});
runtime.restore();
}
});
test('a cached Context subject re-reads its label when enrichment lands after selection', async () => {
// The subject snapshot is captured once at selection. Selecting a
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
+181 -8
View File
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
/** @constant {number} Dead reckoning never moves a contact faster than this
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
* cannot fly a coasting plane hundreds of kilometres. */
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
export const STALE_COAST_MAX_SEC = 300;
/** @constant {number} A report this close to a known ground height holds
* instead of coasting. */
export const STALE_HOLD_MAX_HEIGHT_M = 150;
/**
* Whether a contact whose reports have stopped holds at its last reported
* position instead of coasting: it reported on the ground, or a known ground
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
* taking off). A plane that low has no evidence of being anywhere but where
* it was last heard. Where the ground is unknown, barometric altitude is not
* read as height above it; the contact coasts under the stale cap.
* @param {object} report
* @param {boolean} [report.onGround]
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
* @returns {boolean}
*/
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
if (onGround === true) return true;
return (
Number.isFinite(heightM) &&
Number.isFinite(groundM) &&
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
);
}
/**
* The hold-or-coast decision for a report, made once and kept on the report,
* so a ground floor that resolves while the contact coasts cannot flip it and
* snap the icon back. Height and position come from the report itself.
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
* fix; `staleHolds` is stored on it.
* @param {object} args
* @param {boolean} [args.onGround] - The contact's ground flag.
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
* @returns {boolean}
*/
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
const carto = Cesium.Cartographic.fromCartesian(
fix.position,
Cesium.Ellipsoid.WGS84,
_holdCarto,
);
const groundM =
carto && typeof cachedGroundFloor === 'function'
? cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
)
: null;
fix.staleHolds = staleHoldsAtLastReport({
onGround,
heightM: carto?.height,
groundM,
});
return fix.staleHolds;
}
const _holdCarto = new Cesium.Cartographic();
/**
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
* after it, counting any coast a synthetic fix already took since the real
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
* never coasts further than one report would.
* @param {object} args
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
* @param {number} args.elapsedSec - Display time past `newest`.
* @param {number} args.coastLimitSec - Source limit past `newest`.
* @param {boolean} args.holds
* @returns {number}
*/
export function staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}) {
const alreadySec = Number.isFinite(newest?.reportEpochMs)
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
: 0;
return Math.max(
0,
staleCoastSeconds({
elapsedSec: elapsedSec + alreadySec,
coastLimitSec: coastLimitSec + alreadySec,
holds,
}) - alreadySec,
);
}
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
export const COAST_FLOOR_LIFT_M = 1.5;
/**
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
* ground is known; a coast runs level from the report and terrain may rise
* under it.
* @param {Cesium.Cartesian3} position - Mutated in place.
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
* @returns {Cesium.Cartesian3}
*/
export function floorCoastedPosition(position, cachedGroundFloor) {
if (typeof cachedGroundFloor !== 'function') return position;
const carto = Cesium.Cartographic.fromCartesian(
position,
Cesium.Ellipsoid.WGS84,
_coastCarto,
);
if (!carto) return position;
const ground = cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
return position;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
ground + COAST_FLOOR_LIFT_M,
Cesium.Ellipsoid.WGS84,
position,
);
}
const _coastCarto = new Cesium.Cartographic();
/**
* Seconds to coast past the last report: the elapsed time, bounded by the
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
* holds.
* @param {object} args
* @param {number} args.elapsedSec - Display time past the last report.
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
* @returns {number}
*/
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
if (holds || !(elapsedSec > 0)) return 0;
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
}
/**
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* kinematics. This prevents a late course change from reprojecting the entire
* stale interval and snapping the rendered contact by kilometres.
*
* The same stale rules as the display apply: no fix for a contact that holds
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
* most STALE_COAST_MAX_SEC counted from the real report
* (`reportEpochMs` carries it along a chain), and never below known ground.
* The result is a motion fix only, never a report for the trail.
*
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
* velocity?: number, track?: number}} newest Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
* Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
* cachedGroundFloor?: Function}} next
* New kinematics and the wall/source epoch at which they become authoritative.
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
* velocity: number, track: number}|null} Forward synthetic fix, or null.
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
* Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
if (newest.staleHolds === true) return null;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
const dtSec = staleCoastSecondsFrom({
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
elapsedSec: (next.epochMs - startEpochMs) / 1000,
coastLimitSec: Infinity,
holds: false,
});
arcOffsetEnu(
previousVelocity,
previousTrack,
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
Cesium.Ellipsoid.WGS84,
_forwardFixEnu,
);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
const position = floorCoastedPosition(
Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
),
next.cachedGroundFloor,
);
return {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: startEpochMs,
staleHolds: newest.staleHolds,
position,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
+472
View File
@@ -0,0 +1,472 @@
/**
* What a ground point is over, offline: on land the country (Natural Earth
* admin-0) and its first-level division (admin-1: "Sichuan, China"), plus,
* when asked for a radius, the most prominent populated place within it
* ("near Chengdu"); else the smallest named sea, gulf, strait, channel,
* sound or bay, else the ocean (`marine.json` carries the seven oceans).
* Bays narrower than a satellite frame cares about — fjords, lagoons,
* inlets, reefs and rivers — are skipped, so a pass reads "Bay of Bengal",
* not a harbour.
*
* PURE data module: no Cesium, node-testable. Each pack loads lazily on the
* first lookup that needs it (divisions on the first land hit, places on the
* first radius) and is indexed once, and the loading status names them while
* one is on its way (`placePacksStatus`); a country's division rings are decoded
* the first time a point falls inside it. A failed load is retried later
* (see `createRetryableLoader`). No network.
*/
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
import { containsAnchor, decodeRing } from './adminBoundaries.js';
import { greatCircleKm } from '../geoDistance.js';
/** Marine feature classes named as the place below; oceans come last. */
const MARINE_KINDS = new Set([
'sea',
'gulf',
'strait',
'channel',
'sound',
'bay',
]);
const PACKS = {
countries: new URL(
'./local_data/natural_earth/countries.json',
import.meta.url,
),
marine: new URL('./local_data/natural_earth/marine.json', import.meta.url),
divisions: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
places: new URL(
'./local_data/natural_earth/populated_places.json',
import.meta.url,
),
};
/**
* The packs on their way now, and those that failed in the current load (or
* the last one): a load starts with no failures, so a retry that lands is
* not blamed for a pack that failed before. (A load that does not need the
* failed pack clears it too; the next lookup that needs it tries it again.)
*/
const loadingPacks = new Set();
const failedPacks = new Set();
// One lookup loads its packs one after another (countries and seas, then
// divisions, then places): the status holds them as one load until the
// lookup ends, rather than ending and starting a batch between packs.
let lookupsInFlight = 0;
let lookupLoaded = false;
const loadingListeners = new Set();
function notifyLoading() {
for (const listener of [...loadingListeners]) {
try {
listener();
} catch {
/* A status listener never breaks a lookup. */
}
}
}
/** Whether the status shows a load on its way. */
function loadingNow() {
return loadingPacks.size > 0 || (lookupLoaded && lookupsInFlight > 0);
}
/**
* A pack load that the loading status follows: on its way while it runs,
* and failed, if it fails, until the next load starts.
* @template T
* @param {string} name
* @param {() => Promise<T>} load
* @returns {() => Promise<T>}
*/
export function reportedPlacePackLoad(name, load) {
return async () => {
// A fresh load forgets earlier failures: it ends on what it loads.
if (!loadingNow()) failedPacks.clear();
if (lookupsInFlight > 0) lookupLoaded = true;
loadingPacks.add(name);
notifyLoading();
try {
return await load();
} catch (error) {
failedPacks.add(name);
throw error;
} finally {
loadingPacks.delete(name);
notifyLoading();
}
};
}
/**
* The place packs' state for the loading status. The packs load lazily, on
* the first lookup that needs them, so the status says so meanwhile, and
* says when one would not load.
* @returns {{loading: boolean, failed: boolean}}
*/
export function placePacksStatus() {
return {
loading: loadingNow(),
failed: failedPacks.size > 0,
};
}
/**
* Hear each start and end of a place pack load.
* @param {() => void} listener
* @returns {() => void} Stops listening.
*/
export function onPlacePacksLoadingChange(listener) {
loadingListeners.add(listener);
return () => loadingListeners.delete(listener);
}
/** Test seam: count pack loads (lazy-loading contract). */
export const placePackLoads = { divisions: 0, places: 0 };
/** The zoom from which the view names the populated place it shows. */
export const NEAR_PLACE_MIN_ZOOM = 8;
/**
* How far from the sub-satellite point a populated place may lie and still
* be named: half the view's smaller ground dimension from
* NEAR_PLACE_MIN_ZOOM on, so the place is in the picture whichever way it
* lies; null below it or without a size.
* @param {number} zoom Optical zoom.
* @param {number} widthKm The view's ground width, km.
* @param {number} [heightKm] The view's ground height, km; the width when
* not given.
* @returns {number|null}
*/
export function nearPlaceRadiusKm(zoom, widthKm, heightKm = widthKm) {
if (!(zoom >= NEAR_PLACE_MIN_ZOOM)) return null;
const smallerKm = Math.min(widthKm, heightKm);
return Number.isFinite(smallerKm) && smallerKm > 0 ? smallerKm / 2 : null;
}
/** Shortest wait between two lookups, ms. */
export const PLACE_LOOKUP_MIN_MS = 2000;
/** Ground travel that makes a fresh lookup worth it, km. */
export const PLACE_LOOKUP_MIN_MOVE_KM = 20;
function boxOf(parts) {
let west = Infinity;
let south = Infinity;
let east = -Infinity;
let north = -Infinity;
for (const part of parts)
for (const ring of part)
for (const [lon, lat] of ring) {
if (lon < west) west = lon;
if (lon > east) east = lon;
if (lat < south) south = lat;
if (lat > north) north = lat;
}
return [west, south, east, north];
}
function inBox([west, south, east, north], lat, lon) {
return lat >= south && lat <= north && lon >= west && lon <= east;
}
/** Planar ring area in degrees², shrunk by cos(latitude): enough to rank. */
function ringRank(ring) {
let sum = 0;
let latSum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
latSum += ring[i][1];
}
const meanLat = ring.length ? latSum / ring.length : 0;
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
}
async function readPlaces() {
const [countries, marine] = await Promise.all([
loadBundledJson(PACKS.countries),
loadBundledJson(PACKS.marine),
]);
const decimals = countries.meta?.decimals ?? 3;
const land = [];
for (const feature of countries.features || []) {
const parts = (feature.polygons || []).map((part) =>
part.map((ring) => decodeRing(ring, decimals)),
);
if (!parts.length) continue;
land.push({
name: feature.name,
// Natural Earth writes -99 for units without an ISO code.
countryCode: /^[A-Z]{2}$/.test(feature.iso2 || '') ? feature.iso2 : null,
parts,
box: boxOf(parts),
});
}
const water = [];
for (const feature of marine.features || []) {
const kind = feature.featurecla;
if (kind !== 'ocean' && !MARINE_KINDS.has(kind)) continue;
// Marine rings are stored outer-only and plain [lon, lat].
const parts = (feature.polygons || []).map((ring) => [ring]);
if (!parts.length) continue;
water.push({
name: feature.name,
kind: kind === 'ocean' ? 'ocean' : 'sea',
parts,
box: boxOf(parts),
rank: parts.reduce((sum, [ring]) => sum + ringRank(ring), 0),
});
}
return { land, water };
}
const loadPlaces = createRetryableLoader(
reportedPlacePackLoad('places', readPlaces),
);
/** Divisions grouped by country code; rings decoded per country on demand. */
async function readDivisions() {
placePackLoads.divisions += 1;
const pack = await loadBundledJson(PACKS.divisions);
const decimals = pack.meta?.decimals ?? 3;
const byCountry = new Map();
for (const feature of pack.features || []) {
if (!feature.name || !/^[A-Z]{2}$/.test(feature.iso2 || '')) continue;
const list = byCountry.get(feature.iso2) || [];
list.push({ feature, decimals: feature.d ?? decimals });
byCountry.set(feature.iso2, list);
}
return { byCountry, decoded: new Map() };
}
const loadDivisions = createRetryableLoader(
reportedPlacePackLoad('divisions', readDivisions),
);
/** The first-level division holding a point inside country `code`, or null. */
async function divisionAt(code, lat, lon) {
if (!code) return null;
const { byCountry, decoded } = await loadDivisions();
let units = decoded.get(code);
if (!units) {
units = (byCountry.get(code) || []).map(({ feature, decimals }) => {
const parts = (feature.polygons || []).map((part) =>
part.map((ring) => decodeRing(ring, decimals)),
);
return { name: feature.name, parts, box: boxOf(parts) };
});
decoded.set(code, units);
}
for (const unit of units)
if (inBox(unit.box, lat, lon) && containsAnchor(unit.parts, lat, lon))
return unit.name;
return null;
}
async function readPopulatedPlaces() {
placePackLoads.places += 1;
const pack = await loadBundledJson(PACKS.places);
return (pack.places || []).filter(
(row) =>
Array.isArray(row) &&
row[0] &&
Number.isFinite(row[1]) &&
Number.isFinite(row[2]),
);
}
const loadPopulatedPlaces = createRetryableLoader(
reportedPlacePackLoad('populated-places', readPopulatedPlaces),
);
/**
* The most prominent populated place within `radiusKm` of a point (lowest
* Natural Earth scalerank, then the larger population class, then the
* nearer), or null.
* @returns {Promise<string|null>}
*/
export async function nearPlace(lat, lon, radiusKm) {
if (!(radiusKm > 0) || !Number.isFinite(lat) || !Number.isFinite(lon))
return null;
const places = await loadPopulatedPlaces();
const latSpan = radiusKm / 111;
const cosLat = Math.cos((lat * Math.PI) / 180);
const lonSpan = cosLat > 0.01 ? latSpan / cosLat : 360;
let best = null;
for (const [name, pLat, pLon, scalerank, rankMax] of places) {
if (Math.abs(pLat - lat) > latSpan) continue;
const dLon = Math.abs(((((pLon - lon + 180) % 360) + 360) % 360) - 180);
if (dLon > lonSpan) continue;
const km = greatCircleKm({ lat, lon }, { lat: pLat, lon: pLon });
if (km > radiusKm) continue;
if (
!best ||
scalerank < best.scalerank ||
(scalerank === best.scalerank &&
(rankMax > best.rankMax || (rankMax === best.rankMax && km < best.km)))
)
best = { name, scalerank, rankMax, km };
}
return best ? best.name : null;
}
/** The division and near-place lookups `placeBelow` adds on land. */
const DETAIL_LOOKUPS = Object.freeze({ division: divisionAt, near: nearPlace });
/** A detail lookup's answer, or null when it (its pack) fails. */
async function orNull(lookup) {
try {
return (await lookup()) ?? null;
} catch {
return null;
}
}
/**
* The place under a ground point.
* @param {number} latitude Degrees.
* @param {number} longitude Degrees, any wrap.
* @param {{nearKm?: number|null, detail?: {division: Function,
* near: Function}}} [options] On land, also name the most prominent
* populated place within `nearKm` (`nearPlaceRadiusKm`). `detail` replaces
* the division and near-place lookups (a test seam).
* @returns {Promise<{kind: 'country'|'sea'|'ocean', name: string,
* countryCode: string|null, division?: string|null,
* near?: string|null}|null>} A country carries its first-level `division`
* and the `near` place (each null when none, or when its pack failed to
* load: the country still stands); a sea or ocean neither. Null when no
* feature holds the point (a coastline gap left by the packs'
* simplification) or it is invalid. Rejects only when the country and
* marine packs fail.
*/
export async function placeBelow(latitude, longitude, options) {
lookupsInFlight += 1;
try {
return await findPlaceBelow(latitude, longitude, options);
} finally {
lookupsInFlight -= 1;
if (lookupsInFlight === 0 && lookupLoaded) {
lookupLoaded = false;
notifyLoading();
}
}
}
async function findPlaceBelow(
latitude,
longitude,
{ nearKm = null, detail = DETAIL_LOOKUPS } = {},
) {
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 90
)
return null;
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
const { land, water } = await loadPlaces();
for (const entry of land) {
if (
inBox(entry.box, latitude, lon) &&
containsAnchor(entry.parts, latitude, lon)
)
return {
kind: 'country',
name: entry.name,
countryCode: entry.countryCode,
division: await orNull(() =>
detail.division(entry.countryCode, latitude, lon),
),
near: await orNull(() => detail.near(latitude, lon, nearKm)),
};
}
let best = null;
for (const entry of water) {
if (best && entry.rank >= best.rank) continue;
if (
inBox(entry.box, latitude, lon) &&
containsAnchor(entry.parts, latitude, lon)
)
best = entry;
}
return best ? { kind: best.kind, name: best.name, countryCode: null } : null;
}
/**
* Whether two radii ask for the same detail: both off, or within 20% (a
* zoom step at least halves the view; altitude drift moves it far less).
*/
function sameRadius(a, b) {
if (a === null || b === null) return a === b;
return Math.max(a, b) / Math.min(a, b) < 1.2;
}
/**
* Keep the place below a moving ground point current without a lookup per
* frame: a new lookup once at least `minIntervalMs` has passed AND the point
* has moved `minMoveKm` (a low orbit refreshes about every 2.6 s, a
* geostationary one never after the first). A new `nearKm` radius (a zoom
* step) looks up again at once. One lookup runs at a time; a result that
* arrives after `reset()` is dropped. A failed lookup reports no place (the
* old one would name ground the view has left) and lets the next update
* look up again at once.
* @param {{onPlace: (place: object|null) => void,
* lookup?: (lat: number, lon: number,
* options: {nearKm: number|null}) => Promise<object|null>,
* minIntervalMs?: number, minMoveKm?: number}} options
*/
export function createPlaceTracker({
onPlace,
lookup = placeBelow,
minIntervalMs = PLACE_LOOKUP_MIN_MS,
minMoveKm = PLACE_LOOKUP_MIN_MOVE_KM,
}) {
let generation = 0;
let inFlight = false;
let last = null;
return {
/**
* Offer the current ground point and the radius to name a place in.
* @returns {boolean} True when a lookup started.
*/
update(lat, lon, nowMs, nearKm = null) {
if (inFlight || !Number.isFinite(lat) || !Number.isFinite(lon))
return false;
const radius = Number.isFinite(nearKm) && nearKm > 0 ? nearKm : null;
if (
last &&
sameRadius(last.nearKm, radius) &&
(nowMs - last.ms < minIntervalMs ||
greatCircleKm(last, { lat, lon }) < minMoveKm)
)
return false;
last = { lat, lon, ms: nowMs, nearKm: radius };
inFlight = true;
const token = generation;
Promise.resolve()
.then(() => lookup(lat, lon, { nearKm: radius }))
.then(
(place) => {
if (token !== generation) return;
inFlight = false;
onPlace(place ?? null);
},
() => {
if (token !== generation) return;
inFlight = false;
last = null;
onPlace(null);
},
);
return true;
},
/** Forget the point and drop any lookup still in flight. */
reset() {
generation += 1;
inFlight = false;
last = null;
},
};
}
+280
View File
@@ -0,0 +1,280 @@
// src/data/placeBelow.test.mjs — what the ground track is over, offline:
// division and country on land (with the populated place in view from 8x),
// the smallest named sea, else the ocean; and the tracker that keeps it
// current without a lookup per frame.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
NEAR_PLACE_MIN_ZOOM,
PLACE_LOOKUP_MIN_MOVE_KM,
PLACE_LOOKUP_MIN_MS,
createPlaceTracker,
nearPlaceRadiusKm,
placeBelow,
placePackLoads,
} from './placeBelow.js';
const PLACES_PACK = new URL(
'./local_data/natural_earth/populated_places.json',
import.meta.url,
);
// Runs first: the counters are module-wide.
test('the division and place packs load only when a lookup needs them', async () => {
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 });
await placeBelow(42, 51, { nearKm: 50 });
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 }, 'at sea');
await placeBelow(30.9, 104.2);
assert.deepEqual(placePackLoads, { divisions: 1, places: 0 }, 'at 1x');
await placeBelow(30.9, 104.2, { nearKm: 60 });
await placeBelow(31.2, 104.5, { nearKm: 30 });
assert.deepEqual(placePackLoads, { divisions: 1, places: 1 }, 'kept');
});
test('the populated-place pack is compact rows of name, position and rank', () => {
const bytes = readFileSync(PLACES_PACK);
assert.ok(bytes.length < 300_000, `${bytes.length} bytes`);
const pack = JSON.parse(bytes);
assert.match(pack.meta.url, /nvkelso\/natural-earth-vector/);
assert.match(pack.meta.license, /^Public domain/);
assert.ok(pack.places.length > 7000);
for (const [name, lat, lon, scalerank, rankMax] of pack.places) {
assert.equal(typeof name, 'string');
assert.ok(Math.abs(lat) <= 90 && Math.abs(lon) <= 180, name);
assert.ok(Number.isInteger(scalerank) && Number.isInteger(rankMax), name);
}
});
test('over land the place is the division and country, with its ISO code', async () => {
assert.deepEqual(await placeBelow(48.85, 2.35), {
kind: 'country',
name: 'France',
countryCode: 'FR',
division: 'Paris',
near: null,
});
// Across the antimeridian, and with a wrapped longitude.
assert.equal((await placeBelow(66, -175)).countryCode, 'RU');
assert.equal((await placeBelow(-17.8, 178)).countryCode, 'FJ');
assert.equal((await placeBelow(48.85, 362.35)).countryCode, 'FR');
});
test('at 1x a point in Sichuan reads Sichuan, China, with no city', async () => {
assert.deepEqual(await placeBelow(30.9, 104.2), {
kind: 'country',
name: 'China',
countryCode: 'CN',
division: 'Sichuan',
near: null,
});
assert.equal(nearPlaceRadiusKm(1, 1400), null);
assert.equal(nearPlaceRadiusKm(NEAR_PLACE_MIN_ZOOM / 2, 300), null);
});
test('from 8x the most prominent populated place in view is named', async () => {
assert.equal(NEAR_PLACE_MIN_ZOOM, 8);
// An 8x view ~120 km wide: Chengdu lies ~30 km off, inside half of it.
const nearKm = nearPlaceRadiusKm(8, 120);
assert.equal(nearKm, 60);
const place = await placeBelow(30.9, 104.2, { nearKm });
assert.equal(place.division, 'Sichuan');
assert.equal(place.near, 'Chengdu');
// Deyang lies nearer this point; Chengdu still outranks it.
assert.equal(
(await placeBelow(31.1, 104.35, { nearKm: 60 })).near,
'Chengdu',
);
});
test('no city is named when none lies within half the view', async () => {
// ~100 km west of Chengdu, in the mountains.
const place = await placeBelow(30.67, 103.0, { nearKm: 30 });
assert.equal(place.division, 'Sichuan');
assert.equal(place.near, null);
assert.equal(nearPlaceRadiusKm(8, Number.NaN), null);
});
test('at sea the smallest named sea wins over its ocean', async () => {
assert.deepEqual(await placeBelow(42, 51), {
kind: 'sea',
name: 'Caspian Sea',
countryCode: null,
});
// A radius changes nothing at sea.
assert.deepEqual(await placeBelow(42, 51, { nearKm: 60 }), {
kind: 'sea',
name: 'Caspian Sea',
countryCode: null,
});
assert.equal((await placeBelow(15, 88)).name, 'Bay of Bengal');
assert.equal((await placeBelow(35, 18)).name, 'Mediterranean Sea');
});
test('open water far from any sea is the ocean', async () => {
assert.deepEqual(await placeBelow(0, -150), {
kind: 'ocean',
name: 'South Pacific Ocean',
countryCode: null,
});
assert.equal((await placeBelow(40, 179.9)).name, 'North Pacific Ocean');
assert.equal((await placeBelow(40, -179.9)).name, 'North Pacific Ocean');
assert.equal((await placeBelow(40, -40)).name, 'North Atlantic Ocean');
});
test('an invalid point has no place', async () => {
assert.equal(await placeBelow(Number.NaN, 0), null);
assert.equal(await placeBelow(91, 0), null);
});
/** A lookup whose answers the test releases by hand. */
function manualLookup() {
const calls = [];
const lookup = (lat, lon, options) =>
new Promise((resolve) => calls.push({ lat, lon, options, resolve }));
return { calls, lookup };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
test('the tracker looks up again only after both time and distance', async () => {
const { calls, lookup } = manualLookup();
const places = [];
const tracker = createPlaceTracker({
lookup,
onPlace: (place) => places.push(place),
});
assert.equal(tracker.update(0, 0, 1000), true, 'the first point');
await flush();
assert.equal(tracker.update(5, 5, 1100), false, 'one lookup at a time');
calls[0].resolve({ kind: 'ocean', name: 'A' });
await flush();
assert.deepEqual(places, [{ kind: 'ocean', name: 'A' }]);
// Far enough, but too soon.
assert.equal(tracker.update(5, 5, 1000 + PLACE_LOOKUP_MIN_MS - 1), false);
// Long enough, but barely moved (a geostationary satellite).
assert.equal(tracker.update(0.01, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5), false);
// Both: a low orbit ~2.6 s on.
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
assert.equal(
tracker.update(movedDeg, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5),
true,
);
await flush();
calls[1].resolve(null);
await flush();
assert.deepEqual(places.at(-1), null, 'nothing named there is reported');
});
test('a new radius (a zoom step) looks up again at once', async () => {
const { calls, lookup } = manualLookup();
const tracker = createPlaceTracker({ lookup, onPlace: () => {} });
assert.equal(tracker.update(0, 0, 1000), true);
await flush();
assert.deepEqual(calls[0].lat, 0);
calls[0].resolve(null);
await flush();
// Same point, a moment later: only the zoom changed.
assert.equal(tracker.update(0, 0, 1100, 60), true, 'into 8x');
await flush();
calls[1].resolve(null);
await flush();
// Altitude drift barely moves the radius: the gate holds.
assert.equal(tracker.update(0, 0, 1200, 62), false);
assert.equal(tracker.update(0, 0, 1300, 30), true, 'to 16x');
await flush();
calls[2].resolve(null);
await flush();
assert.equal(tracker.update(0, 0, 1400, null), true, 'back below 8x');
await flush();
assert.deepEqual(
calls.map((call) => call.options),
[{ nearKm: null }, { nearKm: 60 }, { nearKm: 30 }, { nearKm: null }],
);
});
test('a lookup that lands after reset is dropped', async () => {
const { calls, lookup } = manualLookup();
const places = [];
const tracker = createPlaceTracker({
lookup,
onPlace: (place) => places.push(place),
});
tracker.update(0, 0, 0);
await flush();
tracker.reset();
// A new session looks up at once.
assert.equal(tracker.update(10, 10, 1), true);
await flush();
calls[0].resolve({ name: 'stale' });
calls[1].resolve({ name: 'fresh' });
await flush();
assert.deepEqual(places, [{ name: 'fresh' }]);
});
test('a failed lookup frees the tracker for the next point', async () => {
let fail = true;
const places = [];
const tracker = createPlaceTracker({
lookup: async () => {
if (fail) throw new Error('pack failed');
return { name: 'ok' };
},
onPlace: (place) => places.push(place),
minIntervalMs: 0,
minMoveKm: 0,
});
tracker.update(0, 0, 0);
await flush();
assert.deepEqual(places, [null]);
fail = false;
assert.equal(tracker.update(0, 0, 1), true);
await flush();
assert.deepEqual(places, [null, { name: 'ok' }]);
});
test('the near-place radius is half the smaller side of the view', () => {
assert.equal(nearPlaceRadiusKm(8, 120, 60), 30, 'landscape');
assert.equal(nearPlaceRadiusKm(8, 60, 120), 30, 'portrait');
assert.equal(nearPlaceRadiusKm(8, 120), 60, 'square without a height');
assert.equal(nearPlaceRadiusKm(8, 120, Number.NaN), null);
});
test('a failed division or place pack still names the country', async () => {
const fail = async () => {
throw new Error('pack failed');
};
const place = await placeBelow(30.9, 104.2, {
nearKm: 60,
detail: { division: fail, near: fail },
});
assert.equal(place.kind, 'country');
assert.equal(place.countryCode, 'CN');
assert.equal(place.division, null);
assert.equal(place.near, null);
});
test('a failed lookup hides the old place and looks again on the next update', async () => {
let fail = false;
const places = [];
const tracker = createPlaceTracker({
lookup: async () => {
if (fail) throw new Error('pack failed');
return { kind: 'sea', name: 'Bay of Biscay' };
},
onPlace: (place) => places.push(place),
});
assert.equal(tracker.update(45, -5, 0), true);
await flush();
assert.deepEqual(places, [{ kind: 'sea', name: 'Bay of Biscay' }]);
// Over France the lookup fails: the bay must not stay on the band.
fail = true;
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS), true);
await flush();
assert.equal(places.at(-1), null, 'the band hides');
// The next tick tries again at once, even without moving (a GEO view).
fail = false;
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS + 1), true);
await flush();
assert.deepEqual(places.at(-1), { kind: 'sea', name: 'Bay of Biscay' });
});
+111
View File
@@ -0,0 +1,111 @@
// src/data/placePacksLoading.test.mjs — the loading status hears the place
// packs while they are on their way.
import test from 'node:test';
import assert from 'node:assert/strict';
test('a place pack load is reported from its start to its end', async () => {
// A module of its own, so no other test has loaded the packs yet.
const place = await import('./placeBelow.js?loading-status');
const heard = [];
const stop = place.onPlacePacksLoadingChange(() =>
heard.push(place.placePacksStatus().loading),
);
assert.equal(place.placePacksStatus().loading, false);
const lookup = place.nearPlace(48.86, 2.35, 50);
assert.equal(place.placePacksStatus().loading, true, 'on its way');
assert.equal(await lookup, 'Paris');
assert.equal(place.placePacksStatus().loading, false, 'loaded');
assert.deepEqual(heard, [true, false]);
// Loaded once: a later lookup is not reported again.
await place.nearPlace(48.86, 2.35, 50);
assert.deepEqual(heard, [true, false]);
stop();
});
test('a pack that would not load stays failed until it loads', async () => {
const place = await import('./placeBelow.js?failed-status');
let fail = true;
const load = place.reportedPlacePackLoad('test', async () => {
if (fail) throw new Error('offline');
return 1;
});
await assert.rejects(load(), /offline/);
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: true });
fail = false;
const retry = load();
assert.deepEqual(
place.placePacksStatus(),
{ loading: true, failed: false },
'a retry is not a failure until it fails',
);
assert.equal(await retry, 1);
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false });
});
test('one lookup that loads several packs reads as one load', async () => {
const place = await import('./placeBelow.js?one-lookup');
const heard = [];
const stop = place.onPlacePacksLoadingChange(() => {
const { loading } = place.placePacksStatus();
if (heard.at(-1) !== loading) heard.push(loading);
});
// On land with a radius: countries and seas, then divisions, then places.
const found = await place.placeBelow(48.86, 2.35, { nearKm: 50 });
assert.equal(found.near, 'Paris');
assert.deepEqual(heard, [true, false], 'no gap between the packs');
stop();
});
/** Two stand-in packs (not real pack names) that fail while named in `fail`. */
function standInPacks(place, fail) {
const pack = (name) =>
place.reportedPlacePackLoad(name, async () => {
if (fail.has(name)) throw new Error('offline');
return name;
});
return { first: pack('stand-in-first'), second: pack('stand-in-second') };
}
/** The statuses one lookup that loads both stand-ins reports, start to end. */
async function lookupStatuses(place, { first, second }) {
const statuses = [];
const stop = place.onPlacePacksLoadingChange(() =>
statuses.push(place.placePacksStatus()),
);
await place.placeBelow(48.86, 2.35, {
detail: { division: async () => first(), near: async () => second() },
});
stop();
return statuses;
}
test('a load is not blamed for packs that failed in an earlier one', async () => {
const place = await import('./placeBelow.js?two-failed');
const fail = new Set(['stand-in-first', 'stand-in-second']);
const packs = standInPacks(place, fail);
await assert.rejects(packs.first());
await assert.rejects(packs.second());
assert.equal(place.placePacksStatus().failed, true);
fail.clear();
const statuses = await lookupStatuses(place, packs);
assert.deepEqual(statuses[0], { loading: true, failed: false });
assert.ok(
statuses.every((status) => status.failed === false),
'the earlier failures do not colour this load',
);
assert.deepEqual(place.placePacksStatus(), {
loading: false,
failed: false,
});
});
test('a pack that fails in a load keeps it failed when the next one lands', async () => {
const place = await import('./placeBelow.js?mixed');
const packs = standInPacks(place, new Set(['stand-in-first']));
const statuses = await lookupStatuses(place, packs);
assert.deepEqual(statuses.at(-1), { loading: false, failed: true });
assert.deepEqual(place.placePacksStatus(), {
loading: false,
failed: true,
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* @module planespottersPhoto
* @description Reads a Planespotters photo answer into the one credited
* thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked
* on the condition that the photographer is credited next to it and the image
* links back to the photo's page, so a photo without a photographer, or whose
* image or page is not on Planespotters' own hosts, is "no photo": nothing
* arbitrary is ever shown or linked.
*
* Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`,
* `{photos: [...]}`), the cached server shape
* (`{found, thumbnail_large?, thumbnail: {src, width, height}, photographer,
* link}`), and an
* already-read photo (`{found, src, width, height, photographer, link}`).
* Pure: no fetch, no DOM.
*/
/** The image host: Planespotters' thumbnail CDN and its subdomains. */
const IMAGE_HOST = 'plnspttrs.net';
/** The only page host a photo may link to. */
const PAGE_HOST = 'www.planespotters.net';
const NO_PHOTO = Object.freeze({ found: false });
const isObject = (value) => Boolean(value) && typeof value === 'object';
/** The parsed `https:` URL, or null for anything else or with userinfo/port. */
function httpsUrl(value) {
if (typeof value !== 'string' || !value) return null;
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port)
return null;
return url;
}
function imageSrc(value) {
const url = httpsUrl(value);
return url &&
(url.hostname === IMAGE_HOST || url.hostname.endsWith(`.${IMAGE_HOST}`))
? url.href
: null;
}
function pageLink(value) {
const url = httpsUrl(value);
return url && url.hostname === PAGE_HOST ? url.href : null;
}
function dimension(value) {
const number = Number(value);
return value != null && Number.isFinite(number) && number > 0
? Math.round(number)
: null;
}
/** One candidate `{src, width, height, photographer, link}`, checked. */
function checked({ src, width, height, photographer, link }) {
const credit = typeof photographer === 'string' ? photographer.trim() : '';
const image = imageSrc(src);
const page = pageLink(link);
if (!credit || !image || !page) return null;
return {
found: true,
src: image,
width: dimension(width),
height: dimension(height),
photographer: credit,
link: page,
};
}
/** The upstream photo's candidates, large thumbnail first. */
function upstreamCandidates(photo) {
if (!photo || typeof photo !== 'object') return [];
return [photo.thumbnail_large, photo.thumbnail]
.filter((thumb) => thumb && typeof thumb === 'object')
.map((thumb) => ({
src: thumb.src,
width: thumb.size?.width,
height: thumb.size?.height,
photographer: photo.photographer,
link: photo.link,
}));
}
/**
* Read a Planespotters answer into the first photo that passes the checks.
* @param {*} payload Upstream, cached-server, or already-read photo.
* @returns {{found: true, src: string, width: ?number, height: ?number,
* photographer: string, link: string} | {found: false}}
*/
export function parsePlanespottersPhoto(payload) {
if (!payload || typeof payload !== 'object') return NO_PHOTO;
let candidates;
if (Array.isArray(payload.photos)) {
candidates = payload.photos.flatMap(upstreamCandidates);
} else if (payload.found !== true) {
return NO_PHOTO;
} else if (isObject(payload.thumbnail) || isObject(payload.thumbnail_large)) {
// Large first, as for the upstream answer; each must pass the checks.
candidates = [payload.thumbnail_large, payload.thumbnail]
.filter(isObject)
.map((thumb) => ({
...thumb,
photographer: payload.photographer,
link: payload.link,
}));
} else {
candidates = [payload];
}
for (const candidate of candidates) {
const photo = checked(candidate);
if (photo) return photo;
}
return NO_PHOTO;
}
+200
View File
@@ -0,0 +1,200 @@
// src/data/planespottersPhoto.test.mjs — the Planespotters photo answer is read
// into one credited thumbnail, and nothing outside Planespotters' own hosts is
// ever shown or linked.
import test from 'node:test';
import assert from 'node:assert/strict';
import { parsePlanespottersPhoto } from './planespottersPhoto.js';
const UPSTREAM = Object.freeze({
photos: [
{
id: '1745410',
thumbnail: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
size: { width: 200, height: 133 },
},
thumbnail_large: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
size: { width: 420, height: 280 },
},
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
photographer: 'Jan Seler',
},
],
});
const PHOTO = Object.freeze({
found: true,
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
width: 420,
height: 280,
photographer: 'Jan Seler',
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
});
const photo = (overrides) => ({
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
});
test('the upstream answer maps to the large thumbnail, its size, credit and link', () => {
assert.deepEqual(parsePlanespottersPhoto(UPSTREAM), PHOTO);
});
test('the small thumbnail stands in when the large one is missing', () => {
assert.deepEqual(parsePlanespottersPhoto(photo({ thumbnail_large: null })), {
...PHOTO,
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
});
});
test('a server’s cached shape reads the same way', () => {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: { src: PHOTO.src, width: 420, height: 280 },
photographer: 'Jan Seler',
link: PHOTO.link,
}),
PHOTO,
);
assert.deepEqual(parsePlanespottersPhoto({ found: false }), {
found: false,
});
});
test('a server’s cached shape prefers its large thumbnail', () => {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
},
thumbnail_large: { src: PHOTO.src, width: 420, height: 280 },
photographer: 'Jan Seler',
link: PHOTO.link,
}),
PHOTO,
);
});
test('a server’s cached shape falls back to its small thumbnail when the large one fails the checks', () => {
const small = {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
};
for (const thumbnail_large of [
{ src: 'https://evil.example/a.jpg', width: 420, height: 280 },
null,
]) {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: small,
thumbnail_large,
photographer: 'Jan Seler',
link: PHOTO.link,
}),
{ ...PHOTO, ...small },
JSON.stringify(thumbnail_large),
);
}
});
test('no photo, junk, and missing fields are "no photo"', () => {
for (const payload of [
{ photos: [] },
{},
null,
undefined,
'nope',
{ photos: 'nope' },
{ photos: [null] },
photo({ photographer: '' }),
photo({ photographer: ' ' }),
photo({ photographer: 42 }),
photo({ link: '' }),
photo({ thumbnail: null, thumbnail_large: null }),
]) {
assert.deepEqual(
parsePlanespottersPhoto(payload),
{ found: false },
JSON.stringify(payload),
);
}
});
test('a src outside https *.plnspttrs.net is dropped', () => {
for (const src of [
'http://t.plnspttrs.net/a.jpg',
'https://plnspttrs.net.evil.example/a.jpg',
'https://evil.example/t.plnspttrs.net/a.jpg',
'https://t.plnspttrs.net.evil.example/a.jpg',
'https://[email protected]/a.jpg',
'https://t.plnspttrs.net:8443/a.jpg',
'javascript:alert(1)',
'//t.plnspttrs.net/a.jpg',
'not a url',
]) {
assert.deepEqual(
parsePlanespottersPhoto(
photo({
thumbnail_large: { src, size: { width: 420, height: 280 } },
thumbnail: { src, size: { width: 200, height: 133 } },
}),
),
{ found: false },
src,
);
}
});
test('a link anywhere but https://www.planespotters.net is dropped', () => {
for (const link of [
'http://www.planespotters.net/photo/1',
'https://planespotters.net/photo/1',
'https://www.planespotters.net.evil.example/photo/1',
'https://evil.example/?u=https://www.planespotters.net/',
'https://user:[email protected]/photo/1',
'https://www.planespotters.net:444/photo/1',
'javascript:alert(1)',
]) {
assert.deepEqual(
parsePlanespottersPhoto(photo({ link })),
{ found: false },
link,
);
}
});
test('the first photo that passes the checks is the one shown', () => {
const second = {
...UPSTREAM.photos[0],
photographer: 'Second Spotter',
};
assert.deepEqual(
parsePlanespottersPhoto({
photos: [
{ ...UPSTREAM.photos[0], link: 'https://evil.example/' },
second,
],
}),
{ ...PHOTO, photographer: 'Second Spotter' },
);
});
test('a missing size is null and the credit is trimmed', () => {
assert.deepEqual(
parsePlanespottersPhoto(
photo({
photographer: ' Jan Seler ',
thumbnail_large: { src: PHOTO.src },
}),
),
{ ...PHOTO, width: null, height: null },
);
});
+38
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
normalizeRegionalArticles,
recentRegionalArticles,
regionalNewsWindowHours,
normalizeRegionalPlace,
normalizeRegionalWeather,
regionalDistanceM,
@@ -61,3 +65,37 @@ test('regional distance handles nearby movement and missing positions', () => {
assert.ok(distance > 11000 && distance < 11200);
assert.equal(regionalDistanceM(null, null), Infinity);
});
test('regional news is held to one 24-hour window, newest first, undated dropped', () => {
assert.equal(REGIONAL_NEWS_WINDOW_HOURS, 24);
assert.equal(googleNewsWindowOperator(), 'when:1d');
assert.equal(googleNewsWindowOperator(6), 'when:6h');
assert.equal(regionalNewsWindowHours('12'), 12);
assert.equal(regionalNewsWindowHours(0), 24);
assert.equal(regionalNewsWindowHours(undefined), 24);
const now = Date.parse('2026-11-04T12:00:00Z');
const at = (hours) => new Date(now - hours * 3_600_000).toISOString();
const rows = [
{ title: 'a', publishedAt: at(23) },
{ title: 'stale', publishedAt: at(25) },
{ title: 'undated', publishedAt: null },
{ title: 'garbled', publishedAt: 'not a date' },
{ title: 'b', publishedAt: at(1) },
{ title: 'future', publishedAt: at(-2) },
{ title: 'c', publishedAt: at(5) },
];
assert.deepEqual(
recentRegionalArticles(rows, { now }).map((row) => row.title),
['b', 'c', 'a'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, limit: 2 }).map((row) => row.title),
['b', 'c'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, windowHours: 4 }).map((row) => row.title),
['b'],
);
assert.deepEqual(recentRegionalArticles([], { now }), []);
assert.deepEqual(recentRegionalArticles(undefined, { now }), []);
});
+7
View File
@@ -55,6 +55,13 @@ export function normalizeRegionalPlace(payload) {
};
}
export {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
recentRegionalArticles,
regionalNewsWindowHours,
} from './regionalNewsWindow.js';
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
+66
View File
@@ -0,0 +1,66 @@
/** The regional-news freshness window, shared by the servers and the Cockpit. */
/** At most this many articles answer a regional brief. */
export const REGIONAL_NEWS_ROWS = 5;
/**
* The regional-news freshness window, in hours. The servers ask upstream for
* this window, drop anything older and report it as `newsWindowHours`; the
* Cockpit labels its list with it.
*/
export const REGIONAL_NEWS_WINDOW_HOURS = 24;
// A publish time this far ahead of the clock is skew, not a future story.
const REGIONAL_NEWS_CLOCK_SKEW_MS = 15 * 60_000;
/**
* A served window in hours, or the default when it is missing or out of range.
* @param {unknown} value
* @returns {number}
*/
export function regionalNewsWindowHours(value) {
const hours = Number(value);
return Number.isInteger(hours) && hours >= 1 && hours <= 168
? hours
: REGIONAL_NEWS_WINDOW_HOURS;
}
/**
* The Google News search operator asking for the window: `when:1d` for 24 h.
* @param {number} [hours]
* @returns {string}
*/
export function googleNewsWindowOperator(hours = REGIONAL_NEWS_WINDOW_HOURS) {
const span = regionalNewsWindowHours(hours);
return span % 24 === 0 ? `when:${span / 24}d` : `when:${span}h`;
}
/**
* The articles published inside the window, newest first, at most `limit`.
* An article without a parseable publish time is dropped: nothing shows it is
* recent. One dated more than 15 minutes ahead of `now` is dropped too.
* @param {Array<{publishedAt?: string|null}>} articles
* @param {{now?: number, windowHours?: number, limit?: number}} [options]
* @returns {Array<object>}
*/
export function recentRegionalArticles(
articles,
{
now = Date.now(),
windowHours = REGIONAL_NEWS_WINDOW_HOURS,
limit = REGIONAL_NEWS_ROWS,
} = {},
) {
const oldest = now - regionalNewsWindowHours(windowHours) * 3_600_000;
return (Array.isArray(articles) ? articles : [])
.map((article) => ({ article, at: Date.parse(article?.publishedAt) }))
.filter(
({ at }) =>
Number.isFinite(at) &&
at >= oldest &&
at <= now + REGIONAL_NEWS_CLOCK_SKEW_MS,
)
.sort((a, b) => b.at - a.at)
.slice(0, Math.max(0, limit))
.map(({ article }) => article);
}
+48
View File
@@ -11,6 +11,7 @@ import createViteConfig, {
validMilitaryInstallationBox,
validRegionalPoint,
} from '../../vite.config.js';
import { fetchRegionalNews } from '../../server/providers/regional/news.js';
test('missing Google place context is a quiet keyless capability, not a 503', () => {
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
@@ -113,3 +114,50 @@ test('regional brief treats an all-source outage as total failure', () => {
news: { status: 'unavailable' },
}), true);
});
test('local regional news asks for the last day, drops old and undated items, newest first', async (t) => {
const realFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = realFetch; });
const urls = [];
let rss;
let gdelt;
globalThis.fetch = async (url) => {
urls.push(new URL(String(url)));
const answer = String(url).includes('news.google') ? rss : gdelt;
return typeof answer === 'function' ? answer() : answer.clone();
};
const at = (hours) => new Date(Date.now() - hours * 3_600_000).toUTCString();
const item = (title, date) =>
`<item><title>${title}</title><link>https://news.example/${title}</link>${date ? `<pubDate>${date}</pubDate>` : ''}</item>`;
rss = new Response(`<rss><channel>${[
item('old', at(4 * 24)),
item('undated', null),
item('day', at(20)),
item('hour', at(1)),
].join('')}</channel></rss>`);
const place = { locality: 'Austin' };
const ready = await fetchRegionalNews(place);
assert.equal(urls[0].searchParams.get('q'), 'Austin when:1d');
assert.equal(ready.status, 'ready');
assert.deepEqual(ready.articles.map((row) => row.title), ['hour', 'day']);
// Nothing recent in RSS: GDELT is asked for the same 24 h window.
rss = new Response(`<rss><channel>${item('old', at(30))}</channel></rss>`);
gdelt = Response.json({ articles: [] });
urls.length = 0;
const empty = await fetchRegionalNews(place);
assert.equal(urls[1].searchParams.get('timespan'), '24h');
assert.deepEqual(empty, { status: 'empty', query: 'Austin', articles: [], source: 'GDELT fallback' });
// RSS answered with nothing recent and GDELT failed: still a true empty.
rss = new Response('<rss><channel></channel></rss>');
gdelt = () => Promise.reject(new TypeError('network'));
assert.equal((await fetchRegionalNews(place)).status, 'empty');
// Neither source answered, or no place: unavailable.
rss = () => Promise.resolve(new Response('busy', { status: 503 }));
const failed = await fetchRegionalNews(place);
assert.equal(failed.status, 'unavailable');
assert.deepEqual(failed.articles, []);
assert.equal((await fetchRegionalNews(null)).status, 'unavailable');
});
+14
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { worldClock } from '../time/worldClock.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
rocketLaunchesLayer.init(viewer);
initialized = true;
await rocketLaunchesLayer.enable();
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update();
const at = Date.parse(launchTime);
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
const entities = dataSources[0].entities.values;
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
@@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
assert.equal(deselectedPublication[2][0].protected, false);
await rocketLaunchesLayer.disable();
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
assert.deepEqual(hostCalls.slice(-4), [
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
@@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
assert.deepEqual(launch.payloads, []);
});
test('away from live the feed keeps its own window of launches', (t) => {
t.after(() => worldClock.goLive());
worldClock.seek(Date.now() - 20 * 86_400_000);
const net = new Date(Date.now() - 86_400_000).toISOString();
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
});
+10 -1
View File
@@ -46,7 +46,13 @@ function alongTrackKm(position, origin, destination) {
}
const NEAR_ENDPOINT_KM = 130;
/** Least allowed distance off the great circle, and how far past either
* endpoint the plane may be along it. */
const CROSS_TRACK_KM = 200;
/** On a long route the allowed distance off the great circle grows to this
* share of its length: airways and winds take a coast-to-coast flight several
* hundred kilometres off it (JFK–SFO: 623 km). */
const CROSS_TRACK_ROUTE_FRACTION = 0.15;
const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm
const LOCAL_AIRPORT_KM = 150;
@@ -90,6 +96,9 @@ export function routePlausible({
// existing loose check when the segment cannot be judged reliably.
const segmentKnown =
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
const corridorKm = segmentKnown
? Math.max(CROSS_TRACK_KM, CROSS_TRACK_ROUTE_FRACTION * routeKm)
: CROSS_TRACK_KM;
geomOk =
Math.abs(
crossTrackKm(
@@ -100,7 +109,7 @@ export function routePlausible({
destination.lat,
destination.lon,
),
) < CROSS_TRACK_KM &&
) < corridorKm &&
(!segmentKnown ||
(segmentAlongKm >= -CROSS_TRACK_KM &&
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
+18
View File
@@ -27,6 +27,24 @@ test('plane in London with an SFO→LAX route: implausible', () => {
}), false);
});
test('a long route allows a wider corridor; a short one does not', () => {
// JFK→SFO over Kansas, 384 km south of the great circle.
assert.equal(routePlausible({
latDeg: 38.21, lonDeg: -99.69, altitudeM: 10370, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), true);
// 516 km off the 543 km SFO→LAX route is still the wrong route.
assert.equal(routePlausible({
latDeg: 37.9, lonDeg: -115.0, altitudeM: 10000, verticalRateMps: 0,
origin: SFO, destination: LAX,
}), false);
// 960 km off, past 15 % of the 4,152 km JFK→SFO route, is still wrong.
assert.equal(routePlausible({
latDeg: 33.0, lonDeg: -99.69, altitudeM: 10000, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), false);
});
test('vertical trend: low climbing plane near SFO — origin must be local', () => {
const nearSfo = { latDeg: 37.7, lonDeg: -122.4, altitudeM: 2500 };
// JFK→SFO passes geometry (near destination), but a CLIMBING plane here just
+4 -1
View File
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
].map((name) => [name, {}]),
);
services.aircraftPresentation = { isTr3b: () => false };
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
const parts = {
queries: { _contactLabel: (_, info) => info.callsign },
motion: { _signalLost: () => null },
};
const tracking = createTracking({ flightState, services, parts });
for (const [position, expected] of [
[beyond, false],
+34 -59
View File
@@ -1,23 +1,23 @@
/**
* Satellite classification — CelesTrak source group → operator-legible class.
* Satellite labels and colours by CelesTrak source group, beneath the six
* classes.
*
* The catalog already tags every satellite with the CelesTrak group it was
* ingested from (`CATALOG_GROUPS` in satellites.js), so classification is a
* pure lookup: no extra fetch, no heuristics, no per-frame work. This module is
* the single source of truth for the class a satellite belongs to, the label
* shown on its card, and the point color it renders in — the render path, the
* tracked card, and the row legend all read the same table, so a palette edit
* can never leave one surface disagreeing with another.
* The class a satellite is filed under (STATIONS, IMAGING, TELECOM,
* STARLINK, PROBES or DEBRIS) is decided in src/layers/satellites/classes.js
* from SATCAT, the UCS database, names and the group, and that module owns
* each class's colour, legend label and toggle. This module keeps the
* group-based layer: the constellation subtype a card adds to the class
* (`TELECOM · GPS`, `STATIONS · ISS`; satelliteClassText), the group's own
* label for a satellite with no class (satelliteClassLabel), and the colour
* per group (satelliteClassColor), which the layer's base point styles use
* where a satellite has no class (src/layers/satellites/policy.js).
*
* Deliberately Cesium-free: colors are CSS hex strings that satellites.js
* converts once at module load. That keeps this file unit-testable and lets the
* legend swatches reuse the exact same strings the points are drawn with.
*
* Class order below is the legend order: crewed first (the objects people look
* for), then the constellations users can actually name, then the grab-bag.
* Deliberately Cesium-free: colors are CSS hex strings that the layer
* converts once at module load, so this file stays unit-testable.
*/
import { t, msg } from '../i18n/index.js';
import { SATELLITE_CLASS_DISPLAY } from '../layers/satellites/classes.js';
/**
* Class registry: the point color of each class. The card and legend text is
@@ -167,50 +167,25 @@ export function satelliteClassLabel(
}
/**
* Tally satellites into a class → count record.
* Split from the legend builder so the counting rule stays independent of how
* the legend is presented. Entries may be bare group tags, or `{ group, isIss }`
* descriptors when the caller can identify the ISS — the tally classifies
* through satelliteClassOf, so the legend and the card always agree.
* @param {Iterable<string|undefined|null|{group?: string, isIss?: boolean}>} entries
* One entry per satellite.
* @returns {Record<string, number>} Class key → count.
* The class a satellite shows on its card, label, overlay and orbital
* readout: its class (src/layers/satellites/classes.js) with the CelesTrak group's
* subtype where one says more ("TELECOM · GPS", "STATIONS · ISS"). A subtype
* that only repeats the class ("STARLINK · STARLINK") is dropped. Without a
* class the group's label stands (satelliteClassLabel).
* @param {string|null|undefined} satelliteClass Six-class id.
* @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean, localize?: boolean }} [options] As for
* satelliteClassLabel.
* @returns {string} Display label.
*/
export function tallySatelliteClasses(entries) {
const counts = Object.create(null);
for (const entry of entries || []) {
const descriptor =
entry && typeof entry === 'object' ? entry : { group: entry };
const { klass } = satelliteClassOf(descriptor.group, {
isIss: descriptor.isIss,
});
counts[klass] = (counts[klass] || 0) + 1;
}
return counts;
}
/**
* Build the layer-row legend from a class tally.
* Classes with no members are omitted so the legend never advertises a class
* that is not on screen (COMMS only appears once DENSE is on).
* @param {Record<string, number>} counts Class key → count (see tallySatelliteClasses).
* @returns {Array<{ klass: string, label: string, color: string, blurb: string, count: number }>}
* Present classes in legend order.
*/
export function satelliteClassLegend(counts) {
const result = [];
for (const klass of SATELLITE_CLASS_ORDER) {
const count = counts?.[klass];
if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass];
const display = CLASS_DISPLAY_KEYS[klass];
result.push({
klass,
label: t(display.label),
color: spec.color,
blurb: t(display.blurb),
count,
});
}
return result;
export function satelliteClassText(
satelliteClass,
group,
{ isIss = false, localize = true } = {},
) {
const display = SATELLITE_CLASS_DISPLAY[satelliteClass];
if (!display) return satelliteClassLabel(group, { isIss, localize });
const base = localize ? t(display.label) : display.label;
const { subtype } = satelliteClassOf(group, { isIss });
return subtype && subtype !== display.label ? `${base} · ${subtype}` : base;
}
+136 -91
View File
@@ -5,15 +5,15 @@ import {
SATELLITE_CLASS_ORDER,
satelliteClassColor,
satelliteClassLabel,
satelliteClassLegend,
satelliteClassOf,
tallySatelliteClasses,
satelliteClassText,
} from './satelliteClass.js';
import satellitesLayer, {
_catalogGroupForTest,
_clearDenseCatalogStateForTest,
_setDenseCatalogStateForTest,
} from './satellites.js';
import { CATALOG_GROUPS } from '../layers/satellites/policy.js';
/**
* A real, parseable TLE under an arbitrary 5-digit catalog number, so the dense
@@ -35,15 +35,19 @@ const ISS_TLE = tleFor('25544', 'ISS (ZARYA)');
/** Poll the chip until the async dense load settles (or give up). */
async function settleChip(maxTicks = 50) {
for (let i = 0; i < maxTicks; i++) {
const chip = satellitesLayer.getRowControls().chips[0];
const chip = starlinkChip();
if (chip && !chip.busy) return chip;
await new Promise((resolve) => setTimeout(resolve, 1));
}
return satellitesLayer.getRowControls().chips[0];
return starlinkChip();
}
/** The STARLINK class chip: the dense catalog toggle (id `catalog`). */
const starlinkChip = () =>
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
/** Every CelesTrak group the catalog ingests, plus the dense-mode tag. */
const INGESTED_GROUPS = ['stations', 'visual', 'gps-ops', 'glonass', 'galileo', 'geo', 'dense'];
const INGESTED_GROUPS = [...CATALOG_GROUPS.map((group) => group.tag), 'dense'];
test('every ingested CelesTrak group resolves to a real class', () => {
// Guards against drift: adding a group to CATALOG_GROUPS without classifying
@@ -146,69 +150,7 @@ test('the dense shell stays dimmer than the class it sits among', () => {
);
});
test('tallying groups counts by class, folding the GNSS constellations together', () => {
const counts = tallySatelliteClasses([
'gps-ops', 'gps-ops', 'glonass', 'galileo',
'geo', 'geo', 'geo',
'stations',
'visual',
'dense', 'dense',
]);
assert.equal(counts.nav, 4, 'all three GNSS groups fold into NAV');
assert.equal(counts.geo, 3);
assert.equal(counts.station, 1);
assert.equal(counts.visual, 1);
assert.equal(counts.comms, 2);
});
test('tallying tolerates an empty or missing input', () => {
assert.deepEqual({ ...tallySatelliteClasses([]) }, {});
assert.deepEqual({ ...tallySatelliteClasses(undefined) }, {});
});
test('the tally files the ISS under STATION even when a partial outage moved it', () => {
// The legend and the card must never disagree. With the stations feed down,
// the ISS is ingested as `visual`; counting it there would let STATION vanish
// from the legend while the tracked card still reads "STATION · ISS".
const outage = tallySatelliteClasses([
{ group: 'visual', isIss: true },
{ group: 'visual' },
{ group: 'visual' },
]);
assert.equal(outage.station, 1, 'the ISS is counted as a STATION');
assert.equal(outage.visual, 2, 'and is not double-counted under VISUAL');
const legend = satelliteClassLegend(outage);
assert.equal(legend.some((row) => row.klass === 'station'), true,
'STATION stays in the legend, matching the card');
// Bare group tags still work — the descriptor form is additive.
assert.deepEqual({ ...tallySatelliteClasses(['visual', 'visual']) }, { visual: 2 });
assert.equal(satelliteClassOf('visual', { isIss: true }).klass, 'station',
'one helper owns the rule for every consumer');
});
test('the legend lists present classes in order and omits absent ones', () => {
const legend = satelliteClassLegend(tallySatelliteClasses(['geo', 'gps-ops', 'stations']));
assert.deepEqual(legend.map((row) => row.klass), ['station', 'nav', 'geo'],
'legend follows SATELLITE_CLASS_ORDER, not input order');
// COMMS only exists in DENSE mode — the legend must not advertise a class
// that has nothing on screen.
assert.equal(legend.some((row) => row.klass === 'comms'), false);
const row = legend.find((entry) => entry.klass === 'nav');
assert.equal(row.label, 'NAV');
assert.equal(row.color, SATELLITE_CLASSES.nav.color);
assert.equal(row.count, 1);
assert.ok(row.blurb.length > 0, 'every legend row carries a plain-language gloss');
});
test('the legend drops zero and negative counts', () => {
assert.deepEqual(satelliteClassLegend({ nav: 0, geo: -1, station: 2 }).map((r) => r.klass), ['station']);
assert.deepEqual(satelliteClassLegend(null), []);
});
test('the DENSE row chip is stateless and declares the params to apply', () => {
test('the STARLINK row chip is stateless and declares the params to apply', () => {
// The chip never carries its own state: it declares the params to apply, so
// whatever else drives the catalog param (Space Missions capture/restore)
// stays authoritative. Whether it reads ACTIVE is decided by the dense LOAD,
@@ -217,7 +159,7 @@ test('the DENSE row chip is stateless and declares the params to apply', () => {
_setDenseCatalogStateForTest({});
const chip = satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
assert.equal(chip.id, 'catalog');
assert.equal(chip.label, 'DENSE');
assert.equal(chip.label, 'STARLINK');
assert.equal(chip.active, false);
assert.equal(chip.busy, false);
assert.equal(chip.state, 'idle');
@@ -236,7 +178,7 @@ test('an invalid catalog mode is rejected without disturbing the chip', () => {
assert.equal(chip.active, false);
});
test('DENSE reports loading, then ACTIVE only once the points exist', async () => {
test('STARLINK reports loading, then ACTIVE only once the points exist', async () => {
const originalFetch = globalThis.fetch;
const refreshes = [];
try {
@@ -247,7 +189,7 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
satellitesLayer.setParams({ catalog: 'dense' });
// The param flips synchronously, but the shell has not arrived yet — the
// chip must say BUSY, never ACTIVE.
const pending = satellitesLayer.getRowControls().chips[0];
const pending = starlinkChip();
assert.equal(pending.busy, true);
assert.equal(pending.disabled, true);
assert.equal(pending.active, false, 'never active before the points exist');
@@ -256,21 +198,21 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
const settled = await settleChip();
assert.equal(settled.active, true, 'active once the dense points are on screen');
assert.equal(settled.state, 'active');
assert.equal(settled.label, 'DENSE');
assert.equal(settled.label, 'STARLINK');
assert.deepEqual(settled.params, { catalog: 'core' }, 'a settled chip toggles back off');
// The completion pushed a refresh, and the legend gained COMMS with it —
// The completion pushed a refresh, and the legend gained STARLINK with it —
// without that push the row would keep the pre-load counts for 5 minutes.
assert.ok(refreshes.length >= 2, 'load start and load completion each pushed a refresh');
const legend = satellitesLayer.getRowControls().legend;
assert.equal(legend.some((row) => row.klass === 'comms'), true);
assert.equal(legend.some((row) => row.klass === 'starlink'), true);
} finally {
globalThis.fetch = originalFetch;
_clearDenseCatalogStateForTest();
}
});
test('a failed DENSE load reverts the mode rather than leaving an active chip', async () => {
test('a failed STARLINK load reverts the mode rather than leaving an active chip', async () => {
const originalFetch = globalThis.fetch;
const warn = console.warn;
console.warn = () => {};
@@ -285,14 +227,14 @@ test('a failed DENSE load reverts the mode rather than leaving an active chip',
assert.equal(settled.active, false, 'a 502 must never present as a live dense catalog');
assert.equal(settled.state, 'error');
assert.equal(settled.label, 'DENSE ✕');
assert.equal(settled.label, 'STARLINK ✕');
assert.match(settled.title, /502/, 'the chip explains why');
assert.equal(satellitesLayer.getParams().catalog, 'core', 'the mode reverts to reality');
assert.deepEqual(settled.params, { catalog: 'dense' }, 'clicking retries');
assert.equal(
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'comms'),
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'starlink'),
false,
'no COMMS class is advertised when nothing loaded',
'no STARLINK class is advertised when nothing loaded',
);
assert.ok(pushes >= 2, 'the failure pushed its own refresh');
@@ -365,9 +307,9 @@ test('an explicit return to core clears a failure the user never caused', async
// A no-op restore of the pre-mission snapshot.
satellitesLayer.setParams({ catalog: 'core', showPoints: true, showOrbits: true });
const restored = satellitesLayer.getRowControls().chips[0];
const restored = starlinkChip();
assert.equal(restored.state, 'idle', 'the error does not survive the restore');
assert.equal(restored.label, 'DENSE');
assert.equal(restored.label, 'STARLINK');
assert.doesNotMatch(restored.title, /502/);
} finally {
console.warn = warn;
@@ -376,7 +318,7 @@ test('an explicit return to core clears a failure the user never caused', async
}
});
test('a real stations-feed outage keeps STATION in the legend, matching the card', async () => {
test('a real stations-feed outage keeps the ISS under STATIONS in the legend', async () => {
// End-to-end version of the tally rule: drive a real rebuild in which only
// the `visual` group answers, so the ISS is genuinely ingested as `visual`.
// The legend must still file it under STATION or it disagrees with the card.
@@ -398,11 +340,11 @@ test('a real stations-feed outage keeps STATION in the legend, matching the card
'the outage really did ingest the ISS from the visual group');
const legend = satellitesLayer.getRowControls().legend;
assert.equal(legend.find((row) => row.klass === 'station')?.count, 1,
'STATION survives the outage because the ISS is counted there');
assert.equal(legend.some((row) => row.klass === 'visual'), false,
'and it is not also counted under VISUAL');
assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS',
assert.equal(legend.find((row) => row.klass === 'stations')?.count, 1,
'STATIONS survives the outage because the ISS is counted there');
assert.equal(legend.length, 1, 'and it is not also counted under another class');
const [iss] = satellitesLayer.getAnalystRecords();
assert.equal(iss?.satelliteClass, 'STATIONS · ISS',
'the card says the same thing the legend does');
} finally {
console.log = log;
@@ -431,15 +373,21 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
text: async () => (String(url).endsWith(`/${homeGroup}`) ? ALT_TLE : ''),
});
// Every class on: the seeded satellite's class must not hide it.
satellitesLayer.setParams({
classes: { telecom: true, probes: true, debris: true },
});
await satellitesLayer.update(viewer);
const first = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
assert.ok(first, 'the seeded satellite is collectable');
assert.equal(first.klass, 'GEO');
// A payload with no SATCAT record and no recognisable name takes its
// group's class (src/layers/satellites/classes.js).
assert.equal(first.klass, 'TELECOM');
homeGroup = 'visual';
homeGroup = 'resource';
await satellitesLayer.update(viewer);
const second = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
assert.equal(second.klass, 'VISUAL', 'the cached record does not outlive its catalog');
assert.equal(second.klass, 'IMAGING', 'the cached record does not outlive its catalog');
} finally {
console.log = log;
console.warn = warn;
@@ -451,7 +399,7 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
test('a dependency owner hiding the points surrenders the whole row', async () => {
try {
_setDenseCatalogStateForTest({});
assert.equal(satellitesLayer.getRowControls().chips.length, 1);
assert.equal(satellitesLayer.getRowControls().chips.length, 6);
// Space Missions borrows this layer for TLE lookup with showPoints:false.
satellitesLayer.setParams({ showPoints: false });
@@ -460,8 +408,105 @@ test('a dependency owner hiding the points surrenders the whole row', async () =
assert.deepEqual(owned.legend, [], 'no legend describing an empty sky');
satellitesLayer.setParams({ showPoints: true });
assert.equal(satellitesLayer.getRowControls().chips.length, 1, 'released on restore');
assert.equal(satellitesLayer.getRowControls().chips.length, 6, 'released on restore');
} finally {
_clearDenseCatalogStateForTest();
}
});
test('every class but Starlink shows by default; each class chip turns its class on and off', async () => {
const originalFetch = globalThis.fetch;
const log = console.log;
const warn = console.warn;
console.log = () => {};
console.warn = () => {};
const served = {
'/api/celestrak/stations': ISS_TLE,
'/api/celestrak/resource': tleFor('90002', 'SENTINEL-2A'),
'/api/celestrak/gps-ops': tleFor('90001', 'GPS BIII-1'),
'/api/celestrak/visual': tleFor('90003', 'SL-16 R/B'),
};
const shown = () => satellitesLayer.getAllPositions().map((row) => row.id).sort();
const chip = (id) =>
satellitesLayer.getRowControls().chips.find((entry) => entry.id === `class-${id}`);
try {
_setDenseCatalogStateForTest({});
globalThis.fetch = async (url) => ({
ok: true,
text: async () => served[String(url)] || '',
});
await satellitesLayer.update({ scene: { primitives: { add: (p) => p, remove() {} } } });
assert.deepEqual(shown(), [25544, 90001, 90002, 90003], 'all but Starlink');
assert.deepEqual(
satellitesLayer.getRowControls().chips.map((entry) => entry.label),
['STATIONS', 'IMAGING', 'TELECOM', 'STARLINK', 'PROBES', 'DEBRIS'],
);
assert.equal(chip('imaging').active, true);
assert.equal(chip('telecom').active, true);
assert.equal(
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog').active,
false,
);
assert.deepEqual(chip('telecom').params, { classes: { telecom: false } });
assert.deepEqual(
satellitesLayer.getRowControls().legend.map((row) => row.klass),
['stations', 'imaging', 'telecom', 'debris'],
'the legend keys only what is on screen',
);
satellitesLayer.setParams(chip('telecom').params);
satellitesLayer.setParams(chip('debris').params);
assert.deepEqual(shown(), [25544, 90002], 'telecom and debris off');
assert.equal(chip('debris').active, false);
assert.deepEqual(satellitesLayer.getParams().classes, {
stations: true,
imaging: true,
telecom: false,
starlink: false,
probes: true,
debris: false,
});
satellitesLayer.setParams(chip('telecom').params);
satellitesLayer.setParams(chip('imaging').params);
assert.deepEqual(shown(), [25544, 90001], 'telecom back on, imaging off');
assert.equal(satellitesLayer.setParams({ classes: 'all' }), false, 'malformed request refused');
// A saved or shared choice comes back as class letters.
assert.equal(satellitesLayer.setParams({ classes: 'sip' }), true);
assert.deepEqual(satellitesLayer.getParams().classes, {
stations: true,
imaging: true,
telecom: false,
starlink: false,
probes: true,
debris: false,
});
assert.deepEqual(shown(), [25544, 90002], 'restored from letters');
} finally {
console.log = log;
console.warn = warn;
globalThis.fetch = originalFetch;
_clearDenseCatalogStateForTest();
}
});
test('the shown class is the six-class one, with a subtype that says more', () => {
assert.equal(satelliteClassText('telecom', 'gps-ops'), 'TELECOM · GPS');
assert.equal(
satelliteClassText('stations', 'visual', { isIss: true }),
'STATIONS · ISS',
);
assert.equal(satelliteClassText('imaging', 'resource'), 'IMAGING');
assert.equal(satelliteClassText('imaging', 'visual'), 'IMAGING');
// A subtype that only repeats the class is dropped.
assert.equal(satelliteClassText('starlink', 'dense'), 'STARLINK');
// A dead geostationary comsat is debris, not GEO.
assert.equal(satelliteClassText('debris', 'geo'), 'DEBRIS');
// Without a class the group's label stands.
assert.equal(satelliteClassText(null, 'gps-ops'), 'NAV · GPS');
assert.equal(
satelliteClassText('telecom', 'gps-ops', { localize: false }),
'TELECOM · GPS',
);
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @file The satellites layer's class choice as share-link and saved-state
* letters. Shared by the layer-state codec and the layer, which takes a saved
* choice back in this form.
*/
/**
* Satellite classes shown, as one letter each in legend order: stations,
* imaging, telecom, probes, debris (`sitpd`, every one; `0`, none).
* Starlink is the `catalog` option's, so it has no letter here.
*/
const SATELLITE_CLASS_LETTERS = Object.freeze([
['stations', 's'],
['imaging', 'i'],
['telecom', 't'],
['probes', 'p'],
['debris', 'd'],
]);
const SATELLITE_CLASS_MASK = /^(0|s?i?t?p?d?)$/;
/**
* The canonical class letters for a class choice (an object of booleans, as
* the layer's `classes` param, or a partial one) or for letters already,
* else null.
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeSatelliteClassMask(value) {
if (typeof value === 'string')
return value && SATELLITE_CLASS_MASK.test(value) ? value : null;
if (!value || typeof value !== 'object') return null;
// A class the choice leaves out keeps its default, which is shown: a
// partial request (`{debris: false}`) hides only what it names.
const letters = SATELLITE_CLASS_LETTERS.filter(([id]) => value[id] !== false)
.map(([, letter]) => letter)
.join('');
return letters || '0';
}
/**
* The class choice the letters stand for, without Starlink, or null.
* @param {string} mask
* @returns {Record<string, boolean>|null}
*/
export function decodeSatelliteClassMask(mask) {
const normalized = normalizeSatelliteClassMask(mask);
if (normalized === null || typeof mask !== 'string') return null;
return Object.fromEntries(
SATELLITE_CLASS_LETTERS.map(([id, letter]) => [
id,
normalized.includes(letter),
]),
);
}
+49
View File
@@ -152,3 +152,52 @@ test('satellite getAnalystRecords: core rows beat dense extras under the cap', (
_clearDenseCatalogStateForTest();
}
});
test('satellite getAnalystRecords: a hidden class stays out, the tracked one stays in', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const at = (lon, alt) => ({
position: Cesium.Cartesian3.fromDegrees(lon, 10, alt),
});
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point: at(-97, 420_000),
viewer: {},
now: () => Date.now(),
satelliteClass: 'stations',
classes: {
stations: false,
imaging: true,
telecom: false,
starlink: false,
probes: true,
debris: true,
},
neighbours: [
{
noradId: 24876,
name: 'GPS BIIR-2',
group: 'gps-ops',
satelliteClass: 'telecom',
point: at(-100, 20_200_000),
},
{
noradId: 90001,
name: 'TEST IMAGER',
group: 'resource',
satelliteClass: 'imaging',
point: at(-80, 700_000),
},
],
});
try {
assert.deepEqual(
satellitesLayer.getAnalystRecords(10).map((r) => r.noradId),
['25544', '90001'],
);
} finally {
_clearDenseCatalogStateForTest();
}
});
+8
View File
@@ -6,6 +6,14 @@ export function celestrakTleUrl(group) {
return url;
}
/** CelesTrak SATCAT records (object type, status, owner) for a GP group, as CSV. */
export function celestrakSatcatUrl(group) {
const url = new URL('https://celestrak.org/satcat/records.php');
url.searchParams.set('GROUP', group);
url.searchParams.set('FORMAT', 'CSV');
return url;
}
export function launchLibraryRecentUrl(end) {
// Stable minute boundaries let different installations share LL2's cache.
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Upstream-request accounting shared by the keyed tile proxies.
*
* A soft cap per UTC period, persisted as `{date, count}` so it survives a
* restart. Nothing here is provider-specific: the caller picks the period by
* the key it passes, and decides what happens over the cap.
*
* Zero dependencies and Cesium-free, so providers and browser helpers can
* import it and node:test can exercise it directly.
*
* @module data/tileBudget
*/
/**
* UTC calendar-day key for budget bucketing.
*
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
* @returns {string} 'YYYY-MM-DD' in UTC.
*/
export function utcDayKey(epochMs = Date.now()) {
return new Date(epochMs).toISOString().slice(0, 10);
}
/**
* Normalize a persisted budget state against the current period key.
* Rolls the counter to zero when the period changes; replaces missing/corrupt
* state. Returns the SAME object when it is already valid for `periodKey`
* (cheap to call on every request).
*
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
* @param {string} periodKey - Current period key, e.g. from `utcDayKey`.
* @returns {{date:string, count:number}} Valid state for `periodKey`.
*/
export function normalizeBudget(state, periodKey) {
const valid =
Boolean(state) &&
state.date === periodKey &&
Number.isFinite(state.count) &&
state.count >= 0;
return valid ? state : { date: periodKey, count: 0 };
}
/**
* Whether the soft cap for the current period has been reached.
*
* @param {{count:number}} state - Normalized budget state.
* @param {number} limit - Budget per period; non-positive/invalid never blocks.
* @returns {boolean} True when `count >= limit`.
*/
export function isOverBudget(state, limit) {
if (!Number.isFinite(limit) || limit <= 0) return false;
return state.count >= limit;
}
+10
View File
@@ -0,0 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js';
import * as tomtom from './tomtomTiles.js';
test('tile budget helpers live in one module and TomTom re-exports them', () => {
assert.equal(tomtom.utcDayKey, utcDayKey);
assert.equal(tomtom.normalizeBudget, normalizeBudget);
assert.equal(tomtom.isOverBudget, isOverBudget);
});
+6 -40
View File
@@ -1,9 +1,10 @@
/**
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
* @file Pure tile math for the TomTom traffic-flow proxy; budget helpers are
* re-exported from `./tileBudget.js`.
*
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
* cover the current traffic fetch bounds). No external dependencies, Cesium-free, so
* both sides can unit-test against it with node:test.
*
* Slippy scheme: standard Web Mercator XYZ, y grows southward
@@ -131,42 +132,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) {
// ─── Daily budget accounting ───────────────────────────────
/**
* UTC calendar-day key for budget bucketing.
*
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
* @returns {string} 'YYYY-MM-DD' in UTC.
* Re-exported from `./tileBudget.js`, which keyed tile proxies share. The
* traffic provider and its tests keep one import for tile math and budget.
*/
export function utcDayKey(epochMs = Date.now()) {
return new Date(epochMs).toISOString().slice(0, 10);
}
/**
* Normalize a persisted budget state against today's UTC day key.
* Rolls the counter to zero on day change; replaces missing/corrupt state.
* Returns the SAME object when it is already valid for `dayKey` (cheap to
* call on every request).
*
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
* @param {string} dayKey - Today's UTC day key (from `utcDayKey`).
* @returns {{date:string, count:number}} Valid state for `dayKey`.
*/
export function normalizeBudget(state, dayKey) {
const valid =
Boolean(state) &&
state.date === dayKey &&
Number.isFinite(state.count) &&
state.count >= 0;
return valid ? state : { date: dayKey, count: 0 };
}
/**
* Whether the daily soft cap has been reached.
*
* @param {{count:number}} state - Normalized budget state.
* @param {number} limit - Daily tile budget; non-positive/invalid never blocks.
* @returns {boolean} True when `count >= limit`.
*/
export function isOverBudget(state, limit) {
if (!Number.isFinite(limit) || limit <= 0) return false;
return state.count >= limit;
}
export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js';
+607
View File
@@ -0,0 +1,607 @@
/**
* @module trailContinuity
* @description Where a tracked aircraft's trail lifts the pen.
*
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
* was drawn between them. Between two consecutive fixes that are not one path, a new
* segment starts and nothing is drawn between them: no dashes, no arcs.
* Nothing is dropped except the oldest fixes past the layer's point limit.
*
* Two points are not one path when time does not advance, when the gap is
* longer than 48 minutes (a coverage hole or a separate flight), when the
* implied great-circle speed is over 1,500 kt, or when either point has no
* reported altitude. A parked aircraft is the exception: a pair under three
* hours apart within 0.1 nm stays one path.
*
* Civil and military flight tracking share these rules.
*/
import * as Cesium from 'cesium';
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
const METRES_PER_NM = 1852;
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/**
* Whether two consecutive trail points belong to different paths. Time that
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
* parked aircraft: a pair under three hours apart within 0.1 nm.
* @param {number} deltaMs - Later point's time minus the earlier one's.
* @param {number} distanceM - Great-circle distance between the points.
* @returns {boolean}
*/
export function trailPairBreaks(deltaMs, distanceM) {
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
const distanceNm = distanceM / METRES_PER_NM;
if (
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
)
return false;
return (
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
);
}
/**
* Whether the head segment may join the last body point to the aircraft:
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
* @param {number} deltaMs - Current time minus the body point's time.
* @param {number} distanceM - Distance from the body point to the aircraft.
* @returns {boolean}
*/
export function trailHeadJoins(deltaMs, distanceM) {
return (
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
);
}
/**
* Great-circle distance between two trail positions, in metres.
* @param {Cesium.Cartesian3} a
* @param {Cesium.Cartesian3} b
* @returns {number}
*/
export function trailDistanceM(a, b) {
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
}
/**
* Whether the pen lifts between two consecutive fixes.
* @param {object|undefined} prev - Earlier fix, if any.
* @param {object} next - Later fix.
* @returns {boolean}
*/
export function trailFixBreaks(prev, next) {
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
// A recorded point is where the icon was drawn; the speed between it and a
// report it was eased onto is not a flight speed, so only the gap counts.
if (prev.drawn || next.drawn)
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
return trailPairBreaks(
next.timeMs - prev.timeMs,
trailDistanceM(prev.position, next.position),
);
}
/**
* Build backfill fixes from chronological waypoints. Every waypoint with a
* position is kept. A waypoint without one (no altitude and no known ground)
* is skipped, and the line still breaks there.
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
* line breaks after the last one because trailing waypoints were skipped.
*/
export function trailBackfillFixes(waypoints) {
const fixes = [];
let skipped = false;
for (const { position, timeMs, nullAltitude } of waypoints) {
if (!position) {
skipped = true;
continue;
}
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
skipped = false;
fixes.push(fix);
}
return { fixes, breakAfter: skipped };
}
/**
* Put backfill fixes ahead of the live trail. The join follows the same
* rule, so a distant history tail never connects to the first live fix.
* Past `maxPoints` the oldest recorded points go first, then the oldest
* reports.
* @param {{fixes: object[], breakAfter: boolean}} backfill
* @param {object[]} live - Live fixes; the first one's break is recomputed.
* @param {number} maxPoints - Point limit across all segments.
* @returns {object[]} The joined trail.
*/
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
if (!fixes.length) return live;
if (live.length)
live[0].breakBefore =
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
return keepTrailLimit(fixes.concat(live), maxPoints);
}
/**
* How many fixes the drawn body holds: every fix at or before the display
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
* the delayed display) are reached by the head segment instead, so the trail
* never runs past the aircraft, and once the display passes the newest
* report the body ends there.
* @param {object[]} fixes
* @param {number} displayMs - Time the aircraft is drawn at.
* @returns {number}
*/
export function trailBodyCount(fixes, displayMs) {
let count = fixes.length;
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
return count;
}
/**
* Leave out the newest recorded points that lie inside the aircraft's 3D
* model, so the body never runs through the fuselage; the head then runs
* from the newest point outside the model to the plane. Reports are kept.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
* when no model is the visual.
* @param {number|null} envelopeM - The model's radius (m).
* @returns {number}
*/
export function trailBodyCountOutsideModel(
fixes,
count,
modelCenter,
envelopeM,
) {
if (!modelCenter || !(envelopeM > 0)) return count;
while (
count > 0 &&
fixes[count - 1].drawn &&
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
envelopeM
)
count -= 1;
return count;
}
/**
* The drawn trail body, split at breaks: the first `count` fixes.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @returns {Cesium.Cartesian3[][]}
*/
export function trailBodySegments(fixes, count) {
const segments = [];
for (let i = 0; i < count; i++) {
const fix = fixes[i];
// A live report with no altitude keeps no position; it only breaks the line.
if (!fix.position) continue;
if (fix.breakBefore || !segments.length) segments.push([]);
segments[segments.length - 1].push(fix.position);
}
return segments;
}
/**
* Draw body segments. A trail without breaks support draws only the newest
* segment, never a line across a break.
* @param {{setSegments?: Function, setPositions: Function}} trail
* @param {Cesium.Cartesian3[][]} segments
*/
export function drawTrailSegments(trail, segments) {
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
else trail.setPositions(segments[segments.length - 1] ?? []);
}
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
const TRAIL_HEAD_MIN_M = 0.5;
/**
* The body fix the head segment starts from, or null when the head is not
* drawn: no body yet, the aircraft holding at that fix, a body point more
* than 150 km from the aircraft, or one too long ago or too far away to be
* one path.
* @param {object[]} fixes
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
* @param {number} nowMs - Current time.
* @param {number} count - Body fixes, from `trailBodyCount`.
* @returns {object|null}
*/
export function trailHeadStartFix(fixes, head, nowMs, count) {
if (count < 1 || !head) return null;
const last = fixes[count - 1];
if (!last.position) return null;
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
return null;
return trailHeadJoins(
nowMs - last.timeMs,
trailDistanceM(last.position, head),
)
? last
: null;
}
/**
* Epoch ms of a position-history fix (`{epochMs?, time}`).
* @param {object|undefined} fix
* @returns {number} NaN when unknown.
*/
export function historyFixEpochMs(fix) {
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
}
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
export const TRAIL_RECORD_STEP_MS = 1_000;
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
export const TRAIL_RECORD_STEP_M = 150;
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
export const TRAIL_THIN_TOLERANCE_M = 30;
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
const TRAIL_THIN_MAX_ABSORBED = 512;
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
export const TRAIL_SPUR_TURN_DEG = 120;
/** @constant {number} ...over a step shorter than this (m). */
export const TRAIL_SPUR_MAX_STEP_M = 500;
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
const TRAIL_RECORD_MIN_STEP_M = 1;
const _u = new Cesium.Cartesian3();
const _v = new Cesium.Cartesian3();
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
function turnCos(a, b, c) {
Cesium.Cartesian3.subtract(b, a, _u);
Cesium.Cartesian3.subtract(c, b, _v);
const lu = Cesium.Cartesian3.magnitude(_u);
const lv = Cesium.Cartesian3.magnitude(_v);
if (!(lu > 0 && lv > 0)) return NaN;
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
}
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
/** Whether entry `i` and its neighbours are one drawn piece of line. */
function joinedAt(fixes, i) {
const a = fixes[i - 1];
const b = fixes[i];
const c = fixes[i + 1];
return (
a?.position &&
b?.position &&
c?.position &&
!b.breakBefore &&
!c.breakBefore
);
}
/** Whether the line turns back on itself at entry `i` over a short step. */
function spurAt(fixes, i) {
if (!joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const b = fixes[i].position;
const c = fixes[i + 1].position;
if (
Math.min(
Cesium.Cartesian3.distance(a, b),
Cesium.Cartesian3.distance(b, c),
) > TRAIL_SPUR_MAX_STEP_M
)
return false;
return turnCos(a, b, c) < SPUR_COS;
}
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
function removeAt(fixes, i) {
fixes.splice(i, 1);
if (i < fixes.length)
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
}
/**
* Keep the point limit: drop the oldest recorded point first, and the oldest
* report only when no recorded point is left.
* @param {object[]} fixes - The trail, mutated.
* @param {number} maxPoints
* @returns {object[]} `fixes`.
*/
function keepTrailLimit(fixes, maxPoints) {
let excess = fixes.length - maxPoints;
if (excess <= 0) return fixes;
for (let i = 0; i < fixes.length && excess > 0;) {
if (fixes[i].drawn) {
removeAt(fixes, i);
excess -= 1;
} else i += 1;
}
if (excess > 0) fixes.splice(0, excess);
return fixes;
}
const _ab = new Cesium.Cartesian3();
const _ap = new Cesium.Cartesian3();
const _off = new Cesium.Cartesian3();
const _up = new Cesium.Cartesian3();
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
function nearSegment(p, a, b) {
Cesium.Cartesian3.subtract(b, a, _ab);
Cesium.Cartesian3.subtract(p, a, _ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
: 0;
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
Cesium.Cartesian3.subtract(_ap, _off, _off);
Cesium.Cartesian3.normalize(p, _up);
const vertical = Cesium.Cartesian3.dot(_off, _up);
const across = Math.sqrt(
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
);
return (
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
across <= TRAIL_THIN_TOLERANCE_M
);
}
/**
* Drop recorded point `i` when the line without it stays within the
* tolerance of every position it stood for, and the straight piece left is
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
* the next entry. Reports are never dropped.
* @returns {boolean} Whether it was dropped.
*/
function thinAt(fixes, i) {
const entry = fixes[i];
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const next = fixes[i + 1];
if (next.sealed) return false;
const b = next.position;
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
const stood = entry.absorbed ?? [];
const nextStood = next.absorbed ?? [];
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
return false;
if (!nearSegment(entry.position, a, b)) return false;
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
next.absorbed = stood.concat(
[{ position: entry.position, timeMs: entry.timeMs }],
nextStood,
);
removeAt(fixes, i);
return true;
}
/**
* Thin the recorded points either side of entry `i`, reaching past a
* neighbouring report: the point recorded just before a report is only
* judged once the line goes on beyond it.
*/
function thinAround(fixes, i) {
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
thinAt(fixes, i + 2);
if (thinAt(fixes, i - 1)) i -= 1;
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
}
/**
* Drop recorded points that make a spur next to entry `i`. Reports are never
* dropped; at a report the recorded neighbour is dropped instead.
* @returns {number} The index entry `i` ends up at.
*/
function removeSpursAround(fixes, i) {
for (let guard = 0; guard < 64; guard++) {
let changed = false;
for (const j of [i - 1, i, i + 1]) {
if (!spurAt(fixes, j)) continue;
let drop = -1;
if (fixes[j].drawn) drop = j;
else if (fixes[j + 1]?.drawn) drop = j + 1;
else if (fixes[j - 1]?.drawn) drop = j - 1;
if (drop < 0) continue;
removeAt(fixes, drop);
if (drop < i) i -= 1;
changed = true;
break;
}
if (!changed) break;
}
return i;
}
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
function insertionIndex(fixes, timeMs) {
let i = fixes.length;
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
return i;
}
/**
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
* points either side and keep the limit.
*/
function insertAt(fixes, i, entry, maxPoints) {
fixes.splice(i, 0, entry);
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
const after = fixes[i + 1];
if (after) {
after.breakBefore = trailFixBreaks(entry, after);
// Positions the next entry stood for that come before this one are now
// on this entry's side.
if (after.absorbed?.length) {
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
if (mine.length) {
entry.absorbed = mine;
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
}
}
}
i = removeSpursAround(fixes, i);
thinAround(fixes, i);
keepTrailLimit(fixes, maxPoints);
}
/** Index of the newest report before entry `i`, or -1. */
function reportBefore(fixes, i) {
let j = i - 1;
while (j >= 0 && fixes[j].drawn) j -= 1;
return j;
}
/** Index of the oldest report at or after `i`, or `fixes.length`. */
function reportFrom(fixes, i) {
let j = i;
while (j < fixes.length && fixes[j].drawn) j += 1;
return j;
}
/**
* Put a report into the trail in time order. Reports are the record: they
* are kept as anchors among the recorded points.
*
* - A report the display has already passed replaces the guess drawn after
* it: the recorded points timed after it go, and nothing is recorded until
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
* line reads arc, report, corrected path.
* - Recorded points never join two reports that break under
* `trailPairBreaks` (too long or too fast apart to be one flight): the
* points between them go and the line breaks.
* - A live report with no altitude keeps no position and only breaks the
* line.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned position.
* @param {number} timeMs - The report's epoch ms.
* @param {number} maxPoints - Point limit across all segments.
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
* `displayMs`: the time the aircraft is drawn at as the report arrives;
* `settleMs`: how long the icon takes to ease onto a correction.
* @returns {object[]} `fixes`.
*/
export function insertTrailFix(
fixes,
position,
timeMs,
maxPoints,
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
) {
const entry = {
position: nullAltitude ? null : position,
timeMs,
nullAltitude,
drawn: false,
breakBefore: false,
};
let i = insertionIndex(fixes, timeMs);
// The guess drawn after a report the display already passed.
fixes.splice(i, reportFrom(fixes, i) - i);
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
// Recorded points across a pair of reports that are not one flight.
const before = reportBefore(fixes, i);
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
fixes.splice(before + 1, i - before - 1);
i = before + 1;
}
insertAt(fixes, i, entry, maxPoints);
return fixes;
}
/**
* Record where the tracked aircraft was drawn at a display time. Between
* reports this recorded path is the trail, so the line follows every turn
* the icon made instead of a straight chord between two reports. Points are
* thinned by shape: one stays only where dropping it would move the line by
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
* while the aircraft holds.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned drawn position.
* @param {number} timeMs - Display time it was drawn for.
* @param {number} maxPoints - Point limit across all segments.
* @returns {boolean} Whether the trail changed.
*/
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
const i = insertionIndex(fixes, timeMs);
const prev = fixes[i - 1];
if (
prev?.position &&
Cesium.Cartesian3.distance(prev.position, position) <
TRAIL_RECORD_MIN_STEP_M
)
return false;
const entry = {
position,
timeMs,
nullAltitude: false,
drawn: true,
breakBefore: false,
};
insertAt(fixes, i, entry, maxPoints);
// Entries before the second report behind this point can never be thinned
// again, so they let go of the positions they stood for.
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
return true;
}
/** Release the absorbed positions of entry `i` and everything before it. */
function sealThrough(fixes, i) {
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
fixes[j].sealed = true;
delete fixes[j].absorbed;
}
}
/**
* Whether the icon's drawn position must not be recorded at a display time:
* a report on either side of it has no altitude (the icon is drawn at an
* invented height), the reports on either side are not one flight
* (`trailPairBreaks`), or the icon is still easing onto a report that
* arrived after the display had passed it.
* @param {object[]} fixes
* @param {number} displayMs
* @returns {boolean}
*/
export function trailRecordingBlocked(fixes, displayMs) {
const i = insertionIndex(fixes, displayMs);
const before = fixes[reportBefore(fixes, i)];
const after = fixes[reportFrom(fixes, i)];
if (before?.nullAltitude || after?.nullAltitude) return true;
if (before?.settleUntilMs > displayMs) return true;
return !!(before && after && trailFixBreaks(before, after));
}
+269 -58
View File
@@ -14,6 +14,18 @@
* the whole history renders at one readable alpha (dimmer where it passes
* behind/below geometry).
*
* A trail with breaks draws each segment separately and nothing between
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
* adjacent chunks share their boundary point, so the line has no seam. A
* chunk that can no longer change (every chunk except the newest one of the
* newest segment) is FROZEN: an entity with constant positions, built once as
* static geometry, so it costs nothing per frame. Only the newest chunk stays
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
* identified by its position objects, so a chunk survives every update that
* keeps those points: appends, a backfill prepended ahead of it, or the
* oldest points dropping off the front (which rebuilds only the first chunk).
*
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
* assigning a fresh positions array per update is cheap. `allowPicking` has
* no entity equivalent; the polyline entity is excluded from clicks by never
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
const TRAIL_OCCLUDED_ALPHA = 0.4;
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
const NO_POSITIONS = Object.freeze([]);
/** @constant {number} Points per chunk; adjacent chunks share one point. */
export const CHUNK_POINTS = 128;
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
* static geometry before it is dropped anyway. */
const STAND_IN_MAX_FRAMES = 600;
/**
* Create an always-visible polyline trail bound to a viewer.
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
* @param {object} options - Trail options.
* @param {string} options.color - CSS color string for the trail hue.
* @param {number} [options.width=2.5] - Polyline width in pixels.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry, setVisible temporarily
* hides it without discarding history, clear empties it, and destroy removes
* the entity permanently.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry with one line,
* setSegments with one line per segment, setVisible temporarily hides it
* without discarding history, clear empties it, and destroy removes the
* entities permanently.
*/
export function createTrail(viewer, { color, width = 2.5 }) {
const baseColor = Cesium.Color.fromCssColorString(color);
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
let current = [];
const material = baseColor.withAlpha(TRAIL_ALPHA);
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
let destroyed = false;
let visible = true;
/** @type {Cesium.Entity|null} */
let entity = null;
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
let frozen = [];
/** Newest chunk of the newest segment: the only part that changes. */
let tail = NO_POSITIONS;
/** What the dynamic polylines draw: the tail, then frozen chunks whose
* static geometry is not in the scene yet. */
let dynamicRuns = [];
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
const dynamicEntities = [];
let removeReadyListener = null;
const scratchSphere = new Cesium.BoundingSphere();
function ensureEntity() {
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
entity = viewer.entities.add({
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
function polyline(positions) {
return {
positions,
width,
material,
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial,
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
};
}
function addEntity(positions) {
return viewer.entities.add({
id: `gev-trail:${++_trailSeq}`,
show: visible,
polyline: {
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
positions: new Cesium.CallbackProperty(() => current, false),
width,
material: baseColor.withAlpha(TRAIL_ALPHA),
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
},
polyline: polyline(positions),
});
}
function removeEntity(entity) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
}
function syncDynamic() {
dynamicRuns = [];
if (tail.length >= 2) dynamicRuns.push(tail);
for (const chunk of frozen)
if (chunk.standIn) dynamicRuns.push(chunk.positions);
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
const index = dynamicEntities.length;
dynamicEntities.push(
addEntity(
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
new Cesium.CallbackProperty(
() => dynamicRuns[index] ?? NO_POSITIONS,
false,
),
),
);
}
}
/** Whether a frozen chunk's static geometry is drawn in the scene. */
function staticReady(chunk) {
const display = viewer.dataSourceDisplay;
if (typeof display?.getBoundingSphere !== 'function') return true;
if (!visible) return false;
chunk.frames += 1;
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
try {
return (
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
Cesium.BoundingSphereState.DONE
);
} catch {
return false;
}
}
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
function resolveStandIns() {
if (!usable()) return;
let changed = false;
for (const chunk of frozen) {
if (chunk.standIn && staticReady(chunk)) {
chunk.standIn = false;
changed = true;
}
}
if (changed) syncDynamic();
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
}
function stopListening() {
if (removeReadyListener) removeReadyListener();
removeReadyListener = null;
}
function listenForStandIns() {
if (removeReadyListener) return;
const event = viewer.scene?.postRender;
if (typeof event?.addEventListener !== 'function') {
resolveStandIns();
return;
}
removeReadyListener = event.addEventListener(resolveStandIns);
}
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
function dedupe(cartesians) {
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
return positions;
}
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
function sameRun(chunk, seg, start) {
const positions = chunk.positions;
if (start + positions.length > seg.length) return false;
for (let k = 0; k < positions.length; k++)
if (positions[k] !== seg[start + k]) return false;
return true;
}
/**
* Cut segments into chunks, reusing every frozen chunk whose points are
* still drawn in the same order. New chunks end where a reusable one begins,
* so dropping the oldest point rebuilds only the first chunk.
*/
function reconcile(segments) {
const byStart = new Map();
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
const kept = new Set();
const reusable = (seg, j) => {
const chunk = byStart.get(seg[j]);
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
};
const next = [];
let nextTail = NO_POSITIONS;
segments.forEach((seg, s) => {
const newest = s === segments.length - 1;
let i = 0;
while (i < seg.length - 1) {
const existing = reusable(seg, i);
if (existing) {
kept.add(existing);
next.push(existing);
i += existing.positions.length - 1;
continue;
}
if (newest && seg.length - i <= CHUNK_POINTS) {
nextTail = seg.slice(i);
break;
}
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
for (let j = i + 1; j < end; j++) {
if (reusable(seg, j)) {
end = j;
break;
}
}
const positions = seg.slice(i, end + 1);
next.push({
positions,
entity: addEntity(positions),
standIn: true,
frames: 0,
});
i = end;
}
});
for (const chunk of frozen)
if (!kept.has(chunk)) removeEntity(chunk.entity);
frozen = next;
tail = nextTail;
syncDynamic();
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
else stopListening();
}
function release() {
stopListening();
for (const chunk of frozen) removeEntity(chunk.entity);
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
}
return {
/**
* Replace the trail geometry with a chronological position list
* Replace the trail geometry with one chronological position list
* (oldest first). Fewer than 2 distinct positions clears the trail.
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
*/
setPositions(cartesians) {
if (destroyed || !viewer || viewer.isDestroyed()) return;
// Drop consecutive near-duplicates: zero-length segments add nothing.
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
current = positions.length >= 2 ? positions : [];
ensureEntity();
this.setSegments([cartesians]);
},
/**
* Replace the trail geometry with chronological segments (oldest first).
* Nothing is drawn between segments. A segment with fewer than 2 distinct
* positions draws nothing. Pass the same position objects on every
* update: a chunk whose points are unchanged is never rebuilt.
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
*/
setSegments(list) {
if (!usable()) return;
reconcile(
(Array.isArray(list) ? list : [])
.map(dedupe)
.filter((positions) => positions.length >= 2),
);
},
/** Temporarily hide/show the trail without discarding accumulated history. */
setVisible(nextVisible) {
visible = nextVisible !== false;
if (entity) entity.show = visible;
for (const entity of dynamicEntities) entity.show = visible;
for (const chunk of frozen) chunk.entity.show = visible;
},
/** Empty the trail without removing the entity (cheap re-arm). */
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
clear() {
current = [];
if (!usable()) return;
release();
},
/** Remove the trail entity permanently (layer disable/teardown). */
/** Remove every trail entity permanently (layer disable/teardown). */
destroy() {
destroyed = true;
current = [];
if (entity && viewer && !viewer.isDestroyed()) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
if (usable()) {
release();
for (const entity of dynamicEntities) removeEntity(entity);
}
entity = null;
destroyed = true;
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
dynamicEntities.length = 0;
stopListening();
},
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
_chunksForTest() {
return {
frozen: frozen.map((chunk) => ({
positions: chunk.positions,
standIn: chunk.standIn,
entity: chunk.entity,
})),
dynamic: dynamicRuns.slice(),
};
},
};
}
+251 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrail } from './trailRenderer.js';
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
test('trail visibility can change without discarding its accumulated geometry', () => {
const added = [];
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
trail.destroy();
assert.deepEqual(removed, [added[0]]);
});
/**
* A fake viewer. `ready` holds the frozen-chunk entities whose static
* geometry is in the scene; `frame()` runs one post-render pass.
*/
function fakeViewer({ scene = true } = {}) {
const added = [];
const removed = [];
const ready = new Set();
let listeners = [];
const viewer = {
added,
removed,
ready,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove(entity) {
removed.push(entity);
},
},
listenerCount: () => listeners.length,
frame() {
for (const listener of listeners.slice()) listener();
},
};
if (scene) {
viewer.scene = {
postRender: {
addEventListener(listener) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
},
};
viewer.dataSourceDisplay = {
getBoundingSphere: (entity) =>
ready.has(entity)
? Cesium.BoundingSphereState.DONE
: Cesium.BoundingSphereState.PENDING,
};
}
return viewer;
}
const valueOf = (positions) =>
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
/** Every line the scene can draw now: live entities with two or more points. */
function drawnRuns(viewer) {
return viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.map((entity) => valueOf(entity.polyline.positions))
.filter((positions) => positions.length >= 2);
}
/** Distinct position objects across all drawn lines. */
function drawnPoints(viewer) {
return new Set(drawnRuns(viewer).flat());
}
function line(count, offset = 0) {
return Array.from(
{ length: count },
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
);
}
test('a trail with breaks draws each segment and nothing between them', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const first = line(2, 0);
const lone = line(1, 50_000);
const second = line(3, 100_000);
trail.setSegments([first, lone, second]);
assert.deepEqual(
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
[first, second],
);
for (const entity of viewer.added) {
assert.match(String(entity.id), /^gev-trail:/);
assert.equal(entity.polyline.width, 2.5);
}
// The first segment is gone later: its geometry is released.
trail.setSegments([second]);
assert.deepEqual(drawnRuns(viewer), [second]);
trail.setVisible(false);
assert.ok(
viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.every((entity) => entity.show === false),
);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
});
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(300);
trail.setPositions(points);
const { frozen, dynamic } = trail._chunksForTest();
assert.deepEqual(
frozen.map((chunk) => chunk.positions.length),
[CHUNK_POINTS, CHUNK_POINTS],
);
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
assert.equal(dynamic.length, 1);
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
assert.equal(dynamic[0].at(-1), points.at(-1));
assert.equal(drawnPoints(viewer).size, points.length);
// A frozen chunk is constant geometry, not a per-frame callback.
for (const chunk of frozen)
assert.ok(Array.isArray(chunk.entity.polyline.positions));
});
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(CHUNK_POINTS + 20);
for (let n = 2; n <= points.length; n++) {
trail.setPositions(points.slice(0, n));
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
}
const { frozen } = trail._chunksForTest();
assert.equal(frozen.length, 1);
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
assert.ok(
trail._chunksForTest().dynamic.includes(frozen[0].positions),
'the dynamic copy stays until the static geometry is ready',
);
viewer.ready.add(frozen[0].entity);
viewer.frame();
const after = trail._chunksForTest();
assert.equal(after.frozen[0].standIn, false);
assert.ok(!after.dynamic.includes(frozen[0].positions));
assert.equal(after.dynamic.length, 1);
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
// The frozen entity is the same object: freezing built it once.
assert.equal(after.frozen[0].entity, frozen[0].entity);
});
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setVisible(false);
trail.setPositions(line(CHUNK_POINTS + 5));
const [chunk] = trail._chunksForTest().frozen;
viewer.ready.add(chunk.entity);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
trail.setVisible(true);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
});
test('dropping the oldest point rebuilds only the first chunk', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points);
const before = trail._chunksForTest().frozen;
trail.setPositions(points.slice(1));
const after = trail._chunksForTest().frozen;
assert.equal(after.length, before.length);
assert.notEqual(after[0].entity, before[0].entity);
assert.ok(viewer.removed.includes(before[0].entity));
for (let k = 1; k < before.length; k++)
assert.equal(after[k].entity, before[k].entity);
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
assert.equal(drawnPoints(viewer).size, 399);
});
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const live = line(300, 0);
trail.setPositions(live);
const before = trail._chunksForTest().frozen;
// History older than every live point, joined as one path.
const history = Array.from(
{ length: 200 },
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
);
trail.setPositions(history.concat(live));
const after = trail._chunksForTest().frozen;
for (const chunk of before)
assert.ok(after.some((c) => c.entity === chunk.entity));
for (let k = 0; k + 1 < after.length; k++)
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
assert.equal(drawnPoints(viewer).size, 500);
// A backfill that breaks from the live trail is its own segment.
trail.setSegments([history, live]);
assert.equal(drawnPoints(viewer).size, 500);
const runs = drawnRuns(viewer);
assert.ok(
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
'no line across the break',
);
});
test('clearing and re-tracking release every frozen chunk', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setPositions(line(600));
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
assert.ok(frozen.length >= 4);
assert.equal(viewer.listenerCount(), 1);
trail.clear();
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
assert.equal(viewer.listenerCount(), 0);
assert.equal(drawnRuns(viewer).length, 0);
// Another aircraft: nothing of the first one survives.
const other = line(200, 900_000);
trail.setPositions(other);
assert.equal(drawnPoints(viewer).size, 200);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
assert.equal(viewer.listenerCount(), 0);
});
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points.slice());
// A recorded point replaces the newest one; a spur point inside a frozen
// chunk and one at a chunk boundary go.
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
points.splice(200, 1);
points.splice(CHUNK_POINTS - 1, 1);
trail.setPositions(points.slice());
assert.deepEqual(drawnPoints(viewer), new Set(points));
// Every drawn line runs through consecutive trail points only.
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
for (const run of drawnRuns(viewer))
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
});
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
recordTrailPoint,
trailBodySegments,
trailPairBreaks,
trailDistanceM,
trailRecordingBlocked,
} from './trailContinuity.js';
// Reports decide where the line may run: recorded points never join two
// reports too far apart to be one flight, a report the display has already
// passed replaces the guess drawn after it, and old entries let go of the
// positions they stood for.
const SEC = 1000;
const LIMIT = 2_000;
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
);
const at = (e, n, u = 10) =>
Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(e, n, u),
new Cesium.Cartesian3(),
);
/** No two neighbours in one drawn segment are too far apart to be one flight. */
function assertNoBrokenJoin(fixes) {
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
for (const segment of trailBodySegments(fixes, fixes.length)) {
const inSegment = reports.filter((r) =>
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
);
for (let i = 1; i < inSegment.length; i++)
assert.equal(
trailPairBreaks(
inSegment[i].timeMs - inSegment[i - 1].timeMs,
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
),
false,
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
inSegment[i].timeMs / SEC
} s`,
);
}
}
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
for (let t = 1; t < 60; t++) {
// Nothing is recorded across the pair.
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
}
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
// Points recorded before the far report was known are dropped.
const late = [];
insertTrailFix(late, A, 0, LIMIT);
for (let t = 1; t < 60; t++)
recordTrailPoint(
late,
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
t * SEC,
LIMIT,
);
insertTrailFix(late, B, 60 * SEC, LIMIT);
assert.equal(late.filter((fix) => fix.drawn).length, 0);
assert.equal(trailBodySegments(late, late.length).length, 2);
assertNoBrokenJoin(late);
});
test('a coast, a hold and then a far report: nothing is drawn across', () => {
const A = at(0, 0, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
// The icon coasts east for 90 s at 230 m/s, then holds.
for (let t = 1; t <= 200; t++)
recordTrailPoint(
fixes,
at(230 * Math.min(t, 90), 0, 9_000),
t * SEC,
LIMIT,
);
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
// The next report is 150 km away two minutes later: over 1,500 kt.
const B = at(0, 150_000, 9_000);
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
assertNoBrokenJoin(fixes);
const segments = trailBodySegments(fixes, fixes.length);
const segmentOf = (p) =>
segments.findIndex((s) =>
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(segmentOf(A), segmentOf(B));
// No recorded point between the two reports remains.
assert.equal(
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
0,
);
});
for (const off of [200, 400, 600, 1_000]) {
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
const R = 2_000;
const w = 100 / R;
const pos = (t, r = R) =>
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
const fixes = [];
insertTrailFix(fixes, pos(0), 0, LIMIT);
for (let t = 1; t <= 60; t++)
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
const report = pos(50, R - off);
// It arrives with the display at 60 s; the ease takes 900 ms.
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
displayMs: 60 * SEC,
settleMs: 900,
});
// The guess drawn after the report is gone...
assert.deepEqual(
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
[],
);
// ...nothing is recorded while the icon eases onto the correction...
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
// ...and the line reads arc, report, corrected path.
for (let t = 61; t <= 70; t++)
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
const line = trailBodySegments(fixes, fixes.length).flat();
const r = line.findIndex(
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
);
assert.ok(r > 0);
for (const p of line.slice(0, r))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
'a point before the report is off the arc',
);
for (const p of line.slice(r + 1))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
31,
'a point after the report is off the corrected path',
);
});
}
test('entries behind the report before the display let go of the positions they stood for', () => {
const fixes = [];
for (let s = 0; s <= 2 * 3_600; s++) {
const p = at(230 * s, 0, 10_000);
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
}
let held = 0;
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
// Only the newest stretch, up to two reports back, keeps what it stood for.
assert.ok(held <= 30, `${held} positions still held`);
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
TRAIL_THIN_MAX_SPAN_M,
TRAIL_THIN_TOLERANCE_M,
} from './trailContinuity.js';
// Recorded points are thinned by shape: a point stays only where the line
// would move without it. Reports always stay.
const SEC = 1000;
const LIMIT = 2_000;
const LAT0 = 40;
const LON0 = -100;
const M_PER_DEG_LAT = 111_320;
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
/** Straight and level east at `speed` m/s, `tSec` after the start. */
const eastbound = (speed, tSec, heightM = 10_000) =>
Cesium.Cartesian3.fromDegrees(
LON0 + (speed * tSec) / mPerDegLon(LAT0),
LAT0,
heightM,
);
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
function turning(speed, tSec, heightM = 10_000) {
const omega = (3 * Math.PI) / 180;
const radius = speed / omega;
// Centre west of the start; heading north, turning left (towards west).
const angle = omega * tSec;
const east = -radius + radius * Math.cos(angle);
const north = radius * Math.sin(angle);
return Cesium.Cartesian3.fromDegrees(
LON0 + east / mPerDegLon(LAT0),
LAT0 + north / M_PER_DEG_LAT,
heightM,
);
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
const speed = 230;
const fixes = [];
for (const t of [0, 15, 30])
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
// Live order: a report arrives 30 s before the display reaches it, and the
// drawn icon is recorded once a second.
for (let t = 1; t <= 3_600; t++) {
const reportAt = t + 30;
if (reportAt % 15 === 0 && reportAt <= 3_600)
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
}
assert.ok(
drawnCount(fixes) < 60,
`${drawnCount(fixes)} recorded points kept`,
);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
});
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
const speed = 230;
const fixes = [];
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
for (let t = 1; t <= 3_600; t++)
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
const legKm = (speed * 3_600) / 1_000;
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
assert.ok(
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
`${drawnCount(fixes)} points over ${legKm} km`,
);
for (let i = 1; i < fixes.length; i++)
assert.ok(
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
TRAIL_THIN_MAX_SPAN_M + speed,
);
});
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
const speed = 230;
const fixes = [];
const recorded = [];
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
for (let t = 1; t <= 60; t++) {
const position = turning(speed, t);
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
for (const position of recorded) {
const off = distanceToPath(position, line);
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
}
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
});
test('a climb off the straight line keeps its vertices', () => {
const fixes = [];
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
// 30 s level, then a 60 s climb of 20 m/s, then level again.
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
const recorded = [];
for (let t = 1; t <= 150; t++) {
const position = eastbound(200, t, height(t));
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
for (const position of recorded)
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
});
test('at the limit, old recorded points go before any report from the backfill', () => {
const max = 300;
const backfill = [];
for (let i = 0; i < 100; i++)
backfill.push({
position: eastbound(200, i * 60 - 20_000),
timeMs: (i * 60 - 20_000) * SEC,
nullAltitude: false,
});
let fixes = [];
insertTrailFix(fixes, turning(150, 0), 0, max);
// A long series of turns keeps many recorded points.
for (let t = 1; t <= 2_000; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(drawnCount(fixes) > 150);
fixes = joinTrailBackfill(
{
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
breakAfter: false,
},
fixes,
max,
);
assert.ok(fixes.length <= max);
const reports = fixes.filter((fix) => !fix.drawn);
assert.equal(reports.length, 101, 'a report was dropped');
// More recording at the limit drops recorded points, never reports.
for (let t = 2_001; t <= 2_400; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(fixes.length <= max);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
// The recorded points that remain are the newest.
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
});
+798
View File
@@ -389,6 +389,179 @@ function rioRouteLabel(routeId) {
: routeId;
}
/**
* Carris Metropolitana realtime route ids wrap the GTFS route id in an
* operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS
* route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per
* the operator's own `/v2/routes` API. Anything else passes through.
* @param {string|null} routeId
* @returns {string|null}
*/
function carrisMetropolitanaRouteLabel(routeId) {
const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? '');
return match ? match[1] : routeId;
}
/**
* Route ids that wrap the rider-facing line in a fixed prefix, suffix or
* zero-padding. The returned function yields the captured line, or the id
* unchanged when it does not match the operator's pattern.
* @param {RegExp} pattern One capture group: the line a rider would say.
* @returns {(routeId: string|null) => string|null}
*/
function routeIdPattern(pattern) {
return (routeId) => {
const match = pattern.exec(routeId ?? '');
return match ? match[1] : routeId;
};
}
/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */
const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/);
/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */
const gttRouteLabel = routeIdPattern(/^(\w+?)U$/);
/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */
const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/);
/** BC Transit appends the system: `95-VIC` is Victoria route 95. */
const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/);
/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */
const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/);
/** Astuce prefixes the network: `TCAR:93` is Rouen line 93. */
const astuceNetworkLabel = routeIdPattern(/^[A-Z]+:(.+)$/);
/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */
const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/);
/** LiA prefixes its network: `8-C3` is Le Havre line C3, `8-09` line 9. */
const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
/**
* Le Havre LiA runs its two tram lines as A and B and the funicular as
* `FUNI`; every other line is a bus.
* @param {string|null} routeId
* @returns {string}
*/
function liaRouteMode(routeId) {
if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'rail';
return /^[AB]$/.test(line) ? 'tram' : 'bus';
}
/**
* Rouen Astuce lines that are not buses, by route id, from the TCAR
* routes.txt (checked 2026-11-09): line 90 is the Métro (GTFS type 1, a light
* rail that runs mostly on the street) and 99 the Calypso river boat.
*/
const ASTUCE_ROUTES = Object.freeze({
// i18n-ignore: line name, as on the vehicle
'TCAR:90': Object.freeze({ mode: 'tram', label: 'Métro' }),
// i18n-ignore: line name, as on the vehicle
'TCAR:99': Object.freeze({ mode: 'ferry', label: 'Calypso' }),
});
/** @param {string|null} routeId @returns {string} */
function astuceRouteMode(routeId) {
if (!routeId) return 'unknown';
return ASTUCE_ROUTES[routeId]?.mode ?? 'bus';
}
/** `TCAR:93` is Rouen line 93; the Métro and Calypso keep their names. */
function astuceRouteLabel(routeId) {
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
}
/**
* Prague PID route ids are `L` plus a number whose range says what runs it,
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
* 2000 more buses (`L2841` is 841). A label the range cannot give stays the
* route id.
* @param {string|null} routeId
* @returns {{mode: string, label: string|null}}
*/
function pidRoute(routeId) {
const match = /^L(\d+)$/.exec(routeId ?? '');
if (!match) return { mode: 'unknown', label: routeId };
const n = Number(match[1]);
// i18n-ignore: line code, as signed
if (n === 49) return { mode: 'rail', label: 'LD' };
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
if (n < 100) return { mode: 'tram', label: String(n) };
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
if (n < 991) return { mode: 'bus', label: String(n) };
// i18n-ignore: metro line letters, as signed
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
if (n >= 1000 && n < 1100) return { mode: 'rail', label: `S${n - 1000}` };
if (n >= 1100 && n < 1200) return { mode: 'rail', label: `R${n - 1100}` };
if (n >= 1200 && n < 1400) return { mode: 'rail', label: routeId };
if (n >= 1800 && n < 1900) return { mode: 'ferry', label: `P${n - 1800}` };
if (n >= 2800 && n < 2900) return { mode: 'bus', label: String(n - 2000) };
return { mode: n >= 1700 ? 'bus' : 'unknown', label: routeId };
}
/** @param {string|null} routeId @returns {string} */
function pidRouteMode(routeId) {
return pidRoute(routeId).mode;
}
/** @param {string|null} routeId @returns {string|null} */
function pidRouteLabel(routeId) {
return pidRoute(routeId).label;
}
/**
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
*/
const TBM_TRAM_LINES = Object.freeze({
59: 'A',
60: 'B',
61: 'C',
62: 'D',
163: 'E',
164: 'F',
});
const TBM_BOAT_LINES = new Set(['951', '952', '953']);
/**
* Bordeaux TBM: trams A–F and the BAT³ river shuttles; every other line is a
* bus.
* @param {string|null} routeId
* @returns {string}
*/
function tbmRouteMode(routeId) {
if (!routeId) return 'unknown';
if (Object.hasOwn(TBM_TRAM_LINES, routeId)) return 'tram';
return TBM_BOAT_LINES.has(routeId) ? 'ferry' : 'bus';
}
/** `59` is tram A; zero-padded bus ids lose their padding (`01` is 1). */
function tbmRouteLabel(routeId) {
return Object.hasOwn(TBM_TRAM_LINES, routeId ?? '')
? TBM_TRAM_LINES[routeId]
: unpaddedRouteLabel(routeId);
}
/**
* A registry entry, frozen with its centre and terms, and switched on.
* @param {object} fields
* @returns {Readonly<object>}
*/
function transitFeed(fields) {
return Object.freeze({
defaultEnabled: true,
...fields,
center: Object.freeze({ ...fields.center }),
terms: Object.freeze({ ...fields.terms }),
...(fields.headers
? { headers: Object.freeze({ ...fields.headers }) }
: {}),
});
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -398,7 +571,14 @@ function rioRouteLabel(routeId) {
* url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string,
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
* creditDated?: boolean,
* }>>}
* `speedUnit` marks a feed that publishes km/h where the spec requires m/s;
* the proxy converts it before the snapshot leaves the server. `refreshMs`
* stretches the proxy's cache for an upstream that rate-limits below one
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
* to the credit for licences that ask for them (`transitFeedCredit`).
*/
export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({
@@ -788,6 +968,624 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
transitFeed({
id: 'zditm-szczecin',
name: 'Szczecin buses and trams',
operator:
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin',
region: 'Szczecin, Poland',
// Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and
// 14.44–14.77 E, inside 20 km of this point.
center: { lat: 53.44, lon: 14.56 },
loadRadiusKm: 25,
url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb',
license: 'CC0 1.0',
licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers',
attribution:
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)',
terms: {
quote:
'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.',
note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'carris-metropolitana-lisboa',
name: 'Carris Metropolitana',
operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area',
region: 'Lisbon Metropolitan Area, Portugal',
// Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat
// between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point.
center: { lat: 38.73, lon: -9.13 },
loadRadiusKm: 45,
url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb',
license: 'No separate licence for the realtime feed',
licenseUrl: 'https://github.com/carrismetropolitana/api',
attribution: 'Carris Metropolitana',
terms: {
quote:
'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.',
note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.',
},
defaultMode: 'bus',
routeLabel: carrisMetropolitanaRouteLabel,
speedUnit: 'km/h',
}),
transitFeed({
id: 'pid-praha',
name: 'Prague PID',
operator: 'Pražská integrovaná doprava (PID), via Golemio',
region: 'Prague and Central Bohemia, Czechia',
// PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles
// sat within 58 km of Prague, the farthest at 98 km.
center: { lat: 50.08, lon: 14.45 },
loadRadiusKm: 60,
url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb',
license: 'CC BY',
licenseUrl: 'https://pid.cz/o-systemu/opendata/',
attribution: 'Pražská integrovaná doprava (PID) / Golemio',
terms: {
quote:
'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY',
note: 'The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1); `pidRoute` holds the table, read from PID’s GTFS routes.txt on 2026-11-09.',
},
defaultMode: 'bus',
routeLabel: pidRouteLabel,
routeMode: pidRouteMode,
}),
transitFeed({
id: 'ztm-warszawa',
name: 'Warsaw buses and trams',
operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl',
region: 'Warsaw, Poland',
center: { lat: 52.25, lon: 21.02 },
loadRadiusKm: 30,
url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb',
license: 'Warsaw Open Data Project Usage Terms',
licenseUrl: 'https://mkuran.pl/gtfs/',
attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)',
sourceUrl: 'http://api.um.warszawa.pl',
creditDated: true,
terms: {
quote:
'The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.',
note: 'Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API (`sourceUrl`) and carries the date of the feed timestamp (`creditDated`), the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'tbm-bordeaux',
name: 'Bordeaux TBM',
operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole',
region: 'Bordeaux, France',
center: { lat: 44.85, lon: -0.65 },
loadRadiusKm: 20,
url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
license: 'Licence Ouverte / Etalab 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite',
attribution: 'Bordeaux Métropole (TBM)',
terms: {
quote: 'licence: fr-lo',
note: 'The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), so `tbmRouteMode` and `tbmRouteLabel` map them from TBM’s static GTFS; other numbered lines are buses.',
},
defaultMode: 'bus',
routeLabel: tbmRouteLabel,
routeMode: tbmRouteMode,
}),
transitFeed({
id: 'sendai-city-bus',
name: 'Sendai City Bus',
operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT',
region: 'Sendai, Japan',
center: { lat: 38.27, lon: 140.82 },
loadRadiusKm: 22,
url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle',
license: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT',
terms: {
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztm-poznan',
name: 'Poznań buses and trams',
operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)',
region: 'Poznań, Poland',
center: { lat: 52.39, lon: 16.92 },
loadRadiusKm: 20,
url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb',
license: 'ZTM Poznań open-data terms',
licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/',
attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)',
creditDated: true,
terms: {
quote:
'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.',
note: 'Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp (`creditDated`) as the update date. The feed carries no bearing, so heading comes from movement between fixes.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztm-gdansk',
name: 'Gdańsk buses and trams',
operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)',
region: 'Gdańsk, Poland',
center: { lat: 54.35, lon: 18.6 },
loadRadiusKm: 20,
url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions',
license: 'CC BY 4.0',
licenseUrl:
'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt',
attribution: 'Zarząd Transportu Miejskiego w Gdańsku',
terms: {
note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ets-edmonton',
name: 'Edmonton Transit Service',
operator: 'City of Edmonton (Edmonton Transit Service)',
region: 'Edmonton, AB',
center: { lat: 53.53, lon: -113.52 },
loadRadiusKm: 25,
url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb',
license: 'City of Edmonton Open Data Agreement',
licenseUrl:
'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf',
attribution:
'Contains information licensed under the Open Data Agreement – City of Edmonton',
terms: {
note: 'The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s.',
},
defaultMode: 'bus',
routeLabel: unpaddedRouteLabel,
}),
transitFeed({
id: 'halifax-transit',
name: 'Halifax Transit',
operator: 'Halifax Regional Municipality (Halifax Transit)',
region: 'Halifax, NS',
center: { lat: 44.7, lon: -63.6 },
loadRadiusKm: 22,
url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb',
license: 'Open Government Licence – Halifax',
licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license',
attribution:
'Contains information licenced under the Open Government Licence – Halifax',
terms: {
quote:
'The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.',
note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'bc-transit-victoria',
name: 'Victoria Regional Transit',
operator: 'BC Transit (Victoria Regional Transit System)',
region: 'Victoria, BC',
center: { lat: 48.53, lon: -123.43 },
loadRadiusKm: 28,
url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48',
license: 'BC Transit Open Data Terms of Use',
licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use',
attribution: 'BC Transit',
terms: {
quote:
'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.',
note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.',
},
defaultMode: 'bus',
routeLabel: bcTransitRouteLabel,
}),
transitFeed({
id: 'tam-montpellier',
name: 'Montpellier TaM',
operator: 'Montpellier Méditerranée Métropole (TaM)',
region: 'Montpellier, France',
center: { lat: 43.61, lon: 3.87 },
loadRadiusKm: 20,
url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb',
license: 'ODbL',
licenseUrl:
'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain',
attribution: 'Montpellier Méditerranée Métropole (TaM)',
terms: {
quote: 'licence: odc-odbl',
note: 'The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them.',
},
defaultMode: 'bus',
speedUnit: 'km/h',
}),
transitFeed({
id: 'ztp-krakow-bus',
name: 'Kraków buses',
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
region: 'Kraków, Poland',
center: { lat: 50.06, lon: 19.94 },
loadRadiusKm: 22,
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb',
license: 'No licence stated',
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
attribution: 'Zarząd Transportu Publicznego w Krakowie',
terms: {
note: 'ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztp-krakow-tram',
name: 'Kraków trams',
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
region: 'Kraków, Poland',
center: { lat: 50.06, lon: 19.94 },
loadRadiusKm: 22,
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb',
license: 'No licence stated',
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
attribution: 'Zarząd Transportu Publicznego w Krakowie',
terms: { note: 'Same server and basis as the bus feed.' },
defaultMode: 'tram',
}),
transitFeed({
id: 'vilnius-transport',
name: 'Vilnius public transport',
operator: 'SĮ Susisiekimo paslaugos, Vilnius',
region: 'Vilnius, Lithuania',
center: { lat: 54.69, lon: 25.28 },
loadRadiusKm: 20,
url: 'https://www.stops.lt/vilnius/vehicle_positions.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383',
attribution: 'SĮ Susisiekimo paslaugos (stops.lt)',
terms: {
note: 'Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'tpbi-bucuresti',
name: 'Bucharest public transport',
operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov',
region: 'Bucharest, Romania',
center: { lat: 44.43, lon: 26.1 },
loadRadiusKm: 25,
url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104',
attribution: 'TPBI (Transport Public București–Ilfov)',
terms: {
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'roma-mobilita',
name: 'Rome public transport',
operator: 'Roma Servizi per la Mobilità',
region: 'Rome, Italy',
center: { lat: 41.89, lon: 12.5 },
loadRadiusKm: 35,
url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776',
attribution: 'Roma Servizi per la Mobilità',
terms: {
note: 'Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'gtt-torino',
name: 'Turin GTT',
operator: 'Gruppo Torinese Trasporti (GTT)',
region: 'Turin, Italy',
center: { lat: 45.07, lon: 7.68 },
loadRadiusKm: 20,
url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696',
attribution: 'Gruppo Torinese Trasporti (GTT)',
terms: {
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
},
defaultMode: 'bus',
routeLabel: gttRouteLabel,
}),
transitFeed({
id: 'actv-venezia-bus',
name: 'Venice ACTV buses',
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)',
region: 'Venice, Italy',
center: { lat: 45.4, lon: 12.2 },
loadRadiusKm: 38,
url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions',
license: 'ACTV open-data catalogue',
licenseUrl:
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
attribution: 'ACTV S.p.A.',
terms: {
note: 'ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`).',
},
defaultMode: 'bus',
routeLabel: actvRouteLabel,
}),
transitFeed({
id: 'actv-venezia-waterbus',
name: 'Venice ACTV waterbuses',
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione',
region: 'Venice, Italy',
center: { lat: 45.43, lon: 12.33 },
loadRadiusKm: 15,
url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions',
license: 'ACTV open-data catalogue',
licenseUrl:
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
attribution: 'ACTV S.p.A.',
terms: { note: 'Same catalogue and basis as the ACTV bus feed.' },
defaultMode: 'ferry',
routeLabel: actvRouteLabel,
}),
transitFeed({
id: 'adelaide-metro',
name: 'Adelaide Metro',
operator: 'Adelaide Metro (Government of South Australia)',
region: 'Adelaide, SA',
center: { lat: -34.93, lon: 138.6 },
loadRadiusKm: 35,
url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions',
// Any Accept naming a protobuf type gets base64 text labelled JSON; only
// */* returns the binary feed (checked 2026-11-08).
headers: { Accept: '*/*' },
license: 'No licence stated for the realtime feed',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772',
attribution: 'Adelaide Metro',
terms: {
note: 'Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'marta-atlanta',
name: 'MARTA buses',
operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)',
region: 'Atlanta, GA',
center: { lat: 33.78, lon: -84.39 },
loadRadiusKm: 35,
url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601',
attribution: 'MARTA',
terms: {
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'rtd-denver',
name: 'Denver RTD',
operator: 'Regional Transportation District (RTD), Denver',
region: 'Denver, CO',
center: { lat: 39.75, lon: -104.99 },
loadRadiusKm: 55,
url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb',
license: 'RTD GTFS-realtime licence agreement',
licenseUrl:
'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement',
attribution: 'Regional Transportation District (RTD)',
terms: {
quote:
'RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data',
note: 'Read 2026-11-09. The agreement is a click-through (“By clicking to download the Data files, you confirm that you have read and agree”) that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from being used with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ctp-cluj',
name: 'Cluj-Napoca CTP',
operator: 'Compania de Transport Public Cluj-Napoca',
region: 'Cluj-Napoca, Romania',
center: { lat: 46.77, lon: 23.59 },
loadRadiusKm: 15,
url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614',
attribution: 'Compania de Transport Public Cluj-Napoca',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ctp-iasi',
name: 'Iași CTP',
operator: 'Compania de Transport Public Iași',
region: 'Iași, Romania',
center: { lat: 47.16, lon: 27.59 },
loadRadiusKm: 15,
url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118',
attribution: 'Compania de Transport Public Iași',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'mpk-rzeszow',
name: 'Rzeszów city transport',
operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)',
region: 'Rzeszów, Poland',
center: { lat: 50.04, lon: 22.0 },
loadRadiusKm: 15,
url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb',
license: 'MPK Rzeszów open data',
licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/',
attribution: 'MPK Rzeszów',
terms: {
note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'emt-malaga',
name: 'Málaga EMT',
operator: 'Empresa Malagueña de Transportes (EMT)',
region: 'Málaga, Spain',
center: { lat: 36.72, lon: -4.42 },
loadRadiusKm: 15,
url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213',
attribution: 'EMT Málaga',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'carris-lisboa',
name: 'Carris (Lisbon city)',
operator: 'Carris – Companhia Carris de Ferro de Lisboa',
region: 'Lisbon, Portugal',
center: { lat: 38.74, lon: -9.15 },
loadRadiusKm: 15,
url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930',
attribution: 'Carris',
terms: {
note: 'Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant.',
},
defaultMode: 'bus',
routeLabel: carrisRouteLabel,
}),
transitFeed({
id: 'mybas-johor',
name: 'BAS.MY Johor Bahru',
operator: 'BAS.MY, via data.gov.my',
region: 'Johor Bahru, Malaysia',
center: { lat: 1.51, lon: 103.75 },
loadRadiusKm: 35,
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/',
license: 'CC BY 4.0',
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
attribution: 'BAS.MY / data.gov.my',
// data.gov.my allows 4 GTFS-Realtime requests a minute and refreshes
// vehicle positions every 30 seconds, so the proxy asks half as often as
// the default 15 s TTL would.
refreshMs: 30_000,
terms: {
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
note: 'Served keyless by Malaysia’s national open-data API (developer.data.gov.my, read 2026-11-09). Its rate-limit page caps GTFS Realtime at 4 requests a minute and answers 429 above that; the feed updates every 30 seconds, hence `refreshMs`. Without the trailing slash the API redirects. Route ids append an operator code.',
},
defaultMode: 'bus',
routeLabel: mybasJohorRouteLabel,
}),
transitFeed({
id: 'mybas-kota-bharu',
name: 'BAS.MY Kota Bharu',
operator: 'BAS.MY, via data.gov.my',
region: 'Kota Bharu, Malaysia',
// Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08.
center: { lat: 6.06, lon: 102.22 },
loadRadiusKm: 60,
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/',
license: 'CC BY 4.0',
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
attribution: 'BAS.MY / data.gov.my',
refreshMs: 30_000,
terms: {
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
note: 'Same API, licence and rate limit as Johor Bahru.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'astuce-rouen',
name: 'Rouen Astuce',
operator: 'Métropole Rouen Normandie (Astuce), réseau TCAR',
region: 'Rouen, France',
center: { lat: 49.44, lon: 1.1 },
loadRadiusKm: 20,
url: 'https://api.mrn.cityway.fr/dataflow/vehicle-tc-tr/download?provider=TCAR&dataFormat=gtfs-rt',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie',
attribution: 'Métropole Rouen Normandie (Astuce)',
terms: {
quote: 'licence: lov2',
note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted), and this is its own TCAR vehicle feed from that record (178 vehicles on 2026-11-09). The suburban TAE and TNI networks are separate feeds in the same record and are not read here. Route ids carry the network prefix (`TCAR:`); `TCAR:90` is the Métro (a street-running light rail, drawn as a tram) and `TCAR:99` the Calypso boat.',
},
defaultMode: 'bus',
routeLabel: astuceRouteLabel,
routeMode: astuceRouteMode,
}),
transitFeed({
id: 'mpm-toulon',
name: 'Toulon Mistral',
operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)',
region: 'Toulon, France',
center: { lat: 43.12, lon: 5.94 },
loadRadiusKm: 18,
url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee',
attribution: 'Métropole Toulon Provence Méditerranée (Mistral)',
terms: {
quote: 'licence: lov2',
note: 'Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded.',
},
defaultMode: 'bus',
routeLabel: unpaddedRouteLabel,
speedUnit: 'km/h',
}),
transitFeed({
id: 'lia-le-havre',
name: 'Le Havre LiA',
operator: 'Le Havre Seine Métropole (LiA)',
region: 'Le Havre, France',
center: { lat: 49.49, lon: 0.11 },
loadRadiusKm: 18,
url: 'https://opendata.transports-lia.fr/gtfs-rt/VehiclePosition.pb',
license: 'ODbL',
licenseUrl:
'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri',
attribution: 'Le Havre Seine Métropole (LiA)',
terms: {
quote: 'licence: odc-odbl',
note: 'The network publishes under ODbL (dataset record quoted), and this is its own GTFS-RT vehicle feed from that record (105 vehicles on 2026-11-09). The layer draws positions and redistributes no database. Route ids carry the network prefix `8-`; lines A and B are the tramway.',
},
defaultMode: 'bus',
routeLabel: liaRouteLabel,
routeMode: liaRouteMode,
}),
transitFeed({
id: 'filbleu-tours',
name: 'Tours Fil Bleu',
operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)',
region: 'Tours, France',
center: { lat: 47.39, lon: 0.69 },
loadRadiusKm: 18,
url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt',
attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine',
terms: {
quote: 'licence: lov2',
note: 'Dataset record’s licence field quoted.',
},
defaultMode: 'bus',
}),
]);
/**
+226
View File
@@ -552,3 +552,229 @@ test('Rio feeds are CC BY 4.0 and live', () => {
'Rio has a DATA_SOURCES row',
);
});
test('Szczecin is CC0, live, covers the city and not Berlin', () => {
const feed = getTransitFeed('zditm-szczecin');
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.license, 'CC0 1.0');
assert.equal(
feed.licenseUrl,
'https://www.zditm.szczecin.pl/en/zditm/for-developers',
);
assert.match(feed.terms.quote, /CC0 1\.0 licence/);
assert.match(feed.attribution, /ZDiTM/);
assert.equal(feed.routeLabel, undefined, 'the feed has no route ids');
assert.equal(transitModeFor(feed, null), 'bus');
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre');
assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge');
assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin');
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')),
'Szczecin has a DATA_SOURCES row',
);
});
test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb');
assert.equal(feed.speedUnit, 'km/h');
assert.match(feed.terms.note, /no licence for the realtime endpoint/);
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
for (const [place, lat, lon] of [
['Lisbon', 38.7223, -9.1393],
['Mafra', 38.9369, -9.3277],
['Setúbal', 38.5244, -8.8882],
]) {
assert.ok(ids(lat, lon).includes(feed.id), place);
}
assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto');
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')),
'Lisbon has a DATA_SOURCES row',
);
});
test('Carris Metropolitana route ids read as the line number on the bus', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708');
assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772');
assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected');
assert.equal(transitRouteLabel(feed, null), null);
});
// The 2026-11 batch: one landmark per feed, inside its own radius.
const NEW_CITY_LANDMARKS = {
'pid-praha': [50.0875, 14.4213],
'ztm-warszawa': [52.2297, 21.0122],
'tbm-bordeaux': [44.8378, -0.5792],
'sendai-city-bus': [38.2602, 140.8824],
'ztm-poznan': [52.4064, 16.9252],
'ztm-gdansk': [54.352, 18.6466],
'ets-edmonton': [53.5461, -113.4938],
'halifax-transit': [44.6488, -63.5752],
'bc-transit-victoria': [48.4284, -123.3656],
'tam-montpellier': [43.6108, 3.8767],
'ztp-krakow-bus': [50.0614, 19.9366],
'ztp-krakow-tram': [50.0614, 19.9366],
'vilnius-transport': [54.6872, 25.2797],
'tpbi-bucuresti': [44.4268, 26.1025],
'roma-mobilita': [41.9028, 12.4964],
'gtt-torino': [45.0703, 7.6869],
'actv-venezia-bus': [45.4408, 12.3155],
'actv-venezia-waterbus': [45.4408, 12.3155],
'adelaide-metro': [-34.9285, 138.6007],
'marta-atlanta': [33.749, -84.388],
'rtd-denver': [39.7392, -104.9903],
'ctp-cluj': [46.7712, 23.6236],
'ctp-iasi': [47.1585, 27.6014],
'mpk-rzeszow': [50.0412, 21.9991],
'emt-malaga': [36.7213, -4.4214],
'carris-lisboa': [38.7223, -9.1393],
'mybas-johor': [1.4927, 103.7414],
'mybas-kota-bharu': [6.1254, 102.2381],
'astuce-rouen': [49.4432, 1.0999],
'mpm-toulon': [43.1242, 5.928],
'lia-le-havre': [49.4944, 0.1079],
'filbleu-tours': [47.3941, 0.6848],
};
test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => {
for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) {
const feed = getTransitFeed(id);
assert.ok(feed, `${id} is routable`);
assert.ok(
publicTransitCatalog().some((f) => f.id === id),
`${id} is offered to the browser`,
);
assert.ok(feed.terms?.note, `${id} records the basis it ships on`);
assert.ok(
transitFeedsInRange(lat, lon).some((f) => f.id === id),
`${id} covers its city`,
);
}
// A feed's radius stays local: Warsaw is not polled for Kraków.
assert.ok(
!transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'),
);
});
test('feeds with no stated licence say so and name the basis they ship on', () => {
for (const feed of TRANSIT_FEED_REGISTRY.filter((f) =>
/^No licence stated/.test(f.license),
)) {
assert.match(
feed.terms.note,
/Metro Transit and OVapi|Same .*basis/,
`${feed.id} names its basis`,
);
}
});
test('route ids in the 2026-11 batch read as the line on the vehicle', () => {
const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId);
assert.equal(label('pid-praha', 'L135'), '135');
assert.equal(label('ets-edmonton', '009'), '9');
assert.equal(label('mpm-toulon', '0002'), '2');
assert.equal(label('gtt-torino', '10NU'), '10N');
assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1');
assert.equal(label('actv-venezia-bus', '53E_ES'), '53E');
assert.equal(label('bc-transit-victoria', '95-VIC'), '95');
assert.equal(label('carris-lisboa', '172_1'), '172');
assert.equal(label('astuce-rouen', 'TCAR:93'), '93');
assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33');
// Unmatched and absent ids pass through untouched.
assert.equal(label('pid-praha', 'S4'), 'S4');
assert.equal(label('gtt-torino', null), null);
});
test('trams, waterbuses and buses are told apart where the ids allow it', () => {
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
assert.equal(mode('pid-praha', 'L22'), 'tram');
assert.equal(mode('pid-praha', 'L135'), 'bus');
assert.equal(mode('tbm-bordeaux', '16'), 'bus');
assert.equal(mode('lia-le-havre', '8-B'), 'tram');
assert.equal(mode('lia-le-havre', '8-C3'), 'bus');
assert.equal(mode('ztp-krakow-tram', null), 'tram');
assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry');
});
test('feeds that publish km/h are marked for conversion, and only those', () => {
const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h')
.map((f) => f.id)
.sort();
assert.deepEqual(kmh, [
'carris-metropolitana-lisboa',
'mpm-toulon',
'tam-montpellier',
]);
});
// Route ids as the live feeds and the operators' own GTFS routes.txt carried
// them on 2026-11-09: the ranges and numbered tram ids are not guessable from
// the line names riders see.
test('Prague route-id ranges give metro, trains, ferries and trolleybuses their mode', () => {
const feed = getTransitFeed('pid-praha');
const read = (routeId) => [
transitModeFor(feed, routeId),
transitRouteLabel(feed, routeId),
];
assert.deepEqual(read('L22'), ['tram', '22']);
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']);
assert.deepEqual(read('L993'), ['subway', 'C']);
assert.deepEqual(read('L1004'), ['rail', 'S4']);
assert.deepEqual(read('L1109'), ['rail', 'R9']);
assert.deepEqual(read('L1204'), ['rail', 'L1204'], 'no label to derive');
assert.deepEqual(read('L1801'), ['ferry', 'P1']);
assert.deepEqual(read('L1703'), ['bus', 'L1703']);
assert.deepEqual(read('L2841'), ['bus', '841']);
assert.deepEqual(read('S4'), ['bus', 'S4'], 'unknown ids keep the default');
});
test('Bordeaux trams ride numbered route ids and keep their letters', () => {
const feed = getTransitFeed('tbm-bordeaux');
for (const [routeId, line] of [
['59', 'A'],
['62', 'D'],
['163', 'E'],
]) {
assert.equal(transitModeFor(feed, routeId), 'tram', routeId);
assert.equal(transitRouteLabel(feed, routeId), line);
}
assert.equal(transitModeFor(feed, '952'), 'ferry');
assert.equal(transitModeFor(feed, '01'), 'bus');
assert.equal(transitRouteLabel(feed, '01'), '1');
});
test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () => {
const feed = getTransitFeed('astuce-rouen');
assert.equal(transitModeFor(feed, 'TCAR:90'), 'tram');
assert.equal(transitRouteLabel(feed, 'TCAR:90'), 'Métro');
assert.equal(transitModeFor(feed, 'TCAR:99'), 'ferry');
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
const lia = getTransitFeed('lia-le-havre');
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
});
test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
for (const id of ['mybas-johor', 'mybas-kota-bharu']) {
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
}
});
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff. Show more