mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
commit
d4434aa6d7
1489 files changed
+196442
-12353
No files matched your search
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
vesselSnapshot,
|
||||
} from './vessels.js';
|
||||
export {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
} from './standalone.js';
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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(/^ +/, ''));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user