Merge branch 'main' into fix/alpha5-norad-id

# Conflicts:
#	src/layers/satellites/ingestion.js
This commit is contained in:
Sameh Khamis committed 2026-10-17 11:36:21 -07:00
commit 6ae854c121
543 files changed
+42557 -4887

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ export function normalizeGbfsBool(value, fallback = true) {
* @returns {number|null}
*/
export function toNonNegativeInteger(value, fallback = null) {
if (value == null || value === '') return fallback;
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
+9 -8
View File
@@ -1,13 +1,18 @@
// Imported by the server: the catalog-free marker, not src/i18n/index.js. The
// text stays English data; layers show it translated (translateKnown).
import { msg } from '../i18n/marker.js';
const tooLarge = () =>
Object.assign(new Error(msg('Upstream response too large')), {
code: 'RESPONSE_TOO_LARGE',
});
/**
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
* oversized Content-Length, then streams with a running cap so a chunked or
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseTextCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
void response.body?.cancel().catch(() => {});
@@ -61,10 +66,6 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
* Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseBytesCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
await response.body?.cancel().catch(() => {});
+18 -1
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readResponseBytesCapped } from './httpBody.js';
import { setLocale, translateKnown } from '../i18n/index.js';
import { readResponseBytesCapped, readResponseTextCapped } from './httpBody.js';
/**
* A Response that streams `chunk` forever until the reader cancels it. The
@@ -131,3 +132,19 @@ test('falls back to arrayBuffer() for a body with no stream reader', async () =>
assert.equal(buf.byteLength, 16);
assert.equal(buf[0], 9);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: an oversized upstream response is translated`, async () => {
const response = new Response('x'.repeat(10), {
headers: { 'content-length': '10' },
});
const error = await readResponseTextCapped(response, 4).catch((e) => e);
assert.equal(error.message, 'Upstream response too large');
setLocale(locale);
try {
assert.notEqual(translateKnown(error.message), error.message);
} finally {
setLocale('en');
}
});
}
+15 -2
View File
@@ -7,10 +7,12 @@ import {
finite,
} from './contract.js';
const openSkyId = (row) =>
Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
/** Normalize the OpenSky state-vector route, including its regional fallback. */
export function normalizeOpenSkyAircraft(row) {
if (!Array.isArray(row)) return null;
const id = cleanText(row[0]).toLowerCase();
const id = openSkyId(row);
if (!id || !coordinates(row[6], row[5])) return null;
return {
id,
@@ -77,6 +79,16 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
};
}
/**
* OpenSky allows null longitude/latitude for a contact that is still
* broadcasting. Strict nulls also reject rows truncated before the position.
*/
function openSkyUnpositionedId(row) {
return Array.isArray(row) && row[5] === null && row[6] === null
? openSkyId(row)
: '';
}
export function openSkySnapshot(
payload,
{
@@ -96,6 +108,7 @@ export function openSkySnapshot(
return record;
},
'OpenSky',
openSkyUnpositionedId,
);
const observedAtMs = epoch(payload?.time, 1000);
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
+20 -3
View File
@@ -9,6 +9,9 @@
* Snapshots describe coverage and completeness independently of freshness.
* Records contain observation data only, with no scene objects or transport data.
*/
// Messages are English data (AI tools read them too); the Layers panel shows
// them translated by their shape (translateFeedError in src/ui/statusText.js),
// so a new or reworded message is changed there as well.
export class LiveSourceError extends Error {
constructor(
code,
@@ -53,17 +56,30 @@ export function coordinates(latitude, longitude) {
);
}
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
export function admitRecords(rows, normalize, label) {
/**
* Admit a snapshot atomically; an all-invalid nonempty feed is unavailable.
* `unpositionedId(row)` names a rejected row that is a valid contact without
* a position: it is returned in `retainIds` and does not make the snapshot
* incomplete.
*/
export function admitRecords(rows, normalize, label, unpositionedId) {
if (!Array.isArray(rows))
throw new LiveSourceError('malformed', `Malformed ${label} response`);
const records = [];
const ids = new Set();
const retainIds = new Set();
let unpositioned = 0;
for (const row of rows) {
const record = normalize(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
} else if (!record) {
const id = unpositionedId?.(row);
if (id) {
retainIds.add(id);
unpositioned += 1;
}
}
}
if (rows.length && !records.length) {
@@ -71,7 +87,8 @@ export function admitRecords(rows, normalize, label) {
}
return {
records,
complete: records.length === rows.length,
retainIds,
complete: records.length + unpositioned === rows.length,
rejectedCount: rows.length - records.length,
};
}
+44
View File
@@ -67,6 +67,50 @@ test('mixed-invalid and duplicate admissions are explicitly incomplete; all-inva
assert.equal(openSkySnapshot({ states: [] }).complete, true);
});
test('position-less OpenSky rows are seen, not missing; malformed rows stay incomplete', () => {
// OpenSky's spec allows null longitude/latitude; every live /states/all
// carries such rows.
const unpositioned = ['DEF456', ...aircraft.slice(1)];
unpositioned[5] = null;
unpositioned[6] = null;
const snapshot = openSkySnapshot({ states: [aircraft, unpositioned] });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.complete, true);
assert.deepEqual([...snapshot.retainIds], ['def456']);
assert.equal(snapshot.rejectedCount, 1);
const malformed = [...aircraft];
malformed[5] = 181;
const garbled = openSkySnapshot({
states: [aircraft, unpositioned, malformed],
});
assert.equal(garbled.complete, false);
assert.deepEqual([...garbled.retainIds], ['def456']);
// No usable id, only half a position, a truncated row, or no row at all.
for (const row of [
[' ', ...unpositioned.slice(1)],
Object.assign([...unpositioned], { 5: -97 }),
['DEF456'],
null,
])
assert.equal(openSkySnapshot({ states: [aircraft, row] }).complete, false);
// Repeated position-less rows stay complete.
const repeated = openSkySnapshot({
states: [aircraft, unpositioned, unpositioned],
});
assert.equal(repeated.complete, true);
// A feed with no positions at all still cannot prove absence.
assert.throws(() => openSkySnapshot({ states: [unpositioned] }), {
code: 'malformed',
});
// Military readsb admission keeps its existing semantics.
const military = readsbSnapshot(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] },
{ observedAtMs: null },
);
assert.equal(military.complete, false);
assert.equal(military.retainIds.size, 0);
});
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
const row = normalizeReadsbAircraft(
{
+3
View File
@@ -66,6 +66,7 @@ export function createFlightSource({
now = () => Date.now(),
} = {}) {
return {
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
label: 'Flights',
async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams();
@@ -127,6 +128,7 @@ export function createMilitarySource({
now = () => Date.now(),
} = {}) {
return {
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
label: 'Military aircraft',
async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
@@ -186,6 +188,7 @@ export function createVesselSource({
origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) {
return {
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
label: 'Vessels',
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin());
+6 -1
View File
@@ -3,6 +3,11 @@ import { VectorTile } from '@mapbox/vector-tile';
import { createVectorTileSource } from './vectorTiles.js';
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
import { tileToBBox } from '../data/tomtomTiles.js';
// Kept English in data; the map card and Contacts translate it.
import { UNNAMED_MILITARY_AREA } from '../data/installationNames.js';
/** Name of a military polygon the names pack has not named. */
export { UNNAMED_MILITARY_AREA };
const ROAD_TYPES = Object.freeze({
motorway: 'motorway',
@@ -191,7 +196,7 @@ function decodeLayers(bytes, z, x, y, layerNames) {
id: `ofm:${z}/${x}/${y}:${feature.id ?? i}:${p}`,
kind: 'installation',
class: 'military_land',
name: 'Military area',
name: UNNAMED_MILITARY_AREA,
latitude,
longitude,
footprint: ring,
+5 -1
View File
@@ -328,8 +328,12 @@ export function createTransitHistory({
t > now + 10000
)
continue;
const byVehicle = feed.vehicleMode?.(record.id);
const mode =
feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown';
(byVehicle !== 'unknown' && byVehicle) ||
feed.routeMode?.(record.routeId) ||
feed.defaultMode ||
'unknown';
const context = contextOf(record, mode);
if (!context) continue;
const key = `${feed.id}\u0000${record.id}`;
+22 -8
View File
@@ -2,6 +2,11 @@ import { phaseTiming } from './phaseTiming.js';
import { tilesForBounds } from '../data/tomtomTiles.js';
import { readResponseBytesCapped, readResponseJsonCapped } from './httpBody.js';
// Marks text for i18n extraction; the UI translates these failures where it
// shows them (layer row, loading chip). Kept local because AI tool services
// import this module outside the browser.
const msg = (text) => text;
/** Validate a non-dateline geographic viewport before selecting a bounded grid. */
export function validTileBounds(box) {
return (
@@ -70,7 +75,10 @@ export function createVectorTileSource({
const timer = setTimeout(
() =>
controller.abort(
new DOMException('Vector tile request timed out', 'TimeoutError'),
new DOMException(
msg('Vector tile request timed out'),
'TimeoutError',
),
),
12_000,
);
@@ -87,8 +95,9 @@ export function createVectorTileSource({
throw Object.assign(
new Error(
status === null
? 'Vector tiles unavailable'
: `Vector tiles unavailable (HTTP ${status})`,
? msg('Vector tiles unavailable')
: // Shown through the 'Vector tiles unavailable (HTTP {status})' key.
`Vector tiles unavailable (HTTP ${status})`,
),
{ status },
);
@@ -172,7 +181,9 @@ export function createVectorTileSource({
if (flight.controller.signal.aborted || error.name === 'AbortError')
throw error;
metadataError = Object.assign(
new Error('Vector tile metadata unavailable', { cause: error }),
new Error(msg('Vector tile metadata unavailable'), {
cause: error,
}),
{
status: error.status ?? null,
name: error.name === 'TimeoutError' ? 'TimeoutError' : 'Error',
@@ -364,10 +375,13 @@ export function createVectorTileSource({
const limited = candidates.length > maxTiles;
// Refuse over-wide views instead of silently sampling a northwest strip.
if (limited)
throw Object.assign(new Error('Zoom in for vector tile coverage'), {
retryable: false,
code: 'TILE_VIEW_TOO_WIDE',
});
throw Object.assign(
new Error(msg('Zoom in for vector tile coverage')),
{
retryable: false,
code: 'TILE_VIEW_TOO_WIDE',
},
);
let index = 0;
const results = new Array(candidates.length);
await Promise.all(