Merge branch 'main' into fix/tle-catalog-matching

This commit is contained in:
Sameh Khamis committed 2026-10-17 13:45:09 -07:00
commit 155f5165d5
520 files changed
+36512 -5209

No files matched your search

+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');
}
});
}
+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,
+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,
+35 -23
View File
@@ -28,35 +28,47 @@ export function parseTleText(text) {
return result;
}
// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits.
const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/;
const DIGITS = /^\d+$/;
/**
* The catalog number an Alpha-5 field stands for, or null for any other field.
* The catalog number a catalog field stands for, or null for any other field.
*
* Since the catalog passed 99999 (July 2026) a TLE writes the numbers above it
* in Alpha-5: a letter A–Z, skipping I and O, stands for 10–33 in the
* ten-thousands place, so 100000 is "A0000" and 339999 is "Z9999".
* A field is either plain digits or Alpha-5, the TLE spelling of numbers above
* 99999: the letter stands for 10–33 in the ten-thousands place, skipping I
* and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a
* decimal point, hex, an embedded space, a lowercase or trailing letter) is
* not a catalog number.
* @param {string} field the field with any leading spaces already removed
* @returns {number | null}
*/
function alpha5CatalogNumber(field) {
function decodeCatalogField(field) {
if (DIGITS.test(field)) return Number(field);
if (!ALPHA5.test(field)) return null;
const letter = field[0];
if (
letter >= 'A' &&
letter <= 'Z' &&
letter !== 'I' &&
letter !== 'O' &&
/^\d{4}$/.test(field.slice(1))
) {
let tens = letter.charCodeAt(0) - 65 + 10;
if (letter > 'I') tens -= 1;
if (letter > 'O') tens -= 1;
return tens * 10000 + Number(field.slice(1));
}
return null;
let tens = letter.charCodeAt(0) - 65 + 10;
if (letter > 'I') tens -= 1;
if (letter > 'O') tens -= 1;
return tens * 10000 + Number(field.slice(1));
}
/** The NORAD catalog number from a TLE line, or null. */
/**
* NORAD catalog number of a satrec as a number, or NaN.
*
* satellite.js keeps the TLE's five-character field verbatim in
* `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite
* would share one NaN key. Digits of any length (an OMM satrec carries the
* number unpadded) and Alpha-5 decode; anything else, blank included, is NaN.
* @param {string} satnum
* @returns {number}
*/
export function noradIdFromSatnum(satnum) {
return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN;
}
/** The NORAD catalog number from TLE line 1 or 2 (columns 3–7), or null. */
export function tleCatalogNumber(line) {
const field = String(line).slice(2, 7);
const alpha5 = alpha5CatalogNumber(field);
if (alpha5 !== null) return alpha5;
const number = Number.parseInt(field, 10);
return Number.isInteger(number) ? number : null;
return decodeCatalogField(field.replace(/^ +/, ''));
}
+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(