feat(outlines): restore voice outlines without public Overpass

use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback

preserve multipart shapes, holes and cancellation,
and keep pending annotations visible while ground sampling finishes across frames

keep non-Latin name distinctions when selecting provider results,
and retain an outline anchor only when it lies in filled geometry
This commit is contained in:
manjunath committed 2026-10-27 13:24:02 +00:00
commit d4434aa6d7
1489 files changed
+196442 -12353

No files matched your search

+4 -4
View File
@@ -41,12 +41,12 @@ const CATEGORY_PATTERN = /^[A-D][0-7]$/;
/** A previous accepted fix older than this is no reference for a new one. */
export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000;
/** Speed limit (kt) for an aircraft that has not reported a ground speed. */
export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000;
const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000;
/** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */
export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350;
const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350;
/** Reported ground speed is multiplied by this, plus the margin below. */
export const LOCAL_ADSB_SPEED_FACTOR = 1.5;
export const LOCAL_ADSB_SPEED_MARGIN_KT = 50;
const LOCAL_ADSB_SPEED_FACTOR = 1.5;
const LOCAL_ADSB_SPEED_MARGIN_KT = 50;
/** Distance always allowed between two fixes (CPR and reception error). */
export const LOCAL_ADSB_POSITION_MARGIN_M = 500;
/**
+10 -7
View File
@@ -77,7 +77,7 @@ export function simplifyRing(ring, tolM) {
return out.length >= 4 ? out : ring;
}
export function douglasPeucker(points, tol) {
function douglasPeucker(points, tol) {
const n = points.length;
if (n < 3) return points.slice();
// Iterative, index-based (no per-call array slicing / recursion), so a large
@@ -113,7 +113,7 @@ export function douglasPeucker(points, tol) {
return out;
}
export function perpDistance(p, a, b) {
function perpDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
@@ -149,7 +149,7 @@ export function stitchRing(ways) {
return endpointGapM(largest) <= allow ? largest : [];
}
export function largestByArea(components) {
function largestByArea(components) {
let best = components[0];
let bestArea = approximateAreaM2(best);
for (let i = 1; i < components.length; i += 1) {
@@ -162,7 +162,7 @@ export function largestByArea(components) {
return best;
}
export function ringSpanM(chain) {
function ringSpanM(chain) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
@@ -176,7 +176,7 @@ export function ringSpanM(chain) {
return approximateDistanceM(minLat, minLon, maxLat, maxLon);
}
export function buildRingComponents(ways) {
function buildRingComponents(ways) {
const same = (a, b) =>
Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7;
const remaining = ways.map((w) => w.slice());
@@ -207,7 +207,7 @@ export function buildRingComponents(ways) {
return components;
}
export function endpointGapM(chain) {
function endpointGapM(chain) {
if (!chain || chain.length < 2) return Infinity;
const a = chain[0];
const b = chain[chain.length - 1];
@@ -250,10 +250,13 @@ export function approximateAreaM2(coords) {
return Math.abs(area) / 2;
}
/** Local equirectangular distance in metres, the short way round 180°. */
export function approximateDistanceM(latA, lonA, latB, lonB) {
const latScale = 111_320;
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
let dLon = lonB - lonA;
dLon -= 360 * Math.round(dLon / 360);
return Math.hypot((latB - latA) * latScale, dLon * lonScale);
}
export function pointInPolygon(lon, lat, coords) {
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { approximateDistanceM } from './featureGeometry.js';
test('approximate distance takes the short way round 180°', () => {
// Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart.
const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33);
assert.ok(across > 150_000 && across < 200_000, String(across));
assert.equal(
Math.round(approximateDistanceM(0, 179.9, 0, -179.9)),
Math.round(approximateDistanceM(0, -0.1, 0, 0.1)),
);
// Away from 180° nothing changes.
assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320);
});
+68 -6
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);
@@ -50,6 +51,60 @@ export function extractStationsArray(payload) {
return [];
}
/**
* Station name text as a reader expects it. JCDecaux's Spanish contracts
* (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words
* with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word
* break. Other spacing is the operator's own and stays as published.
*/
function tidyStationName(text) {
return text.replace(/\s*_+\s*/g, ' ').trim();
}
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) {
if (typeof value === 'string') return tidyStationName(value);
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
const text = tidyStationName(translation.text);
if (!text) continue;
if (!fallback) fallback = text;
if (
typeof translation.language === 'string' &&
/^en(?:-|$)/i.test(translation.language.trim())
)
return text;
}
return fallback;
}
/**
* Docks a station physically has. PBSC adds the bikes a station accepts in its
* geofenced area around the docks to `capacity` and reports that part as
* `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as
* capacity 839 with geofenced_capacity 800. Without the extension, or when it
* would exceed the total, `capacity` is read as given. PBSC also counts the
* bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so
* a crowded station can report more bikes than docks.
*/
function dockCapacity(raw) {
const capacity = toNonNegativeInteger(raw?.capacity);
const geofenced = toNonNegativeInteger(raw?.geofenced_capacity);
if (capacity == null || geofenced == null || geofenced > capacity)
return capacity;
return capacity - geofenced;
}
/** Read a numeric coordinate without treating blank text or other JSON types as zero. */
function coordinateNumber(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim()))
return NaN;
return Number(value);
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
@@ -63,18 +118,25 @@ export function parseStationInformation(payload) {
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
// Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const lat = coordinateNumber(raw?.lat ?? raw?.latitude);
const lon = coordinateNumber(raw?.lon ?? raw?.longitude);
if (
!stationId ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
capacity: dockCapacity(raw),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
+321
View File
@@ -0,0 +1,321 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseStationInformation } from './gbfsStations.js';
const station = (fields = {}) => ({
station_id: 's1',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
...fields,
});
const parseName = (fields, version) =>
parseStationInformation({
version,
data: { stations: [station(fields)] },
}).get('s1').name;
const parseCapacity = (fields) =>
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
.capacity;
test('station information skips missing, blank and malformed coordinates', () => {
for (const field of ['lat', 'lon', 'latitude', 'longitude']) {
for (const value of [
undefined,
null,
'',
' \t ',
false,
true,
[],
[0],
{},
'bad',
NaN,
Infinity,
]) {
const raw = { station_id: 'bad', lat: 0, lon: 0 };
if (field === 'latitude') delete raw.lat;
if (field === 'longitude') delete raw.lon;
raw[field] = value;
const parsed = parseStationInformation({
data: { stations: [raw, station({ station_id: 'good' })] },
});
assert.deepEqual(
[...parsed.keys()],
['good'],
`${field} = ${JSON.stringify(value)}`,
);
}
}
});
test('station information skips coordinates outside latitude and longitude bounds', () => {
for (const fields of [
{ lat: 90.001 },
{ lat: -90.001 },
{ lon: 180.001 },
{ lon: -180.001 },
{ lat: undefined, latitude: '91' },
{ lon: undefined, longitude: '-181' },
]) {
const parsed = parseStationInformation({
data: { stations: [station(fields)] },
});
assert.equal(parsed.size, 0, JSON.stringify(fields));
}
});
test('station information preserves zero, boundary coordinates and numeric aliases', () => {
for (const [fields, expected] of [
[{ lat: 0, lon: 0 }, [0, 0]],
[{ lat: ' 0 ', lon: '0' }, [0, 0]],
[{ lat: 90, lon: 180 }, [90, 180]],
[{ lat: '-90', lon: '-180' }, [-90, -180]],
[
{ lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' },
[30.2681, -97.7469],
],
]) {
const parsed = parseStationInformation({
data: { stations: [station(fields)] },
}).get('s1');
assert.ok(parsed, JSON.stringify(fields));
assert.deepEqual([parsed.lat, parsed.lon], expected);
}
});
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
});
test('capacity is read as given without a usable geofenced_capacity', () => {
assert.equal(parseCapacity({ capacity: 27 }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
});
test('GBFS v2 station names remain trimmed strings', () => {
assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
'Republic Square',
);
});
test('underscores in station names read as word breaks', () => {
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
for (const [published, shown] of [
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
['Plaza__Mayor_ ', 'Plaza Mayor'],
]) {
assert.equal(parseName({ name: published }), shown);
}
});
test('spacing without underscores stays as the operator published it', () => {
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
});
test('a name of only underscores falls back to short_name, then to empty', () => {
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
assert.equal(parseName({ name: '__', short_name: '_' }), '');
});
test('underscores are read the same in GBFS v3 localized names', () => {
assert.equal(
parseName(
{
name: [
{ language: 'es', text: '_' },
{ language: 'en', text: '_COLON_I' },
],
},
'3.0',
),
'COLON I',
);
});
test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal(
parseName(
{
name: [
{ language: 'hu', text: 'Köztársaság tér' },
{ language: 'en', text: ' Republic Square ' },
],
},
'3.0',
),
'Republic Square',
);
});
test('English preference accepts case differences and regional language tags', () => {
for (const language of ['EN', 'en-GB', 'En-US']) {
assert.equal(
parseName({
name: [
{ language: 'hu', text: 'Hungarian' },
{ language, text: 'English' },
],
}),
'English',
);
}
});
test('without usable English text the first usable translation wins', () => {
assert.equal(
parseName({
name: [
null,
{ language: 'en', text: ' ' },
{ language: 'en', text: 42 },
{ language: 'hu', text: ' Első ' },
{ language: 'fr', text: 'Deuxième' },
],
}),
'Első',
);
});
test('malformed language fields cannot abort translation selection', () => {
const name = [
null,
false,
'bad',
{},
{ language: 42, text: 'Fallback' },
{ language: { unexpected: true }, text: 'Other' },
{ language: 'en', text: 'English' },
];
assert.equal(parseName({ name }), 'English');
assert.equal(
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
'Usable text without a locale',
);
});
test('a usable name takes precedence over an English short name', () => {
assert.equal(
parseName({
name: [{ language: 'hu', text: 'Full name' }],
short_name: [{ language: 'en', text: 'Short name' }],
}),
'Full name',
);
});
test('missing, empty and malformed names fall back to localized short_name', () => {
for (const name of [
undefined,
null,
'',
' ',
[],
{},
0,
42,
false,
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
]) {
assert.equal(
parseName({
name,
short_name: [
{ language: 'hu', text: 'Rövid' },
{ language: 'en', text: ' Short ' },
],
}),
'Short',
);
}
});
test('localized names can fall back to legacy short_name strings', () => {
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
});
test('no usable name leaves the station available with an empty name', () => {
for (const value of [
undefined,
null,
'',
[],
{},
42,
true,
[null, { text: false }, { language: 'en', text: ' ' }],
]) {
assert.equal(parseName({ name: value, short_name: value }), '');
}
});
test('name shape is normalized even when a feed omits or misstates its version', () => {
const name = [{ language: 'en', text: 'Republic Square' }];
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
assert.equal(parseName({ name }, version), 'Republic Square');
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
}
});
test('bad names do not lose valid stations or their metadata', () => {
const information = parseStationInformation({
version: '3.0',
data: {
stations: [
station({ station_id: 'bad', name: [null, {}] }),
station({
station_id: 'short',
name: [],
short_name: [{ language: 'en', text: 'Short' }],
}),
station({
station_id: 'good',
name: [{ language: 'en', text: 'Republic Square' }],
}),
],
},
});
assert.equal(information.size, 3);
assert.equal(information.get('bad').name, '');
assert.equal(information.get('short').name, 'Short');
assert.deepEqual(information.get('good'), {
stationId: 'good',
name: 'Republic Square',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
isInstalled: true,
isRenting: true,
isReturning: true,
});
});
test('normalizing a localized name leaves the feed document unchanged', () => {
const payload = {
version: '3.0',
data: {
stations: [
station({
name: [
{ language: 'hu', text: 'Magyar' },
{ language: 'en', text: ' English ' },
],
}),
],
},
};
const original = structuredClone(payload);
parseStationInformation(payload);
assert.deepEqual(payload, original);
});
+12 -9
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(() => {});
@@ -55,14 +60,12 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
/**
* Read a fetch() Response body as bytes with the same hard cap as
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
* readResponseTextCapped — for protobuf and tile upstreams (GTFS-Realtime,
* TomTom flow). Same early Content-Length rejection, running cap while
* streaming and cancellation on abort, so callers can treat the two alike.
* 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(() => {});
+150
View File
@@ -0,0 +1,150 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
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
* zero high-water mark keeps the stream from pulling eagerly at construction,
* so `pulls()` counts only what a reader actually asked for.
*/
function endlessResponse(chunk, onCancel, headers = {}) {
let pulls = 0;
const stream = new ReadableStream(
{
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel: onCancel,
},
{ highWaterMark: 0 },
);
return { response: new Response(stream, { headers }), pulls: () => pulls };
}
function chunkedResponse(chunks, headers = {}) {
const stream = new ReadableStream(
{
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
},
{ highWaterMark: 0 },
);
return new Response(stream, { headers });
}
const bytes = (n, fill) => new Uint8Array(n).fill(fill);
test('returns the exact bytes of a body that fits, untouched by text decoding', async () => {
// 0xFF and 0xFE are invalid UTF-8 lead bytes; a text path would mangle them.
const payload = Uint8Array.from([0xff, 0xfe, 0x00, 0x7f, 0x80, 0x01]);
const buf = await readResponseBytesCapped(chunkedResponse([payload]), 1024);
assert.ok(buf instanceof Uint8Array);
assert.deepEqual([...buf], [...payload]);
});
test('joins chunks in order and reports the total length', async () => {
const buf = await readResponseBytesCapped(
chunkedResponse([bytes(3, 1), bytes(2, 2), bytes(1, 3)]),
1024,
);
assert.deepEqual([...buf], [1, 1, 1, 2, 2, 3]);
});
test('refuses on a declared Content-Length without pulling the body', async () => {
let cancelled = false;
const { response, pulls } = endlessResponse(
bytes(512, 0),
() => {
cancelled = true;
},
{ 'content-length': '4096' },
);
await assert.rejects(readResponseBytesCapped(response, 1024), {
code: 'RESPONSE_TOO_LARGE',
});
assert.equal(pulls(), 0);
assert.ok(cancelled);
});
test('refuses a chunked body the moment it passes the cap and stops pulling', async () => {
let cancelled = false;
const { response, pulls } = endlessResponse(bytes(512, 0), () => {
cancelled = true;
});
await assert.rejects(readResponseBytesCapped(response, 1024), {
code: 'RESPONSE_TOO_LARGE',
});
// Three 512-byte chunks is the first total over 1024; an uncapped read of
// this never-ending stream would not terminate at all.
assert.ok(pulls() <= 3, `pulled ${pulls()} chunks`);
assert.ok(cancelled);
});
test('accepts a body exactly at the cap', async () => {
const buf = await readResponseBytesCapped(
chunkedResponse([bytes(512, 7), bytes(512, 7)]),
1024,
);
assert.equal(buf.byteLength, 1024);
});
test('cancels a body still streaming when the signal aborts, and never returns a partial one', async () => {
const controller = new AbortController();
let cancelled = false;
let pulls = 0;
const stream = new ReadableStream(
{
pull(ctrl) {
pulls += 1;
ctrl.enqueue(bytes(64, 0));
if (pulls === 2) controller.abort();
},
cancel() {
cancelled = true;
},
},
{ highWaterMark: 0 },
);
await assert.rejects(
readResponseBytesCapped(new Response(stream), 1 << 20, controller.signal),
);
assert.ok(cancelled);
assert.ok(pulls <= 3, `pulled ${pulls} chunks after abort`);
});
test('refuses immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const { response, pulls } = endlessResponse(bytes(8, 0), () => {});
await assert.rejects(
readResponseBytesCapped(response, 1024, controller.signal),
);
assert.equal(pulls(), 0);
});
test('falls back to arrayBuffer() for a body with no stream reader', async () => {
const response = new Response(bytes(16, 9));
const buf = await readResponseBytesCapped(response, 1024);
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');
}
});
}
+28 -3
View File
@@ -1,3 +1,4 @@
import { normalizeSquawk } from '../../data/aircraftEmergency.js';
import {
admitRecords,
cleanText,
@@ -6,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,
@@ -30,6 +33,9 @@ export function normalizeOpenSkyAircraft(row) {
typeCode: null,
registration: null,
operator: null,
squawk: normalizeSquawk(row[14]),
// State vectors carry no ADS-B emergency field; the squawk stands alone.
emergency: undefined,
};
}
@@ -68,9 +74,21 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
typeCode: cleanText(row.t),
registration: cleanText(row.r),
operator: cleanText(row.ownOp),
squawk: normalizeSquawk(row.squawk),
emergency: cleanText(row.emergency).toLowerCase() || null,
};
}
/**
* 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,
{
@@ -82,8 +100,15 @@ export function openSkySnapshot(
) {
const admitted = admitRecords(
payload?.states,
normalizeOpenSkyAircraft,
(row) => {
const record = normalizeOpenSkyAircraft(row);
const statuses = payload?.emergencyByIcao;
if (record && statuses && Object.hasOwn(statuses, record.id))
record.emergency = cleanText(statuses[record.id]).toLowerCase() || null;
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,
};
}
+86 -23
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
normalizeReadsbAircraft,
normalizeVesselObservation,
openSkySnapshot,
@@ -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(
{
@@ -133,7 +177,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
const requests = [];
const fetchImpl = async (url, init) => {
requests.push({ url, init });
if (url.startsWith('/api/opensky?'))
if (url.startsWith('/api/flights?'))
return response(
{ time: now / 1000, states: [aircraft] },
{
@@ -141,27 +185,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
'x-flight-coverage': '250 nm regional',
},
);
if (url.startsWith('/api/opensky-track'))
if (url.startsWith('/api/flights/track'))
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil')
if (url === '/api/military')
return response(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
{ 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
);
return response({
timestamp: now / 1000 - 60,
trace: [[10, 30, -97, 1000]],
});
};
const civil = createOpenSkySource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now });
const civil = createFlightSource({ fetchImpl, now: () => now });
const military = createMilitarySource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0);
const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot(
{ latitude: 30.123456, longitude: -97 },
{ signal },
);
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].url, '/api/flights?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional');
@@ -170,17 +214,16 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
assert.equal(mil.observedAtMs, now - 60000);
assert.equal(mil.records[0].positionTimeMs, now - 75000);
assert.equal(mil.stale, true);
const track = await military.getTrack('abc123');
assert.equal(track.complete, false);
assert.equal(track.records[0].observedAtMs, now - 50000);
assert.equal(track.records[0].baroAltitudeM, 304.8);
assert.equal(track.records[0].ellipsoidAltitudeM, null);
assert.equal(military.getTrack, undefined);
assert.ok(
requests.every(({ url }) => !url.startsWith('/api/military/track')),
);
});
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release;
const controller = new AbortController();
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => ({
ok: true,
json: () =>
@@ -199,7 +242,7 @@ test('cancellation after slow body parsing rejects even with a transport that ig
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) {
let calls = 0;
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => {
calls++;
return response({ error: 'sensitive upstream detail' }, {}, status);
@@ -216,12 +259,12 @@ test('denials and outages never start another source and do not echo arbitrary r
});
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
origin: () => 'http://example.test',
fetchImpl: async (url) => {
if (url.includes('/track?'))
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
assert.equal(url, 'http://example.test/api/vessels?maxRows=500');
return response({
status: 'reconnecting',
refreshing: true,
@@ -249,7 +292,7 @@ test('AIS reports limited received coverage and keeps connection state separate
});
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
fetchImpl: async () =>
response({
status: 'live',
@@ -273,7 +316,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
});
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () =>
response({
ac: [
@@ -293,12 +336,12 @@ test('classification identities include positionless aircraft without admitting
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({
const malformed = createMilitarySource({
fetchImpl: async () => response({ ac: [{}] }),
});
await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController();
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () => ({
ok: true,
status: 200,
@@ -313,3 +356,23 @@ test('identity lookup validates its response and honors body-parse cancellation'
name: 'AbortError',
});
});
test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => {
const squawking = [...aircraft, '7700'];
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergency, undefined);
assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null);
const mil = normalizeReadsbAircraft(
{ hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' },
now,
);
assert.equal(mil.squawk, '7600');
assert.equal(mil.emergency, 'nordo');
const quiet = normalizeReadsbAircraft(
{ hex: 'abc123', lat: 30, lon: -97 },
now,
);
assert.equal(quiet.squawk, null);
assert.equal(quiet.emergency, null);
});
+3 -3
View File
@@ -13,7 +13,7 @@ export {
vesselSnapshot,
} from './vessels.js';
export {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
} from './standalone.js';
+53 -53
View File
@@ -1,5 +1,4 @@
import {
epoch,
finite,
httpError,
LiveSourceError,
@@ -17,7 +16,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) {
const error = httpError(response, 'OpenSky');
const error = httpError(response, 'Flights');
const mode = String(
header(response, 'x-opensky-auth-mode-used') ||
header(response, 'x-opensky-auth') ||
@@ -32,9 +31,7 @@ function openSkyError(response) {
const reasons = {
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
basic_invalid_credentials: 'OpenSky username/password rejected',
missing_basic_creds: 'OpenSky auth missing',
missing_oauth_and_basic_creds: 'OpenSky auth missing',
missing_oauth_creds: 'OpenSky auth missing',
auth_required: 'OpenSky auth required',
forced_anonymous: 'OpenSky auth required',
};
@@ -49,13 +46,25 @@ function openSkyError(response) {
return error;
}
/** Existing same-origin aircraft routes; no request starts during construction. */
export function createOpenSkySource({
/**
* The provider a feed response names in `X-Feed-Source`, so a server that
* answers a feed route from another provider is reported as that provider.
*/
function feedSource(response, fallback) {
const named = header(response, 'x-feed-source');
return typeof named === 'string' && named.trim()
? named.trim().slice(0, 80)
: fallback;
}
/** The flights feed (/api/flights); no request starts during construction. */
export function createFlightSource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'OpenSky Network',
// 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();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -64,14 +73,17 @@ export function createOpenSkySource({
}
const { response, payload } = await readResponse(
fetchImpl,
`/api/opensky${params.size ? '?' + params : ''}`,
`/api/flights${params.size ? '?' + params : ''}`,
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw openSkyError(response);
return {
...openSkySnapshot(payload, {
source: header(response, 'x-flight-source') || 'OpenSky Network',
// A fallback provider names itself in X-Flight-Source.
source:
header(response, 'x-flight-source') ||
feedSource(response, 'Flights'),
coverage:
header(response, 'x-flight-coverage') ||
'worldwide upstream snapshot',
@@ -83,11 +95,11 @@ export function createOpenSkySource({
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
'/api/flights/track?icao24=' + encodeURIComponent(reference),
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw httpError(response, 'OpenSky');
if (!response.ok) throw httpError(response, 'Flights');
return {
records: normalizeAircraftTrack(payload?.path),
complete: false,
@@ -108,70 +120,55 @@ export function createOpenSkySource({
};
}
export function createAdsbLolSource({
export function createMilitarySource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'adsb.lol',
// 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(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
if (!response.ok) throw httpError(response, 'Military aircraft');
return readsbIdentities(payload);
},
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
if (!response.ok) throw httpError(response, 'Military aircraft');
const age = finite(header(response, 'x-feed-age-ms'));
return {
...readsbSnapshot(payload, {
source: feedSource(response, 'Military aircraft'),
observedAtMs: now() - (age != null && age > 0 ? age : 0),
now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE',
stale: header(response, 'x-feed-cache') === 'STALE',
}),
status: response.status,
};
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
{ signal },
'adsb.lol',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
const baseTimeMs = epoch(payload?.timestamp, 1000);
return {
records:
baseTimeMs == null
? []
: normalizeAircraftTrack(payload?.trace, {
baseTimeMs,
readsb: true,
}),
complete: false,
};
},
// ADSB.lol does not expose a supported live-history endpoint. Omitting
// optional getTrack keeps the tracking layer on its accumulated trail.
};
}
export function createAisStreamSource({
export function createVesselSource({
fetchImpl = defaultFetch,
apiUrl = '/api/ais-live',
apiUrl = '/api/vessels',
origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) {
return {
label: 'AISStream',
// 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());
url.searchParams.set('maxRows', String(maxRows));
@@ -190,10 +187,10 @@ export function createAisStreamSource({
fetchImpl,
url.toString(),
{ signal, cache: 'no-store' },
'AIS live',
'Vessels',
);
if (!response.ok) {
const error = httpError(response, 'AIS live');
const error = httpError(response, 'Vessels');
const reasons = {
'missing-key': 'AISSTREAM_API_KEY not set',
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
@@ -204,16 +201,19 @@ export function createAisStreamSource({
error.message = reasons[payload?.status] || error.message;
throw error;
}
return { ...vesselSnapshot(payload), status: response.status };
return {
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
status: response.status,
};
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
'/api/vessels/track?mmsi=' + encodeURIComponent(reference),
{ signal },
'AIS live',
'Vessels',
);
if (!response.ok) throw httpError(response, 'AIS live');
if (!response.ok) throw httpError(response, 'Vessels');
return {
records: normalizeVesselTrack(payload?.samples),
complete: false,
+13 -6
View File
@@ -30,9 +30,15 @@ function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return null;
const out = [];
for (const point of ring) {
const lon = Number(point?.[0]);
const lat = Number(point?.[1]);
if (!(Math.abs(lon) <= 180 && Math.abs(lat) <= 90)) return null;
if (!Array.isArray(point)) return null;
const [lon, lat] = point;
if (
!Number.isFinite(lon) ||
!Number.isFinite(lat) ||
Math.abs(lon) > 180 ||
Math.abs(lat) > 90
)
return null;
out.push([lon, lat]);
}
return out;
@@ -42,10 +48,11 @@ function validPolygons(value) {
if (!Array.isArray(value) || !value.length) return null;
const polygons = [];
for (const rings of value) {
if (!Array.isArray(rings)) continue;
if (!Array.isArray(rings) || !rings.length) return null;
const clean = rings.map(validRing);
// The outer ring must be valid; invalid holes are dropped.
if (clean[0]) polygons.push(clean.filter(Boolean));
// Never fill a malformed hole or silently discard a malformed part.
if (clean.some((ring) => !ring)) return null;
polygons.push(clean);
}
return polygons.length ? polygons : null;
}
+56 -8
View File
@@ -26,31 +26,47 @@ const reply = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers });
test('an accepted outline comes back with its polygons and is remembered', async () => {
const hole = ring.map(([lon, lat]) => [
2.35 + (lon - 2.35) / 2,
48.85 + (lat - 48.85) / 2,
]);
const { calls, lookup } = client(() =>
reply(200, {
status: 'OK',
outline: { name: 'Paris', class: 'boundary', type: 'administrative', polygons: [[ring]] },
outline: {
name: 'Paris',
class: 'boundary',
type: 'administrative',
polygons: [[ring, hole]],
},
}),
);
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
const outline = await lookup(ask);
assert.deepEqual(outline.polygons, [[ring]]);
assert.deepEqual(outline.polygons, [[ring, hole]]);
assert.equal(outline.class, 'boundary');
assert.match(calls[0], /^\/api\/geocode\/outline\?q=Paris&kind=city&lat=48\.850&lon=2\.350$/);
assert.match(
calls[0],
/^\/api\/geocode\/outline\?q=Paris&kind=city&lat=48\.850&lon=2\.350$/,
);
await lookup(ask);
assert.equal(calls.length, 1, 'one request per ask in a session');
});
test('no result is a definitive null; failures are transient undefined', async () => {
const { lookup } = client((n) =>
n === 1 ? reply(200, { status: 'ZERO_RESULTS', outline: null }) : reply(503, { error: 'x' }),
n === 1
? reply(200, { status: 'ZERO_RESULTS', outline: null })
: reply(503, { error: 'x' }),
);
assert.equal(await lookup({ query: 'Nowhere', kind: 'landmark' }), null);
assert.equal(await lookup({ query: 'Down', kind: 'landmark' }), undefined);
});
test('busy is rate-limited with its Retry-After', async () => {
const { lookup } = client(() => reply(429, { error: 'busy' }, { 'Retry-After': '20' }));
const { lookup } = client(() =>
reply(429, { error: 'busy' }, { 'Retry-After': '20' }),
);
assert.deepEqual(await lookup({ query: 'Paris', kind: 'city' }), {
rateLimited: true,
retryAfterMs: 20_000,
@@ -74,17 +90,49 @@ test('disabled, capped or missing routes stop asking for the session', async ()
});
test('bad input and malformed polygons never become outlines', async () => {
const badRing = [
[999, 0],
[1, 1],
[2, 2],
[999, 0],
];
let polygons = [[badRing]];
const { calls, lookup } = client(() =>
reply(200, { status: 'OK', outline: { polygons: [[[[999, 0], [1, 1], [2, 2], [999, 0]]]] } }),
reply(200, {
status: 'OK',
outline: {
polygons,
},
}),
);
assert.equal(await lookup({ query: '', kind: 'city' }), null);
assert.equal(await lookup({ query: 'Paris', kind: 'street' }), null);
assert.equal(calls.length, 0);
assert.equal(await lookup({ query: 'Paris', kind: 'city' }), undefined);
const ask = { query: 'Paris', kind: 'city' };
for (const malformed of [
[[badRing]],
[[ring, badRing]],
[[ring], [badRing]],
[[ring.map((_point, i) => (i === 1 ? [null, 48.8] : ring[i]))]],
[[ring.map((_point, i) => (i === 1 ? ['2.4', 48.8] : ring[i]))]],
[[ring.map((_point, i) => (i === 1 ? '12' : ring[i]))]],
]) {
polygons = malformed;
const before = calls.length;
assert.equal(await lookup(ask), undefined);
assert.equal(await lookup(ask), undefined);
assert.equal(
calls.length,
before + 2,
'malformed outlines are not remembered',
);
}
});
test('a cancelled ask sends nothing', async () => {
const { calls, lookup } = client(() => reply(200, { status: 'ZERO_RESULTS' }));
const { calls, lookup } = client(() =>
reply(200, { status: 'ZERO_RESULTS' }),
);
await assert.rejects(
lookup({ query: 'Paris', kind: 'city' }, { signal: AbortSignal.abort() }),
{ name: 'AbortError' },
+24 -10
View File
@@ -6,6 +6,11 @@ import {
} 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',
@@ -81,7 +86,7 @@ export function clearSharedOpenFreeMapTiles() {
* OpenMapTiles folds private access into `no`. Tunnels cannot be placed on
* the surface above them; ramps/bridges remain valid only on a motor road.
*/
export function isDrivableOpenMapRoad(properties = {}) {
function isDrivableOpenMapRoad(properties = {}) {
return (
Object.hasOwn(ROAD_TYPES, properties.class) &&
!properties.subclass &&
@@ -253,7 +258,7 @@ function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
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,
@@ -292,6 +297,7 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
let resizeCacheEntry = null;
let cacheBound = false;
const idleCallbacks = new Map();
const cancelIdleProjection = (name) => {
if (!idleCallbacks.has(name)) return;
const handle = idleCallbacks.get(name);
@@ -315,8 +321,6 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
return value;
};
const scheduleIdleProjections = () => {
// Scheduling is optional unless both lifecycle operations are available;
// otherwise eviction could not release the callback's decoded-tile closure.
if (
!cacheBound ||
typeof globalThis.requestIdleCallback !== 'function' ||
@@ -340,6 +344,7 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
idleCallbacks.set(name, handle);
}
};
return {
...base,
project,
@@ -362,7 +367,15 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
};
}
/** Construct an immutable-version road/military tile source without starting I/O. */
/**
* Construct an immutable-version road/military tile source without starting I/O.
*
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
* never a silent fall back to the default host (#933). Passing
* `allowedOrigin` explicitly still overrides both.
*/
export function createOpenFreeMapSource(options = {}) {
const {
projection = null,
@@ -371,19 +384,20 @@ export function createOpenFreeMapSource(options = {}) {
selectDecoded,
...sourceOptions
} = options;
const shared = !decode && !selectDecoded;
const shared = !decode;
return createVectorTileSource({
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
allowedOrigin: 'https://tiles.openfreemap.org',
decode: shared
? decodeSharedOpenFreeMapTile
: decode || decodeOpenFreeMapTile,
...(shared
? {
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
selectDecoded: projection
? (value) => value.project(projection)
: ({ roads, military }) => ({ roads, military }),
selectDecoded:
selectDecoded ||
(projection
? (value) => value.project(projection)
: ({ roads, military }) => ({ roads, military })),
}
: {
...(sharedDecodedCache ? { sharedDecodedCache } : {}),
+38
View File
@@ -347,3 +347,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri
assert.ok(records.some((r) => r.area === area && !r.drivable));
}
});
test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => {
const urls = [];
const mirror = 'http://127.0.0.1:12348';
const source = createOpenFreeMapSource({
fetchImpl: async (url) => {
urls.push(url);
return url.endsWith('/planet')
? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] })
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
},
tileJsonUrl: `${mirror}/planet`,
});
await source.fetchBounds(
{ south: 30.264, north: 30.271, west: -97.75, east: -97.74 },
{ zoom: 14 },
);
assert.ok(urls.length > 1, 'tiles were fetched');
assert.deepEqual(
urls.filter((url) => !url.startsWith(mirror)),
[],
'a repointed source must not fetch from tiles.openfreemap.org',
);
});
test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => {
// A transparent caching proxy returns upstream's TileJSON unchanged, whose
// tiles[0] is an absolute upstream URL. That must be an error, not 100% of
// tile traffic quietly leaving the configured mirror.
const source = createOpenFreeMapSource({
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
}),
tileJsonUrl: 'http://127.0.0.1:12348/planet',
});
await assert.rejects(source.getMetadata(), { retryable: false });
});
+86 -17
View File
@@ -12,6 +12,7 @@ import {
closeRing,
} from './featureGeometry.js';
import { phaseTiming } from './phaseTiming.js';
import { normalizePlaceName } from '../data/placeBoundaries.js';
/**
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
@@ -209,15 +210,41 @@ function decodePolygons(layer, z, x, y, keep, describe) {
: geometry.type === 'MultiPolygon'
? geometry.coordinates
: [];
// Preserve one feature's complete topology, including features without an
// id. A cut or malformed part makes every part incomplete, not a smaller
// exact building that happens to contain the pointer.
const incomplete =
!polygons.length ||
polygons.some(
(rings) =>
!Array.isArray(rings) ||
!rings.length ||
rings.some(
(ring) =>
!Array.isArray(ring) ||
ring.length < 4 ||
ring.some(
(point) =>
!Array.isArray(point) ||
point.length < 2 ||
!Number.isFinite(point[0]) ||
!Number.isFinite(point[1]),
),
),
);
const clipped =
incomplete || polygons.some((rings) => rings[0].some(onCut));
for (let part = 0; part < polygons.length; part++) {
const rings = polygons[part];
if (!Array.isArray(rings?.[0]) || rings[0].length < 4) continue;
out.push({
rings,
featureParts: polygons,
id: feature.id ?? null,
part,
tile: `${z}/${x}/${y}`,
clipped: rings[0].some(onCut),
clipped,
incomplete,
...describe(props),
});
}
@@ -443,7 +470,8 @@ function distanceToLineM(lon, lat, ring) {
* The building at a point: the smallest footprint containing it, else the
* nearest within `maxDistanceM`, else null.
*
* @returns {null | {ring: Array<[number, number]>, rings: Array<Array<[number, number]>>, heightM: number|null, contains: boolean}}
* The selected part remains primary; `polygons` carries every part and hole
* of the same complete feature, regardless of whether it has an id.
*/
export function buildingFromTiles(
tiles,
@@ -454,6 +482,22 @@ export function buildingFromTiles(
let nearest = null;
for (const tile of tiles || []) {
for (const building of tile?.buildings || []) {
if (building.incomplete) {
const outer = building.rings?.[0];
if (
Array.isArray(outer) &&
outer.length >= 4 &&
outer.every(
(point) =>
Array.isArray(point) &&
Number.isFinite(point[0]) &&
Number.isFinite(point[1]),
) &&
insideRing(lon, lat, outer)
)
fragmentContains = true;
continue;
}
if (insidePolygon(lon, lat, building.rings)) {
// A fragment cut at a tile edge is never the whole footprint.
if (building.clipped) {
@@ -477,12 +521,16 @@ export function buildingFromTiles(
const pick =
containing?.building || (fragmentContains ? null : nearest?.building);
if (!pick) return null;
const rings = pick.rings.map((ring) =>
closeRing(ring.map((p) => [p[0], p[1]])),
);
const parts = pick.featureParts || [pick.rings];
const polygons = [
pick.rings,
...parts.filter((part) => part !== pick.rings),
].map((part) => part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))));
const rings = polygons[0];
return {
ring: rings[0],
rings,
polygons,
heightM: pick.heightM,
contains: Boolean(containing),
};
@@ -490,28 +538,49 @@ export function buildingFromTiles(
/**
* The open area enclosing grounds at a point: the smallest park, landuse or
* park-like cover polygon that contains it and is at least grounds-sized.
* Tiles carry no names for these, so this is only used after a named lookup
* found nothing better.
* park-like cover feature that contains it and is at least grounds-sized.
* Containment alone is not identity: only a matching named feature can be an
* exact answer to the requested grounds, and holes are not filled grounds.
*/
export function enclosingAreaFromTiles(
tiles,
{ lat, lon, minAreaM2 = 20_000, maxAreaM2 = 60e6 },
{ lat, lon, name, minAreaM2 = 20_000, maxAreaM2 = 60e6 },
) {
const query = normalizePlaceName(name);
if (!query) return null;
let best = null;
for (const tile of tiles || []) {
for (const area of tile?.areas || []) {
// Grounds ENCLOSE: the outer boundary counts, since a hole in grounds is
// typically the building the ask is about (the Capitol inside its lawn).
if (area.clipped || !insideRing(lon, lat, area.rings[0])) continue;
const size = ringAreaM2(area.rings[0]);
if (
area.clipped ||
normalizePlaceName(area.name) !== query ||
!insidePolygon(lon, lat, area.rings)
)
continue;
const size = (area.featureParts || [area.rings]).reduce(
(sum, [outer, ...holes]) =>
sum +
ringAreaM2(outer) -
holes.reduce((n, hole) => n + ringAreaM2(hole), 0),
0,
);
if (size < minAreaM2 || size > maxAreaM2) continue;
if (!best || size < best.size) best = { area, size };
}
}
if (!best) return null;
const rings = best.area.rings.map((ring) =>
closeRing(ring.map((p) => [p[0], p[1]])),
);
return { ring: rings[0], rings, class: best.area.class, areaM2: best.size };
const parts = best.area.featureParts || [best.area.rings];
const polygons = [
best.area.rings,
...parts.filter((part) => part !== best.area.rings),
].map((part) => part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))));
const rings = polygons[0];
return {
ring: rings[0],
rings,
polygons,
name: best.area.name,
class: best.area.class,
areaM2: best.size,
};
}
+11 -9
View File
@@ -136,16 +136,18 @@ test('a point beside a building picks the nearest within 40 m, else none', () =>
assert.equal(buildingFromTiles(tiles, { lat: 0.0001, lon: 0.0008 }), null);
});
test('the open area enclosing the Capitol is its grounds, not a building', () => {
const grounds = enclosingAreaFromTiles(TILES, CAPITOL);
assert.ok(grounds, 'the park-like cover around the Capitol is found');
assert.ok(
grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000,
`${grounds.areaM2} m²`,
);
assert.equal(grounds.class, 'park');
test('tile enclosure alone does not identify the named Capitol grounds', () => {
assert.equal(
enclosingAreaFromTiles(TILES, { ...CAPITOL, minAreaM2: 1e6 }),
enclosingAreaFromTiles(TILES, { ...CAPITOL, name: 'Texas Capitol' }),
null,
'the former unlabeled enclosure is not an exact named answer',
);
assert.equal(
enclosingAreaFromTiles(TILES, {
...CAPITOL,
name: 'Texas Capitol',
minAreaM2: 1e6,
}),
null,
'nothing grounds-sized above the floor',
);
+89 -4
View File
@@ -42,6 +42,66 @@ test('decoded polygons keep identity and mark tile-cut fragments', () => {
buildingFromTiles([TILE], CAPITOL)?.contains,
'the Capitol is complete',
);
const parts = [
[square(0, 0, 0.001), square(0.0002, 0.0002, 0.0002)],
[square(0.003, 0, 0.001)],
];
const decode = (coordinates, cut = false) =>
decodeOpenFreeMapOutlineTile(null, 0, 0, 0, {
layers: {
building: {
length: 1,
extent: 4096,
feature: () => ({
properties: { render_height: 20 },
loadGeometry: () => [[{ x: cut ? -1 : 1, y: 1 }]],
toGeoJSON: () => ({
geometry: { type: 'MultiPolygon', coordinates },
}),
}),
},
},
});
const multipart = decode(parts);
assert.equal(multipart.buildings[0].id, null);
assert.equal(
multipart.buildings[0].featureParts,
multipart.buildings[1].featureParts,
'null ids do not split one feature into independent buildings',
);
const picked = buildingFromTiles([multipart], { lon: 0.0035, lat: 0.0005 });
assert.deepEqual(
picked.polygons,
[parts[1], parts[0]],
'selected part first; all parts and holes retained',
);
const clipped = decode(
[parts[0], [square(-180 - 360 / 4096, 0, 0.001)]],
true,
);
assert.ok(clipped.buildings.every((part) => part.clipped));
assert.equal(
buildingFromTiles([clipped], { lon: 0.0008, lat: 0.0008 }),
null,
'a cut sibling prevents a partial exact footprint',
);
const malformed = decode([parts[0], [parts[1][0], null]]);
const neighbor = {
rings: [square(0.0011, 0, 0.001)],
heightM: 20,
clipped: false,
};
assert.ok(
malformed.buildings.every((part) => part.clipped && part.incomplete),
);
assert.equal(
buildingFromTiles(
[{ ...malformed, buildings: [...malformed.buildings, neighbor] }],
{ lon: 0.0008, lat: 0.0008 },
),
null,
'a malformed sibling cannot become a partial exact building',
);
});
test('a clipped fragment is never the building, and no neighbour stands in', () => {
@@ -68,9 +128,18 @@ test('a clipped fragment is never the building, and no neighbour stands in', ()
point,
)?.contains,
);
const lawn = { rings: [square(0, 0, 0.01)], clipped: true, class: 'park' };
const lawn = {
rings: [square(0, 0, 0.01)],
clipped: true,
class: 'park',
name: 'Test Park',
};
assert.equal(
enclosingAreaFromTiles([{ areas: [lawn] }], { lat: 0.005, lon: 0.005 }),
enclosingAreaFromTiles([{ areas: [lawn] }], {
lat: 0.005,
lon: 0.005,
name: 'Test Park',
}),
null,
'clipped grounds are not grounds',
);
@@ -94,17 +163,33 @@ test('a point in a courtyard is not inside the building', () => {
assert.ok(near && !near.contains, 'near the courtyard walls, not inside');
});
test('grounds enclose the building sitting in their hole', () => {
test('named grounds require matching identity and filled containment', () => {
const lawn = {
rings: [square(0, 0, 0.004), square(0.0015, 0.0015, 0.001).reverse()],
clipped: false,
class: 'park',
name: 'Capitol Park',
};
const grounds = enclosingAreaFromTiles([{ areas: [lawn] }], {
lat: 0.002,
lon: 0.002,
name: 'Capitol Park',
});
assert.ok(grounds, 'the Capitol-in-its-lawn case');
assert.equal(grounds, null, 'a hole is not filled grounds');
const filled = { lat: 0.0005, lon: 0.0005, name: 'the Capitol Park' };
assert.ok(enclosingAreaFromTiles([{ areas: [lawn] }], filled));
assert.equal(
enclosingAreaFromTiles([{ areas: [lawn] }], {
...filled,
name: 'Other Park',
}),
null,
);
assert.equal(
enclosingAreaFromTiles([{ areas: [{ ...lawn, name: null }] }], filled),
null,
'an unlabeled enclosing feature has no exact named authority',
);
});
test('street proximity uses segments and clips to the search extent', () => {
+11 -7
View File
@@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) {
.trim();
}
/** Normalize a station/filter tag and cap its final, case-folded representation. */
export function normalizeRadioTag(value) {
return cleanRadioText(
String(value ?? '')
.toLowerCase()
.replace(/[_-]+/g, ' '),
80,
);
}
export function isNonGlobalIpv4(hostname) {
const pieces = hostname.split('.');
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
@@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation(
if (!name) return null;
const tags = String(raw?.tags ?? '')
.split(',')
.map((tag) =>
cleanRadioText(tag, 80)
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.map(normalizeRadioTag)
.filter(Boolean)
.filter((tag, index, all) => all.indexOf(tag) === index)
.slice(0, 24);
+4
View File
@@ -1,12 +1,16 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import { createAdsbGnssSource } from '../layers/gnss/source.js';
/** Construct the existing reference feeds independently of application setup. */
export function createReferenceSources() {
return {
earthquakes: createUsgsEarthquakeSource(),
'air-quality': createEcccAirQualitySource(),
'fire-perimeters': createWfigsPerimeterSource(),
cables: createBundledCableSource(),
'gnss-integrity': createAdsbGnssSource(),
};
}
+6
View File
@@ -14,14 +14,20 @@ test('reference factories retain compatibility without starting acquisition or s
const second = createReferenceSources();
assert.deepEqual(Object.keys(first), [
'earthquakes',
'air-quality',
'fire-perimeters',
'cables',
'gnss-integrity',
]);
assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['air-quality'], second['air-quality']);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first.cables, second.cables);
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['air-quality'].getSnapshot, 'function');
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
assert.equal(typeof first.cables.fetch, 'function');
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
assert.equal(requests, 0);
});
+54 -7
View File
@@ -1,6 +1,6 @@
/**
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
* Malformed sets are skipped without dropping the valid sets that follow.
*/
export function parseTleText(text) {
const lines = String(text)
@@ -9,19 +9,66 @@ export function parseTleText(text) {
.map((line) => line.trim())
.filter((line) => line.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
for (let i = 0; i < lines.length - 2; i++) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const cat1 = tleCatalogNumber(line1);
const cat2 = tleCatalogNumber(line2);
if (
line1.startsWith('1 ') &&
line2.startsWith('2 ') &&
cat1 !== null &&
cat1 === cat2
) {
result.push({ name, line1, line2 });
i += 2;
}
}
return result;
}
/** The NORAD catalog number from TLE line 1, or null. */
export function tleCatalogNumber(line1) {
const number = Number.parseInt(String(line1).slice(2, 7), 10);
return Number.isInteger(number) ? number : null;
// 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 a catalog field stands for, or null for any other field.
*
* 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 decodeCatalogField(field) {
if (DIGITS.test(field)) return Number(field);
if (!ALPHA5.test(field)) return null;
const letter = field[0];
let tens = letter.charCodeAt(0) - 65 + 10;
if (letter > 'I') tens -= 1;
if (letter > 'O') tens -= 1;
return tens * 10000 + Number(field.slice(1));
}
/**
* 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);
return decodeCatalogField(field.replace(/^ +/, ''));
}
+6 -2
View File
@@ -1,7 +1,7 @@
import { validTransitIdentifier } from './transitHistory.js';
export { validTransitIdentifier } from './transitHistory.js';
/** Bounded, process-local operator position history. No upstream or scene access. */
export const TRANSIT_HISTORY_LIMITS = Object.freeze({
const TRANSIT_HISTORY_LIMITS = Object.freeze({
retentionMs: 900000,
fixes: 128,
feedBytes: 16 * 1024 * 1024,
@@ -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}`;
+79 -39
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 (
@@ -16,10 +21,44 @@ export function validTileBounds(box) {
);
}
/**
* The origin a configured TileJSON is served from, or undefined when there is
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
* the old host: the origin check below then names the mismatch instead of
* passing it (#933).
*/
function tileJsonOrigin(tileJsonUrl) {
if (typeof tileJsonUrl !== 'string') return undefined;
try {
return new URL(tileJsonUrl).origin;
} catch {
return undefined;
}
}
const DECODER_IDS = new WeakMap();
let nextDecoderId = 1;
// Shared completion must not run every consumer's selection in one task.
const nextTask = () =>
globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
function sharedDecoderKey(decode) {
let id = DECODER_IDS.get(decode);
if (!id) {
id = nextDecoderId++;
DECODER_IDS.set(decode, id);
}
return id;
}
/**
* Bounded decoded ownership shared by compatible vector-tile consumers.
* Entries are keyed by the immutable tile template plus XYZ identity; callers
* still keep their own consumer-shaped caches and request lifetimes.
* Entries are keyed by decoder identity, immutable tile template and XYZ;
* callers still keep their own consumer-shaped caches and request lifetimes.
*/
export function createSharedDecodedTileCache({
maxEntries = 192,
@@ -29,6 +68,7 @@ export function createSharedDecodedTileCache({
const flights = new Map();
let cacheBytes = 0;
let generation = 0;
const release = (entry) => {
if (!entry || entry.released) return;
entry.released = true;
@@ -44,8 +84,7 @@ export function createSharedDecodedTileCache({
};
const evict = () => {
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
remove(oldest);
remove(cache.keys().next().value);
}
};
const resize = (key, value, entry, size) => {
@@ -92,6 +131,7 @@ export function createSharedDecodedTileCache({
}
});
};
return {
get(key) {
const hit = cache.get(key);
@@ -145,7 +185,7 @@ export function createSharedDecodedTileCache({
export function createVectorTileSource({
tileJsonUrl,
template,
allowedOrigin,
allowedOrigin = tileJsonOrigin(tileJsonUrl),
decode,
fetchImpl = (...args) => globalThis.fetch(...args),
maxTiles = 16,
@@ -171,8 +211,6 @@ export function createVectorTileSource({
let fetched = 0;
let activeWorkers = 0;
const waiters = [];
const consumerValue = (value) =>
sharedDecodedCache ? structuredClone(value) : value;
async function request(url, parentSignal, read, trackAsSource = true) {
const controller = new AbortController();
@@ -183,7 +221,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,
);
@@ -200,8 +241,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 },
);
@@ -285,7 +327,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',
@@ -361,7 +405,7 @@ export function createVectorTileSource({
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
flight.promise
.then((value) => resolve(consumerValue(value)), reject)
.then(resolve, reject)
.finally(() => signal?.removeEventListener('abort', abort));
}).finally(() => {
if (--flight.users === 0 && flights.get(key) === flight) {
@@ -373,35 +417,20 @@ export function createVectorTileSource({
async function getTile(tile, meta, signal, epoch) {
signal?.throwIfAborted();
if (epoch !== generation)
throw new DOMException('Source cleared', 'AbortError');
const key = `${meta.template}:${tile.z}/${tile.x}/${tile.y}`;
const sharedKey = `${sharedDecoderKey(decode)}:${key}`;
const hit = cache.get(key);
if (hit && now() - hit.at < ttlMs) {
cache.delete(key);
cache.set(key, hit);
return consumerValue(hit.value);
return hit.value;
}
if (hit) {
cache.delete(key);
cacheBytes -= hit.size;
}
const shared = sharedDecodedCache?.get(key);
if (shared) {
// A shared decode is authoritative cache state. Give each consumer its
// own value so traffic, outlines, or a future caller cannot mutate the
// cached object (including nested coordinate arrays) for everyone else.
const value = structuredClone(selectDecoded(shared.value, tile));
const size = shared.size;
if (epoch === generation && size <= maxCacheBytes) {
cache.set(key, { at: now(), value, size });
cacheBytes += size;
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).size;
cache.delete(oldest);
}
}
return consumerValue(value);
}
const loadDecoded = async (ownedSignal, sharedOwnership = false) => {
await acquire(ownedSignal);
try {
@@ -453,15 +482,23 @@ export function createVectorTileSource({
flights.set(key, flight);
flight.promise = (async () => {
const decodedEntry = sharedDecodedCache
? await sharedDecodedCache.load(key, ownedSignal, (sharedSignal) =>
loadDecoded(sharedSignal, true),
? await sharedDecodedCache.load(
sharedKey,
ownedSignal,
(sharedSignal) => loadDecoded(sharedSignal, true),
)
: await loadDecoded(ownedSignal);
ownedSignal.throwIfAborted();
if (sharedDecodedCache) {
await nextTask();
ownedSignal.throwIfAborted();
if (epoch !== generation)
throw new DOMException('Source cleared', 'AbortError');
}
const selected = selectDecoded(decodedEntry.value, tile);
// Shared entries stay authoritative; consumers own their nested arrays.
const value = sharedDecodedCache ? structuredClone(selected) : selected;
const currentShared = sharedDecodedCache?.get(key);
const size = currentShared?.size ?? decodedEntry.size;
const size = decodedEntry.size;
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
@@ -525,10 +562,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(
+216 -119
View File
@@ -207,60 +207,61 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
);
});
test('compatible consumers share bounded decoded ownership without sharing local lifetimes', async () => {
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 2,
maxCacheBytes: 100,
test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => {
// Without an explicit allowedOrigin the mirror's own TileJSON is honoured…
const mirrored = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
decode: () => [],
fetchImpl: async () =>
Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }),
});
let decodes = 0,
requests = 0;
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
},
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const roads = createVectorTileSource({
...common,
selectDecoded: (value) => value.roads,
assert.equal(
(await mirrored.getMetadata()).template,
'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf',
);
// …while a TileJSON that names a different host is refused rather than
// silently fetched, which is what the origin check is for.
const foreign = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
decode: () => [],
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
}),
});
const outlines = createVectorTileSource({
...common,
selectDecoded: (value) => value.outlines,
await assert.rejects(foreign.getMetadata(), { retryable: false });
});
test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => {
const source = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
allowedOrigin: 'https://tiles.example',
decode: () => [],
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
}),
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
assert.deepEqual((await roads.fetchBounds(box, { zoom: 14, tiles })).tiles, [
['road'],
]);
roads.clear();
assert.deepEqual(
(await outlines.fetchBounds(box, { zoom: 14, tiles })).tiles,
[['outline']],
assert.equal(
(await source.getMetadata()).template,
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
);
assert.equal(requests, 1);
assert.equal(decodes, 1);
assert.deepEqual(sharedDecodedCache.stats(), { entries: 1, bytes: 12 });
});
test('shared decoded tiles are isolated from consumer mutation', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let decodes = 0,
requests = 0;
let decodes = 0;
let requests = 0;
const decode = () => {
decodes++;
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
};
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
},
decode,
selectDecoded: (value) => value.roads,
fetchImpl: async () => {
requests++;
@@ -269,95 +270,39 @@ test('shared decoded tiles are isolated from consumer mutation', async () => {
};
const first = createVectorTileSource(common);
const second = createVectorTileSource(common);
const third = createVectorTileSource(common);
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const firstResult = await first.fetchBounds(box, { zoom: 14, tiles });
firstResult.tiles[0][0].name = 'first mutation';
firstResult.tiles[0][0].name = 'mutated';
firstResult.tiles[0][0].coordinates[0][0] = 0;
const secondResult = await second.fetchBounds(box, { zoom: 14, tiles });
assert.equal(secondResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(secondResult.tiles[0][0].coordinates[0][0], -97);
secondResult.tiles[0][0].name = 'second mutation';
secondResult.tiles[0][0].coordinates[0][0] = 1;
const thirdResult = await third.fetchBounds(box, { zoom: 14, tiles });
assert.equal(thirdResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(thirdResult.tiles[0][0].coordinates[0][0], -97);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('one source isolates in-flight and completed cache results from consumer mutation', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let decodes = 0,
requests = 0,
releaseResponse;
const responseReady = new Promise((resolve) => {
releaseResponse = resolve;
});
let requestStarted;
const started = new Promise((resolve) => {
requestStarted = resolve;
});
const source = createVectorTileSource({
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
},
selectDecoded: (value) => value.roads,
fetchImpl: async () => {
requests++;
requestStarted();
await responseReady;
return new Response(new Uint8Array([1, 2, 3]));
},
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const firstPending = source.fetchBounds(box, { zoom: 14, tiles });
await started;
const concurrentPending = source.fetchBounds(box, { zoom: 14, tiles });
releaseResponse();
const firstResult = await firstPending;
const concurrentResult = await concurrentPending;
firstResult.tiles[0][0].name = 'first mutation';
firstResult.tiles[0][0].coordinates[0][0] = 0;
assert.equal(concurrentResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(concurrentResult.tiles[0][0].coordinates[0][0], -97);
concurrentResult.tiles[0][0].name = 'concurrent mutation';
concurrentResult.tiles[0][0].coordinates[0][0] = 1;
const cachedResult = await source.fetchBounds(box, { zoom: 14, tiles });
assert.equal(cachedResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(cachedResult.tiles[0][0].coordinates[0][0], -97);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('compatible consumers share one in-flight fetch and survive one consumer clearing', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let requests = 0,
decodes = 0,
releaseResponse;
let requests = 0;
let decodes = 0;
let releaseResponse;
let requestStarted;
const responseReady = new Promise((resolve) => {
releaseResponse = resolve;
});
let requestStarted;
const started = new Promise((resolve) => {
requestStarted = resolve;
});
const decode = () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
};
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
},
decode,
fetchImpl: async () => {
requests++;
requestStarted();
@@ -387,10 +332,114 @@ test('compatible consumers share one in-flight fetch and survive one consumer cl
assert.equal(decodes, 1);
});
test('queued shared hits preserve sibling ownership and cannot refill after clear', async (t) => {
const sharedDecodedCache = createSharedDecodedTileCache();
let requests = 0,
decodes = 0,
selections = 0;
const geometry = [
{
name: 'Congress Avenue',
coordinates: [
[-97, 30],
[-97, 31],
],
},
];
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: geometry };
},
selectDecoded: (value) => {
selections++;
return value.roads;
},
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const tiles = [{ z: 14, x: 3743, y: 6745 }];
await createVectorTileSource(common).fetchBounds(box, { zoom: 14, tiles });
selections = 0;
const scheduler = Object.getOwnPropertyDescriptor(globalThis, 'scheduler');
let queued = Promise.withResolvers();
let resume;
Object.defineProperty(globalThis, 'scheduler', {
configurable: true,
value: {
yield: () =>
new Promise((resolve) => {
resume = resolve;
queued.resolve();
}),
},
});
t.after(() => {
resume?.();
if (scheduler) Object.defineProperty(globalThis, 'scheduler', scheduler);
else delete globalThis.scheduler;
});
const source = createVectorTileSource(common);
const controller = new AbortController();
const cancelled = source.fetchBounds(box, {
zoom: 14,
tiles,
signal: controller.signal,
});
const rejected = assert.rejects(cancelled, { name: 'AbortError' });
const sibling = source.fetchBounds(box, { zoom: 14, tiles });
await queued.promise;
controller.abort();
await rejected;
resume();
assert.deepEqual((await sibling).tiles, [geometry]);
assert.equal(selections, 1);
assert.deepEqual(source.getStats(), {
tilesFetched: 0,
cacheEntries: 1,
cacheBytes: 12,
});
selections = 0;
queued = Promise.withResolvers();
const cleared = createVectorTileSource(common);
let published = 0;
const pending = cleared.fetchBounds(box, {
zoom: 14,
tiles,
onTile: () => published++,
});
const clearRejected = assert.rejects(pending, { name: 'AbortError' });
await queued.promise;
cleared.clear();
resume();
await clearRejected;
assert.equal(selections, 0);
assert.equal(published, 0);
assert.deepEqual(cleared.getStats(), {
tilesFetched: 0,
cacheEntries: 0,
cacheBytes: 0,
});
queued = Promise.withResolvers();
const fresh = cleared.fetchBounds(box, { zoom: 14, tiles });
await queued.promise;
resume();
assert.deepEqual((await fresh).tiles, [geometry]);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('shared ownership recharges materialized projections and evicts over budget', () => {
const shared = createSharedDecodedTileCache({ maxCacheBytes: 100 });
let resize,
releases = 0;
let resize;
let releases = 0;
const decoded = {
bindSharedCacheEntry(update) {
resize = update;
@@ -405,26 +454,30 @@ test('shared ownership recharges materialized projections and evicts over budget
assert.deepEqual(shared.stats(), { entries: 1, bytes: 80 });
resize(101);
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(entry.size, 101, 'the returned entry retains its real size');
assert.equal(releases, 1, 'self-eviction releases decoded ownership once');
assert.equal(entry.size, 101);
assert.equal(releases, 1);
shared.clear();
assert.equal(releases, 1, 'clearing after eviction does not release twice');
assert.equal(releases, 1);
});
test('shared clear releases entries and prevents a pending loader from refilling', async () => {
const shared = createSharedDecodedTileCache();
let releases = 0;
const decoded = {
releaseSharedCacheEntry() {
releases++;
shared.set(
'resident',
{
releaseSharedCacheEntry() {
releases++;
},
},
};
shared.set('resident', decoded, 12);
12,
);
shared.clear();
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(releases, 1);
let loaderStarted, resolveLoader;
let loaderStarted;
let resolveLoader;
const started = new Promise((resolve) => {
loaderStarted = resolve;
});
@@ -441,3 +494,47 @@ test('shared clear releases entries and prevents a pending loader from refilling
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
});
test('shared decoded ownership is isolated by decoder identity', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let requests = 0;
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1]));
},
};
const a = createVectorTileSource({ ...common, decode: () => ['a'] });
const b = createVectorTileSource({ ...common, decode: () => ['b'] });
const tiles = [{ z: 14, x: 3743, y: 6745 }];
assert.deepEqual((await a.fetchBounds(box, { zoom: 14, tiles })).tiles, [
['a'],
]);
assert.deepEqual((await b.fetchBounds(box, { zoom: 14, tiles })).tiles, [
['b'],
]);
assert.equal(requests, 2);
});
test('clear prevents an old generation from starting tile I/O', async () => {
let requests = 0;
const source = createVectorTileSource({
...options,
template: meta.tiles[0],
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1]));
},
});
await source.getMetadata();
const pending = source.fetchBounds(box, {
zoom: 14,
tiles: [{ z: 14, x: 3743, y: 6745 }],
});
source.clear();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(requests, 0);
});