mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Merge upstream main into pr/street-level-show-action
gevActions.test.mjs: keep the PR's street-level tests and main's analyst display tests.
This commit is contained in:
commit
5370b8a268
258 files changed
+20257
-734
No files matched your search
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+22
-1
@@ -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
|
||||
@@ -242,5 +251,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 || (!vessel && !entity?.position)) return false;',
|
||||
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -181,3 +181,64 @@ export function lookReadoutParts(look, restPitchDeg) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,16 @@ 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';
|
||||
|
||||
@@ -135,3 +139,57 @@ test('the readout names the side, the bearing and the elevation', () => {
|
||||
'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,
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -457,6 +457,30 @@ 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.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bridgeEntryAllowed,
|
||||
orbitEntryAllowed,
|
||||
cockpitEntryAllowed,
|
||||
contextAllowedLayerIds,
|
||||
contextLayerEnableBlockReason,
|
||||
@@ -440,6 +441,19 @@ test('bridge entry needs only Live AIS Vessels', () => {
|
||||
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 () => {
|
||||
const idleOwner = { _contextModeChanging: false };
|
||||
await runWithContextModeChanging(idleOwner, async () => {
|
||||
|
||||
@@ -1802,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 });
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
+38
-5
@@ -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:
|
||||
@@ -150,10 +157,16 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'ucs-satellite-database',
|
||||
html:
|
||||
'Satellite purposes: ' +
|
||||
'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:
|
||||
@@ -667,20 +680,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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
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>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
import {
|
||||
DATA_CREDITS,
|
||||
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 +43,13 @@ 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.
|
||||
@@ -52,6 +64,15 @@ test('the UCS Satellite Database is acknowledged with its version and webpage',
|
||||
);
|
||||
});
|
||||
|
||||
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'),
|
||||
@@ -181,3 +202,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
@@ -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) : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
+17
-1
@@ -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
|
||||
@@ -10,6 +10,7 @@ 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 |
|
||||
| `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 +104,23 @@ 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.
|
||||
|
||||
## 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 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
|
||||
@@ -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 |
@@ -1,8 +1,9 @@
|
||||
# UCS Satellite Database pack
|
||||
|
||||
Offline 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)
|
||||
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 |
|
||||
@@ -23,7 +24,7 @@ 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 purposes: UCS 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.
|
||||
@@ -33,13 +34,17 @@ DATA_SOURCES.md.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Kept column: Purpose, the one the classes read. Everything else
|
||||
(operators, owners, orbits, masses, contractors, launch data, sources) is
|
||||
left out.
|
||||
- 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, purpose]` with a string-table index, `0` meaning empty.
|
||||
- The 3,996 Starlink rows are dropped: every Starlink object is classed by
|
||||
name, so they would only add size.
|
||||
`[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.
|
||||
@@ -1 +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":{"purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","purpose"],"strings":{"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,3],[19548,3],[20253,3],[20580,5],[20776,3],[21639,3],[22049,5],[22314,3],[22491,1],[22787,3],[22988,3],[23223,1],[23467,3],[23553,3],[23613,3],[23628,3],[23855,1],[23893,3],[24674,3],[24753,1],[24812,3],[24876,11],[24916,3],[24920,1],[24936,3],[25019,3],[25034,1],[25071,3],[25112,3],[25113,3],[25114,3],[25115,3],[25116,3],[25117,3],[25118,3],[25119,3],[25153,3],[25159,3],[25258,3],[25336,1],[25338,1],[25414,3],[25415,3],[25416,3],[25418,3],[25419,3],[25420,3],[25462,3],[25475,3],[25477,3],[25481,3],[25504,1],[25544,5],[25639,3],[25652,3],[25682,1],[25757,4],[25867,5],[25924,3],[25933,11],[25967,3],[25980,3],[25981,3],[25982,3],[25984,3],[25985,3],[25989,5],[25991,1],[25994,1],[26038,3],[26052,3],[26107,3],[26356,1],[26360,11],[26369,3],[26388,3],[26390,3],[26407,11],[26410,5],[26411,5],[26451,3],[26463,5],[26464,5],[26469,3],[26473,1],[26483,3],[26554,3],[26575,3],[26580,3],[26590,3],[26605,11],[26608,3],[26624,3],[26626,3],[26638,3],[26690,11],[26694,3],[26702,1],[26715,3],[26719,3],[26766,3],[26824,3],[26853,3],[26880,1],[26900,3],[26958,9],[26959,12],[26998,5],[27004,2],[27056,3],[27057,3],[27168,3],[27378,3],[27380,3],[27389,3],[27399,3],[27400,3],[27414,3],[27424,1],[27426,3],[27434,1],[27438,3],[27445,3],[27460,3],[27464,3],[27465,3],[27509,1],[27513,3],[27528,3],[27540,5],[27566,3],[27603,3],[27606,3],[27607,3],[27612,3],[27640,1],[27651,5],[27663,11],[27691,3],[27704,11],[27711,3],[27715,3],[27811,3],[27825,3],[27831,3],[27843,5],[27848,2],[27854,3],[27858,5],[27868,3],[27869,3],[27875,3],[27937,1],[27939,2],[27954,3],[28051,1],[28054,1],[28058,3],[28089,3],[28117,3],[28158,1],[28184,3],[28187,3],[28190,11],[28220,1],[28252,3],[28358,3],[28366,7],[28369,2],[28370,2],[28371,1],[28372,7],[28376,1],[28378,3],[28384,3],[28413,5],[28414,5],[28419,3],[28420,3],[28446,3],[28470,1],[28474,11],[28485,5],[28492,1],[28493,3],[28526,3],[28537,1],[28541,1],[28542,3],[28626,3],[28628,3],[28646,1],[28649,1],[28654,1],[28659,3],[28702,3],[28707,3],[28737,5],[28786,3],[28790,3],[28810,2],[28868,3],[28874,11],[28884,3],[28885,3],[28888,1],[28890,1],[28899,3],[28903,3],[28908,3],[28912,1],[28924,3],[28935,3],[28945,3],[28946,3],[29045,3],[29047,1],[29048,1],[29050,1],[29051,1],[29052,1],[29055,3],[29079,1],[29107,1],[29108,4],[29155,1],[29162,3],[29236,3],[29249,1],[29252,2],[29268,12],[29270,3],[29272,3],[29273,3],[29349,3],[29479,5],[29486,11],[29494,3],[29495,3],[29505,5],[29506,5],[29520,3],[29522,1],[29526,3],[29601,11],[29643,3],[29644,3],[29658,1],[29670,11],[29671,11],[29709,12],[29710,1],[30580,5],[30776,2],[30794,3],[30797,5],[30798,5],[31102,3],[31118,1],[31119,3],[31121,3],[31124,3],[31125,3],[31127,3],[31304,1],[31306,3],[31307,3],[31571,3],[31573,3],[31574,3],[31576,3],[31598,1],[31601,1],[31698,1],[31701,1],[31708,1],[31789,2],[31792,1],[31797,1],[31800,3],[31862,3],[32018,3],[32019,3],[32060,1],[32252,3],[32253,3],[32258,3],[32260,11],[32263,3],[32264,3],[32265,3],[32275,11],[32276,11],[32283,1],[32293,3],[32294,3],[32299,3],[32376,1],[32378,3],[32382,1],[32384,11],[32388,3],[32393,11],[32395,11],[32404,3],[32476,1],[32478,3],[32487,3],[32706,1],[32711,11],[32729,3],[32750,1],[32763,3],[32767,3],[32768,3],[32779,3],[32783,1],[32789,2],[32790,2],[32791,2],[32794,3],[32951,3],[32954,3],[32955,3],[32956,3],[33051,3],[33053,5],[33055,3],[33056,3],[33153,3],[33154,3],[33207,3],[33244,1],[33274,3],[33275,3],[33278,3],[33320,1],[33321,1],[33331,1],[33373,3],[33376,3],[33396,1],[33401,5],[33408,5],[33409,5],[33412,1],[33433,1],[33434,1],[33436,3],[33446,1],[33453,3],[33459,3],[33460,3],[33490,1],[33492,1],[33496,2],[33499,2],[33591,1],[33595,3],[33749,3],[33750,3],[33751,2],[337Line truncated
|
||||
{"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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
@@ -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',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -257,24 +257,43 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
truncated: decoded.truncated,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: repairVehicleTimestamps(
|
||||
decoded.vehicles,
|
||||
feed.speedUnit === 'km/h'
|
||||
? decoded.vehicles.map((v) =>
|
||||
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
|
||||
)
|
||||
: decoded.vehicles,
|
||||
decoded.timestamp,
|
||||
Math.floor(now / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh window for one feed's cached snapshot: the shared TTL, stretched by a
|
||||
* registry `refreshMs` for upstreams that rate-limit below one request per
|
||||
* TTL (data.gov.my allows 4 a minute). Never shorter than the shared TTL.
|
||||
* @param {{ refreshMs?: number }|null|undefined} feed Registry entry.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function transitFeedTtlMs(feed) {
|
||||
const refresh = feed?.refreshMs;
|
||||
return Number.isFinite(refresh) && refresh > TRANSIT_PROXY_TTL_MS
|
||||
? refresh
|
||||
: TRANSIT_PROXY_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @param {number} [ttlMs] Fresh window; `transitFeedTtlMs(feed)` for a feed.
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
export function transitCacheState(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < 0) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
|
||||
if (age < ttlMs) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
repairVehicleTimestamps,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitFeedTtlMs,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
@@ -294,6 +295,27 @@ test('Rio speeds pass through as the m/s they are published in', () => {
|
||||
assert.equal(rio.vehicles[0].speedMps, 10);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => {
|
||||
// 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement.
|
||||
const bytes = encodeFeed({
|
||||
entities: [
|
||||
{ id: 'moving', lat: 38.72, lon: -9.14, speed: 36 },
|
||||
{ id: 'parked', lat: 38.73, lon: -9.15 },
|
||||
],
|
||||
});
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
const lisbon = buildTransitSnapshot(feed, bytes, 1);
|
||||
const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v]));
|
||||
assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6);
|
||||
assert.equal(byId.parked.speedMps, null, 'no speed stays no speed');
|
||||
const szczecin = buildTransitSnapshot(
|
||||
getTransitFeed('zditm-szczecin'),
|
||||
encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }),
|
||||
1,
|
||||
);
|
||||
assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched');
|
||||
});
|
||||
|
||||
test('conditional-request validators and gzip ride along with feed identification', () => {
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
const plain = transitUpstreamHeaders(entur);
|
||||
@@ -375,3 +397,24 @@ test('client history validates response bounds, epochs and cancellation through
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => {
|
||||
const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro'));
|
||||
assert.equal(headers.Accept, '*/*');
|
||||
// Every other feed keeps the protobuf-first Accept.
|
||||
assert.match(
|
||||
transitUpstreamHeaders(getTransitFeed('mbta')).Accept,
|
||||
/protobuf/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed refreshMs stretches its fresh window, never shortens it', () => {
|
||||
const johor = getTransitFeed('mybas-johor');
|
||||
assert.equal(transitFeedTtlMs(johor), 30_000);
|
||||
assert.equal(transitFeedTtlMs(getTransitFeed('mbta')), TRANSIT_PROXY_TTL_MS);
|
||||
assert.equal(transitFeedTtlMs({ refreshMs: 1_000 }), TRANSIT_PROXY_TTL_MS);
|
||||
const now = 1_000_000;
|
||||
const entry = { at: now - 20_000 };
|
||||
assert.equal(transitCacheState(entry, now), 'stale');
|
||||
assert.equal(transitCacheState(entry, now, transitFeedTtlMs(johor)), 'fresh');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file The bundled UCS Satellite Database pack: the purpose of each active
|
||||
* satellite, keyed by NORAD catalog number.
|
||||
* @file The bundled UCS Satellite Database pack: operator, owner country and
|
||||
* purpose for active satellites, keyed by NORAD catalog number.
|
||||
*
|
||||
* Data: `local_data/ucs_satellites/ucs_satellites.json`, built by
|
||||
* `scripts/build-ucs-satellites.py` from UCS-Satellite-Database 5-1-2023
|
||||
@@ -20,8 +20,9 @@ const PACK_URL = new URL(
|
||||
);
|
||||
|
||||
/**
|
||||
* One UCS row: what UCS says a satellite is for.
|
||||
* @typedef {{noradId: number, purpose: string|null}} UcsSatellite
|
||||
* One UCS row: what UCS says about a satellite's operator and purpose.
|
||||
* @typedef {{noradId: number, operator: string|null,
|
||||
* operatorCountry: string|null, purpose: string|null}} UcsSatellite
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,12 @@ test('the bundled UCS pack names its release, licence and curation', () => {
|
||||
pack.meta.curation.invalidNoradDropped +
|
||||
pack.meta.curation.duplicateNoradDropped,
|
||||
);
|
||||
assert.deepEqual(pack.fields, ['noradId', 'purpose']);
|
||||
assert.deepEqual(pack.fields, [
|
||||
'noradId',
|
||||
'operator',
|
||||
'operatorCountry',
|
||||
'purpose',
|
||||
]);
|
||||
// Small enough to load with the layer.
|
||||
assert.ok(readFileSync(PACK_URL).length < 200_000);
|
||||
});
|
||||
@@ -33,26 +38,35 @@ test('the pack loads into records keyed by NORAD number', async () => {
|
||||
const iss = records.get(25544);
|
||||
assert.ok(iss, 'the ISS is in UCS');
|
||||
assert.equal(iss.noradId, 25544);
|
||||
assert.ok(iss.operator);
|
||||
assert.ok(iss.purpose);
|
||||
for (const record of records.values()) {
|
||||
assert.doesNotMatch(
|
||||
`${record.operator ?? ''} ${record.purpose ?? ''}`,
|
||||
/starlink/i,
|
||||
'Starlink rows are dropped',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('indexing reads the string tables and turns empty strings into null', () => {
|
||||
const records = indexUcsPack({
|
||||
fields: ['noradId', 'purpose'],
|
||||
strings: { purpose: ['', 'Earth Observation'] },
|
||||
fields: ['noradId', 'operator', 'purpose'],
|
||||
strings: { operator: ['', 'ESA'], purpose: ['', 'Earth Observation'] },
|
||||
records: [
|
||||
[39634, 1],
|
||||
[40000, 0],
|
||||
['bad', 1],
|
||||
[-5, 1],
|
||||
[39634, 1, 1],
|
||||
[40000, 0, 1],
|
||||
['bad', 1, 1],
|
||||
[-5, 1, 1],
|
||||
],
|
||||
});
|
||||
assert.deepEqual([...records.keys()], [39634, 40000]);
|
||||
assert.deepEqual(records.get(39634), {
|
||||
noradId: 39634,
|
||||
operator: 'ESA',
|
||||
purpose: 'Earth Observation',
|
||||
});
|
||||
assert.equal(records.get(40000).purpose, null);
|
||||
assert.equal(records.get(40000).operator, null);
|
||||
});
|
||||
|
||||
test('a pack of the wrong shape throws, so the loader tries again', () => {
|
||||
|
||||
@@ -150,8 +150,10 @@
|
||||
"Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "التكلفة التقديرية للجلسة على {modelId} — عدد الاستجابات: {responses}. تنبيه عند {warn}، وتنتهي الجلسة عند {cap}.",
|
||||
"Aircraft cockpit view": "عرض قمرة قيادة الطائرة",
|
||||
"Ship bridge view": "عرض غرفة قيادة السفينة",
|
||||
"Satellite orbital view": "العرض المداري من القمر الصناعي",
|
||||
"EXIT COCKPIT": "الخروج من القمرة",
|
||||
"EXIT BRIDGE": "الخروج من غرفة القيادة",
|
||||
"EXIT ORBIT": "الخروج من المدار",
|
||||
"GROUND SPEED": "السرعة الأرضية",
|
||||
"SPEED OVER GROUND": "السرعة فوق القاع",
|
||||
"COURSE OVER GROUND": "المسار فوق القاع",
|
||||
@@ -279,6 +281,7 @@
|
||||
"RESET": "إعادة ضبط",
|
||||
"Exit cockpit view": "الخروج من عرض القمرة",
|
||||
"Exit bridge view": "الخروج من عرض غرفة القيادة",
|
||||
"Exit orbital view": "الخروج من العرض المداري",
|
||||
"Estimated flight plan": "خطة الرحلة التقديرية",
|
||||
"Reported voyage": "الرحلة المُبلَّغ عنها",
|
||||
"ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية",
|
||||
@@ -372,6 +375,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · المسار {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · الاتجاه {heading}°",
|
||||
"{label} · NO HEADING": "{label} · لا اتجاه",
|
||||
"{label} · NADIR VIEW": "{label} · عرض باتجاه النظير",
|
||||
"Select flight {title}": "اختيار الرحلة {title}",
|
||||
"CONTEXT STANDBY": "السياق في وضع الاستعداد",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "فعّل السياق العالمي لإشارات العناصر القريبة",
|
||||
@@ -618,6 +622,7 @@
|
||||
"DISABLING": "جارٍ الإيقاف",
|
||||
"Military Flights": "الرحلات العسكرية",
|
||||
"Satellites": "الأقمار الصناعية",
|
||||
"Place names": "أسماء الأماكن",
|
||||
"Space Missions (30d)": "المهام الفضائية (30 يومًا)",
|
||||
"Street Traffic": "حركة مرور الشوارع",
|
||||
"Radio": "الراديو",
|
||||
@@ -800,6 +805,40 @@
|
||||
"RECONSTRUCTED ESTIMATE": "تقدير مُعاد بناؤه",
|
||||
"{value} KM": "{value} كم",
|
||||
"{value} KM/S": "{value} كم/ث",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}",
|
||||
"SITES STANDBY": "المواقع في وضع الاستعداد",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "فعّل المنشآت المرسومة لإشارات المواقع",
|
||||
"Release the camera from {name}": "تحرير الكاميرا من {name}",
|
||||
"RETURN · {name}": "عودة · {name}",
|
||||
"PREVIOUS SATELLITE": "القمر الصناعي السابق",
|
||||
"Return to {name}": "العودة إلى {name}",
|
||||
"SATELLITE": "قمر صناعي",
|
||||
"Ride {name}": "الانتقال إلى {name}",
|
||||
"Unnamed site": "موقع بلا اسم",
|
||||
"OFF-NADIR {angle}°": "انحراف عن النظير {angle}°",
|
||||
"FOCUS · {name}": "تركيز · {name}",
|
||||
"Keep the camera on {name}": "إبقاء الكاميرا على {name}",
|
||||
"ZOOM {zoom}×": "تكبير {zoom}×",
|
||||
"SWATH {value} KM": "عرض نطاق التصوير {value} كم",
|
||||
"SWATH ≈ {value} KM": "عرض نطاق التصوير ≈ {value} كم",
|
||||
"Orbital view": "العرض المداري",
|
||||
"ORBITAL VIEW · NADIR": "العرض المداري · النظير",
|
||||
"ORBITAL VELOCITY": "السرعة المدارية",
|
||||
"SUB-SATELLITE POINT": "نقطة تحت القمر الصناعي",
|
||||
"NORTH UP": "الشمال للأعلى",
|
||||
"CURRENTLY OVER": "يحلّق الآن فوق",
|
||||
"{division}, {country}": "{division}، {country}",
|
||||
"{region} · near {place}": "{region} · قرب {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "الحقبة {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "إظهار مجموعة العناصر ثنائية السطر",
|
||||
"Hide the two-line element set": "إخفاء مجموعة العناصر ثنائية السطر",
|
||||
"Two-line element set": "مجموعة العناصر ثنائية السطر",
|
||||
"Return the view to nadir": "إعادة العرض إلى النظير",
|
||||
"Return to nadir (Home or 0)": "العودة إلى النظير (Home أو 0)",
|
||||
"NADIR": "إلى النظير",
|
||||
"REPLAY ASCENT": "إعادة عرض الصعود",
|
||||
"Replay the estimated ascent with a following camera": "إعادة عرض الصعود التقديري بكاميرا مرافقة",
|
||||
"Pause replay": "إيقاف إعادة العرض مؤقتًا",
|
||||
@@ -2966,6 +3005,15 @@
|
||||
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
|
||||
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
|
||||
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
|
||||
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
|
||||
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
|
||||
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
|
||||
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
|
||||
},
|
||||
"degraded": "متدهور",
|
||||
"stale": "متقادم",
|
||||
"partial": "جزئي",
|
||||
@@ -3150,9 +3198,11 @@
|
||||
"SIG": "إشارات",
|
||||
"LOCAL": "محلي",
|
||||
"ESC EXIT": "ESC خروج",
|
||||
"C TOGGLE": "C تبديل",
|
||||
"ARROWS LOOK": "الأسهم للنظر",
|
||||
"HOME RESET": "Home إعادة الضبط",
|
||||
"+/− ZOOM": "+/− للتكبير",
|
||||
"ARROWS TILT": "الأسهم للإمالة",
|
||||
"HOME NADIR": "Home النظير",
|
||||
"Active weather products": "منتجات الطقس النشطة",
|
||||
"RECENT IMAGERY": "الصور الحديثة",
|
||||
"Imagery days in the box": "أيام الصور في المنطقة",
|
||||
@@ -3555,7 +3605,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "عرض نطاق المستشعر {value} كم",
|
||||
"SENSOR SWATH ≈ {value} KM": "عرض نطاق المستشعر ≈ {value} كم",
|
||||
"SENSOR": "المستشعر",
|
||||
"Zoom to the imaging sensor swath": "التكبير إلى نطاق مستشعر التصوير",
|
||||
"<1 H OLD": "قبل أقل من ساعة",
|
||||
"{value} H OLD": "قبل {value} س",
|
||||
"{value} D OLD": "قبل {value} ي",
|
||||
"FWD": "أمام",
|
||||
"AFT": "خلف",
|
||||
"RIGHT": "يمين",
|
||||
"LEFT": "يسار",
|
||||
"TRACK UP": "المسار للأعلى",
|
||||
"COURSE HOLD · AIS": "تثبيت المسار · AIS",
|
||||
"Zoom in (+)": "تكبير (+)",
|
||||
"Zoom out (−)": "تصغير (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Live": "مباشر",
|
||||
@@ -3564,5 +3629,6 @@
|
||||
"1 wk/s": "أسبوع/ث",
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث"
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Photo © {photographer}": "صورة © {photographer}"
|
||||
}
|
||||
@@ -191,6 +191,40 @@
|
||||
"Select {mission}": "{mission} auswählen",
|
||||
"{value} KM": "{value} KM",
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}",
|
||||
"SITES STANDBY": "ORTE STANDBY",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN",
|
||||
"Release the camera from {name}": "Kamera von {name} lösen",
|
||||
"RETURN · {name}": "ZURÜCK · {name}",
|
||||
"PREVIOUS SATELLITE": "VORHERIGER SATELLIT",
|
||||
"Return to {name}": "Zurück zu {name}",
|
||||
"SATELLITE": "SATELLIT",
|
||||
"Ride {name}": "Mit {name} fliegen",
|
||||
"Unnamed site": "Unbenannter Ort",
|
||||
"OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°",
|
||||
"FOCUS · {name}": "FOKUS · {name}",
|
||||
"Keep the camera on {name}": "Kamera auf {name} halten",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "STREIFENBREITE {value} KM",
|
||||
"SWATH ≈ {value} KM": "STREIFENBREITE ≈ {value} KM",
|
||||
"Orbital view": "Orbitalansicht",
|
||||
"ORBITAL VIEW · NADIR": "ORBITALANSICHT · NADIR",
|
||||
"ORBITAL VELOCITY": "BAHNGESCHWINDIGKEIT",
|
||||
"SUB-SATELLITE POINT": "SUBSATELLITENPUNKT",
|
||||
"NORTH UP": "NORDEN OBEN",
|
||||
"CURRENTLY OVER": "AKTUELL ÜBER",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · nahe {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "EPOCHE {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Zweizeilen-Bahnelemente anzeigen",
|
||||
"Hide the two-line element set": "Zweizeilen-Bahnelemente ausblenden",
|
||||
"Two-line element set": "Zweizeilen-Bahnelemente",
|
||||
"Return the view to nadir": "Ansicht zurück auf den Nadir richten",
|
||||
"Return to nadir (Home or 0)": "Zurück zum Nadir (Pos1 oder 0)",
|
||||
"NADIR": "ZUM NADIR",
|
||||
"SELECTED SPACE MISSION": "GEWÄHLTE RAUMMISSION",
|
||||
"Show all missions": "Alle Missionen anzeigen",
|
||||
"Deselect mission": "Mission abwählen",
|
||||
@@ -483,6 +517,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · KURS {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · STEUERKURS {heading}°",
|
||||
"{label} · NO HEADING": "{label} · KEIN STEUERKURS",
|
||||
"{label} · NADIR VIEW": "{label} · NADIRANSICHT",
|
||||
"Context could not restore every layer; try again": "Kontext konnte nicht alle Ebenen wiederherstellen; erneut versuchen",
|
||||
"No selected data layers": "Keine Datenebenen ausgewählt",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -509,6 +544,7 @@
|
||||
"Military Flights": "Militärflüge",
|
||||
"Earthquakes (24h)": "Erdbeben (24 h)",
|
||||
"Satellites": "Satelliten",
|
||||
"Place names": "Ortsnamen",
|
||||
"Space Missions (30d)": "Raummissionen (30 T)",
|
||||
"Street Traffic": "Straßenverkehr",
|
||||
"CCTV": "CCTV",
|
||||
@@ -670,6 +706,7 @@
|
||||
"Radio did not start": "Radio wurde nicht gestartet",
|
||||
"Aircraft cockpit view": "Flugzeug-Cockpitansicht",
|
||||
"Ship bridge view": "Ansicht von der Schiffsbrücke",
|
||||
"Satellite orbital view": "Orbitalansicht vom Satelliten",
|
||||
"LOOK STBD {bearing}°": "BLICK STEUERBORD {bearing}°",
|
||||
"LOOK PORT {bearing}°": "BLICK BACKBORD {bearing}°",
|
||||
"LOOK ASTERN": "BLICK ACHTERAUS",
|
||||
@@ -757,8 +794,10 @@
|
||||
"RESET": "RESET",
|
||||
"Exit cockpit view": "Cockpitansicht verlassen",
|
||||
"Exit bridge view": "Brückenansicht verlassen",
|
||||
"Exit orbital view": "Orbitalansicht verlassen",
|
||||
"EXIT COCKPIT": "COCKPIT VERLASSEN",
|
||||
"EXIT BRIDGE": "BRÜCKE VERLASSEN",
|
||||
"EXIT ORBIT": "ORBIT VERLASSEN",
|
||||
"Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets",
|
||||
"Expand Visual Presets": "Visuelle Presets ausklappen",
|
||||
"Pin visual presets": "Visuelle Presets anheften",
|
||||
@@ -2712,6 +2751,11 @@
|
||||
"one": "Hier noch kein Feed · {n} Region verfügbar",
|
||||
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
|
||||
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
|
||||
},
|
||||
"degraded": "eingeschränkt",
|
||||
"stale": "veraltet",
|
||||
"partial": "teilweise",
|
||||
@@ -2872,9 +2916,11 @@
|
||||
"SIG": "SIG",
|
||||
"LOCAL": "LOKAL",
|
||||
"ESC EXIT": "ESC BEENDEN",
|
||||
"C TOGGLE": "C UMSCHALTEN",
|
||||
"ARROWS LOOK": "PFEILE UMSEHEN",
|
||||
"HOME RESET": "POS1 ZURÜCKSETZEN",
|
||||
"+/− ZOOM": "+/− ZOOMEN",
|
||||
"ARROWS TILT": "PFEILE NEIGEN",
|
||||
"HOME NADIR": "POS1 NADIR",
|
||||
"Active weather products": "Aktive Wetterprodukte",
|
||||
"RECENT IMAGERY": "AKTUELLE BILDDATEN",
|
||||
"Imagery days in the box": "Bildtage im Bereich",
|
||||
@@ -3251,7 +3297,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT liefert Typ und Status jedes Objekts. Ohne aktuelle Kopie stützen sich die Klassen auf die UCS-Datenbank, Namen und CelesTrak-Gruppen, sodass ein inzwischen ausgefallener Satellit noch als aktiv erscheinen kann.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "SENSORSTREIFEN {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen",
|
||||
"<1 H OLD": "<1 STD ALT",
|
||||
"{value} H OLD": "{value} STD ALT",
|
||||
"{value} D OLD": "{value} T ALT",
|
||||
"FWD": "VORAUS",
|
||||
"AFT": "ACHTERN",
|
||||
"RIGHT": "RECHTS",
|
||||
"LEFT": "LINKS",
|
||||
"TRACK UP": "KURS OBEN",
|
||||
"COURSE HOLD · AIS": "KURS FIX · AIS",
|
||||
"Zoom in (+)": "Hineinzoomen (+)",
|
||||
"Zoom out (−)": "Herauszoomen (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG",
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
"Live": "Live",
|
||||
@@ -3260,5 +3321,6 @@
|
||||
"1 wk/s": "1 Wo./s",
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s"
|
||||
"1 min/s": "1 Min./s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -39,8 +39,10 @@
|
||||
"Close data attribution": "Cerrar la atribución de datos",
|
||||
"Aircraft cockpit view": "Vista de cabina de la aeronave",
|
||||
"Ship bridge view": "Vista desde el puente del buque",
|
||||
"Satellite orbital view": "Vista orbital desde el satélite",
|
||||
"EXIT COCKPIT": "SALIR DE CABINA",
|
||||
"EXIT BRIDGE": "SALIR DEL PUENTE",
|
||||
"EXIT ORBIT": "SALIR DE LA ÓRBITA",
|
||||
"GROUND SPEED": "VEL. SUELO",
|
||||
"SPEED OVER GROUND": "VEL. SOBRE FONDO",
|
||||
"COURSE OVER GROUND": "RUMBO SOBRE FONDO",
|
||||
@@ -166,6 +168,7 @@
|
||||
"RESET": "RESTABLECER",
|
||||
"Exit cockpit view": "Salir de la vista de cabina",
|
||||
"Exit bridge view": "Salir de la vista del puente",
|
||||
"Exit orbital view": "Salir de la vista orbital",
|
||||
"Estimated flight plan": "Plan de vuelo estimado",
|
||||
"Reported voyage": "Viaje notificado",
|
||||
"ESTIMATED FLIGHT PLAN": "PLAN DE VUELO ESTIMADO",
|
||||
@@ -261,6 +264,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · RUMBO {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · PROA {heading}°",
|
||||
"{label} · NO HEADING": "{label} · SIN PROA",
|
||||
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
|
||||
"Select flight {title}": "Seleccionar el vuelo {title}",
|
||||
"CONTEXT STANDBY": "CONTEXTO EN ESPERA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVAR CONTEXTO GLOBAL PARA PINGS DE PROXIMIDAD",
|
||||
@@ -503,6 +507,7 @@
|
||||
"DISABLING": "APAGANDO",
|
||||
"Military Flights": "Vuelos militares",
|
||||
"Satellites": "Satélites",
|
||||
"Place names": "Nombres de lugares",
|
||||
"Space Missions (30d)": "Misiones espaciales (30 d)",
|
||||
"Street Traffic": "Tráfico urbano",
|
||||
"Radio": "Radio",
|
||||
@@ -658,6 +663,40 @@
|
||||
"RECONSTRUCTED ESTIMATE": "ESTIMACIÓN RECONSTRUIDA",
|
||||
"{value} KM": "{value} KM",
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}",
|
||||
"SITES STANDBY": "SITIOS EN ESPERA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS",
|
||||
"Release the camera from {name}": "Soltar la cámara de {name}",
|
||||
"RETURN · {name}": "VOLVER · {name}",
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
"Return to {name}": "Volver a {name}",
|
||||
"SATELLITE": "SATÉLITE",
|
||||
"Ride {name}": "Viajar en {name}",
|
||||
"Unnamed site": "Sitio sin nombre",
|
||||
"OFF-NADIR {angle}°": "FUERA DEL NADIR {angle}°",
|
||||
"FOCUS · {name}": "ENFOQUE · {name}",
|
||||
"Keep the camera on {name}": "Mantener la cámara sobre {name}",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "BARRIDO {value} KM",
|
||||
"SWATH ≈ {value} KM": "BARRIDO ≈ {value} KM",
|
||||
"Orbital view": "Vista orbital",
|
||||
"ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR",
|
||||
"ORBITAL VELOCITY": "VELOCIDAD ORBITAL",
|
||||
"SUB-SATELLITE POINT": "PUNTO SUBSATELITAL",
|
||||
"NORTH UP": "NORTE ARRIBA",
|
||||
"CURRENTLY OVER": "SOBREVOLANDO",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · cerca de {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "ÉPOCA {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Mostrar el conjunto de elementos de dos líneas",
|
||||
"Hide the two-line element set": "Ocultar el conjunto de elementos de dos líneas",
|
||||
"Two-line element set": "Conjunto de elementos de dos líneas",
|
||||
"Return the view to nadir": "Volver la vista al nadir",
|
||||
"Return to nadir (Home or 0)": "Volver al nadir (Inicio o 0)",
|
||||
"NADIR": "AL NADIR",
|
||||
"REPLAY ASCENT": "REPETIR ASCENSO",
|
||||
"Replay the estimated ascent with a following camera": "Reproduce el ascenso estimado con una cámara de seguimiento",
|
||||
"Pause replay": "Pausar repetición",
|
||||
@@ -1642,9 +1681,11 @@
|
||||
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORALMENTE NO DISPONIBLE",
|
||||
"Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com",
|
||||
"ESC EXIT": "ESC SALIR",
|
||||
"C TOGGLE": "C ALTERNAR",
|
||||
"ARROWS LOOK": "FLECHAS MIRAR",
|
||||
"HOME RESET": "INICIO RESTABLECER",
|
||||
"+/− ZOOM": "+/− ACERCAR/ALEJAR",
|
||||
"ARROWS TILT": "FLECHAS INCLINAR",
|
||||
"HOME NADIR": "INICIO NADIR",
|
||||
"SIG": "SIG",
|
||||
"NEWS": "NOTICIAS",
|
||||
"LOCAL": "LOCAL",
|
||||
@@ -2874,6 +2915,12 @@
|
||||
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
|
||||
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
|
||||
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
|
||||
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
|
||||
},
|
||||
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
|
||||
"Decoder feeds": "Fuentes del decodificador",
|
||||
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
|
||||
@@ -3327,7 +3374,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak da el tipo y el estado de cada objeto. Sin una copia actual, las clases se basan en la base de datos UCS, los nombres y los grupos de CelesTrak, así que un satélite que haya dejado de funcionar puede seguir apareciendo como operativo.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "BARRIDO DEL SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "BARRIDO DEL SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Ampliar al barrido del sensor de imagen",
|
||||
"<1 H OLD": "HACE <1 H",
|
||||
"{value} H OLD": "HACE {value} H",
|
||||
"{value} D OLD": "HACE {value} D",
|
||||
"FWD": "ADELANTE",
|
||||
"AFT": "ATRÁS",
|
||||
"RIGHT": "DERECHA",
|
||||
"LEFT": "IZQUIERDA",
|
||||
"TRACK UP": "TRAYECTORIA ARRIBA",
|
||||
"COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS",
|
||||
"Zoom in (+)": "Acercar (+)",
|
||||
"Zoom out (−)": "Alejar (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR",
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
"Live": "En directo",
|
||||
@@ -3336,5 +3398,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
+88
-25
@@ -820,13 +820,47 @@
|
||||
"FLIGHT {count}": "VOL {count}",
|
||||
"REUSED": "RÉUTILISÉ",
|
||||
"NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE",
|
||||
"{count} / 30D": "{count} / 30J",
|
||||
"{count} / 30D": "{count} / 30 J",
|
||||
"NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS",
|
||||
"DATE UNAVAILABLE": "DATE INDISPONIBLE",
|
||||
"UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ",
|
||||
"Select {mission}": "Sélectionner {mission}",
|
||||
"{value} KM": "{value} KM",
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "HORS NADIR {angle}° · {offset}",
|
||||
"SITES STANDBY": "SITES EN VEILLE",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVER LES INSTALLATIONS CARTOGRAPHIÉES POUR LES PINGS DE SITES",
|
||||
"Release the camera from {name}": "Libérer la caméra de {name}",
|
||||
"RETURN · {name}": "RETOUR · {name}",
|
||||
"PREVIOUS SATELLITE": "SATELLITE PRÉCÉDENT",
|
||||
"Return to {name}": "Revenir à {name}",
|
||||
"SATELLITE": "SATELLITE",
|
||||
"Ride {name}": "Suivre {name}",
|
||||
"Unnamed site": "Site sans nom",
|
||||
"OFF-NADIR {angle}°": "HORS NADIR {angle}°",
|
||||
"FOCUS · {name}": "CENTRÉ · {name}",
|
||||
"Keep the camera on {name}": "Garder la caméra sur {name}",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "FAUCHÉE {value} KM",
|
||||
"SWATH ≈ {value} KM": "FAUCHÉE ≈ {value} KM",
|
||||
"Orbital view": "Vue orbitale",
|
||||
"ORBITAL VIEW · NADIR": "VUE ORBITALE · NADIR",
|
||||
"ORBITAL VELOCITY": "VITESSE ORBITALE",
|
||||
"SUB-SATELLITE POINT": "POINT SUBSATELLITE",
|
||||
"NORTH UP": "NORD EN HAUT",
|
||||
"CURRENTLY OVER": "SURVOL ACTUEL",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · près de {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "ÉPOQUE {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Afficher le jeu d'éléments à deux lignes",
|
||||
"Hide the two-line element set": "Masquer le jeu d'éléments à deux lignes",
|
||||
"Two-line element set": "Jeu d'éléments à deux lignes",
|
||||
"Return the view to nadir": "Ramener la vue au nadir",
|
||||
"Return to nadir (Home or 0)": "Retour au nadir (Home ou 0)",
|
||||
"NADIR": "AU NADIR",
|
||||
"Selected Space Mission": "Mission spatiale sélectionnée",
|
||||
"SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE",
|
||||
"Show all missions": "Afficher toutes les missions",
|
||||
@@ -1018,11 +1052,11 @@
|
||||
"Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°",
|
||||
"Select one side of the dateline": "Ne traversez pas la ligne de changement de date",
|
||||
"Box must enclose an area": "La zone doit délimiter une surface",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)",
|
||||
"Loading the Starlink shell…": "Chargement de la constellation Starlink…",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)",
|
||||
"Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…",
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer",
|
||||
"load failed": "échec du chargement",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement",
|
||||
"DENSE": "DENSE",
|
||||
"CelesTrak unreachable": "CelesTrak injoignable",
|
||||
"DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}",
|
||||
@@ -1103,7 +1137,7 @@
|
||||
"Hybrid": "Hybride",
|
||||
"Lines": "Lignes",
|
||||
"Dots": "Points",
|
||||
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
|
||||
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
|
||||
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
|
||||
"Moving dots": "Points mobiles",
|
||||
"TomTom key unavailable": "Clé TomTom indisponible",
|
||||
@@ -1119,7 +1153,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %",
|
||||
@@ -1216,7 +1250,7 @@
|
||||
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
|
||||
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
|
||||
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
|
||||
"Color: {units}": "Couleur : {units}",
|
||||
"Color: {units}": "Couleur : {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
|
||||
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
|
||||
@@ -1348,7 +1382,7 @@
|
||||
"{label} is unavailable": "{label} est indisponible",
|
||||
"This map stack": "Ce fond de carte",
|
||||
"{label} unavailable: {hint}": "{label} indisponible : {hint}",
|
||||
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
|
||||
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
|
||||
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion",
|
||||
"Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra",
|
||||
"Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu",
|
||||
@@ -1487,7 +1521,7 @@
|
||||
"Apply details": "Appliquer les détails",
|
||||
"Select a scene first": "Sélectionnez d'abord une scène",
|
||||
"Share selected scene": "Partager la scène sélectionnée",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
|
||||
"Download scene JSON": "Télécharger le JSON de scène",
|
||||
"Choose data-pack files": "Choisir des fichiers de paquet de données",
|
||||
"Or choose a data-pack folder": "Ou choisir un dossier de paquet de données",
|
||||
@@ -1754,6 +1788,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · ROUTE {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · CAP {heading}°",
|
||||
"{label} · NO HEADING": "{label} · SANS CAP",
|
||||
"{label} · NADIR VIEW": "{label} · VUE AU NADIR",
|
||||
"Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez",
|
||||
"No selected data layers": "Aucune couche de données sélectionnée",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1814,6 +1849,7 @@
|
||||
"Military Flights": "Vols militaires",
|
||||
"Earthquakes (24h)": "Séismes (24 h)",
|
||||
"Satellites": "Satellites",
|
||||
"Place names": "Noms de lieux",
|
||||
"Space Missions (30d)": "Missions spatiales (30 j)",
|
||||
"Street Traffic": "Trafic routier",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2548,6 +2584,12 @@
|
||||
"many": "Pas encore de flux ici · {n} de régions disponibles",
|
||||
"other": "Pas encore de flux ici · {n} régions disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Pas encore de vélos partagés ici · {n} système disponible",
|
||||
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
|
||||
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
|
||||
},
|
||||
"degraded": "dégradé",
|
||||
"stale": "obsolète",
|
||||
"partial": "partiel",
|
||||
@@ -2704,6 +2746,7 @@
|
||||
"HERE": "ICI",
|
||||
"Aircraft cockpit view": "Vue cockpit de l'aéronef",
|
||||
"Ship bridge view": "Vue depuis la passerelle du navire",
|
||||
"Satellite orbital view": "Vue orbitale depuis le satellite",
|
||||
"LOOK STBD {bearing}°": "VUE TRIBORD {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VUE BÂBORD {bearing}°",
|
||||
"LOOK ASTERN": "VUE ARRIÈRE",
|
||||
@@ -2799,16 +2842,20 @@
|
||||
"VOLUME": "VOLUME",
|
||||
"Cockpit Radio volume": "Volume de la Radio du cockpit",
|
||||
"ESC EXIT": "ESC QUITTER",
|
||||
"C TOGGLE": "C BASCULER",
|
||||
"ARROWS LOOK": "FLÈCHES REGARDER",
|
||||
"HOME RESET": "HOME RECENTRER",
|
||||
"+/− ZOOM": "+/− ZOOMER",
|
||||
"ARROWS TILT": "FLÈCHES INCLINER",
|
||||
"HOME NADIR": "HOME AU NADIR",
|
||||
"View switcher": "Sélecteur de vue",
|
||||
"Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier",
|
||||
"RESET": "RÉINIT.",
|
||||
"Exit cockpit view": "Quitter la vue cockpit",
|
||||
"Exit bridge view": "Quitter la vue passerelle",
|
||||
"Exit orbital view": "Quitter la vue orbitale",
|
||||
"EXIT COCKPIT": "QUITTER LE COCKPIT",
|
||||
"EXIT BRIDGE": "QUITTER LA PASSERELLE",
|
||||
"EXIT ORBIT": "QUITTER L'ORBITE",
|
||||
"Navigation, voice, and visual preset controls": "Commandes de navigation, de voix et de préréglages visuels",
|
||||
"Expand Visual Presets": "Développer les préréglages visuels",
|
||||
"Pin visual presets": "Épingler les préréglages visuels",
|
||||
@@ -2850,7 +2897,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
|
||||
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
|
||||
"Internet radio and local SDR": "Radio Internet et SDR local",
|
||||
@@ -2871,7 +2918,7 @@
|
||||
"Radio volume": "Volume de la radio",
|
||||
"STATION SITE": "SITE DE LA STATION",
|
||||
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.",
|
||||
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
|
||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
|
||||
@@ -3234,7 +3281,7 @@
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l’observation de {name}",
|
||||
"Show {name} sighting": "Afficher l'observation : {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
"observed {age} ago": "observé il y a {age}",
|
||||
"Be Whale Wise": "Respectez les cétacés",
|
||||
@@ -3272,7 +3319,7 @@
|
||||
"Cetacean": "Cétacé",
|
||||
"Wildlife Sightings": "Observations naturalistes",
|
||||
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation",
|
||||
"Wildlife": "Faune et flore",
|
||||
"Zoom in for local sightings": "Zoomez pour les observations locales",
|
||||
"Loading photo...": "Chargement de la photo…",
|
||||
@@ -3282,23 +3329,23 @@
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} observations dans la vue",
|
||||
"Close sighting": "Fermer l’observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Close sighting": "Fermer l'observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"{days}d": "{days} j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l'aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l'image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
|
||||
"Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo",
|
||||
"Captured {month}": "Prise en {month}",
|
||||
@@ -3311,7 +3358,7 @@
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
|
||||
"TELECOM": "TÉLÉCOM",
|
||||
@@ -3324,10 +3371,25 @@
|
||||
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
|
||||
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
|
||||
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAUCHÉE CAPTEUR ≈ {value} KM",
|
||||
"SENSOR": "CAPTEUR",
|
||||
"Zoom to the imaging sensor swath": "Zoomer sur la fauchée du capteur imageur",
|
||||
"<1 H OLD": "IL Y A <1 H",
|
||||
"{value} H OLD": "IL Y A {value} H",
|
||||
"{value} D OLD": "IL Y A {value} J",
|
||||
"FWD": "AVANT",
|
||||
"AFT": "ARRIÈRE",
|
||||
"RIGHT": "DROITE",
|
||||
"LEFT": "GAUCHE",
|
||||
"TRACK UP": "TRACE EN HAUT",
|
||||
"COURSE HOLD · AIS": "VERROU ROUTE · AIS",
|
||||
"Zoom in (+)": "Zoomer (+)",
|
||||
"Zoom out (−)": "Dézoomer (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR",
|
||||
"World time": "Heure du monde",
|
||||
"Back to live": "Revenir au direct",
|
||||
"Live": "Direct",
|
||||
@@ -3336,5 +3398,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Photo © {photographer}"
|
||||
}
|
||||
@@ -827,6 +827,40 @@
|
||||
"Select {mission}": "Seleziona {mission}",
|
||||
"{value} KM": "{value} KM",
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "FUORI NADIR {angle}° · {offset}",
|
||||
"SITES STANDBY": "SITI IN ATTESA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATTIVA LE INSTALLAZIONI MAPPATE PER I PING DEI SITI",
|
||||
"Release the camera from {name}": "Sblocca la camera da {name}",
|
||||
"RETURN · {name}": "TORNA · {name}",
|
||||
"PREVIOUS SATELLITE": "SATELLITE PRECEDENTE",
|
||||
"Return to {name}": "Torna a {name}",
|
||||
"SATELLITE": "SATELLITE",
|
||||
"Ride {name}": "Segui {name}",
|
||||
"Unnamed site": "Sito senza nome",
|
||||
"OFF-NADIR {angle}°": "FUORI NADIR {angle}°",
|
||||
"FOCUS · {name}": "PUNTAMENTO · {name}",
|
||||
"Keep the camera on {name}": "Mantieni la camera su {name}",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "STRISCIATA {value} KM",
|
||||
"SWATH ≈ {value} KM": "STRISCIATA ≈ {value} KM",
|
||||
"Orbital view": "Vista orbitale",
|
||||
"ORBITAL VIEW · NADIR": "VISTA ORBITALE · NADIR",
|
||||
"ORBITAL VELOCITY": "VELOCITÀ ORBITALE",
|
||||
"SUB-SATELLITE POINT": "PUNTO SUBSATELLITARE",
|
||||
"NORTH UP": "NORD IN ALTO",
|
||||
"CURRENTLY OVER": "ATTUALMENTE SOPRA",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · vicino a {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "EPOCA {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Mostra il set di elementi a due righe",
|
||||
"Hide the two-line element set": "Nascondi il set di elementi a due righe",
|
||||
"Two-line element set": "Set di elementi a due righe",
|
||||
"Return the view to nadir": "Riporta la vista al nadir",
|
||||
"Return to nadir (Home or 0)": "Torna al nadir (Home o 0)",
|
||||
"NADIR": "AL NADIR",
|
||||
"Selected Space Mission": "Missione spaziale selezionata",
|
||||
"SELECTED SPACE MISSION": "MISSIONE SPAZIALE SELEZIONATA",
|
||||
"Show all missions": "Mostra tutte le missioni",
|
||||
@@ -1754,6 +1788,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · ROTTA {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · PRUA {heading}°",
|
||||
"{label} · NO HEADING": "{label} · NESSUNA PRUA",
|
||||
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
|
||||
"Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova",
|
||||
"No selected data layers": "Nessun livello dati selezionato",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1814,6 +1849,7 @@
|
||||
"Military Flights": "Voli militari",
|
||||
"Earthquakes (24h)": "Terremoti (24 h)",
|
||||
"Satellites": "Satelliti",
|
||||
"Place names": "Nomi dei luoghi",
|
||||
"Space Missions (30d)": "Missioni spaziali (30 g)",
|
||||
"Street Traffic": "Traffico stradale",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2548,6 +2584,12 @@
|
||||
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
|
||||
"other": "Ancora nessun feed qui · {n} regioni disponibili"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
|
||||
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
|
||||
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
|
||||
},
|
||||
"degraded": "degradato",
|
||||
"stale": "obsoleto",
|
||||
"partial": "parziale",
|
||||
@@ -2704,6 +2746,7 @@
|
||||
"HERE": "QUI",
|
||||
"Aircraft cockpit view": "Vista cockpit dell'aereo",
|
||||
"Ship bridge view": "Vista dalla plancia della nave",
|
||||
"Satellite orbital view": "Vista orbitale dal satellite",
|
||||
"LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°",
|
||||
"LOOK ASTERN": "VISTA A POPPA",
|
||||
@@ -2799,16 +2842,20 @@
|
||||
"VOLUME": "VOLUME",
|
||||
"Cockpit Radio volume": "Volume Radio del cockpit",
|
||||
"ESC EXIT": "ESC ESCI",
|
||||
"C TOGGLE": "C ALTERNA",
|
||||
"ARROWS LOOK": "FRECCE GUARDA",
|
||||
"HOME RESET": "HOME RIPRISTINA",
|
||||
"+/− ZOOM": "+/− AVVICINA/ALLONTANA",
|
||||
"ARROWS TILT": "FRECCE INCLINA",
|
||||
"HOME NADIR": "HOME AL NADIR",
|
||||
"View switcher": "Selettore vista",
|
||||
"Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero",
|
||||
"RESET": "RESET",
|
||||
"Exit cockpit view": "Esci dalla vista cockpit",
|
||||
"Exit bridge view": "Esci dalla vista plancia",
|
||||
"Exit orbital view": "Esci dalla vista orbitale",
|
||||
"EXIT COCKPIT": "ESCI DAL COCKPIT",
|
||||
"EXIT BRIDGE": "ESCI DALLA PLANCIA",
|
||||
"EXIT ORBIT": "ESCI DALL'ORBITA",
|
||||
"Navigation, voice, and visual preset controls": "Controlli di navigazione, voce e preset visivi",
|
||||
"Expand Visual Presets": "Espandi Preset visivi",
|
||||
"Pin visual presets": "Fissa i preset visivi",
|
||||
@@ -3327,7 +3374,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "STRISCIATA SENSORE {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "STRISCIATA SENSORE ≈ {value} KM",
|
||||
"SENSOR": "SENSORE",
|
||||
"Zoom to the imaging sensor swath": "Zoom sulla strisciata del sensore di ripresa",
|
||||
"<1 H OLD": "<1 H FA",
|
||||
"{value} H OLD": "{value} H FA",
|
||||
"{value} D OLD": "{value} G FA",
|
||||
"FWD": "AVANTI",
|
||||
"AFT": "INDIETRO",
|
||||
"RIGHT": "DESTRA",
|
||||
"LEFT": "SINISTRA",
|
||||
"TRACK UP": "TRACCIA IN ALTO",
|
||||
"COURSE HOLD · AIS": "BLOCCO ROTTA · AIS",
|
||||
"Zoom in (+)": "Avvicina (+)",
|
||||
"Zoom out (−)": "Allontana (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR",
|
||||
"World time": "Ora del mondo",
|
||||
"Back to live": "Torna alla diretta",
|
||||
"Live": "Diretta",
|
||||
@@ -3336,5 +3398,6 @@
|
||||
"1 wk/s": "1 sett/s",
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -801,6 +801,40 @@
|
||||
"Select {mission}": "{mission}を選択",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示",
|
||||
"Release the camera from {name}": "{name} からカメラを解放",
|
||||
"RETURN · {name}": "戻る · {name}",
|
||||
"PREVIOUS SATELLITE": "前の衛星",
|
||||
"Return to {name}": "{name} に戻る",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "{name} に乗る",
|
||||
"Unnamed site": "名称のない地点",
|
||||
"OFF-NADIR {angle}°": "オフナディア {angle}°",
|
||||
"FOCUS · {name}": "フォーカス · {name}",
|
||||
"Keep the camera on {name}": "カメラを {name} に向け続ける",
|
||||
"ZOOM {zoom}×": "ズーム {zoom}×",
|
||||
"SWATH {value} KM": "観測幅 {value} km",
|
||||
"SWATH ≈ {value} KM": "観測幅 ≈ {value} km",
|
||||
"Orbital view": "軌道ビュー",
|
||||
"ORBITAL VIEW · NADIR": "軌道ビュー · 直下",
|
||||
"ORBITAL VELOCITY": "軌道速度",
|
||||
"SUB-SATELLITE POINT": "衛星直下点",
|
||||
"NORTH UP": "ノースアップ",
|
||||
"CURRENTLY OVER": "現在の上空",
|
||||
"{division}, {country}": "{division}、{country}",
|
||||
"{region} · near {place}": "{region} · {place}付近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "元期 {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "2行軌道要素を表示",
|
||||
"Hide the two-line element set": "2行軌道要素を隠す",
|
||||
"Two-line element set": "2行軌道要素",
|
||||
"Return the view to nadir": "ビューを直下に戻す",
|
||||
"Return to nadir (Home or 0)": "直下に戻る(Homeまたは0)",
|
||||
"NADIR": "直下に戻す",
|
||||
"Selected Space Mission": "選択中の宇宙ミッション",
|
||||
"SELECTED SPACE MISSION": "選択中の宇宙ミッション",
|
||||
"Show all missions": "すべてのミッションを表示",
|
||||
@@ -1702,6 +1736,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · 船首方位 {heading}°",
|
||||
"{label} · NO HEADING": "{label} · 船首方位なし",
|
||||
"{label} · NADIR VIEW": "{label} · 直下視",
|
||||
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
|
||||
"No selected data layers": "選択中のデータレイヤーはありません",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1752,6 +1787,7 @@
|
||||
"Military Flights": "軍用機",
|
||||
"Earthquakes (24h)": "地震(24時間)",
|
||||
"Satellites": "衛星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "宇宙ミッション 30日",
|
||||
"Street Traffic": "道路交通",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2418,6 +2454,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
|
||||
},
|
||||
"degraded": "性能低下",
|
||||
"stale": "更新遅延",
|
||||
"partial": "部分的",
|
||||
@@ -2562,6 +2602,7 @@
|
||||
"HERE": "ここ",
|
||||
"Aircraft cockpit view": "航空機コックピットビュー",
|
||||
"Ship bridge view": "船舶ブリッジビュー",
|
||||
"Satellite orbital view": "衛星軌道ビュー",
|
||||
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "視線 船尾方向",
|
||||
@@ -2655,16 +2696,20 @@
|
||||
"VOLUME": "音量",
|
||||
"Cockpit Radio volume": "コックピットラジオの音量",
|
||||
"ESC EXIT": "Escで終了",
|
||||
"C TOGGLE": "Cで切替",
|
||||
"ARROWS LOOK": "矢印キーで見回す",
|
||||
"HOME RESET": "Homeでリセット",
|
||||
"+/− ZOOM": "+/−でズーム",
|
||||
"ARROWS TILT": "矢印キーで傾ける",
|
||||
"HOME NADIR": "Homeで直下",
|
||||
"View switcher": "ビュー切替",
|
||||
"Exit cockpit and return to full globe view": "コックピットを終了して地球全体ビューに戻る",
|
||||
"RESET": "リセット",
|
||||
"Exit cockpit view": "コックピットビューを終了",
|
||||
"Exit bridge view": "ブリッジビューを終了",
|
||||
"Exit orbital view": "軌道ビューを終了",
|
||||
"EXIT COCKPIT": "コックピット終了",
|
||||
"EXIT BRIDGE": "ブリッジ終了",
|
||||
"EXIT ORBIT": "軌道ビュー終了",
|
||||
"Navigation, voice, and visual preset controls": "ナビゲーション、音声、ビジュアルプリセットの操作",
|
||||
"Expand Visual Presets": "ビジュアルプリセットを展開",
|
||||
"Pin visual presets": "ビジュアルプリセットを固定",
|
||||
@@ -3175,7 +3220,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "センサー観測幅 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km",
|
||||
"SENSOR": "センサー",
|
||||
"Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム",
|
||||
"<1 H OLD": "1時間未満前",
|
||||
"{value} H OLD": "{value}時間前",
|
||||
"{value} D OLD": "{value}日前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "トラックアップ",
|
||||
"COURSE HOLD · AIS": "対地針路保持 · AIS",
|
||||
"Zoom in (+)": "ズームイン (+)",
|
||||
"Zoom out (−)": "ズームアウト (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
"Live": "ライブ",
|
||||
@@ -3184,5 +3244,6 @@
|
||||
"1 wk/s": "1週間/秒",
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒"
|
||||
"1 min/s": "1分/秒",
|
||||
"Photo © {photographer}": "写真 © {photographer}"
|
||||
}
|
||||
@@ -825,6 +825,40 @@
|
||||
"Select {mission}": "Selecionar {mission}",
|
||||
"{value} KM": "{value} KM",
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "FORA DO NADIR {angle}° · {offset}",
|
||||
"SITES STANDBY": "LOCAIS EM ESPERA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATIVE AS INSTALAÇÕES MAPEADAS PARA PINGS DE LOCAIS",
|
||||
"Release the camera from {name}": "Soltar a câmera de {name}",
|
||||
"RETURN · {name}": "VOLTAR · {name}",
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
"Return to {name}": "Voltar para {name}",
|
||||
"SATELLITE": "SATÉLITE",
|
||||
"Ride {name}": "Acompanhar {name}",
|
||||
"Unnamed site": "Local sem nome",
|
||||
"OFF-NADIR {angle}°": "FORA DO NADIR {angle}°",
|
||||
"FOCUS · {name}": "FOCO · {name}",
|
||||
"Keep the camera on {name}": "Manter a câmera em {name}",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "FAIXA IMAGEADA {value} KM",
|
||||
"SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM",
|
||||
"Orbital view": "Vista orbital",
|
||||
"ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR",
|
||||
"ORBITAL VELOCITY": "VELOCIDADE ORBITAL",
|
||||
"SUB-SATELLITE POINT": "PONTO SUBSATÉLITE",
|
||||
"NORTH UP": "NORTE PARA CIMA",
|
||||
"CURRENTLY OVER": "SOBREVOANDO AGORA",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · perto de {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "ÉPOCA {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Mostrar o conjunto de elementos de duas linhas",
|
||||
"Hide the two-line element set": "Ocultar o conjunto de elementos de duas linhas",
|
||||
"Two-line element set": "Conjunto de elementos de duas linhas",
|
||||
"Return the view to nadir": "Voltar a vista para o nadir",
|
||||
"Return to nadir (Home or 0)": "Voltar ao nadir (Home ou 0)",
|
||||
"NADIR": "AO NADIR",
|
||||
"Selected Space Mission": "Missão espacial selecionada",
|
||||
"SELECTED SPACE MISSION": "MISSÃO ESPACIAL SELECIONADA",
|
||||
"Show all missions": "Mostrar todas as missões",
|
||||
@@ -1754,6 +1788,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · PROA {heading}°",
|
||||
"{label} · NO HEADING": "{label} · SEM PROA",
|
||||
"{label} · NADIR VIEW": "{label} · VISTA AO NADIR",
|
||||
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
|
||||
"No selected data layers": "Nenhuma camada de dados selecionada",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1814,6 +1849,7 @@
|
||||
"Military Flights": "Voos militares",
|
||||
"Earthquakes (24h)": "Terremotos (24 h)",
|
||||
"Satellites": "Satélites",
|
||||
"Place names": "Nomes de lugares",
|
||||
"Space Missions (30d)": "Missões espaciais (30 d)",
|
||||
"Street Traffic": "Trânsito urbano",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2550,6 +2586,12 @@
|
||||
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
|
||||
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
|
||||
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
|
||||
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
|
||||
},
|
||||
"degraded": "degradado",
|
||||
"stale": "desatualizado",
|
||||
"partial": "parcial",
|
||||
@@ -2706,6 +2748,7 @@
|
||||
"HERE": "AQUI",
|
||||
"Aircraft cockpit view": "Vista da cabine da aeronave",
|
||||
"Ship bridge view": "Vista do passadiço da embarcação",
|
||||
"Satellite orbital view": "Vista orbital do satélite",
|
||||
"LOOK STBD {bearing}°": "VISTA BORESTE {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA BOMBORDO {bearing}°",
|
||||
"LOOK ASTERN": "VISTA À RÉ",
|
||||
@@ -2801,16 +2844,20 @@
|
||||
"VOLUME": "VOLUME",
|
||||
"Cockpit Radio volume": "Volume do rádio da cabine",
|
||||
"ESC EXIT": "ESC SAIR",
|
||||
"C TOGGLE": "C ALTERNAR",
|
||||
"ARROWS LOOK": "SETAS OLHAR",
|
||||
"HOME RESET": "HOME REDEFINIR",
|
||||
"+/− ZOOM": "+/− APROXIMAR/AFASTAR",
|
||||
"ARROWS TILT": "SETAS INCLINAR",
|
||||
"HOME NADIR": "HOME AO NADIR",
|
||||
"View switcher": "Alternador de vista",
|
||||
"Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo",
|
||||
"RESET": "REDEFINIR",
|
||||
"Exit cockpit view": "Sair da vista da cabine",
|
||||
"Exit bridge view": "Sair da vista do passadiço",
|
||||
"Exit orbital view": "Sair da vista orbital",
|
||||
"EXIT COCKPIT": "SAIR DA CABINE",
|
||||
"EXIT BRIDGE": "SAIR DO PASSADIÇO",
|
||||
"EXIT ORBIT": "SAIR DA ÓRBITA",
|
||||
"Navigation, voice, and visual preset controls": "Controles de navegação, voz e predefinições visuais",
|
||||
"Expand Visual Presets": "Expandir predefinições visuais",
|
||||
"Pin visual presets": "Fixar predefinições visuais",
|
||||
@@ -3327,7 +3374,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAIXA DO SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Aproximar até a faixa do sensor de imagem",
|
||||
"<1 H OLD": "HÁ <1 H",
|
||||
"{value} H OLD": "HÁ {value} H",
|
||||
"{value} D OLD": "HÁ {value} D",
|
||||
"FWD": "FRENTE",
|
||||
"AFT": "TRÁS",
|
||||
"RIGHT": "DIREITA",
|
||||
"LEFT": "ESQUERDA",
|
||||
"TRACK UP": "TRAJETÓRIA PARA CIMA",
|
||||
"COURSE HOLD · AIS": "TRAVA DE RUMO · AIS",
|
||||
"Zoom in (+)": "Aproximar (+)",
|
||||
"Zoom out (−)": "Afastar (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR",
|
||||
"World time": "Hora do mundo",
|
||||
"Back to live": "Voltar ao vivo",
|
||||
"Live": "Ao vivo",
|
||||
@@ -3336,5 +3398,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -187,6 +187,40 @@
|
||||
"Select {mission}": "Выбрать {mission}",
|
||||
"{value} KM": "{value} КМ",
|
||||
"{value} KM/S": "{value} КМ/С",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ",
|
||||
"Release the camera from {name}": "Освободить камеру от {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК",
|
||||
"Return to {name}": "Вернуться к {name}",
|
||||
"SATELLITE": "СПУТНИК",
|
||||
"Ride {name}": "Перейти на {name}",
|
||||
"Unnamed site": "Безымянный объект",
|
||||
"OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Удерживать камеру на {name}",
|
||||
"ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×",
|
||||
"SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ",
|
||||
"SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ",
|
||||
"Orbital view": "Орбитальный вид",
|
||||
"ORBITAL VIEW · NADIR": "ОРБИТАЛЬНЫЙ ВИД · НАДИР",
|
||||
"ORBITAL VELOCITY": "ОРБИТАЛЬНАЯ СКОРОСТЬ",
|
||||
"SUB-SATELLITE POINT": "ПОДСПУТНИКОВАЯ ТОЧКА",
|
||||
"NORTH UP": "СЕВЕР ВВЕРХУ",
|
||||
"CURRENTLY OVER": "СЕЙЧАС НАД",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · поблизости: {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "ЭПОХА {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Показать двухстрочный набор элементов",
|
||||
"Hide the two-line element set": "Скрыть двухстрочный набор элементов",
|
||||
"Two-line element set": "Двухстрочный набор элементов",
|
||||
"Return the view to nadir": "Вернуть вид в надир",
|
||||
"Return to nadir (Home or 0)": "Вернуться в надир (Home или 0)",
|
||||
"NADIR": "В НАДИР",
|
||||
"SELECTED SPACE MISSION": "ВЫБРАННАЯ КОСМИЧЕСКАЯ МИССИЯ",
|
||||
"Show all missions": "Показать все миссии",
|
||||
"Deselect mission": "Снять выбор миссии",
|
||||
@@ -465,6 +499,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · NO HEADING": "{label} · НЕТ КУРСА",
|
||||
"{label} · NADIR VIEW": "{label} · ВИД В НАДИР",
|
||||
"Restore real aircraft": "Вернуть реальный самолёт",
|
||||
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
|
||||
"No selected data layers": "Нет выбранных слоёв данных",
|
||||
@@ -502,6 +537,7 @@
|
||||
"Military Flights": "Военные рейсы",
|
||||
"Earthquakes (24h)": "Землетрясения (24 ч)",
|
||||
"Satellites": "Спутники",
|
||||
"Place names": "Названия мест",
|
||||
"Space Missions (30d)": "Космические миссии (30 дн.)",
|
||||
"Street Traffic": "Дорожный трафик",
|
||||
"CCTV": "CCTV",
|
||||
@@ -646,6 +682,7 @@
|
||||
"Exit cockpit and return to full globe view": "Выйти из кабины и вернуться к виду всего глобуса",
|
||||
"Exit cockpit view": "Выйти из кабины",
|
||||
"Exit bridge view": "Выйти с мостика",
|
||||
"Exit orbital view": "Выйти из орбитального вида",
|
||||
"Estimated destination direction": "Расчётное направление на пункт назначения",
|
||||
"BRIDGE WINDOW · FWD": "ОКНО МОСТИКА · ПО НОСУ",
|
||||
"OPTICAL PLANE · 01": "ОПТИЧЕСКАЯ ПЛОСКОСТЬ · 01",
|
||||
@@ -667,6 +704,7 @@
|
||||
"View switcher": "Переключатель вида",
|
||||
"Aircraft cockpit view": "Вид кабины самолёта",
|
||||
"Ship bridge view": "Вид с ходового мостика",
|
||||
"Satellite orbital view": "Орбитальный вид со спутника",
|
||||
"Estimated flight plan": "Расчётный план полёта",
|
||||
"Reported voyage": "Заявленный рейс",
|
||||
"Live signals": "Сигналы в реальном времени",
|
||||
@@ -708,6 +746,7 @@
|
||||
"RESET": "СБРОС",
|
||||
"EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ",
|
||||
"EXIT BRIDGE": "ВЫЙТИ С МОСТИКА",
|
||||
"EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ",
|
||||
"CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ",
|
||||
"RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ",
|
||||
"{count} CONTACTS": {
|
||||
@@ -1670,9 +1709,11 @@
|
||||
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS ВРЕМЕННО НЕДОСТУПЕН",
|
||||
"Weather data by Open-Meteo.com": "Данные о погоде: Open-Meteo.com",
|
||||
"ESC EXIT": "ESC ВЫХОД",
|
||||
"C TOGGLE": "C ПЕРЕКЛ.",
|
||||
"ARROWS LOOK": "СТРЕЛКИ ОБЗОР",
|
||||
"HOME RESET": "HOME СБРОС",
|
||||
"+/− ZOOM": "+/− МАСШТАБ",
|
||||
"ARROWS TILT": "СТРЕЛКИ НАКЛОН",
|
||||
"HOME NADIR": "HOME НАДИР",
|
||||
"SIG": "СИГН.",
|
||||
"NEWS": "НОВОСТИ",
|
||||
"LOCAL": "МЕСТН.",
|
||||
@@ -2938,6 +2979,13 @@
|
||||
"many": "Здесь пока нет потока данных · доступно {n} регионов",
|
||||
"other": "Здесь пока нет потока данных · доступно {n} региона"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
|
||||
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
|
||||
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
|
||||
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
|
||||
},
|
||||
"Wind speed field unavailable": "Поле скорости ветра недоступно",
|
||||
"Decoder feeds": "Потоки декодера",
|
||||
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
|
||||
@@ -3403,7 +3451,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора",
|
||||
"<1 H OLD": "<1 Ч НАЗАД",
|
||||
"{value} H OLD": "{value} Ч НАЗАД",
|
||||
"{value} D OLD": "{value} Д НАЗАД",
|
||||
"FWD": "ВПЕРЁД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ВПРАВО",
|
||||
"LEFT": "ВЛЕВО",
|
||||
"TRACK UP": "ТРАССА ВВЕРХУ",
|
||||
"COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS",
|
||||
"Zoom in (+)": "Приблизить (+)",
|
||||
"Zoom out (−)": "Отдалить (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Live": "Сейчас",
|
||||
@@ -3412,5 +3475,6 @@
|
||||
"1 wk/s": "1 нед/с",
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с"
|
||||
"1 min/s": "1 мин/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
}
|
||||
@@ -839,6 +839,40 @@
|
||||
"Select {mission}": "Вибрати {mission}",
|
||||
"{value} KM": "{value} КМ",
|
||||
"{value} KM/S": "{value} КМ/С",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ",
|
||||
"Release the camera from {name}": "Звільнити камеру від {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК",
|
||||
"Return to {name}": "Повернутися до {name}",
|
||||
"SATELLITE": "СУПУТНИК",
|
||||
"Ride {name}": "Перейти на {name}",
|
||||
"Unnamed site": "Безіменний об'єкт",
|
||||
"OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Утримувати камеру на {name}",
|
||||
"ZOOM {zoom}×": "НАБЛИЖЕННЯ {zoom}×",
|
||||
"SWATH {value} KM": "СМУГА ОГЛЯДУ {value} КМ",
|
||||
"SWATH ≈ {value} KM": "СМУГА ОГЛЯДУ ≈ {value} КМ",
|
||||
"Orbital view": "Орбітальний вигляд",
|
||||
"ORBITAL VIEW · NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · НАДИР",
|
||||
"ORBITAL VELOCITY": "ОРБІТАЛЬНА ШВИДКІСТЬ",
|
||||
"SUB-SATELLITE POINT": "ПІДСУПУТНИКОВА ТОЧКА",
|
||||
"NORTH UP": "ПІВНІЧ УГОРІ",
|
||||
"CURRENTLY OVER": "ЗАРАЗ НАД",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · поблизу: {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "ЕПОХА {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "Показати дворядковий набір елементів",
|
||||
"Hide the two-line element set": "Сховати дворядковий набір елементів",
|
||||
"Two-line element set": "Дворядковий набір елементів",
|
||||
"Return the view to nadir": "Повернути вигляд у надир",
|
||||
"Return to nadir (Home or 0)": "Повернутися в надир (Home або 0)",
|
||||
"NADIR": "У НАДИР",
|
||||
"Selected Space Mission": "Вибрана космічна місія",
|
||||
"SELECTED SPACE MISSION": "ВИБРАНА КОСМІЧНА МІСІЯ",
|
||||
"Show all missions": "Показати всі місії",
|
||||
@@ -1779,6 +1813,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · NO HEADING": "{label} · НЕМАЄ КУРСУ",
|
||||
"{label} · NADIR VIEW": "{label} · ВИГЛЯД У НАДИР",
|
||||
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
|
||||
"No selected data layers": "Шари даних не вибрано",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1844,6 +1879,7 @@
|
||||
"Military Flights": "Військові рейси",
|
||||
"Earthquakes (24h)": "Землетруси (24 год)",
|
||||
"Satellites": "Супутники",
|
||||
"Place names": "Назви місць",
|
||||
"Space Missions (30d)": "Космічні місії (30 дн)",
|
||||
"Street Traffic": "Дорожній рух",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2612,6 +2648,13 @@
|
||||
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
|
||||
"other": "Тут поки немає потоку даних · доступно {n} регіону"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
|
||||
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
|
||||
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
|
||||
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
|
||||
},
|
||||
"degraded": "з перебоями",
|
||||
"stale": "застарів",
|
||||
"partial": "частково",
|
||||
@@ -2774,6 +2817,7 @@
|
||||
"HERE": "ТУТ",
|
||||
"Aircraft cockpit view": "Вигляд кабіни повітряного судна",
|
||||
"Ship bridge view": "Вигляд з ходового містка судна",
|
||||
"Satellite orbital view": "Орбітальний вигляд із супутника",
|
||||
"LOOK STBD {bearing}°": "ПОГЛЯД ПР. БОРТ {bearing}°",
|
||||
"LOOK PORT {bearing}°": "ПОГЛЯД ЛІВ. БОРТ {bearing}°",
|
||||
"LOOK ASTERN": "ПОГЛЯД ЗА КОРМУ",
|
||||
@@ -2870,16 +2914,20 @@
|
||||
"VOLUME": "ГУЧНІСТЬ",
|
||||
"Cockpit Radio volume": "Гучність радіо кабіни",
|
||||
"ESC EXIT": "ESC ВИХІД",
|
||||
"C TOGGLE": "C ПЕРЕМК.",
|
||||
"ARROWS LOOK": "СТРІЛКИ ОГЛЯД",
|
||||
"HOME RESET": "HOME СКИДАННЯ",
|
||||
"+/− ZOOM": "+/− МАСШТАБ",
|
||||
"ARROWS TILT": "СТРІЛКИ НАХИЛ",
|
||||
"HOME NADIR": "HOME НАДИР",
|
||||
"View switcher": "Перемикач вигляду",
|
||||
"Exit cockpit and return to full globe view": "Вийти з кабіни та повернутися до вигляду всього глобуса",
|
||||
"RESET": "СКИНУТИ",
|
||||
"Exit cockpit view": "Вийти з вигляду кабіни",
|
||||
"Exit bridge view": "Вийти з вигляду містка",
|
||||
"Exit orbital view": "Вийти з орбітального вигляду",
|
||||
"EXIT COCKPIT": "ВИЙТИ З КАБІНИ",
|
||||
"EXIT BRIDGE": "ВИЙТИ З МІСТКА",
|
||||
"EXIT ORBIT": "ВИЙТИ З ОРБІТИ",
|
||||
"Navigation, voice, and visual preset controls": "Керування навігацією, голосом і візуальними пресетами",
|
||||
"Expand Visual Presets": "Розгорнути візуальні пресети",
|
||||
"Pin visual presets": "Закріпити візуальні пресети",
|
||||
@@ -3403,7 +3451,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "СМУГА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Наблизити до смуги знімального сенсора",
|
||||
"<1 H OLD": "<1 ГОД ТОМУ",
|
||||
"{value} H OLD": "{value} ГОД ТОМУ",
|
||||
"{value} D OLD": "{value} ДН ТОМУ",
|
||||
"FWD": "ВПЕРЕД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ПРАВОРУЧ",
|
||||
"LEFT": "ЛІВОРУЧ",
|
||||
"TRACK UP": "ТРАСА УГОРІ",
|
||||
"COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS",
|
||||
"Zoom in (+)": "Наблизити (+)",
|
||||
"Zoom out (−)": "Віддалити (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Live": "Зараз",
|
||||
@@ -3412,5 +3475,6 @@
|
||||
"1 wk/s": "1 тиж/с",
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с"
|
||||
"1 min/s": "1 хв/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
}
|
||||
@@ -778,6 +778,40 @@
|
||||
"Select {mission}": "选择 {mission}",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待机",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒",
|
||||
"Release the camera from {name}": "让镜头离开{name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一颗卫星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"SATELLITE": "卫星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地点",
|
||||
"OFF-NADIR {angle}°": "偏离天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "让镜头持续对准{name}",
|
||||
"ZOOM {zoom}×": "变焦 {zoom}×",
|
||||
"SWATH {value} KM": "幅宽 {value} km",
|
||||
"SWATH ≈ {value} KM": "幅宽 ≈ {value} km",
|
||||
"Orbital view": "轨道视图",
|
||||
"ORBITAL VIEW · NADIR": "轨道视图 · 天底",
|
||||
"ORBITAL VELOCITY": "轨道速度",
|
||||
"SUB-SATELLITE POINT": "星下点",
|
||||
"NORTH UP": "北向上",
|
||||
"CURRENTLY OVER": "当前上空",
|
||||
"{division}, {country}": "{division},{country}",
|
||||
"{region} · near {place}": "{region} · {place}附近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "历元 {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "显示两行轨道根数",
|
||||
"Hide the two-line element set": "隐藏两行轨道根数",
|
||||
"Two-line element set": "两行轨道根数",
|
||||
"Return the view to nadir": "将视图恢复到天底",
|
||||
"Return to nadir (Home or 0)": "返回天底(Home 或 0)",
|
||||
"NADIR": "回到天底",
|
||||
"Selected Space Mission": "已选太空任务",
|
||||
"SELECTED SPACE MISSION": "已选太空任务",
|
||||
"Show all missions": "显示所有任务",
|
||||
@@ -1678,6 +1712,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · 船首向 {heading}°",
|
||||
"{label} · NO HEADING": "{label} · 无船首向",
|
||||
"{label} · NADIR VIEW": "{label} · 天底视图",
|
||||
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
|
||||
"No selected data layers": "没有已选数据图层",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1729,6 +1764,7 @@
|
||||
"Military Flights": "军用航班",
|
||||
"Earthquakes (24h)": "地震(24 小时)",
|
||||
"Satellites": "卫星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "太空任务(30 天)",
|
||||
"Street Traffic": "街道交通",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2319,6 +2355,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
|
||||
},
|
||||
"degraded": "降级",
|
||||
"stale": "过期",
|
||||
"partial": "部分可用",
|
||||
@@ -2461,6 +2501,7 @@
|
||||
"HERE": "此处",
|
||||
"Aircraft cockpit view": "飞机座舱视图",
|
||||
"Ship bridge view": "船舶驾驶台视图",
|
||||
"Satellite orbital view": "卫星轨道视图",
|
||||
"LOOK STBD {bearing}°": "视线 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "视线 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "视线 船尾",
|
||||
@@ -2554,16 +2595,20 @@
|
||||
"VOLUME": "音量",
|
||||
"Cockpit Radio volume": "座舱电台音量",
|
||||
"ESC EXIT": "ESC 退出",
|
||||
"C TOGGLE": "C 切换",
|
||||
"ARROWS LOOK": "方向键 环视",
|
||||
"HOME RESET": "HOME 复位",
|
||||
"+/− ZOOM": "+/− 缩放",
|
||||
"ARROWS TILT": "方向键 倾斜",
|
||||
"HOME NADIR": "HOME 天底",
|
||||
"View switcher": "视图切换",
|
||||
"Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图",
|
||||
"RESET": "重置",
|
||||
"Exit cockpit view": "退出座舱视图",
|
||||
"Exit bridge view": "退出驾驶台视图",
|
||||
"Exit orbital view": "退出轨道视图",
|
||||
"EXIT COCKPIT": "退出座舱",
|
||||
"EXIT BRIDGE": "退出驾驶台",
|
||||
"EXIT ORBIT": "退出轨道视图",
|
||||
"Navigation, voice, and visual preset controls": "导航、语音和视觉预设控件",
|
||||
"Expand Visual Presets": "展开视觉预设",
|
||||
"Pin visual presets": "固定视觉预设",
|
||||
@@ -3175,7 +3220,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "传感器幅宽 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km",
|
||||
"SENSOR": "传感器",
|
||||
"Zoom to the imaging sensor swath": "缩放到成像传感器幅宽",
|
||||
"<1 H OLD": "不到1小时前",
|
||||
"{value} H OLD": "{value}小时前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "后方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航迹向上",
|
||||
"COURSE HOLD · AIS": "对地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "缩小 (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
"Live": "实时",
|
||||
@@ -3184,5 +3244,6 @@
|
||||
"1 wk/s": "1 周/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒"
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
}
|
||||
@@ -773,6 +773,40 @@
|
||||
"Select {mission}": "選擇 {mission}",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地點待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒",
|
||||
"Release the camera from {name}": "讓鏡頭離開{name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一顆衛星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地點",
|
||||
"OFF-NADIR {angle}°": "偏離天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "讓鏡頭持續對準{name}",
|
||||
"ZOOM {zoom}×": "變焦 {zoom}×",
|
||||
"SWATH {value} KM": "幅寬 {value} km",
|
||||
"SWATH ≈ {value} KM": "幅寬 ≈ {value} km",
|
||||
"Orbital view": "軌道視圖",
|
||||
"ORBITAL VIEW · NADIR": "軌道視圖 · 天底",
|
||||
"ORBITAL VELOCITY": "軌道速度",
|
||||
"SUB-SATELLITE POINT": "星下點",
|
||||
"NORTH UP": "北向上",
|
||||
"CURRENTLY OVER": "目前上空",
|
||||
"{division}, {country}": "{division},{country}",
|
||||
"{region} · near {place}": "{region} · {place}附近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "曆元 {time}",
|
||||
"TLE": "TLE",
|
||||
"Show the two-line element set": "顯示兩行軌道根數",
|
||||
"Hide the two-line element set": "隱藏兩行軌道根數",
|
||||
"Two-line element set": "兩行軌道根數",
|
||||
"Return the view to nadir": "將視圖恢復到天底",
|
||||
"Return to nadir (Home or 0)": "返回天底(Home 或 0)",
|
||||
"NADIR": "回到天底",
|
||||
"Selected Space Mission": "已選太空任務",
|
||||
"SELECTED SPACE MISSION": "已選太空任務",
|
||||
"Show all missions": "顯示所有任務",
|
||||
@@ -1668,6 +1702,7 @@
|
||||
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · 船首向 {heading}°",
|
||||
"{label} · NO HEADING": "{label} · 無船首向",
|
||||
"{label} · NADIR VIEW": "{label} · 天底視圖",
|
||||
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
|
||||
"No selected data layers": "沒有已選數據圖層",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -1719,6 +1754,7 @@
|
||||
"Military Flights": "軍用航班",
|
||||
"Earthquakes (24h)": "地震(24 小時)",
|
||||
"Satellites": "衛星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "太空任務(30 天)",
|
||||
"Street Traffic": "街道交通",
|
||||
"CCTV": "CCTV",
|
||||
@@ -2307,6 +2343,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
|
||||
},
|
||||
"degraded": "降級",
|
||||
"stale": "過期",
|
||||
"partial": "部分可用",
|
||||
@@ -2449,6 +2489,7 @@
|
||||
"HERE": "此處",
|
||||
"Aircraft cockpit view": "飛機座艙視圖",
|
||||
"Ship bridge view": "船舶駕駛台視圖",
|
||||
"Satellite orbital view": "衛星軌道視圖",
|
||||
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "視線 船尾",
|
||||
@@ -2542,16 +2583,20 @@
|
||||
"VOLUME": "音量",
|
||||
"Cockpit Radio volume": "座艙電臺音量",
|
||||
"ESC EXIT": "ESC 退出",
|
||||
"C TOGGLE": "C 切換",
|
||||
"ARROWS LOOK": "方向鍵 環視",
|
||||
"HOME RESET": "HOME 復位",
|
||||
"+/− ZOOM": "+/− 縮放",
|
||||
"ARROWS TILT": "方向鍵 傾斜",
|
||||
"HOME NADIR": "HOME 天底",
|
||||
"View switcher": "視圖切換",
|
||||
"Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖",
|
||||
"RESET": "重置",
|
||||
"Exit cockpit view": "退出座艙視圖",
|
||||
"Exit bridge view": "退出駕駛台視圖",
|
||||
"Exit orbital view": "退出軌道視圖",
|
||||
"EXIT COCKPIT": "退出座艙",
|
||||
"EXIT BRIDGE": "退出駕駛台",
|
||||
"EXIT ORBIT": "退出軌道視圖",
|
||||
"Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件",
|
||||
"Expand Visual Presets": "展開視覺預設",
|
||||
"Pin visual presets": "固定視覺預設",
|
||||
@@ -3175,7 +3220,22 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "感測器幅寬 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km",
|
||||
"SENSOR": "感測器",
|
||||
"Zoom to the imaging sensor swath": "縮放到成像感測器幅寬",
|
||||
"<1 H OLD": "不到1小時前",
|
||||
"{value} H OLD": "{value}小時前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航跡向上",
|
||||
"COURSE HOLD · AIS": "對地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "縮小 (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時",
|
||||
@@ -3184,5 +3244,6 @@
|
||||
"1 wk/s": "1 週/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒"
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
"HDG {value}°",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
@@ -32,12 +33,14 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
@@ -118,6 +121,7 @@
|
||||
"NEWS",
|
||||
"NNW",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"NW",
|
||||
@@ -152,6 +156,7 @@
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
@@ -179,6 +184,7 @@
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -194,6 +200,7 @@
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"acartia.io ↗",
|
||||
"ZOOM {zoom}×",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
@@ -210,7 +217,9 @@
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -288,6 +297,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
@@ -310,6 +320,7 @@
|
||||
"SAR",
|
||||
"SE",
|
||||
"SECTOR {lat} {lon}",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
@@ -323,6 +334,7 @@
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TELECOM",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -330,6 +342,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
@@ -349,7 +362,9 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -462,6 +477,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"New York",
|
||||
@@ -519,6 +535,7 @@
|
||||
"Suggestions",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"TYPE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -530,6 +547,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
@@ -554,7 +572,9 @@
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
@@ -565,7 +585,9 @@
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
@@ -648,6 +670,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
@@ -694,6 +717,7 @@
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
"TLE",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -711,6 +735,7 @@
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
@@ -732,7 +757,9 @@
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
"{count} scenes",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
|
||||
"{source} HTTP {status}",
|
||||
@@ -751,7 +778,8 @@
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"SATELLITE"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -780,6 +808,7 @@
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"MSG/S",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"POS: {time}Z",
|
||||
"REC",
|
||||
@@ -789,6 +818,7 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
@@ -802,6 +832,7 @@
|
||||
"kt",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
@@ -882,6 +913,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
@@ -901,6 +933,7 @@
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
@@ -917,6 +950,7 @@
|
||||
"TAGS · {tags}",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -927,6 +961,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"ZOOM {zoom}×",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
@@ -941,7 +976,9 @@
|
||||
"mph",
|
||||
"nominal",
|
||||
"view band|GLOBAL",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
@@ -969,6 +1006,7 @@
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NAV",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"R&B",
|
||||
"REC",
|
||||
@@ -977,12 +1015,15 @@
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}"
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
@@ -994,17 +1035,21 @@
|
||||
"FLIR",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}"
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -1030,6 +1075,7 @@
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
@@ -1039,6 +1085,7 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -1056,6 +1103,7 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
@@ -1097,6 +1145,7 @@
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
@@ -1106,6 +1155,7 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -1123,6 +1173,7 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { normalizeDisplayText } from '../../data/displayText.js';
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
const t = normalizeDisplayText(value);
|
||||
return t || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,28 @@ export function createPanel({ state: layerState, parts }) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The tracked airliner's photo with its credit, or null. The subject
|
||||
* refresh resolves it; only the subject it was resolved for shows it. */
|
||||
function presentPhoto(subject) {
|
||||
const held = layerState.subjectPhoto;
|
||||
if (
|
||||
!held ||
|
||||
held.layerId !== subject.layerId ||
|
||||
String(held.id) !== String(subject.id)
|
||||
)
|
||||
return null;
|
||||
const { photo } = held;
|
||||
return {
|
||||
src: photo.src,
|
||||
width: photo.width ?? null,
|
||||
height: photo.height ?? null,
|
||||
link: photo.link,
|
||||
credit: t('Photo © {photographer}', {
|
||||
photographer: photo.photographer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
ensurePanel();
|
||||
@@ -191,6 +213,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
kind: 'results',
|
||||
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||
subjectNames: presentNames(subject.memberNames),
|
||||
photo: presentPhoto(subject),
|
||||
navigation: presentNavigation(),
|
||||
rows: cohorts.map(presentRow),
|
||||
note: t(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createSubject } from './subject.js';
|
||||
|
||||
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
|
||||
const cohort = (id, source, sourceStatus) => ({
|
||||
@@ -36,3 +37,119 @@ test('a Contacts row names its provider as the Layers panel does, with its feed
|
||||
);
|
||||
state.offPanelLocale?.();
|
||||
});
|
||||
|
||||
const PHOTO = Object.freeze({
|
||||
src: 'https://t.plnspttrs.net/1/abc123_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/n123ab',
|
||||
});
|
||||
|
||||
/** The published Contacts view for one subject and the refresh's photo. */
|
||||
function photoView(subject, subjectPhoto) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
results: { subject, cohorts: [] },
|
||||
subjectPhoto,
|
||||
cohortPages: new Map(),
|
||||
panelListeners: new Set(),
|
||||
panelSignature: '',
|
||||
navigationIndex: 0,
|
||||
};
|
||||
const parts = {
|
||||
navigation: { canNavigateNext: () => false },
|
||||
rendering: { hideDirectionOverlay() {} },
|
||||
};
|
||||
createPanel({ state, parts }).renderResults();
|
||||
state.offPanelLocale?.();
|
||||
return state.panelView;
|
||||
}
|
||||
|
||||
const FLIGHT = Object.freeze({
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
label: 'N123AB',
|
||||
});
|
||||
|
||||
test('the Contacts view carries the tracked flight’s photo with its credit', () => {
|
||||
const view = photoView(FLIGHT, {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
photo: PHOTO,
|
||||
});
|
||||
assert.deepEqual(view.photo, {
|
||||
src: PHOTO.src,
|
||||
width: 280,
|
||||
height: 186,
|
||||
link: PHOTO.link,
|
||||
credit: 'Photo © Jane Doe',
|
||||
});
|
||||
});
|
||||
|
||||
test('the Contacts view has no photo without one, or with another subject’s', () => {
|
||||
assert.equal(photoView(FLIGHT, null).photo, null);
|
||||
assert.equal(
|
||||
photoView(FLIGHT, { layerId: 'flights', id: 'def456', photo: PHOTO }).photo,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
photoView(
|
||||
{ layerId: 'ais-live-vessels', id: 'abc123', label: 'VESSEL' },
|
||||
{ layerId: 'flights', id: 'abc123', photo: PHOTO },
|
||||
).photo,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => {
|
||||
const tracked = {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
label: 'N123AB',
|
||||
photo: PHOTO,
|
||||
};
|
||||
let current = tracked;
|
||||
let lookups = 0;
|
||||
const flights = {
|
||||
getTrackedSubject: () => {
|
||||
lookups += 1;
|
||||
return current;
|
||||
},
|
||||
};
|
||||
const military = {
|
||||
getTrackedSubject: () => ({ ...tracked, layerId: 'military' }),
|
||||
};
|
||||
const { resolveSubjectPhoto } = createSubject({
|
||||
state: {},
|
||||
services: { flights, military, vessels: {}, installations: {} },
|
||||
parts: {
|
||||
navigation: {
|
||||
isFlightLayer: (id) => id === 'flights' || id === 'military',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }),
|
||||
PHOTO,
|
||||
);
|
||||
assert.equal(lookups, 1);
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'def456' }), null);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'military-installations', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'military', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveSubjectPhoto(null), null);
|
||||
current = null;
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
|
||||
current = { ...tracked, photo: null };
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
|
||||
});
|
||||
@@ -29,6 +29,8 @@ export function createState({ services }) {
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
/** The tracked flight's photo with the subject it belongs to, or null. */
|
||||
subjectPhoto: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
/** Whether layer views replace the panel's initial markup yet. */
|
||||
|
||||
@@ -378,19 +378,48 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
*
|
||||
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
|
||||
* @param {{layerId: string, id: string, label: string}} subject Current subject.
|
||||
* @param {?object} [tracked] The subject's tracked descriptor, when the
|
||||
* caller already has it (see trackedSubjectOf).
|
||||
* @returns {string} The current label, or the cached one when the owning layer
|
||||
* has no tracked subject (e.g. a cross-layer handoff is in flight).
|
||||
*/
|
||||
|
||||
function resolveSubjectLabel(subject) {
|
||||
if (!parts.navigation.isFlightLayer(subject?.layerId))
|
||||
return subject?.label;
|
||||
function resolveSubjectLabel(subject, tracked = trackedSubjectOf(subject)) {
|
||||
return tracked?.label || subject?.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning flight layer's tracked-subject descriptor when it describes
|
||||
* this subject, else null. Built once per refresh and shared by the label
|
||||
* and the photo.
|
||||
* @param {{layerId: string, id: string}|null} subject Current subject.
|
||||
* @returns {?{id: string, label: string, photo?: ?object}}
|
||||
*/
|
||||
function trackedSubjectOf(subject) {
|
||||
if (!parts.navigation.isFlightLayer(subject?.layerId)) return null;
|
||||
const layer =
|
||||
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
||||
const tracked = layer.getTrackedSubject?.();
|
||||
if (tracked?.label && String(tracked.id) === String(subject.id))
|
||||
return tracked.label;
|
||||
return subject.label;
|
||||
return tracked && String(tracked.id) === String(subject.id)
|
||||
? tracked
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracked airliner's Planespotters photo, for the Contacts panel.
|
||||
*
|
||||
* Only the flights layer looks photos up, and only for the plane it tracks;
|
||||
* a vessel, an installation, a military aircraft or an untracked flight has
|
||||
* none.
|
||||
* @param {{layerId: string, id: string}|null} subject Current subject.
|
||||
* @param {?object} [tracked] The subject's tracked descriptor, when the
|
||||
* caller already has it.
|
||||
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}}
|
||||
*/
|
||||
function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) {
|
||||
return subject?.layerId === 'flights' && tracked?.photo
|
||||
? tracked.photo
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Refresh proximity counts/distances against the subject's current live position. */
|
||||
@@ -417,8 +446,21 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
|
||||
else if (presence === SUBJECT_PRESENCE.MISSING)
|
||||
layerState.subjectMissing = true;
|
||||
const nextLabel = resolveSubjectLabel(layerState.subject);
|
||||
const tracked = trackedSubjectOf(layerState.subject);
|
||||
const nextLabel = resolveSubjectLabel(layerState.subject, tracked);
|
||||
const labelChanged = nextLabel !== layerState.subject.label;
|
||||
// The panel reads the photo from here, with the subject it belongs to.
|
||||
const photo = resolveSubjectPhoto(layerState.subject, tracked);
|
||||
layerState.subjectPhoto = photo
|
||||
? {
|
||||
layerId: layerState.subject.layerId,
|
||||
id: layerState.subject.id,
|
||||
photo,
|
||||
}
|
||||
: null;
|
||||
// A photo arriving after selection is render-worthy on its own, as a label.
|
||||
const photoChanged =
|
||||
(photo?.src ?? null) !== (layerState.panelView?.photo?.src ?? null);
|
||||
layerState.subject = { ...layerState.subject, position, label: nextLabel };
|
||||
const movementM = layerState.lastEvaluatedPosition
|
||||
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
|
||||
@@ -439,7 +481,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
// kept the ICAO hex until unrelated movement, a source-revision bump, or
|
||||
// page rotation happened to repaint it. Gated on an ACTUAL change so the
|
||||
// common no-op refresh (every 750 ms) still costs no repaint.
|
||||
if (labelChanged) parts.panel.renderResults();
|
||||
if (labelChanged || photoChanged) parts.panel.renderResults();
|
||||
parts.rendering.scheduleDirectionOverlayUpdate(force);
|
||||
return;
|
||||
}
|
||||
@@ -502,6 +544,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
militaryInstallationsLayer.setContextAnchor?.(null);
|
||||
layerState.autoFocusRetryPending = false;
|
||||
layerState.subject = null;
|
||||
layerState.subjectPhoto = null;
|
||||
layerState.subjectMissing = false;
|
||||
layerState.results = null;
|
||||
layerState.navigationHistory = [];
|
||||
@@ -528,6 +571,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
resolveSubjectPosition,
|
||||
collectionSubjectPosition,
|
||||
resolveSubjectLabel,
|
||||
resolveSubjectPhoto,
|
||||
refreshSelectedSubject,
|
||||
subjectFromContext,
|
||||
markSubjectEvicted,
|
||||
|
||||
Vendored
+37
-4
@@ -1,5 +1,6 @@
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { STATUS_POLL_MS } from './policy.js';
|
||||
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -13,6 +14,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
updateInterval: STATUS_POLL_MS,
|
||||
|
||||
/**
|
||||
* Kept so camera moves can repaint the row before the next status poll.
|
||||
* @param {object} dataManager DataLayerManager instance.
|
||||
*/
|
||||
attachDataManager(dataManager) {
|
||||
layerState._dataManager = dataManager;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a sampled array of detectable station objects for HUD overlay rendering.
|
||||
* @param {Object} [options] - Sampling options (maxCount, seed).
|
||||
@@ -57,15 +66,39 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return current layer statistics for the UI status display.
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
|
||||
*/
|
||||
getStats() {
|
||||
// A city in range without points is loading even before its fetch
|
||||
// starts: the row repaints on entry, ahead of beginLoading().
|
||||
const loading =
|
||||
layerState._loading ||
|
||||
[...layerState._activeCityIds].some(
|
||||
(cityId) =>
|
||||
!layerState._cityRuntime.has(cityId) &&
|
||||
!layerState._failedCityIds.has(cityId),
|
||||
);
|
||||
const stats = {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
loading,
|
||||
};
|
||||
if (layerState._loading) {
|
||||
// No system in range: the camera is too high or no system is near. A
|
||||
// failed city also leaves range empty and reports its failure below.
|
||||
if (
|
||||
layerState._enabled &&
|
||||
!loading &&
|
||||
layerState._activeCityIds.size === 0 &&
|
||||
layerState._failedCityIds.size === 0
|
||||
) {
|
||||
// English: the analyst's empty note and the row's prompt, both shown
|
||||
// through translateStatusMessage (ui/statusText.js).
|
||||
const note = layerState._altitudeGateEnabled
|
||||
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
|
||||
return { ...stats, status: 'zoom-in', statusMessage: note };
|
||||
}
|
||||
if (loading) {
|
||||
stats.loadingLabel =
|
||||
layerState._activeCityIds.size > 0
|
||||
? t('syncing {count} city feeds...', {
|
||||
|
||||
@@ -169,6 +169,7 @@ export function createLifecycle({
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
}
|
||||
layerState._dataManager = null;
|
||||
layerState._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// Bike Share's Layers row. With no system in range it says why instead of
|
||||
// "never" and keeps no date for data it no longer draws. After the camera
|
||||
// settles, the last row the panel painted must match what the layer reports
|
||||
// now. Each case drives the real viewport,
|
||||
// ingestion, rendering and controls; the manager is a spy that records the
|
||||
// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
|
||||
|
||||
const BOSTON_ID = 'boston-bluebikes';
|
||||
const TORONTO_ID = 'toronto-bike-share';
|
||||
const BUFFALO_ID = 'buffalo-reddy';
|
||||
const LONDON = { lat: 51.5072, lon: -0.1276 };
|
||||
const BOSTON = { lat: 42.3601, lon: -71.0589 };
|
||||
const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 };
|
||||
const BETWEEN = { lat: 43.25, lon: -79.1 };
|
||||
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
|
||||
const LOW_M = 10_000;
|
||||
|
||||
globalThis.document ??= {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function makeSource() {
|
||||
const cityOf = (url) => {
|
||||
for (const [id, city] of CITY_BY_ID) {
|
||||
if (url === city.stationInformationUrl) return { id, kind: 'info' };
|
||||
if (url === city.stationStatusUrl) return { id, kind: 'status' };
|
||||
}
|
||||
throw new Error(`unexpected GBFS url ${url}`);
|
||||
};
|
||||
const source = {
|
||||
dead: new Set(),
|
||||
/** `${cityId}:${kind}` → deferred that gates the answer. */
|
||||
gates: new Map(),
|
||||
async getStations(url, { signal } = {}) {
|
||||
const { id, kind } = cityOf(url);
|
||||
const gate = source.gates.get(`${id}:${kind}`);
|
||||
if (gate) {
|
||||
await new Promise((resolve, reject) => {
|
||||
gate.promise.then(resolve);
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (source.dead.has(id)) throw new Error('GBFS HTTP 503');
|
||||
const city = CITY_BY_ID.get(id);
|
||||
if (kind === 'info') {
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
name: `${id} dock`,
|
||||
lat: city.centerLat,
|
||||
lon: city.centerLon,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
num_bikes_available: 3,
|
||||
num_docks_available: 7,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return source;
|
||||
}
|
||||
|
||||
/** The row as the panel draws it: state chip, count, meta text. */
|
||||
const rowText = (raw) => {
|
||||
const stats = {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
...raw,
|
||||
loading: raw.loading === true,
|
||||
};
|
||||
const meta = LayerPanel.prototype._buildMetaText({
|
||||
id: 'bikeshare',
|
||||
source: 'GBFS',
|
||||
enabled: true,
|
||||
lifecycleState: 'enabled',
|
||||
stats,
|
||||
});
|
||||
return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`;
|
||||
};
|
||||
|
||||
function makeLiveLayer() {
|
||||
const source = makeSource();
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.model = createModel(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.selection = {
|
||||
_installClickHandler() {},
|
||||
_clearSelection() {},
|
||||
_onKeyDown() {},
|
||||
};
|
||||
parts.queries = { collectDetectableStations: () => [] };
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const { methods: controls } = createControls(context);
|
||||
|
||||
const camera = { ...LONDON, height: LOW_M };
|
||||
const viewer = {
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return Cesium.Cartographic.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.height,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
changed: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
scene: { globe: {}, primitives: { add() {}, remove() {} } },
|
||||
};
|
||||
parts.lifecycle.methods.init(viewer);
|
||||
|
||||
const painted = [];
|
||||
controls.attachDataManager({
|
||||
refreshLayerStats() {
|
||||
painted.push(rowText(controls.getStats()));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
source,
|
||||
painted,
|
||||
lastPainted: () => painted.at(-1),
|
||||
stats: () => controls.getStats(),
|
||||
live: () => rowText(controls.getStats()),
|
||||
moveTo(place, height = LOW_M) {
|
||||
Object.assign(camera, place, { height });
|
||||
return parts.viewport.runProximityCheck();
|
||||
},
|
||||
async enable() {
|
||||
parts.lifecycle.methods.enable(viewer);
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 5; i += 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
|
||||
test('entering a city paints the loading row, not "never"', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
const info = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, info);
|
||||
await layer.enable();
|
||||
const before = layer.painted.length;
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
assert.ok(layer.painted.length > before, 'entering repainted');
|
||||
const atEntry = layer.lastPainted();
|
||||
const liveAfter = layer.live();
|
||||
info.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.match(liveAfter, /syncing 1 city feeds/);
|
||||
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
|
||||
assert.doesNotMatch(atEntry, /never/);
|
||||
});
|
||||
test('re-entering a city with cached station info paints loading', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
await layer.moveTo(LONDON);
|
||||
await settle();
|
||||
const status = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:status`, status);
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
const atEntry = layer.lastPainted();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const liveAfter = layer.live();
|
||||
status.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.match(liveAfter, /syncing 1 city feeds/);
|
||||
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
|
||||
assert.doesNotMatch(atEntry, /never/);
|
||||
});
|
||||
|
||||
test('leaving a city mid-load does not leave the row on LOADING', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, deferred());
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.equal(layer.state._loading, true);
|
||||
|
||||
await layer.moveTo(LONDON);
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.state._loading, false);
|
||||
assert.match(layer.live(), /No bike share here yet/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('a camera nudge during the first load still repaints the drawn stations', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
const status = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:status`, status);
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn');
|
||||
|
||||
await layer.moveTo(BOSTON_NUDGED);
|
||||
status.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.state._count, 1, 'one station on the map');
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('leaving a city whose load failed repaints the error away', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BOSTON_ID);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.match(layer.lastPainted(), /GBFS fetch error/i);
|
||||
|
||||
await layer.moveTo(LONDON);
|
||||
await settle();
|
||||
assert.match(layer.live(), /No bike share here yet/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('leaving the failed city of two repaints DEGRADED away', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO_ID);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BETWEEN);
|
||||
await settle();
|
||||
assert.match(layer.lastPainted(), /^degraded/);
|
||||
|
||||
await layer.moveTo(TORONTO_ONLY);
|
||||
await settle();
|
||||
assert.doesNotMatch(layer.live(), /^degraded/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('above the altitude gate the row says how low to fly', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(LONDON, 2_000_000);
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
|
||||
assert.equal(stats.error, undefined);
|
||||
});
|
||||
|
||||
test('a city with no system says so and counts the ones there are', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(
|
||||
stats.statusMessage,
|
||||
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
|
||||
);
|
||||
assert.equal(stats.count, 0);
|
||||
});
|
||||
|
||||
test('a city in range, loading, failed or a disabled layer gets no prompt', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
assert.equal(layer.stats().status, undefined, 'disabled');
|
||||
|
||||
const info = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, info);
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
assert.equal(layer.stats().status, undefined, 'loading');
|
||||
info.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.stats().status, undefined, 'loaded');
|
||||
|
||||
// Left mid-load: range is empty, but the aborted load still owns the row.
|
||||
const leaving = makeLiveLayer();
|
||||
leaving.source.gates.set(`${BOSTON_ID}:info`, deferred());
|
||||
await leaving.enable();
|
||||
const loading = leaving.moveTo(BOSTON);
|
||||
await settle();
|
||||
const left = leaving.moveTo(LONDON);
|
||||
assert.equal(leaving.state._activeCityIds.size, 0);
|
||||
assert.equal(leaving.stats().loading, true);
|
||||
assert.equal(leaving.stats().status, undefined, 'leaving mid-load');
|
||||
await Promise.all([loading, left]);
|
||||
|
||||
const failed = makeLiveLayer();
|
||||
failed.source.dead.add(BOSTON_ID);
|
||||
await failed.enable();
|
||||
await failed.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.equal(failed.stats().status, undefined, 'failed');
|
||||
assert.equal(failed.stats().error, 'GBFS fetch error');
|
||||
});
|
||||
|
||||
test('leaving every system behind drops the date of the data it drew', async () => {
|
||||
for (const height of [LOW_M, 2_000_000]) {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.ok(layer.stats().lastUpdate, 'Boston is dated');
|
||||
|
||||
await layer.moveTo(LONDON, height);
|
||||
assert.equal(layer.stats().lastUpdate, null, `at ${height} m`);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */
|
||||
|
||||
layerState._dataManager = null;
|
||||
|
||||
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
|
||||
|
||||
layerState._pointCollection = null;
|
||||
|
||||
@@ -116,6 +116,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
parts.rendering.removeCityPoints(cityId);
|
||||
}
|
||||
|
||||
/** Repaint the row now: it follows the camera, the manager tick is 60 s. */
|
||||
function refreshRow() {
|
||||
layerState._dataManager?.refreshLayerStats?.('bikeshare');
|
||||
}
|
||||
|
||||
/** Deactivate all currently active cities and clear the active set. */
|
||||
|
||||
function deactivateAllCities() {
|
||||
@@ -125,6 +130,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
// A failure belongs to a city in range; none are once everything is off.
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._count = 0;
|
||||
// Nothing is left on the map to date.
|
||||
layerState._lastUpdate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,9 +210,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
|
||||
const generation = ++layerState._proximityGeneration;
|
||||
const altitude = getCameraAltitude(layerState._viewer);
|
||||
const wasGateEnabled = layerState._altitudeGateEnabled;
|
||||
// Altitude gate: disable all cities when camera is too high
|
||||
if (!shouldActivateAtAltitude(altitude)) {
|
||||
deactivateAllCities();
|
||||
if (wasGateEnabled) refreshRow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,15 +229,25 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
// A failure never outlives its city's range.
|
||||
let failureCleared = false;
|
||||
for (const cityId of layerState._failedCityIds) {
|
||||
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
|
||||
if (!nextActive.has(cityId)) {
|
||||
layerState._failedCityIds.delete(cityId);
|
||||
failureCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
const changed =
|
||||
!wasGateEnabled ||
|
||||
failureCleared ||
|
||||
nextActive.size !== layerState._activeCityIds.size ||
|
||||
[...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId));
|
||||
layerState._activeCityIds = nextActive;
|
||||
if (layerState._activeCityIds.size === 0) {
|
||||
if (nextActive.size === 0) {
|
||||
layerState._count = 0;
|
||||
return;
|
||||
layerState._lastUpdate = null;
|
||||
}
|
||||
if (changed) refreshRow();
|
||||
|
||||
// Only activate cities that don't already have rendered points
|
||||
const toActivate = [];
|
||||
@@ -240,6 +259,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
await Promise.all(
|
||||
toActivate.map((cityId) => activateCity(cityId, generation)),
|
||||
);
|
||||
// Superseded loads repaint too: a newer check may have painted the row
|
||||
// while this one was still loading, or found nothing to load itself.
|
||||
if (layerState._enabled) refreshRow();
|
||||
}
|
||||
|
||||
/** Schedule a debounced proximity check after camera movement. */
|
||||
|
||||
@@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// sampler. It remains event-driven, a few probes per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
if (sampleGround && parts.model.projectionTilesReady()) {
|
||||
record.groundMeshSampleRequestCount =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ENRICH_AMBIENT_REFILL_TOKENS,
|
||||
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||
ENRICH_AMBIENT_PER_SWEEP,
|
||||
ENRICH_RETRY_COOLDOWN_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createEnrichment({
|
||||
@@ -17,11 +19,62 @@ export function createEnrichment({
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
function _enqueueEnrich(key, query, onData, priority = false) {
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { isTr3b } = services.aircraftPresentation;
|
||||
/**
|
||||
* Whether `key` is answered, queued, or cooling down after a failure. A
|
||||
* key past its cool-down is released here so it can be asked again.
|
||||
* @param {string} key Lookup key.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _enrichHeld(key) {
|
||||
if (flightState._enrichSeen.has(key)) return true;
|
||||
const retryAfter = flightState._enrichRetryAfter.get(key);
|
||||
if (retryAfter === undefined) return false;
|
||||
if (Date.now() < retryAfter) return true;
|
||||
flightState._enrichRetryAfter.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source failure is permanent: an unsupported lookup, or an HTTP
|
||||
* 4xx other than 401/403 (an authorization refusal that may clear), 408
|
||||
* (timeout) and 429 (rate limited). Anything else, including an error without a
|
||||
* status, is temporary.
|
||||
* @param {*} error The rejection, as `LiveSourceError` / `httpError` carry it.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _enrichFailureIsFinal(error) {
|
||||
if (error?.code === 'unsupported') return true;
|
||||
const status = Number(error?.status);
|
||||
return (
|
||||
Number.isInteger(status) &&
|
||||
status >= 400 &&
|
||||
status < 500 &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 408 &&
|
||||
status !== 429
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask each key at most once per session, except that a lookup whose source
|
||||
* failed temporarily (rejected or threw) may be asked again after
|
||||
* ENRICH_RETRY_COOLDOWN_MS; an answer, found or not, and a permanent
|
||||
* refusal (see _enrichFailureIsFinal) are final.
|
||||
* @param {string} key Session-unique lookup key.
|
||||
* @param {object} query The source's enrichment query.
|
||||
* @param {function(object):void} onData Receives a `found` answer.
|
||||
* @param {boolean} [priority] Front of the queue.
|
||||
* @param {function(*):?object} [read] Reads the source's answer first, for
|
||||
* a lookup whose answer may arrive in more than one shape.
|
||||
*/
|
||||
function _enqueueEnrich(key, query, onData, priority = false, read = null) {
|
||||
if (flightState.lifetime.signal.aborted) return;
|
||||
if (flightState._enrichSeen.has(key)) return;
|
||||
if (_enrichHeld(key)) return;
|
||||
flightState._enrichSeen.add(key);
|
||||
const job = { query, onData };
|
||||
const job = { key, query, onData, read };
|
||||
// Priority (tracked / model-eligible) goes to the FRONT so a deep ambient
|
||||
// backlog can never delay the plane the user just clicked or zoomed into.
|
||||
if (priority) flightState._enrichQueue.unshift(job);
|
||||
@@ -61,9 +114,24 @@ export function createEnrichment({
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
})
|
||||
.then((data) => {
|
||||
if (!lifetime.signal.aborted && data && data.found) job.onData(data);
|
||||
})
|
||||
.then(
|
||||
(answer) => {
|
||||
const data = job.read ? job.read(answer) : answer;
|
||||
if (!lifetime.signal.aborted && data && data.found)
|
||||
job.onData(data);
|
||||
},
|
||||
(error) => {
|
||||
// The source failed: release the key after a cool-down. A layer
|
||||
// teardown is not a failure and schedules nothing, and a
|
||||
// permanent refusal is as final as an answer.
|
||||
if (lifetime.signal.aborted || _enrichFailureIsFinal(error)) return;
|
||||
flightState._enrichSeen.delete(job.key);
|
||||
flightState._enrichRetryAfter.set(
|
||||
job.key,
|
||||
Date.now() + ENRICH_RETRY_COOLDOWN_MS,
|
||||
);
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
/* enrichment never surfaces errors */
|
||||
})
|
||||
@@ -119,6 +187,14 @@ export function createEnrichment({
|
||||
(data) => {
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
if (!meta) return;
|
||||
// A late answer for a callsign the plane no longer flies is dropped.
|
||||
if (
|
||||
String(meta.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase() !== cs
|
||||
)
|
||||
return;
|
||||
meta.routeCallsign = cs;
|
||||
meta.airline = data.airline || meta.airline;
|
||||
if (data.origin && data.destination)
|
||||
meta.route = { origin: data.origin, destination: data.destination };
|
||||
@@ -129,6 +205,31 @@ export function createEnrichment({
|
||||
); // route lookups only fire for the TRACKED plane — front of the queue
|
||||
}
|
||||
|
||||
/** The tracked plane's photo from Planespotters, asked once per hex at the
|
||||
* front of the queue like its route. A miss is final; a temporary failure
|
||||
* is asked again after the cool-down. A military aircraft or a converted
|
||||
* TR-3B is never looked up. The answer is read with
|
||||
* `parsePlanespottersPhoto` whatever shape the source returns (the upstream
|
||||
* answer, a server's cached shape, or an already-read photo), so only a
|
||||
* checked photo is ever stored. */
|
||||
function _requestPhotoEnrichment(icao24) {
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) return;
|
||||
const id = icao24.toLowerCase();
|
||||
if (isMilitaryIcao(id) || isTr3b(id)) return;
|
||||
_enqueueEnrich(
|
||||
`p:${id}`,
|
||||
{ kind: 'photo', id },
|
||||
(data) => {
|
||||
const meta = flightState.records.data.get(id);
|
||||
if (!meta) return; // evicted while the lookup was in flight
|
||||
const { src, width, height, photographer, link } = data;
|
||||
meta.photo = { src, width, height, photographer, link };
|
||||
},
|
||||
true,
|
||||
parsePlanespottersPhoto,
|
||||
);
|
||||
}
|
||||
|
||||
/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the
|
||||
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
|
||||
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
|
||||
@@ -196,7 +297,9 @@ export function createEnrichment({
|
||||
);
|
||||
const cand = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
||||
// Answered, queued, negative this session, or cooling down after a
|
||||
// failure: never spend a token on it.
|
||||
if (_enrichHeld(`t:${icao24}`)) continue;
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
||||
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||
@@ -233,6 +336,7 @@ export function createEnrichment({
|
||||
_drainEnrich,
|
||||
_requestTypeEnrichment,
|
||||
_requestRouteEnrichment,
|
||||
_requestPhotoEnrichment,
|
||||
_ambientBudgetKnobs,
|
||||
_refillAmbientBudget,
|
||||
_sweepAmbientEnrichment,
|
||||
|
||||
@@ -312,6 +312,7 @@ export function createLifecycle({
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._enrichQueue.length = 0;
|
||||
flightState._enrichSeen.clear();
|
||||
flightState._enrichRetryAfter.clear();
|
||||
if (flightState._enrichDripTimer) {
|
||||
clearTimeout(flightState._enrichDripTimer);
|
||||
flightState._enrichDripTimer = null;
|
||||
|
||||
@@ -37,6 +37,22 @@ import {
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
} from './policy.js';
|
||||
|
||||
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
|
||||
* clamped coordinate inside the cell. */
|
||||
const HALF_CELL_DEG = 0.00049;
|
||||
|
||||
/**
|
||||
* A coordinate held within half a cell of the cell's centre (the short way
|
||||
* round for longitude), so a read lands in the given cell.
|
||||
* @param {number} deg @param {number} centreDeg
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampToCell(deg, centreDeg) {
|
||||
let d = deg - centreDeg;
|
||||
d -= 360 * Math.round(d / 360);
|
||||
return centreDeg + Math.max(-HALF_CELL_DEG, Math.min(HALF_CELL_DEG, d));
|
||||
}
|
||||
|
||||
export function createMotion({
|
||||
flightState,
|
||||
services,
|
||||
@@ -865,12 +881,15 @@ export function createMotion({
|
||||
);
|
||||
// Boundary hysteresis: a position jittering across a cell edge would flip
|
||||
// floors at fleet-tick rate (see stickyFloorCell).
|
||||
const cell = stickyFloorCell(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
state?.cell,
|
||||
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
||||
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
||||
const cell = stickyFloorCell(latDeg, lonDeg, state?.cell);
|
||||
// A cell holds rendered-surface readings at the points they were taken,
|
||||
// so the floor is read where the contact is, held inside its sticky cell.
|
||||
const floor = cachedGroundFloor(
|
||||
clampToCell(latDeg, cell.lat),
|
||||
clampToCell(lonDeg, cell.lon),
|
||||
);
|
||||
const floor = cachedGroundFloor(cell.lat, cell.lon);
|
||||
const next = state || {
|
||||
cell,
|
||||
in: new Cesium.Cartesian3(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createCivilFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createEnrichment } from './enrichment.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -29,6 +30,14 @@ function services() {
|
||||
};
|
||||
}
|
||||
|
||||
/** The services enrichment reads: which hexes are military or a TR-3B. */
|
||||
function enrichServices({ military = [], tr3b = [] } = {}) {
|
||||
return {
|
||||
militaryRegistry: { isMilitaryIcao: (id) => military.includes(id) },
|
||||
aircraftPresentation: { isTr3b: (id) => tr3b.includes(id) },
|
||||
};
|
||||
}
|
||||
|
||||
test('constructing independent flight layers performs no source request and isolates selection', () => {
|
||||
let requests = 0;
|
||||
const source = {
|
||||
@@ -90,7 +99,11 @@ test('aborted enrichment cannot write into a later lifecycle or decrement its ac
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ source, services: services() });
|
||||
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices(),
|
||||
parts: {},
|
||||
});
|
||||
let writes = 0;
|
||||
enrichment._enqueueEnrich(
|
||||
'test',
|
||||
@@ -118,7 +131,11 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
|
||||
},
|
||||
},
|
||||
});
|
||||
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices(),
|
||||
parts: {},
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
enrichment._enqueueEnrich('test', {}, () => assert.fail()),
|
||||
);
|
||||
@@ -146,3 +163,336 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the tracked plane’s photo is asked once per hex and lands on its record', async () => {
|
||||
const queries = [];
|
||||
let answer = {
|
||||
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/x?utm_source=api',
|
||||
};
|
||||
const flightState = createFlightState({
|
||||
services: services(),
|
||||
source: {
|
||||
async getEnrichment(query) {
|
||||
queries.push(query);
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
});
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices(),
|
||||
parts: {},
|
||||
});
|
||||
flightState.records.data.set('a0f1bb', { callsign: 'AAL1' });
|
||||
flightState._trackedIcao = 'a0f1bb';
|
||||
flightState._trackedEntity = {};
|
||||
enrichment._requestPhotoEnrichment('A0F1BB');
|
||||
enrichment._requestPhotoEnrichment('a0f1bb');
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.deepEqual(queries, [{ kind: 'photo', id: 'a0f1bb' }]);
|
||||
assert.deepEqual(flightState.records.data.get('a0f1bb').photo, {
|
||||
src: answer.src,
|
||||
width: 420,
|
||||
height: 280,
|
||||
photographer: 'Jan Seler',
|
||||
link: answer.link,
|
||||
});
|
||||
|
||||
// A failure is not asked again within its cool-down.
|
||||
answer = new Error('outage');
|
||||
flightState.records.data.set('abc123', {});
|
||||
enrichment._requestPhotoEnrichment('abc123');
|
||||
await new Promise((done) => setTimeout(done, 300));
|
||||
enrichment._requestPhotoEnrichment('abc123');
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(queries.length, 2);
|
||||
assert.equal(flightState.records.data.get('abc123').photo, undefined);
|
||||
enrichment._requestPhotoEnrichment('not-hex');
|
||||
assert.equal(queries.length, 2);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a military aircraft or a converted TR-3B is never looked up for a photo', async () => {
|
||||
const queries = [];
|
||||
const flightState = createFlightState({
|
||||
services: services(),
|
||||
source: {
|
||||
async getEnrichment(query) {
|
||||
queries.push(query);
|
||||
return { found: false };
|
||||
},
|
||||
},
|
||||
});
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices({ military: ['ae1234'], tr3b: ['a0f1bb'] }),
|
||||
parts: {},
|
||||
});
|
||||
for (const id of ['ae1234', 'a0f1bb']) {
|
||||
flightState.records.data.set(id, {});
|
||||
enrichment._requestPhotoEnrichment(id);
|
||||
}
|
||||
enrichment._requestPhotoEnrichment('AE1234');
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.deepEqual(queries, []);
|
||||
assert.equal(flightState._enrichSeen.size, 0);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a photo answer in the upstream shape or a server’s cached shape lands checked', async () => {
|
||||
const SRC = 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg';
|
||||
const LINK = 'https://www.planespotters.net/photo/1745410/x?utm_source=api';
|
||||
const answers = {
|
||||
a0f1bb: {
|
||||
photos: [
|
||||
{
|
||||
thumbnail_large: { src: SRC, size: { width: 420, height: 280 } },
|
||||
link: LINK,
|
||||
photographer: 'Jan Seler',
|
||||
},
|
||||
],
|
||||
},
|
||||
abc123: {
|
||||
found: true,
|
||||
thumbnail: { src: SRC, width: 420, height: 280 },
|
||||
photographer: 'Jan Seler',
|
||||
link: LINK,
|
||||
},
|
||||
def456: {
|
||||
found: true,
|
||||
thumbnail: { src: 'https://evil.example/a.jpg', width: 1, height: 1 },
|
||||
photographer: 'Jan Seler',
|
||||
link: LINK,
|
||||
},
|
||||
fed789: { photos: [] },
|
||||
};
|
||||
const flightState = createFlightState({
|
||||
services: services(),
|
||||
source: { getEnrichment: async ({ id }) => answers[id] },
|
||||
});
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices(),
|
||||
parts: {},
|
||||
});
|
||||
const ids = Object.keys(answers);
|
||||
for (const id of ids) flightState.records.data.set(id, {});
|
||||
for (const id of ids) {
|
||||
enrichment._requestPhotoEnrichment(id);
|
||||
// One dispatch per drip gap.
|
||||
await new Promise((done) => setTimeout(done, 250));
|
||||
}
|
||||
const photo = {
|
||||
src: SRC,
|
||||
width: 420,
|
||||
height: 280,
|
||||
photographer: 'Jan Seler',
|
||||
link: LINK,
|
||||
};
|
||||
assert.deepEqual(flightState.records.data.get('a0f1bb').photo, photo);
|
||||
assert.deepEqual(flightState.records.data.get('abc123').photo, photo);
|
||||
assert.equal(flightState.records.data.get('def456').photo, undefined);
|
||||
assert.equal(flightState.records.data.get('fed789').photo, undefined);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
/** An enrichment queue over `answer(query)` with a hand-driven clock. */
|
||||
function enrichmentClock(t, answer) {
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const queries = [];
|
||||
const flightState = createFlightState({
|
||||
services: services(),
|
||||
source: {
|
||||
getEnrichment(query) {
|
||||
queries.push(query.id);
|
||||
return answer(query);
|
||||
},
|
||||
},
|
||||
});
|
||||
const enrichment = createEnrichment({
|
||||
flightState,
|
||||
services: enrichServices(),
|
||||
parts: { tracking: { _updateTrackedLabelModel() {} } },
|
||||
});
|
||||
const settle = () => new Promise((done) => setImmediate(done));
|
||||
const advance = (ms) => {
|
||||
now += ms;
|
||||
};
|
||||
return { flightState, enrichment, queries, settle, advance };
|
||||
}
|
||||
|
||||
test('a failed lookup is asked again only after its cool-down', async (t) => {
|
||||
let fail = true;
|
||||
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
|
||||
t,
|
||||
async () => {
|
||||
if (fail) throw new Error('503');
|
||||
return { found: true, typeCode: 'A321' };
|
||||
},
|
||||
);
|
||||
flightState.records.data.set('a0f1bb', {});
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.deepEqual(queries, ['a0f1bb']);
|
||||
advance(59_000);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.deepEqual(queries, ['a0f1bb'], 'not within the cool-down');
|
||||
fail = false;
|
||||
advance(1_000);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.deepEqual(queries, ['a0f1bb', 'a0f1bb'], 'asked again after it');
|
||||
assert.equal(flightState.records.data.get('a0f1bb').typeCode, 'A321');
|
||||
advance(120_000);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.equal(queries.length, 2, 'an answer is final');
|
||||
assert.equal(flightState._enrichActive, 0);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a synchronous source failure cools down like a rejected one', async (t) => {
|
||||
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
|
||||
t,
|
||||
() => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
);
|
||||
enrichment._requestPhotoEnrichment('a0f1bb');
|
||||
await settle();
|
||||
advance(60_000);
|
||||
enrichment._requestPhotoEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.deepEqual(queries, ['a0f1bb', 'a0f1bb']);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a "not found" answer is never asked again', async (t) => {
|
||||
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
|
||||
t,
|
||||
async ({ kind }) => (kind === 'photo' ? { photos: [] } : { found: false }),
|
||||
);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
advance(1_000); // past the dispatch drip
|
||||
enrichment._requestPhotoEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.equal(queries.length, 2);
|
||||
advance(3_600_000);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
enrichment._requestPhotoEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.equal(queries.length, 2);
|
||||
assert.equal(flightState._enrichRetryAfter.size, 0);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a lookup cut off by the layer’s teardown schedules no retry', async (t) => {
|
||||
let release;
|
||||
const { flightState, enrichment, settle } = enrichmentClock(
|
||||
t,
|
||||
(query) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
release = () => reject(new DOMException('aborted', 'AbortError'));
|
||||
}),
|
||||
);
|
||||
enrichment._requestTypeEnrichment('a0f1bb');
|
||||
await settle();
|
||||
flightState.lifetime.abort();
|
||||
release();
|
||||
await settle();
|
||||
assert.equal(flightState._enrichRetryAfter.size, 0);
|
||||
assert.equal(flightState._enrichSeen.has('t:a0f1bb'), true);
|
||||
});
|
||||
|
||||
test('a permanent refusal is final; a timeout, rate limit, outage or authorization refusal cools down', async (t) => {
|
||||
const errors = {
|
||||
aaaaa1: new LiveSourceError('denied', 'adsbdb HTTP 404', { status: 404 }),
|
||||
aaaaa2: new LiveSourceError('unsupported', 'Enrichment unavailable'),
|
||||
aaaaa3: new LiveSourceError('limited', 'adsbdb rate limited', {
|
||||
status: 429,
|
||||
}),
|
||||
aaaaa4: new LiveSourceError('unavailable', 'adsbdb HTTP 503', {
|
||||
status: 503,
|
||||
}),
|
||||
aaaaa5: new LiveSourceError('unavailable', 'adsbdb HTTP 408', {
|
||||
status: 408,
|
||||
}),
|
||||
aaaaa6: new Error('network'),
|
||||
aaaaa7: new LiveSourceError('denied', 'Unauthorized', { status: 401 }),
|
||||
aaaaa8: new LiveSourceError('denied', 'Forbidden', { status: 403 }),
|
||||
};
|
||||
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
|
||||
t,
|
||||
async ({ id }) => {
|
||||
throw errors[id];
|
||||
},
|
||||
);
|
||||
const ids = Object.keys(errors);
|
||||
for (const id of ids) {
|
||||
enrichment._requestTypeEnrichment(id);
|
||||
await settle();
|
||||
advance(1_000); // past the dispatch drip
|
||||
}
|
||||
assert.deepEqual(queries, ids);
|
||||
advance(60_000);
|
||||
for (const id of ids) {
|
||||
enrichment._requestTypeEnrichment(id);
|
||||
await settle();
|
||||
advance(1_000);
|
||||
}
|
||||
assert.deepEqual(
|
||||
queries.slice(ids.length),
|
||||
['aaaaa3', 'aaaaa4', 'aaaaa5', 'aaaaa6', 'aaaaa7', 'aaaaa8'],
|
||||
'a 404 and an unsupported lookup are never asked again',
|
||||
);
|
||||
assert.equal(flightState._enrichSeen.has('t:aaaaa1'), true);
|
||||
assert.equal(flightState._enrichRetryAfter.has('t:aaaaa1'), false);
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
|
||||
test('a route answer for a callsign the plane no longer flies is dropped', async (t) => {
|
||||
const pending = [];
|
||||
const { flightState, enrichment, settle, advance } = enrichmentClock(
|
||||
t,
|
||||
(query) => new Promise((resolve) => pending.push({ query, resolve })),
|
||||
);
|
||||
const meta = { callsign: 'UAL9' };
|
||||
flightState.records.data.set('a0f1bb', meta);
|
||||
enrichment._requestRouteEnrichment('a0f1bb');
|
||||
await settle();
|
||||
meta.callsign = 'UAL10'; // the next poll's callsign
|
||||
advance(1_000);
|
||||
enrichment._requestRouteEnrichment('a0f1bb');
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
pending.map(({ query }) => query.id),
|
||||
['UAL9', 'UAL10'],
|
||||
);
|
||||
const route = (code) => ({
|
||||
found: true,
|
||||
airline: `${code} Air`,
|
||||
origin: { code: `${code}O` },
|
||||
destination: { code: `${code}D` },
|
||||
});
|
||||
pending[0].resolve(route('OLD'));
|
||||
await settle();
|
||||
assert.equal(meta.route, undefined, 'the late UAL9 answer is ignored');
|
||||
assert.equal(meta.airline, undefined);
|
||||
pending[1].resolve(route('NEW'));
|
||||
await settle();
|
||||
assert.deepEqual(meta.route, {
|
||||
origin: { code: 'NEWO' },
|
||||
destination: { code: 'NEWD' },
|
||||
});
|
||||
assert.equal(meta.routeCallsign, 'UAL10');
|
||||
flightState.lifetime.abort();
|
||||
});
|
||||
@@ -254,6 +254,11 @@ export const ENRICH_MAX_INFLIGHT = 4;
|
||||
|
||||
export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
|
||||
/** A lookup whose source failed (an outage, a timeout, a restarting server) may
|
||||
* be asked again after this long; a real "not found" answer never is. */
|
||||
|
||||
export const ENRICH_RETRY_COOLDOWN_MS = 60_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live
|
||||
// category field is 0/"no info" for ~94% of planes, so ambient classification
|
||||
|
||||
@@ -24,7 +24,8 @@ export function createQueries({
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
||||
const { isTr3b, tr3bTypeLabel, tr3bAircraftClass } =
|
||||
services.aircraftPresentation;
|
||||
const { formatFlightLevel } = services.labels;
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
@@ -208,6 +209,12 @@ export function createQueries({
|
||||
destination: { ...info.route.destination },
|
||||
}
|
||||
: null,
|
||||
// The airframe's Planespotters photo for the Context panel; a military
|
||||
// aircraft or a converted TR-3B (it would name the real one) shows none.
|
||||
photo:
|
||||
info?.photo && !isTr3b(icao24) && !isMilitaryIcao(icao24)
|
||||
? { ...info.photo }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -875,8 +882,9 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Describe the currently tracked aircraft at its dead-reckoned position.
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Tracked aircraft info, or null when nothing is tracked.
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null, photo: {src: string, width: number|null, height: number|null, photographer: string, link: string}|null}|null}
|
||||
* Tracked aircraft info, or null when nothing is tracked. `photo` is the
|
||||
* airframe's credited Planespotters photo, or null.
|
||||
*/
|
||||
getTrackedInfo() {
|
||||
if (!flightState._trackedIcao) return null;
|
||||
@@ -906,6 +914,8 @@ export function createQueries({
|
||||
_toCleanText(described.registration) ||
|
||||
described.icao24,
|
||||
position: Cesium.Cartesian3.clone(described.position),
|
||||
// The airframe's photo for the Context panel, or null.
|
||||
photo: described.photo,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -198,10 +198,16 @@ export class FlightRecords {
|
||||
!!prevMeta && (prevMeta.onGround === true) !== onGround;
|
||||
// Store flight metadata for click-to-track labels
|
||||
const cat = stickyNumber(category, prevMeta?.category, null);
|
||||
const nextCallsign = stickyText(callsign, prevMeta?.callsign);
|
||||
// A route (and its airline) belongs to the callsign it was looked up for;
|
||||
// a new callsign drops it, so an old flight's route never shows under it.
|
||||
const routeHeld =
|
||||
Boolean(prevMeta?.routeCallsign) &&
|
||||
prevMeta.routeCallsign === nextCallsign.toUpperCase();
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
callsign: nextCallsign,
|
||||
altitude: alt,
|
||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||
// untouched aviation `altitude` — never rename/replace it (labels,
|
||||
@@ -256,8 +262,11 @@ export class FlightRecords {
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: prevMeta?.airline ?? null,
|
||||
route: prevMeta?.route ?? null,
|
||||
airline: routeHeld ? (prevMeta.airline ?? null) : null,
|
||||
route: routeHeld ? (prevMeta.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
|
||||
// Planespotters photo of the tracked airframe (Core asks it directly).
|
||||
photo: prevMeta?.photo ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
|
||||
@@ -39,6 +39,7 @@ test('civil records keep aviation units separate from render height and survive
|
||||
assert.equal(first.meta.renderAltitudeM, 1040);
|
||||
assert.equal(first.fixEpochMs, 1700000000000);
|
||||
first.meta.typeCode = 'B738';
|
||||
first.meta.photo = { src: 'https://t.plnspttrs.net/1/2_280.jpg' };
|
||||
const second = store.receive(
|
||||
observation({
|
||||
callsign: '',
|
||||
@@ -55,6 +56,7 @@ test('civil records keep aviation units separate from render height and survive
|
||||
assert.equal(second.meta.velocity, 120);
|
||||
assert.equal(second.meta.true_track, 90);
|
||||
assert.equal(second.meta.typeCode, 'B738');
|
||||
assert.equal(second.meta.photo, first.meta.photo, 'the photo survives polls');
|
||||
assert.equal(Object.hasOwn(second.meta, 'cullPosition'), false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta);
|
||||
});
|
||||
@@ -140,3 +142,25 @@ test('civil emergency status follows the latest poll instead of sticking', () =>
|
||||
assert.equal(next.meta.squawk, '2000');
|
||||
assert.equal(next.meta.emergency, null);
|
||||
});
|
||||
|
||||
test('a route belongs to its callsign: a new callsign drops it, a blank one keeps it', () => {
|
||||
const store = records();
|
||||
store.geoidReady = true;
|
||||
const first = store.receive(observation({ callsign: 'UAL9' }), view());
|
||||
first.meta.route = { origin: { code: 'AUS' }, destination: { code: 'DEN' } };
|
||||
first.meta.airline = 'United';
|
||||
first.meta.routeCallsign = 'UAL9';
|
||||
const same = store.receive(observation({ callsign: 'UAL9 ' }), view());
|
||||
assert.equal(same.meta.route, first.meta.route);
|
||||
const blank = store.receive(observation({ callsign: '' }), view());
|
||||
assert.equal(
|
||||
blank.meta.route,
|
||||
first.meta.route,
|
||||
'a dropped callsign is sticky',
|
||||
);
|
||||
assert.equal(blank.meta.airline, 'United');
|
||||
const next = store.receive(observation({ callsign: 'UAL10' }), view());
|
||||
assert.equal(next.meta.route, null, 'the old route never shows under UAL10');
|
||||
assert.equal(next.meta.airline, null);
|
||||
assert.equal(next.meta.routeCallsign, null);
|
||||
});
|
||||
@@ -230,6 +230,13 @@ export function createFlightSnapshotRenderer({
|
||||
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||
if (isTracked && flightState._trackedEntity) {
|
||||
tracking._updateTrackedLabelModel(icao24);
|
||||
// Re-ask the tracked plane's lookups each poll. Answered and queued
|
||||
// keys return at once; a lookup whose source failed runs again once
|
||||
// its cool-down has passed, and the route uses the callsign this
|
||||
// snapshot just recorded.
|
||||
enrichment._requestTypeEnrichment(icao24, true);
|
||||
enrichment._requestRouteEnrichment(icao24);
|
||||
enrichment._requestPhotoEnrichment(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,8 +307,8 @@ export function createFlightSnapshotRenderer({
|
||||
// floor cells collected in the loop (fire-and-forget, single-flight;
|
||||
// read synchronously by NEXT poll's clamp — the military-layer pattern).
|
||||
warmGroundFloor(floorWarmPoints);
|
||||
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// Round 4: sample the RENDERED mesh for those same cells (a few probes
|
||||
// per cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
|
||||
@@ -342,6 +342,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._enrichSeen = new Set();
|
||||
|
||||
/** Lookup key -> epoch ms before which a failed lookup is not asked again. */
|
||||
|
||||
flightState._enrichRetryAfter = new Map();
|
||||
|
||||
flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL;
|
||||
|
||||
/** Epoch ms the bucket last accounted a refill window from (0 = unset). */
|
||||
|
||||
@@ -190,8 +190,20 @@ export function createTesting({
|
||||
);
|
||||
}
|
||||
|
||||
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
|
||||
/** Forget every enrichment lookup and the dispatch clock, so a test that
|
||||
* moved `Date.now` leaves no future-dated drip or cool-down behind. */
|
||||
function _resetEnrichmentForTest() {
|
||||
flightState._enrichQueue.length = 0;
|
||||
flightState._enrichSeen.clear();
|
||||
flightState._enrichRetryAfter.clear();
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
if (flightState._enrichDripTimer) {
|
||||
clearTimeout(flightState._enrichDripTimer);
|
||||
flightState._enrichDripTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
|
||||
async function _ensureFleetModelForTest(icao24) {
|
||||
await parts.rendering._ensureModel(icao24);
|
||||
return flightState._models.get(icao24) || null;
|
||||
@@ -212,5 +224,6 @@ export function createTesting({
|
||||
_trackedBillboardColorForTest,
|
||||
_driveFleetModelHandoffForTest,
|
||||
_ensureFleetModelForTest,
|
||||
_resetEnrichmentForTest,
|
||||
};
|
||||
}
|
||||
@@ -1060,6 +1060,7 @@ export function createTracking({
|
||||
// starts one.
|
||||
parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue
|
||||
parts.enrichment._requestRouteEnrichment(icao24);
|
||||
parts.enrichment._requestPhotoEnrichment(icao24);
|
||||
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
|
||||
// aircraft's history is retrievable on select. Grounded flights positions
|
||||
// are already surface-clamped (the surfaceM chain), so seeds/appends drape.
|
||||
|
||||
Vendored
+35
@@ -164,6 +164,41 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* A loaded mapped installation by id, as `getNearby` returns it (with its
|
||||
* drawn `position`), or null while the layer is off or the id is not
|
||||
* loaded. Read-only: nothing is selected.
|
||||
* @param {string} id Source-backed installation id.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getById(id) {
|
||||
if (!layerState.enabled || id === null || id === undefined) return null;
|
||||
const record = layerState.recordById.get(String(id));
|
||||
if (!record || record.kind !== 'installation') return null;
|
||||
return {
|
||||
...record,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The mapped installation a `scene.pick()` result landed on: a named
|
||||
* marker point (`{ installationId }`) or a site entity, as this layer's
|
||||
* own click selection reads them. Null for anything else.
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {object|null} As {@link getById}.
|
||||
*/
|
||||
findByPick(picked) {
|
||||
const id =
|
||||
picked?.id?.installationId ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
return id ? methods.getById(id) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Select and frame a mapped installation from another contextual UI.
|
||||
* @param {string} id Source-backed installation id.
|
||||
|
||||
@@ -228,8 +228,8 @@ export function createMilitarySnapshotRenderer({
|
||||
const bbStale = flightState._billboards.get(icao24);
|
||||
if (bbStale) bbStale.position = liftedPos;
|
||||
}
|
||||
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
|
||||
// budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||
// Round 4: sample the RENDERED mesh for those cells (a few probes per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||
// this layer's own billboards/models from the probe.
|
||||
{
|
||||
const viewerCarto =
|
||||
|
||||
@@ -159,7 +159,8 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
// the known cause, behavior and complexity values and the wording around them.
|
||||
const facts = [];
|
||||
if (Number.isFinite(row.acres)) {
|
||||
const acres = Math.round(row.acres);
|
||||
const acres =
|
||||
row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres);
|
||||
facts.push(
|
||||
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
|
||||
);
|
||||
|
||||
@@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => {
|
||||
'Catron County · $85K to date',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => {
|
||||
const row = {
|
||||
stableId: 'x',
|
||||
name: 'Grey',
|
||||
acres: 0.25,
|
||||
containedPct: null,
|
||||
state: 'US-WA',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
};
|
||||
assert.equal(
|
||||
buildIncidentCard(row, 1758000000000).details[0],
|
||||
'0.25 ac · containment unknown · US-WA',
|
||||
);
|
||||
assert.equal(
|
||||
buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0],
|
||||
'0.01 ac · containment unknown · US-WA',
|
||||
);
|
||||
assert.equal(
|
||||
buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0],
|
||||
'1 ac · containment unknown · US-WA',
|
||||
);
|
||||
});
|
||||
@@ -124,10 +124,13 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
satrec,
|
||||
group: 'dense',
|
||||
launchYear: tleLineLaunchYear(entry.line1),
|
||||
line1: entry.line1,
|
||||
line2: entry.line2,
|
||||
// The dense feed is CelesTrak's Starlink group.
|
||||
satelliteClass: 'starlink',
|
||||
ucs: null,
|
||||
satcat: null,
|
||||
sensor: null,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
|
||||
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user