mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/tle-catalog-matching
This commit is contained in:
commit
155f5165d5
520 files changed
+36512
-5209
No files matched your search
@@ -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(() => {});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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(/^ +/, ''));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user