Merge upstream main into PR #1435 for integration review

This commit is contained in:
Said Turksever committed 2026-11-03 14:50:23 +00:00
commit a6d20120a7
1364 files changed
+102726 -23267

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
/** A readsb rate field: a finite JSON number, else null. */
function rateField(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* Vertical rate in ft/min from a readsb-style aircraft row (readsb, tar1090,
* adsb.lol): the barometric `baro_rate`, else the GNSS-sourced `geom_rate`
* readsb sends instead when it has no barometric rate. A barometric 0 is kept.
* Only a finite number counts; null when neither field has one.
* @param {object|null|undefined} row
* @returns {number|null}
*/
export function readAdsbVerticalRateFpm(row) {
return rateField(row?.baro_rate) ?? rateField(row?.geom_rate);
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readAdsbVerticalRateFpm } from './adsbFields.js';
test('the barometric rate wins, a barometric 0 included', () => {
assert.equal(
readAdsbVerticalRateFpm({ baro_rate: -640, geom_rate: -832 }),
-640,
);
assert.equal(readAdsbVerticalRateFpm({ baro_rate: 0, geom_rate: 600 }), 0);
});
test('a GNSS rate stands in when no barometric rate is usable', () => {
for (const baro_rate of [
undefined,
null,
Number.NaN,
Infinity,
'300',
false,
]) {
assert.equal(readAdsbVerticalRateFpm({ baro_rate, geom_rate: -832 }), -832);
}
});
test('no usable rate reads as null', () => {
assert.equal(readAdsbVerticalRateFpm({}), null);
assert.equal(readAdsbVerticalRateFpm(null), null);
assert.equal(
readAdsbVerticalRateFpm({ baro_rate: '300', geom_rate: true }),
null,
);
});
+3 -1
View File
@@ -30,6 +30,8 @@
* This module is portable: no Cesium, DOM, network or storage access.
*/
import { readAdsbVerticalRateFpm } from './adsbFields.js';
/** A marker is dropped once its newest position is this old. */
export const LOCAL_ADSB_POSITION_STALE_MS = 60_000;
/** An aircraft is forgotten once no message has arrived for this long. */
@@ -247,7 +249,7 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
altitudeFt: altitude,
groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track),
verticalRateFpm: finiteOrNull(entry.baro_rate),
verticalRateFpm: readAdsbVerticalRateFpm(entry),
lastPositionAt: hasPosition
? receivedAt - Math.max(0, seenPosS) * 1000
: null,
+14
View File
@@ -169,6 +169,20 @@ test('a feed document names its band on every record', () => {
assert.ok(uat.every((record) => record.source === 'feed'));
});
test('a decoder feed reads a GNSS-sourced vertical rate when no baro rate is sent', () => {
// dump1090-fa and readsb file a velocity message's rate under geom_rate
// instead of baro_rate when its source bit says GNSS.
const rate = (fields) =>
normalizeDump1090Aircraft(
{ aircraft: [{ hex: 'abc123', seen: 1, ...fields }] },
10_000,
)[0].verticalRateFpm;
assert.equal(rate({ geom_rate: -832 }), -832);
assert.equal(rate({ baro_rate: -768, geom_rate: -832 }), -768);
assert.equal(rate({ baro_rate: 0, geom_rate: -832 }), 0);
assert.equal(rate({}), null);
});
function heard(overrides = {}) {
return {
icao: 'abc123',
+72 -20
View File
@@ -14,28 +14,48 @@ export function ringAreaM2(ring) {
return Math.abs(area) / 2;
}
export function stitchLine(segments) {
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
/**
* Join line pieces whose ends meet (closer than `toleranceM`, or at most it
* when `inclusive`) into as few chains as possible. Each chain grows greedily
* from the first unused piece.
*/
export function stitchLines(
segments,
{ toleranceM = 2, inclusive = false, maxChains = Infinity } = {},
) {
const same = (a, b) => {
const d = approximateDistanceM(a[1], a[0], b[1], b[0]);
return inclusive ? d <= toleranceM : d < toleranceM;
};
const remaining = segments.map((s) => s.slice());
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
const chains = [];
while (remaining.length && chains.length < maxChains) {
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
}
}
chains.push(line);
}
return line;
return chains;
}
/** The chain grown from the first segment. */
export function stitchLine(segments) {
return stitchLines(segments, { maxChains: 1 })[0];
}
export function bufferCorridor(line, halfWidthM) {
@@ -77,7 +97,8 @@ export function simplifyRing(ring, tolM) {
return out.length >= 4 ? out : ring;
}
function douglasPeucker(points, tol) {
/** Douglas-Peucker on [lon, lat] points with a tolerance in degrees. */
export 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
@@ -233,6 +254,37 @@ export function ringCentroid(ring) {
return { lat: sumLat / ring.length, lon: sumLon / ring.length };
}
/**
* The point on any of `lines` ([lon, lat] chains) nearest `lat, lon`, with
* its distance in metres, or null when no line has a segment.
*/
export function nearestPointOnLines(lines, lat, lon) {
const mLat = 111320;
const mLon = mLat * Math.cos((lat * Math.PI) / 180);
let best = null;
let bestD2 = Infinity;
for (const line of lines || []) {
for (let i = 0; i + 1 < line.length; i++) {
const ax = (line[i][0] - lon) * mLon;
const ay = (line[i][1] - lat) * mLat;
const dx = (line[i + 1][0] - line[i][0]) * mLon;
const dy = (line[i + 1][1] - line[i][1]) * mLat;
const len2 = dx * dx + dy * dy;
const t = len2
? Math.max(0, Math.min(1, -(ax * dx + ay * dy) / len2))
: 0;
const x = ax + t * dx;
const y = ay + t * dy;
if (x * x + y * y < bestD2) {
bestD2 = x * x + y * y;
best = { lat: lat + y / mLat, lon: lon + x / mLon, distanceM: 0 };
}
}
}
if (best) best.distanceM = Math.sqrt(bestD2);
return best;
}
export function approximateAreaM2(coords) {
// Shoelace in a local equirectangular projection (good enough for buildings).
if (coords.length < 3) return 0;
+2 -1
View File
@@ -6,6 +6,7 @@ import {
epoch,
finite,
} from './contract.js';
import { readAdsbVerticalRateFpm } from '../adsbFields.js';
const openSkyId = (row) =>
Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
@@ -50,7 +51,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
const baroFt = finite(row.alt_baro),
geoFt = finite(row.alt_geom);
const speedKts = finite(row.gs),
rateFtMin = finite(row.baro_rate);
rateFtMin = readAdsbVerticalRateFpm(row);
const seen = finite(row.seen),
seenPos = finite(row.seen_pos);
return {
+14
View File
@@ -166,6 +166,20 @@ test('readsb units and position/contact ages are normalized once against the sou
);
});
test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => {
// readsb reports the rate under geom_rate when the aircraft's source is
// GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that.
const row = { hex: 'ae638d', lat: 20.3, lon: -158.5, geom_rate: -2432 };
assert.equal(
normalizeReadsbAircraft(row, now).verticalRateMps,
-2432 * 0.00508,
);
assert.equal(
normalizeReadsbAircraft({ ...row, baro_rate: -2400 }, now).verticalRateMps,
-2400 * 0.00508,
);
});
test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => {
const row = normalizeVesselObservation(
{
+178
View File
@@ -0,0 +1,178 @@
import { readResponseJsonCapped } from './httpBody.js';
import { createNominatimStatus } from './nominatimStatus.js';
/**
* Browser client for the server's guarded outline route. The server owns the
* endpoint, pacing, caps and caches; this client only asks once per explicit
* voice ask and reads the tri-state answer the annotation resolver expects:
*
* - `{ polygons, name, class, type, osm }` — an accepted outline;
* - `null` — the service answered and has no suitable polygon;
* - `undefined` — a transient failure; a later retry may succeed;
* - `{ rateLimited: true, retryAfterMs }` — busy; retry no sooner;
* - `{ unavailable: true, retryable: false }` — disabled, capped for the
* day, or no route: stop asking this session.
*/
const DEFAULT_ENDPOINT = '/api/geocode/outline';
const MAX_RESPONSE_BYTES = 3 * 1024 * 1024;
const TIMEOUT_MS = 15_000;
/** Ask kinds the route accepts. */
export const NOMINATIM_OUTLINE_KINDS = Object.freeze([
'city',
'admin',
'neighborhood',
'landmark',
'building',
]);
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return null;
const out = [];
for (const point of ring) {
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;
}
function validPolygons(value) {
if (!Array.isArray(value) || !value.length) return null;
const polygons = [];
for (const rings of value) {
if (!Array.isArray(rings) || !rings.length) return null;
const clean = rings.map(validRing);
// 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;
}
export function createNominatimOutlineClient({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = DEFAULT_ENDPOINT,
maxRemembered = 64,
// The app's own route can say up front that Nominatim is not configured;
// another endpoint is simply asked.
configured = endpoint === DEFAULT_ENDPOINT
? createNominatimStatus({ fetchImpl })
: null,
} = {}) {
let unavailable = null;
const remembered = new Map();
function remember(key, value) {
remembered.delete(key);
remembered.set(key, value);
while (remembered.size > maxRemembered)
remembered.delete(remembered.keys().next().value);
}
/**
* `lat, lon` bias the search; `anchorLat, anchorLon` (the resolved place)
* pick which same-name result answers.
*
* @param {{query: string, kind: string, lat?: number, lon?: number, anchorLat?: number, anchorLon?: number}} ask
* @param {{signal?: AbortSignal}} [options]
*/
async function lookup(
{ query, kind, lat, lon, anchorLat, anchorLon },
{ signal } = {},
) {
if (unavailable) return unavailable;
const text = String(query || '').trim();
if (!text || text.length > 200 || !NOMINATIM_OUTLINE_KINDS.includes(kind))
return null;
const params = new URLSearchParams({ q: text, kind });
if (Number.isFinite(lat) && Number.isFinite(lon)) {
params.set('lat', lat.toFixed(3));
params.set('lon', lon.toFixed(3));
}
if (Number.isFinite(anchorLat) && Number.isFinite(anchorLon)) {
// Full anchor precision: rounding could cross a building's edge.
params.set('alat', anchorLat.toFixed(6));
params.set('alon', anchorLon.toFixed(6));
}
const key = params.toString();
if (remembered.has(key)) return remembered.get(key);
// A cancelled ask spends nothing.
signal?.throwIfAborted();
const capability = configured ? await configured() : null;
// The probe may have waited: an ask cancelled meanwhile still spends nothing.
signal?.throwIfAborted();
if (capability === false) {
unavailable = {
unavailable: true,
retryable: false,
code: 'NOMINATIM_DISABLED',
};
return unavailable;
}
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(abort, TIMEOUT_MS);
try {
const response = await fetchImpl(`${endpoint}?${key}`, {
signal: controller.signal,
});
let body = null;
try {
body = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
controller.signal,
);
} catch {
body = null;
}
if (response.status === 404 || body?.retryable === false) {
// No outline route here, disabled, or today's allowance is used.
unavailable = { unavailable: true, retryable: false, code: body?.code };
return unavailable;
}
if (response.status === 429) {
const seconds = Number(response.headers?.get?.('retry-after'));
return {
rateLimited: true,
retryAfterMs: Number.isFinite(seconds) ? seconds * 1000 : null,
};
}
if (!response.ok || !body) return undefined;
if (body.status === 'ZERO_RESULTS') {
remember(key, null);
return null;
}
const polygons = validPolygons(body.outline?.polygons);
if (body.status !== 'OK' || !polygons) return undefined;
const outline = {
polygons,
name: body.outline.name || null,
class: body.outline.class || null,
type: body.outline.type || null,
osm: body.outline.osm || null,
};
remember(key, outline);
return outline;
} catch {
signal?.throwIfAborted();
return undefined;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}
return { lookup };
}
+230
View File
@@ -0,0 +1,230 @@
// Browser client for /api/geocode/outline: tri-state answers, session stop.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNominatimOutlineClient } from './nominatimOutlines.js';
const ring = [
[2.3, 48.8],
[2.4, 48.8],
[2.4, 48.9],
[2.3, 48.9],
[2.3, 48.8],
];
const reply = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers });
// `calls` counts outline requests only; the capability probe is answered by
// `status` and defaults to an older server without it (capability unknown).
function client(
respond,
{ status = () => reply(404, { error: 'Not Found' }) } = {},
) {
const calls = [];
const probes = [];
const lookup = createNominatimOutlineClient({
fetchImpl: async (url) => {
if (String(url) === '/api/geocode/status') {
probes.push(String(url));
return status(probes.length);
}
calls.push(String(url));
return respond(calls.length);
},
}).lookup;
return { calls, probes, lookup };
}
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, hole]],
},
}),
);
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
const outline = await lookup(ask);
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$/,
);
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' }),
);
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' }),
);
assert.deepEqual(await lookup({ query: 'Paris', kind: 'city' }), {
rateLimited: true,
retryAfterMs: 20_000,
});
});
test('disabled, capped or missing routes stop asking for the session', async () => {
for (const [status, body] of [
[503, { code: 'NOMINATIM_DISABLED', retryable: false }],
[429, { code: 'NOMINATIM_DAILY_CAP', retryable: false }],
[404, { error: 'Not Found' }],
]) {
const { calls, lookup } = client(() => reply(status, body));
const first = await lookup({ query: 'Paris', kind: 'city' });
assert.equal(first.unavailable, true);
assert.equal(first.retryable, false);
const second = await lookup({ query: 'Tokyo', kind: 'city' });
assert.equal(second.unavailable, true);
assert.equal(calls.length, 1, `${status}: no further requests`);
}
});
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,
},
}),
);
assert.equal(await lookup({ query: '', kind: 'city' }), null);
assert.equal(await lookup({ query: 'Paris', kind: 'street' }), null);
assert.equal(calls.length, 0);
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' }),
);
await assert.rejects(
lookup({ query: 'Paris', kind: 'city' }, { signal: AbortSignal.abort() }),
{ name: 'AbortError' },
);
assert.equal(calls.length, 0);
});
test('a server that reports Nominatim unconfigured is never asked for outlines', async () => {
const { calls, probes, lookup } = client(
() => {
throw new Error('the outline route must not be asked');
},
{ status: () => reply(200, { configured: false }) },
);
const first = await lookup({ query: 'Austin High School', kind: 'landmark' });
assert.deepEqual(first, {
unavailable: true,
retryable: false,
code: 'NOMINATIM_DISABLED',
});
const second = await lookup({ query: 'Paris', kind: 'city' });
assert.equal(second.unavailable, true);
assert.equal(calls.length, 0, 'no outline request at all');
assert.equal(probes.length, 1, 'one capability probe per session');
});
test('a configured server is asked as usual after one probe', async () => {
const { calls, probes, lookup } = client(
() => reply(200, { status: 'ZERO_RESULTS', outline: null }),
{ status: () => reply(200, { configured: true }) },
);
assert.equal(await lookup({ query: 'Nowhere', kind: 'landmark' }), null);
assert.equal(await lookup({ query: 'Elsewhere', kind: 'landmark' }), null);
assert.equal(calls.length, 2);
assert.equal(probes.length, 1);
});
test('an ask cancelled while the capability probe is pending spends nothing', async () => {
for (const [label, answer] of [
['configured', () => reply(200, { configured: true })],
['unknown', () => reply(404, { error: 'Not Found' })],
['unconfigured', () => reply(200, { configured: false })],
]) {
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const { calls, lookup } = client(
() => reply(200, { status: 'ZERO_RESULTS', outline: null }),
{
status: async () => {
await held;
return answer();
},
},
);
const controller = new AbortController();
const pending = lookup(
{ query: 'Paris', kind: 'city' },
{ signal: controller.signal },
);
controller.abort();
release();
await assert.rejects(pending, { name: 'AbortError' }, label);
assert.equal(calls.length, 0, `${label}: no outline request`);
}
});
test('the resolved anchor is sent at full precision, apart from the bias', async () => {
const { calls, lookup } = client(() =>
reply(200, { status: 'OK', outline: { polygons: [[ring]] } }),
);
await lookup({
query: 'Paris',
kind: 'city',
lat: 48.85,
lon: 2.35,
anchorLat: 48.8566,
anchorLon: 2.35221,
});
assert.match(
calls[0],
/&lat=48\.850&lon=2\.350&alat=48\.856600&alon=2\.352210$/,
);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Whether the app's server has a Nominatim endpoint configured, asked once per
* page from `/api/geocode/status` (like `/api/overpass/status`). When it says
* no, callers skip lookups the server could only refuse, so a default install
* never asks and never logs a failed request. An older server without the
* probe, a failed or timed-out probe and a malformed answer leave the
* capability unknown (`null`): lookups then go to the server as before, and
* the probe is retried after a short backoff.
*
* @returns {() => Promise<boolean|null>}
*/
export function createNominatimStatus({
fetchImpl = (...args) => globalThis.fetch(...args),
url = '/api/geocode/status',
timeoutMs = 3000,
retryMs = 30_000,
now = Date.now,
} = {}) {
let decision = null;
let retryAt = 0;
return function configured() {
if (decision) return decision;
if (now() < retryAt) return Promise.resolve(null);
decision = (async () => {
try {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(timeoutMs),
});
const body = response.ok ? await response.json() : null;
if (typeof body?.configured === 'boolean') return body.configured;
} catch {
// Unknown capability: ask the server as before.
}
decision = null;
retryAt = now() + retryMs;
return null;
})();
return decision;
};
}
+155 -6
View File
@@ -1,6 +1,9 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
import { tileToBBox } from '../data/tomtomTiles.js';
// Kept English in data; the map card and Contacts translate it.
@@ -18,6 +21,65 @@ const ROAD_TYPES = Object.freeze({
minor: 'residential',
});
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
maxEntries: 192,
maxCacheBytes: 32 * 1024 * 1024,
});
const TILE_PROJECTIONS = new Map();
const MAX_PROJECTED_NUMBERS = 500_000;
/** Conservative, deterministic retained-size estimate with a hard geometry cap. */
export function estimateOpenFreeMapProjectionBytes(value) {
const seen = new Set();
const stack = [value];
let bytes = 0;
let numbers = 0;
while (stack.length) {
const item = stack.pop();
if (item == null) continue;
if (typeof item === 'number') {
numbers += 1;
if (numbers > MAX_PROJECTED_NUMBERS)
throw new Error('Vector tile projection coordinate limit exceeded');
bytes += 8;
} else if (typeof item === 'string') {
bytes += 16 + item.length * 2;
} else if (typeof item === 'boolean') {
bytes += 4;
} else if (typeof item === 'object') {
if (seen.has(item)) continue;
seen.add(item);
if (Array.isArray(item)) {
bytes += 24 + item.length * 8;
for (let i = 0; i < item.length; i += 1) stack.push(item[i]);
} else {
const entries = Object.entries(item);
bytes += 48 + entries.length * 16;
for (const [key, child] of entries) {
bytes += key.length * 2;
stack.push(child);
}
}
}
}
return bytes;
}
/** Register one consumer view over the parsed OpenFreeMap tile. */
export function registerOpenFreeMapProjection(name, project) {
if (!name || typeof project !== 'function')
throw new TypeError('Invalid OpenFreeMap tile projection');
const current = TILE_PROJECTIONS.get(name);
if (current && current !== project)
throw new Error(`OpenFreeMap tile projection already registered: ${name}`);
TILE_PROJECTIONS.set(name, project);
}
/** Explicit test/runtime reset; individual consumers never evict each other. */
export function clearSharedOpenFreeMapTiles() {
SHARED_DECODED_TILES.clear();
}
/**
* Surface traffic uses public motor-road classes only. Untyped service ways
* include car parks and grounds access, so none are used as through traffic.
@@ -147,8 +209,8 @@ export function decodeOpenFreeMapMilitaryTile(bytes, z, x, y) {
return decodeLayers(bytes, z, x, y, ['landuse']);
}
function decodeLayers(bytes, z, x, y, layerNames) {
const tile = new VectorTile(new PbfReader(bytes));
function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
const roads = [],
military = [];
const box = tileToBBox(z, x, y);
@@ -219,6 +281,65 @@ function decodeLayers(bytes, z, x, y, layerNames) {
return { roads, military };
}
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
// Each layer is decoded when a consumer first selects it, so installations
// never pay for roads, nor outlines for either.
let roads = null;
let military = null;
const projections = new Map();
let baseBytes = 0;
let projectionBytes = 0;
let resizeCacheEntry = null;
const project = (name) => {
if (projections.has(name)) return projections.get(name);
const projector = TILE_PROJECTIONS.get(name);
if (!projector)
throw new Error(`Unknown OpenFreeMap tile projection: ${name}`);
const value = projector(bytes, z, x, y, tile);
const retainedBytes = estimateOpenFreeMapProjectionBytes(value);
projections.set(name, value);
projectionBytes += retainedBytes;
resizeCacheEntry?.(baseBytes + projectionBytes);
return value;
};
return {
get roads() {
return (roads ??= decodeLayers(
bytes,
z,
x,
y,
['transportation'],
tile,
).roads);
},
get military() {
return (military ??= decodeLayers(
bytes,
z,
x,
y,
['landuse'],
tile,
).military);
},
project,
bindSharedCacheEntry(resize, initialBytes) {
resizeCacheEntry = resize;
baseBytes = initialBytes;
resizeCacheEntry(baseBytes + projectionBytes);
},
releaseSharedCacheEntry() {
projections.clear();
baseBytes = 0;
projectionBytes = 0;
resizeCacheEntry = null;
},
};
}
/**
* Construct an immutable-version road/military tile source without starting I/O.
*
@@ -229,11 +350,39 @@ function decodeLayers(bytes, z, x, y, layerNames) {
* `allowedOrigin` explicitly still overrides both.
*/
export function createOpenFreeMapSource(options = {}) {
const {
projection = null,
decode,
sharedDecodedCache,
selectDecoded,
...sourceOptions
} = options;
const shared = !decode;
// Shared consumers hold only their slice of each tile. The shared decode
// (32 MiB) plus traffic and installations (12 MiB each) and outlines
// (8 MiB) are charged 64 MiB together, the budget of the single OpenFreeMap
// source traffic and installations shared before; charges are estimates.
return createVectorTileSource({
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
decode: decodeOpenFreeMapTile,
decode: shared
? decodeSharedOpenFreeMapTile
: decode || decodeOpenFreeMapTile,
...(shared
? {
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
selectedSize: estimateOpenFreeMapProjectionBytes,
selectDecoded:
selectDecoded ||
(projection
? (value) => value.project(projection)
: ({ roads, military }) => ({ roads, military })),
}
: {
...(sharedDecodedCache ? { sharedDecodedCache } : {}),
...(selectDecoded ? { selectDecoded } : {}),
}),
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
...options,
maxCacheBytes: (shared ? 12 : 64) * 1024 * 1024,
...sourceOptions,
});
}
+61 -1
View File
@@ -7,11 +7,41 @@ import {
polygonCentroid,
clipTileLine,
createOpenFreeMapSource,
estimateOpenFreeMapProjectionBytes,
} from './openFreeMap.js';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
const fixture = (name) =>
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
test('projected tile geometry has deterministic accounting and a coordinate cap', () => {
const small = {
streets: [
{
name: 'A',
coordinates: [
[0, 1],
[2, 3],
],
},
],
};
assert.equal(
estimateOpenFreeMapProjectionBytes(small),
estimateOpenFreeMapProjectionBytes(structuredClone(small)),
);
assert.ok(estimateOpenFreeMapProjectionBytes(small) > 4 * 8);
assert.throws(
() =>
estimateOpenFreeMapProjectionBytes({
coordinates: Array.from({ length: 250_001 }, () => [0, 0]),
}),
/coordinate limit/,
);
});
test('real z14 and z12 Austin tiles decode only drivable roads', () => {
for (const [z, x, y, name] of [
[14, 3743, 6745, 'ofm-austin-14-3743-6745.pbf'],
@@ -118,6 +148,36 @@ test('Camp Mabry tile emits an unnamed polygon and its centroid marker', () => {
);
});
test('a shared consumer is charged only its own slice of each tile', async () => {
const bytes = fixture('ofm-austin-14-3743-6745.pbf');
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 4,
maxCacheBytes: 64 * 1024 * 1024,
});
const source = createOpenFreeMapSource({
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
sharedDecodedCache,
selectDecoded: ({ military }) => ({ military }),
fetchImpl: async () => new Response(bytes),
});
try {
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
const { tiles } = await source.fetchBounds(box, { zoom: 14 });
const stats = source.getStats();
assert.equal(stats.cacheEntries, 1);
assert.equal(
stats.cacheBytes,
estimateOpenFreeMapProjectionBytes(tiles[0]),
'the slice estimate, not the whole decoded tile',
);
assert.ok(stats.cacheBytes < bytes.byteLength * 4);
assert.ok(sharedDecodedCache.stats().bytes >= bytes.byteLength * 4);
} finally {
source.clear();
sharedDecodedCache.clear();
}
});
test('TileJSON is resolved once and decoded tile LRU prevents per-pan refetch', async () => {
const calls = [];
const source = createOpenFreeMapSource({
+689
View File
@@ -0,0 +1,689 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import {
clipTileLine,
createOpenFreeMapSource,
registerOpenFreeMapProjection,
} from './openFreeMap.js';
import { tileToBBox } from '../data/tomtomTiles.js';
import {
approximateDistanceM,
ringAreaM2,
closeRing,
stitchLines,
nearestPointOnLines,
} from './featureGeometry.js';
import { phaseTiming } from './phaseTiming.js';
import { normalizePlaceName } from '../data/placeBoundaries.js';
import { foldPlaceName } from '../data/placeNames.js';
import { pointInRing, polygonsContain } from '../data/adminBoundaries.js';
/**
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
* traffic layer reads. No new tile provider: the source below points at the
* same immutable tile URLs and bounded decoded owner. Outline extraction runs
* only when an outline consumer asks for it; traffic loading never warms it.
*/
/** Tile zoom used for outlines (the highest OpenMapTiles zoom with buildings). */
export const OUTLINE_TILE_ZOOM = 14;
/** Radius around the geocoded point searched for a named street. */
export const STREET_RADIUS_M = 1500;
/** Bounds on what one street ask can return. */
const STREET_MAX_CHAINS = 12;
const STREET_MAX_POINTS = 4000;
const STREET_MIN_CHAIN_M = 20;
const JOIN_TOLERANCE_M = 3;
/** How far from the point a building may be and still be "this building". */
export const BUILDING_MAX_DISTANCE_M = 40;
const LAYER_FEATURE_LIMIT = 40_000;
/** Area classes (OpenMapTiles `class`, or `subclass` for landcover) that can be grounds. */
const GROUNDS_AREA_CLASSES = Object.freeze({
park: new Set(['park', 'nature_reserve', 'protected_area', 'national_park']),
landuse: new Set([
'school',
'university',
'college',
'hospital',
'cemetery',
'stadium',
'zoo',
'theme_park',
'military',
'religious',
'pitch',
'track',
'playground',
]),
landcover: new Set(['park', 'garden', 'grass', 'recreation_ground']),
});
/** Lines of one tile's `transportation_name` layer and polygons of `building`. */
export function decodeOpenFreeMapOutlineTile(
bytes,
z,
x,
y,
parsedTile = null,
) {
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
const box = tileToBBox(z, x, y);
const streets = [];
const streetsStart = performance.now();
const names = tile.layers.transportation_name;
if (names) {
if (names.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
for (let i = 0; i < names.length; i++) {
const feature = names.feature(i);
const props = feature.properties;
const name = props.name || props['name:latin'] || props.name_en;
if (!name) continue;
const geometry = feature.toGeoJSON(x, y, z).geometry;
const lines =
geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.type === 'MultiLineString'
? geometry.coordinates
: [];
// Clip to the tile core so neighbouring tiles' buffers never duplicate.
for (const line of lines)
for (const clipped of clipTileLine(line, box))
streets.push({
name: String(name),
nameEn: props.name_en ? String(props.name_en) : null,
class: props.class || null,
coordinates: clipped,
});
}
}
phaseTiming(
'street-extract',
streetsStart,
{
tile: `${z}/${x}/${y}`,
features: names?.length || 0,
streets: streets.length,
},
'outlines',
);
const polygonsStart = performance.now();
const buildings = decodePolygons(
tile.layers.building,
z,
x,
y,
null,
(props) => {
const height = Number(props.render_height);
return { heightM: Number.isFinite(height) && height > 0 ? height : null };
},
true,
);
// Open areas that can enclose grounds: parks, landuse and park-like cover.
const areas = [];
for (const name of ['park', 'landuse', 'landcover']) {
areas.push(
...decodePolygons(
tile.layers[name],
z,
x,
y,
(props) =>
GROUNDS_AREA_CLASSES[name].has(String(props.subclass || props.class)),
(props) => ({
layer: name,
class: String(props.subclass || props.class),
name: props.name ? String(props.name) : null,
}),
),
);
}
phaseTiming(
'polygon-extract',
polygonsStart,
{
tile: `${z}/${x}/${y}`,
buildings: buildings.length,
areas: areas.length,
},
'outlines',
);
return { streets, buildings, areas };
}
registerOpenFreeMapProjection('outlines', decodeOpenFreeMapOutlineTile);
/** Tile pixel -> longitude/latitude, exactly as vector-tile's toGeoJSON projects. */
function pixelToLonLat(px, py, extent, z, x, y) {
const size = extent * 2 ** z;
const lon = ((extent * x + px) * 360) / size - 180;
const y2 = 180 - ((extent * y + py) * 360) / size;
const lat = (360 / Math.PI) * Math.atan(Math.exp((y2 * Math.PI) / 180)) - 90;
return [lon, lat];
}
/**
* Polygons of one layer with their identity and completeness.
*
* Tiles cut geometry at a buffer beyond the tile edge. A polygon with a vertex
* on that cut line is a fragment of something larger: it keeps its feature id
* and tile, and is marked `clipped` so it is never used as a whole outline. A
* neighbouring tile may carry the same feature complete within its buffer.
*/
function decodePolygons(
layer,
z,
x,
y,
keep,
describe,
componentLocal = false,
) {
if (!layer) return [];
if (layer.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
const extent = layer.extent || 4096;
// Cut lines: the layer's extreme coordinates, at or past the tile edge.
let minX = 0;
let minY = 0;
let maxX = extent;
let maxY = extent;
for (let i = 0; i < layer.length; i++)
for (const ring of layer.feature(i).loadGeometry())
for (const point of ring) {
if (point.x < minX) minX = point.x;
if (point.y < minY) minY = point.y;
if (point.x > maxX) maxX = point.x;
if (point.y > maxY) maxY = point.y;
}
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, extent, z, x, y);
// Past a buffer, any vertex on the cut line marks a fragment. A side with
// no buffer was cut at the tile edge itself, where a whole building may
// also just touch the edge: there a fragment shows as an edge running
// along the boundary, not a single vertex on it.
const eps = 1e-9;
const near = (a, b) => Math.abs(a - b) < eps;
const onBufferedCut = ([lon, lat]) =>
(minX < 0 && near(lon, westCut)) ||
(maxX > extent && near(lon, eastCut)) ||
(minY < 0 && near(lat, northCut)) ||
(maxY > extent && near(lat, southCut));
const alongEdge = ([lonA, latA], [lonB, latB]) =>
(minX === 0 && near(lonA, westCut) && near(lonB, westCut)) ||
(maxX === extent && near(lonA, eastCut) && near(lonB, eastCut)) ||
(minY === 0 && near(latA, northCut) && near(latB, northCut)) ||
(maxY === extent && near(latA, southCut) && near(latB, southCut));
const isCut = (ring) =>
ring.some(
(point, index) =>
onBufferedCut(point) ||
(index > 0 && alongEdge(ring[index - 1], point)),
);
const out = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const props = feature.properties;
if (keep && !keep(props)) continue;
const geometry = feature.toGeoJSON(x, y, z).geometry;
const polygons =
geometry.type === 'Polygon'
? [geometry.coordinates]
: geometry.type === 'MultiPolygon'
? geometry.coordinates
: [];
// A tile building feature may aggregate unrelated structures. Validate its
// selected component independently; grounds keep the feature's parts, and
// a match by class alone is narrowed to its own part when selected.
const incompleteParts = polygons.map(
(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 clippedParts = polygons.map(
(rings, part) => incompleteParts[part] || isCut(rings[0]),
);
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: componentLocal ? null : polygons,
id: feature.id ?? null,
part,
tile: `${z}/${x}/${y}`,
clipped: componentLocal
? clippedParts[part]
: clippedParts.some(Boolean),
partClipped: clippedParts[part],
incomplete: componentLocal
? incompleteParts[part]
: incompleteParts.some(Boolean),
...describe(props),
});
}
}
return out;
}
/** Construct the outline tile source: same OpenFreeMap tiles, outline decode. */
export function createOpenFreeMapOutlineSource(options = {}) {
return createOpenFreeMapSource({
projection: 'outlines',
maxEntries: 32,
maxCacheBytes: 8 * 1024 * 1024,
...options,
});
}
/** Geographic box of `radiusM` around a point. */
export function boxAround(lat, lon, radiusM) {
const dLat = radiusM / 111_320;
const dLon =
radiusM / (111_320 * Math.max(0.05, Math.cos((lat * Math.PI) / 180)));
return {
south: Math.max(-85, lat - dLat),
north: Math.min(85, lat + dLat),
west: Math.max(-180, lon - dLon),
east: Math.min(180, lon + dLon),
};
}
const STREET_WORDS = Object.freeze({
st: 'street',
str: 'street',
ave: 'avenue',
av: 'avenue',
blvd: 'boulevard',
rd: 'road',
dr: 'drive',
ln: 'lane',
hwy: 'highway',
pkwy: 'parkway',
pl: 'place',
ct: 'court',
sq: 'square',
nw: 'northwest',
ne: 'northeast',
sw: 'southwest',
se: 'southeast',
});
const STREET_TYPES = new Set(Object.values(STREET_WORDS));
const DIRECTION_LETTERS = Object.freeze({
n: 'north',
s: 'south',
e: 'east',
w: 'west',
});
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
const QUADRANTS = new Set(['northwest', 'northeast', 'southwest', 'southeast']);
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
export function normalizeStreetName(value) {
const words = foldPlaceName(String(value ?? '').split(',')[0])
.split(' ')
.filter(Boolean)
.map((word) => STREET_WORDS[word] || word);
// A lone letter is a direction only as the prefix or suffix of a name it
// qualifies ("N Lamar Blvd", "Main St S"); "E St NW" is the street named E.
const named = words.some(
(word) =>
!DIRECTION_LETTERS[word] &&
!DIRECTIONS.has(word) &&
!QUADRANTS.has(word) &&
!STREET_TYPES.has(word),
);
return words
.map((word, i) =>
named && (i === 0 || i === words.length - 1)
? DIRECTION_LETTERS[word] || word
: word,
)
.join(' ');
}
/**
* Whether a tile street name answers the asked street. Exact after
* normalization; otherwise the same name with only a leading direction added
* ("North Congress Avenue" for "Congress Avenue"), or a quadrant suffix the
* ask leaves unsaid ("E Street Northwest" for "E Street") — never a different
* street that merely shares a word.
*/
export function streetNameMatches(candidate, asked) {
const want = normalizeStreetName(asked);
if (!want) return false;
const answers = (have) => {
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
};
const have = normalizeStreetName(candidate);
if (answers(have)) return true;
const quadrant = streetQuadrant(have);
if (!quadrant || streetQuadrant(want)) return false;
const words = have.split(' ');
return answers(
(quadrant.leading ? words.slice(1) : words.slice(0, -1)).join(' '),
);
}
/** A normalized street name's quadrant ("e street northwest", "southeast main street"). */
function streetQuadrant(name) {
const words = name.split(' ');
if (words.length < 2) return null;
if (QUADRANTS.has(words.at(-1)))
return { name: words.at(-1), leading: false };
if (QUADRANTS.has(words[0])) return { name: words[0], leading: true };
return null;
}
const lengthM = (line) => {
let total = 0;
for (let i = 1; i < line.length; i++)
total += approximateDistanceM(
line[i - 1][1],
line[i - 1][0],
line[i][1],
line[i][0],
);
return total;
};
/**
* The asked street near a point as a bounded set of merged polylines, longest
* first, or null when no tile street carries that name.
*
* @param {Array<{tiles?: object[], streets?: object[]}>|object[]} tiles Decoded tiles.
* @param {{name: string, lat: number, lon: number, radiusM?: number}} ask
* @returns {null | {name: string, lines: Array<Array<[number, number]>>}}
*/
export function streetFromTiles(
tiles,
{ name, lat, lon, radiusM = STREET_RADIUS_M },
) {
const pieces = [];
let matchedName = null;
const extent = boxAround(lat, lon, radiusM);
const matches = [];
for (const tile of tiles || []) {
for (const street of tile?.streets || []) {
const spelled = streetNameMatches(street.name, name)
? street.name
: street.nameEn && streetNameMatches(street.nameEn, name)
? street.nameEn
: null;
if (!spelled) continue;
// Segment distance, not vertex distance: a long straight piece through
// the point has no vertex near it.
const distanceM = distanceToLineM(lon, lat, street.coordinates);
if (distanceM > radiusM) continue;
const quadrant =
streetQuadrant(normalizeStreetName(spelled))?.name || null;
matches.push({ street, distanceM, quadrant });
}
}
// An ask without a quadrant ("E Street" where E St NW and E St NE both
// run) means the one beside the point, not every quadrant's street.
const nearest = matches.reduce(
(best, match) => (!best || match.distanceM < best.distanceM ? match : best),
null,
);
for (const { street, quadrant } of matches) {
if (quadrant !== nearest.quadrant) continue;
// Only what lies within the search extent is drawn.
const clipped = clipTileLine(street.coordinates, extent);
if (!clipped.length) continue;
matchedName ||= street.name;
pieces.push(...clipped);
}
if (!pieces.length) return null;
const chains = stitchLines(
pieces.filter((piece) => piece.length >= 2),
{ toleranceM: JOIN_TOLERANCE_M, inclusive: true },
)
.map((line) => ({ line, length: lengthM(line) }))
.filter((entry) => entry.length >= STREET_MIN_CHAIN_M)
.sort((a, b) => b.length - a.length)
.slice(0, STREET_MAX_CHAINS);
const lines = [];
let points = 0;
for (const { line } of chains) {
if (points + line.length > STREET_MAX_POINTS) break;
lines.push(line);
points += line.length;
}
return lines.length ? { name: matchedName, lines } : null;
}
/** Distance (m) from a point to the nearest edge of any ring of a polygon. */
function distanceToPolygonM(lon, lat, rings) {
return Math.min(...rings.map((ring) => distanceToLineM(lon, lat, ring)));
}
/** Distance (m) from a point to the nearest segment of a line or ring. */
function distanceToLineM(lon, lat, ring) {
return nearestPointOnLines([ring], lat, lon)?.distanceM ?? Infinity;
}
/**
* The building at a point: the smallest footprint containing it, else the
* nearest within `maxDistanceM`, else null.
*
* Returns only the selected footprint component and its holes. A vector-tile
* feature id does not certify that disjoint siblings are one physical building.
*/
export function buildingFromTiles(
tiles,
{ lat, lon, maxDistanceM = BUILDING_MAX_DISTANCE_M },
) {
let containing = null;
let fragmentContains = false;
let nearestFragmentM = Infinity;
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]),
) &&
pointInRing(outer, lat, lon)
)
fragmentContains = true;
continue;
}
if (polygonsContain([building.rings], lat, lon)) {
// A fragment cut at a tile edge is never the whole footprint.
if (building.clipped) {
fragmentContains = true;
continue;
}
const area = ringAreaM2(building.rings[0]);
if (!containing || area < containing.area)
containing = { building, area };
} else {
const distance = distanceToPolygonM(lon, lat, building.rings);
if (building.clipped)
nearestFragmentM = Math.min(nearestFragmentM, distance);
else if (
distance <= maxDistanceM &&
(!nearest || distance < nearest.distance)
)
nearest = { building, distance };
}
}
}
// The point is on, or nearest to, a building only seen in pieces: no
// farther neighbour stands in. A complete copy from a neighbouring tile is
// never farther than its fragment, so the centimetre tolerance keeps it.
const fragmentNearer =
fragmentContains || nearestFragmentM + 0.01 < (nearest?.distance ?? 0);
const pick =
containing?.building || (fragmentNearer ? null : nearest?.building);
if (!pick) return null;
const polygons = [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),
};
}
/** Head nouns that say what kind of grounds is meant, and the classes that answer them. */
const GROUNDS_CLASS_HEADS = Object.freeze([
[/\b(?:school|academy|elementary)$/, ['school']],
[
/\b(?:university|college)$|^(?:university|college) of\b/,
['university', 'college'],
],
[/\b(?:hospital|medical cent(?:er|re))$/, ['hospital']],
[/\b(?:cemetery|graveyard)$/, ['cemetery']],
[/\bstadium$/, ['stadium']],
[/\bzoo$/, ['zoo']],
]);
/**
* A grounds name for comparison: folded, possessive " s" dropped and grounds
* words removed wherever they stand, as the outline rung cuts them from the ask
* ("Mueller Sports Complex", "Campus Martius Park", "Lincoln School's").
*/
function groundsKey(value) {
return normalizePlaceName(value)
.replace(/ s(?= |$)/g, '')
.replace(GROUNDS_WORDS_RE, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Words that describe grounds rather than name the place. */
const GROUNDS_WORDS =
'grounds|campus|compound|premises|property|site|area|complex';
/** Grounds words wherever they stand, any case (the outline rung cuts these). */
export const GROUNDS_WORDS_RE = new RegExp(`\\b(?:${GROUNDS_WORDS})\\b`, 'gi');
const GROUNDS_SUFFIX_RE = new RegExp(`(?:\\s+(?:${GROUNDS_WORDS}))+$`);
/**
* Area classes an unnamed tile feature may answer for a grounds ask, from the
* kind of place the ask names: its head noun once grounds words are removed
* ("Lincoln High School campus" → school, "University of Texas grounds" →
* university). "Campus" alone names no kind ("Google campus"), and a class word
* elsewhere in a name ("University Avenue Park", "Zoo Lake") does not count.
* Tile landuse for schools, hospitals and universities is usually unnamed.
*
* @param {string} text
* @returns {Set<string>}
*/
export function groundsClassesFor(text) {
// "Lincoln School's grounds" folds to "lincoln school s grounds".
const head = foldPlaceName(String(text ?? '').split(',')[0])
.replace(/ s(?= |$)/g, '')
.replace(GROUNDS_SUFFIX_RE, '');
const classes = new Set();
for (const [pattern, matches] of GROUNDS_CLASS_HEADS)
if (pattern.test(head)) for (const c of matches) classes.add(c);
return classes;
}
/**
* The open area enclosing grounds at a point: the smallest park, landuse or
* park-like cover feature that contains it and is at least grounds-sized.
* Containment alone is not identity: a feature named as asked answers first;
* otherwise only an unnamed feature of a class the ask itself names (a school
* for "… School grounds") may answer. A differently named feature never does,
* and holes are not filled grounds.
*/
export function enclosingAreaFromTiles(
tiles,
{
lat,
lon,
name,
unnamedClasses = null,
minAreaM2 = 20_000,
maxAreaM2 = 60e6,
},
) {
const query = groundsKey(name);
if (!query) return null;
let named = null;
let unnamed = null;
for (const tile of tiles || []) {
for (const area of tile?.areas || []) {
const isNamed = Boolean(area.name) && groundsKey(area.name) === query;
const isClassed = !area.name && Boolean(unnamedClasses?.has(area.class));
// A class match stands for its own part, so only that part's cut counts.
const clipped = isNamed
? area.clipped
: (area.partClipped ?? area.clipped);
if (
clipped ||
!(isNamed || isClassed) ||
!polygonsContain([area.rings], lat, lon)
)
continue;
// Only a name ties a feature's other parts to the asked place; an
// unnamed class match stands for the part holding the point.
const parts = isNamed ? area.featureParts || [area.rings] : [area.rings];
const size = parts.reduce(
(sum, [outer, ...holes]) =>
sum +
ringAreaM2(outer) -
holes.reduce((n, hole) => n + ringAreaM2(hole), 0),
0,
);
if (size < minAreaM2 || size > maxAreaM2) continue;
if (isNamed) {
if (!named || size < named.size) named = { area, size, parts };
} else if (!unnamed || size < unnamed.size)
unnamed = { area, size, parts };
}
}
const best = named || unnamed;
if (!best) return null;
const parts = best.parts;
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,
};
}
+325
View File
@@ -0,0 +1,325 @@
// Street and building outlines from OpenFreeMap z14 tiles (fixtures: trimmed
// real tiles around the Texas Capitol, see src/data/fixtures/README.md).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildingFromTiles,
createOpenFreeMapOutlineSource,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
normalizeStreetName,
streetFromTiles,
streetNameMatches,
} from './openFreeMapOutlines.js';
import {
clearSharedOpenFreeMapTiles,
createOpenFreeMapSource,
registerOpenFreeMapProjection,
} from './openFreeMap.js';
import { createSharedDecodedTileCache } from './vectorTiles.js';
import { approximateDistanceM, stitchLines } from './featureGeometry.js';
const fixture = (y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
y,
);
const TILES = [fixture(6745), fixture(6746)];
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
test('tiles decode named street lines and building polygons', () => {
const names = new Set(TILES.flatMap((t) => t.streets.map((s) => s.name)));
assert.ok(names.has('Congress Avenue'));
assert.ok(names.has('South Congress Avenue'));
assert.ok(TILES[0].buildings.length > 0);
for (const street of TILES.flatMap((t) => t.streets))
for (const [lon, lat] of street.coordinates) {
assert.ok(lon > -97.76 && lon < -97.72, 'clipped to the tile core');
assert.ok(lat > 30.24 && lat < 30.29);
}
});
test('street names compare with abbreviations and a leading direction only', () => {
assert.equal(normalizeStreetName('Congress Ave.'), 'congress avenue');
assert.equal(
normalizeStreetName('Lombard St, San Francisco'),
'lombard street',
);
assert.ok(streetNameMatches('Congress Avenue', 'congress ave'));
assert.ok(streetNameMatches('North Congress Avenue', 'Congress Avenue'));
assert.ok(!streetNameMatches('Congress Avenue', 'South Congress Avenue'));
assert.ok(!streetNameMatches('Congress Street', 'Congress Avenue'));
assert.ok(!streetNameMatches('East 11th Street', '11th'));
assert.ok(!streetNameMatches('कुल मार्ग', 'काली मार्ग'));
assert.ok(streetNameMatches('काली मार्ग', 'काली मार्ग'));
assert.ok(streetNameMatches('Champs-Élysées', 'Champs Elysees'));
// Japanese: dakuten and handakuten are kept, so these stay distinct.
assert.ok(!streetNameMatches('ハシ通り', 'バシ通り'));
assert.ok(!streetNameMatches('ハシ通り', 'パシ通り'));
assert.ok(streetNameMatches('表参道', '表参道'));
// Thai: vowel and tone marks are kept.
assert.ok(!streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขมวท'));
assert.ok(!streetNameMatches('ถนนข้าวสาร', 'ถนนขาวสาร'));
assert.ok(streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขุมวิท'));
});
test('a lone letter is a direction only beside a name; quadrants may go unsaid', () => {
assert.equal(normalizeStreetName('N Lamar Blvd'), 'north lamar boulevard');
assert.equal(normalizeStreetName('Main St S'), 'main street south');
assert.equal(normalizeStreetName('E St NW'), 'e street northwest');
assert.ok(!streetNameMatches('East Street', 'E Street'));
assert.ok(!streetNameMatches('E Street', 'East Street'));
assert.ok(streetNameMatches('North Lamar Boulevard', 'N Lamar Blvd'));
assert.ok(streetNameMatches('E Street Northwest', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street Northwest'));
assert.ok(!streetNameMatches('E St NE', 'E Street Northwest'));
assert.ok(!streetNameMatches('E Street', 'E Street Northwest'));
assert.ok(!streetNameMatches('F Street Northwest', 'E Street'));
// A leading quadrant may go unsaid too (Portland's "SE Main St").
assert.ok(streetNameMatches('SE Main St', 'Main Street'));
assert.ok(!streetNameMatches('SE Main St', 'NE Main Street'));
});
test('an ask without a quadrant takes the quadrant beside the point', () => {
const street = (name, lat) => ({
name,
coordinates: [
[-77.03, lat],
[-77.02, lat],
],
});
const tiles = [
{
streets: [
street('E Street Northwest', 38.8962),
street('E Street Northeast', 38.8972),
street('E Street Southeast', 38.8822),
],
},
];
const near = streetFromTiles(tiles, {
name: 'E Street',
lat: 38.8963,
lon: -77.025,
});
assert.equal(near.name, 'E Street Northwest');
assert.equal(near.lines.length, 1, 'one quadrant, not three streets');
assert.ok(near.lines[0].every(([, lat]) => lat === 38.8962));
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
const street = streetFromTiles(TILES, {
name: 'Congress Avenue',
...CAPITOL,
});
assert.equal(street.name, 'Congress Avenue');
assert.equal(street.lines.length, 1, 'the tile pieces join into one line');
const line = street.lines[0];
const [start, end] = [line[0], line.at(-1)];
const spanM = approximateDistanceM(start[1], start[0], end[1], end[0]);
assert.ok(
spanM > 1200,
`Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`,
);
// South Congress is a different street and stays out.
const south = streetFromTiles(TILES, {
name: 'South Congress Avenue',
...CAPITOL,
});
assert.ok(south);
assert.ok(south.lines.every((l) => l.every(([, lat]) => lat < 30.265)));
});
test('street lookups are bounded by radius and answer null for unknown names', () => {
assert.equal(
streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }),
null,
);
assert.equal(
streetFromTiles(TILES, { name: 'Congress Avenue', lat: 30.4, lon: -97.74 }),
null,
'no piece within the radius of a far point',
);
});
test('merge joins reversed and out-of-order pieces and keeps gaps apart', () => {
const a = [
[0, 0],
[0.001, 0],
];
const b = [
[0.002, 0],
[0.001, 0],
]; // reversed, shares a's end
const c = [
[0.01, 0],
[0.011, 0],
]; // separate
const chains = stitchLines([b, c, a], { toleranceM: 3 });
test('stitchLines joins at exactly the tolerance only when inclusive', () => {
const a = [
[0, 0],
[0.001, 0],
];
const b = [
[0.00102, 0],
[0.002, 0],
];
const gap = approximateDistanceM(0, 0.001, 0, 0.00102);
assert.equal(
stitchLines([a, b], { toleranceM: gap, inclusive: true }).length,
1,
);
assert.equal(stitchLines([a, b], { toleranceM: gap }).length, 2);
});
assert.equal(chains.length, 2);
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
});
test('the building containing a point is picked (Texas Capitol)', () => {
const building = buildingFromTiles(TILES, CAPITOL);
assert.ok(building.contains);
assert.ok(building.ring.length >= 8);
assert.deepEqual(building.ring[0], building.ring.at(-1), 'closed ring');
assert.ok(building.heightM > 20, 'render height carried for extrusion');
});
test('a point beside a building picks the nearest within 40 m, else none', () => {
const box = [
[0, 0],
[0.0002, 0],
[0.0002, 0.0002],
[0, 0.0002],
[0, 0],
];
const tiles = [{ buildings: [{ rings: [box], heightM: null }] }];
const near = buildingFromTiles(tiles, { lat: 0.0001, lon: 0.00045 }); // ~28 m east
assert.ok(near && !near.contains);
assert.equal(buildingFromTiles(tiles, { lat: 0.0001, lon: 0.0008 }), null);
});
test('tile enclosure alone does not identify the named Capitol grounds', () => {
assert.equal(
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',
);
});
test('traffic projects outlines only on demand and shares the decoded tile', async () => {
clearSharedOpenFreeMapTiles();
const idle = [];
const previousIdle = globalThis.requestIdleCallback;
const previousCancelIdle = globalThis.cancelIdleCallback;
globalThis.requestIdleCallback = (callback) => {
idle.push(callback);
return idle.length;
};
globalThis.cancelIdleCallback = () => {};
let projectionRuns = 0;
registerOpenFreeMapProjection('requested-outlines-test', (...args) => {
projectionRuns++;
return decodeOpenFreeMapOutlineTile(...args);
});
let requests = 0;
const bytes = readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
);
const fetchImpl = async () => {
requests++;
return new Response(bytes);
};
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const box = { south: 30.27, north: 30.28, west: -97.75, east: -97.73 };
try {
const traffic = createOpenFreeMapSource({
template:
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
fetchImpl,
});
const outlines = createOpenFreeMapOutlineSource({
projection: 'requested-outlines-test',
template:
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
fetchImpl,
});
const trafficResult = await traffic.fetchBounds(box, { zoom: 14, tiles });
assert.equal(idle.length, 0, 'traffic schedules no outline extraction');
assert.equal(projectionRuns, 0);
const result = await outlines.fetchBounds(box, { zoom: 14, tiles });
assert.ok(result.tiles[0].buildings.length > 0);
await outlines.fetchBounds(box, { zoom: 14, tiles });
assert.equal(
projectionRuns,
1,
'the first explicit request owns the cached projection',
);
assert.deepEqual(
(await traffic.fetchBounds(box, { zoom: 14, tiles })).tiles,
trafficResult.tiles,
);
assert.equal(requests, 1);
} finally {
globalThis.requestIdleCallback = previousIdle;
globalThis.cancelIdleCallback = previousCancelIdle;
clearSharedOpenFreeMapTiles();
}
});
test('an explicitly requested over-budget projection releases its shared entry', async () => {
let projectionRuns = 0;
registerOpenFreeMapProjection('projection-lifecycle-test', () => {
projectionRuns++;
return { coordinates: Array.from({ length: 20_000 }, (_, i) => [i, i]) };
});
const firstBytes = readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
);
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 1,
maxCacheBytes: 100_000,
});
const source = createOpenFreeMapSource({
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
projection: 'projection-lifecycle-test',
sharedDecodedCache,
fetchImpl: async () => new Response(firstBytes),
});
const box = { south: 30.24, north: 30.29, west: -97.75, east: -97.73 };
try {
await source.fetchBounds(box, {
zoom: 14,
tiles: [{ z: 14, x: 3743, y: 6745 }],
});
assert.equal(projectionRuns, 1);
assert.deepEqual(sharedDecodedCache.stats(), { entries: 0, bytes: 0 });
} finally {
source.clear();
sharedDecodedCache.clear();
}
});
@@ -0,0 +1,520 @@
// Tile fragments, holes and street extents for the OpenFreeMap outline rungs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildingFromTiles,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from './openFreeMapOutlines.js';
const TILE = decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
),
14,
3743,
6745,
);
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
const square = (lon, lat, d) => [
[lon, lat],
[lon + d, lat],
[lon + d, lat + d],
[lon, lat + d],
[lon, lat],
];
test('decoded polygons keep identity and mark tile-cut fragments', () => {
assert.ok(
TILE.buildings.every((b) => b.tile === '14/3743/6745' && 'id' in b),
);
assert.ok(
TILE.buildings.some((b) => b.clipped),
'fragments at the cut line are marked',
);
assert.ok(
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);
const picked = buildingFromTiles([multipart], { lon: 0.0035, lat: 0.0005 });
assert.deepEqual(
picked.polygons,
[parts[1]],
'an anonymous tile feature does not certify sibling building identity',
);
assert.deepEqual(
buildingFromTiles([multipart], { lon: 0.0008, lat: 0.0008 }).polygons,
[parts[0]],
'the selected component retains its courtyard hole',
);
const clipped = decode(
[parts[0], [square(-180 - 360 / 4096, 0, 0.001)]],
true,
);
assert.equal(clipped.buildings[0].clipped, false);
assert.equal(clipped.buildings[1].clipped, true);
assert.deepEqual(
buildingFromTiles([clipped], { lon: 0.0008, lat: 0.0008 }).polygons,
[parts[0]],
'a remote cut sibling cannot disqualify an intact component',
);
assert.equal(
buildingFromTiles([clipped], {
lon: -180 - 360 / 4096 + 0.0005,
lat: 0.0005,
}),
null,
);
const malformed = decode([parts[0], [parts[1][0], null]]);
const neighbor = {
rings: [square(0.0041, 0, 0.001)],
heightM: 20,
clipped: false,
};
assert.deepEqual(
buildingFromTiles([malformed], { lon: 0.0008, lat: 0.0008 }).polygons,
[parts[0]],
'a remote malformed sibling cannot disqualify an intact component',
);
assert.equal(
buildingFromTiles(
[{ ...malformed, buildings: [...malformed.buildings, neighbor] }],
{ lon: 0.0038, lat: 0.0008 },
),
null,
'a selected malformed component never substitutes its neighbour',
);
});
test('a clipped fragment is never the building, and no neighbour stands in', () => {
const fragment = {
rings: [square(0, 0, 0.001)],
clipped: true,
heightM: null,
};
const neighbour = {
rings: [square(0.0011, 0, 0.0002)],
clipped: false,
heightM: null,
};
const point = { lat: 0.0005, lon: 0.0008 };
assert.equal(
buildingFromTiles([{ buildings: [fragment, neighbour] }], point),
null,
);
// A complete copy from the neighbouring tile's buffer is used.
const complete = { ...fragment, clipped: false };
assert.ok(
buildingFromTiles(
[{ buildings: [fragment, neighbour] }, { buildings: [complete] }],
point,
)?.contains,
);
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,
name: 'Test Park',
}),
null,
'clipped grounds are not grounds',
);
});
test('a nearer cut fragment stops a farther whole building standing in', () => {
// About 110 m on a side, 2 m east of the point.
const fragment = {
rings: [square(0.00002, 0, 0.001)],
clipped: true,
heightM: null,
};
// Whole, about 30 m west of the point.
const neighbour = {
rings: [square(-0.0004, 0.00045, 0.0001)],
clipped: false,
heightM: null,
};
const point = { lat: 0.0005, lon: 0 };
assert.equal(
buildingFromTiles([{ buildings: [fragment, neighbour] }], point),
null,
);
// The fragment's complete copy from the neighbouring tile is the answer.
const complete = { ...fragment, clipped: false };
assert.deepEqual(
buildingFromTiles(
[{ buildings: [fragment, neighbour] }, { buildings: [complete] }],
point,
)?.polygons,
[fragment.rings],
);
// A fragment farther than the whole building does not block it.
assert.ok(
buildingFromTiles([{ buildings: [fragment, neighbour] }], {
...point,
lon: -0.00028,
}),
);
});
test('grounds touching an unbuffered tile edge are cut, not complete', () => {
// One tile at z0 whose park layer has no buffer: the cut is the edge itself.
const decode = (coordinates) =>
decodeOpenFreeMapOutlineTile(null, 0, 0, 0, {
layers: {
park: {
length: 1,
extent: 4096,
feature: () => ({
properties: { class: 'park', name: 'Edge Park' },
loadGeometry: () => [[{ x: 0, y: 2048 }]],
toGeoJSON: () => ({
geometry: { type: 'Polygon', coordinates },
}),
}),
},
},
});
const ask = { lat: 0.005, lon: -179.995, name: 'Edge Park' };
const cut = decode([square(-180, 0, 0.01)]);
assert.equal(cut.areas[0].clipped, true);
assert.equal(enclosingAreaFromTiles([cut], ask), null);
const inside = decode([square(-179.999, 0, 0.01)]);
assert.equal(inside.areas[0].clipped, false);
assert.ok(enclosingAreaFromTiles([inside], ask));
// A whole shape that only touches the edge at one corner is not cut.
const tangent = decode([
[
[-180, 0.005],
[-179.995, 0],
[-179.99, 0.005],
[-179.995, 0.01],
[-180, 0.005],
],
]);
assert.equal(tangent.areas[0].clipped, false);
});
test('a point in a courtyard is not inside the building', () => {
const block = {
rings: [square(0, 0, 0.001), square(0.0004, 0.0004, 0.0002).reverse()],
clipped: false,
heightM: 20,
};
const courtyard = { lat: 0.0005, lon: 0.0005 };
assert.equal(
buildingFromTiles([{ buildings: [block] }], {
...courtyard,
maxDistanceM: 0,
}),
null,
);
const near = buildingFromTiles([{ buildings: [block] }], courtyard);
assert.ok(near && !near.contains, 'near the courtyard walls, not inside');
});
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.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', () => {
// One straight 4 km piece whose vertices are both 2 km from the point.
const through = {
name: 'Long Road',
coordinates: [
[-0.018, 0],
[0.018, 0],
],
};
const street = streetFromTiles([{ streets: [through] }], {
name: 'Long Road',
lat: 0,
lon: 0,
radiusM: 500,
});
assert.ok(street, 'a street passing through the point is found');
for (const [lon] of street.lines.flat())
assert.ok(Math.abs(lon) <= 500 / 111_320 + 1e-9, 'within the extent');
const far = {
name: 'Long Road',
coordinates: [
[-0.018, 0.02],
[0.018, 0.02],
],
};
assert.equal(
streetFromTiles([{ streets: [far] }], {
name: 'Long Road',
lat: 0,
lon: 0,
radiusM: 500,
}),
null,
);
});
test('unnamed school, hospital and campus grounds answer only an ask of that kind', () => {
assert.deepEqual(
[...groundsClassesFor('Lincoln High School grounds')],
['school'],
);
assert.deepEqual(
[...groundsClassesFor('Lincoln High School campus')],
['school'],
);
assert.deepEqual([...groundsClassesFor('City College campus site')].sort(), [
'college',
'university',
]);
assert.deepEqual(
[...groundsClassesFor('St. Mary Medical Center')],
['hospital'],
);
assert.deepEqual([...groundsClassesFor('Texas Capitol grounds')], []);
assert.deepEqual(
[...groundsClassesFor('University of Texas grounds')].sort(),
['college', 'university'],
);
for (const name of [
'University Avenue Park grounds',
'Zoo Lake grounds',
'School Street Playground grounds',
'Academy of Sciences grounds',
'Stadium Station',
'Google campus',
'Mayo Clinic campus',
'Google campus',
'Mayo Clinic campus',
])
assert.deepEqual([...groundsClassesFor(name)], [], name);
// About 1.2 km on a side: grounds-sized.
const school = {
rings: [square(0, 0, 0.01)],
clipped: false,
class: 'school',
name: null,
};
const otherSchool = {
...school,
rings: [square(-0.001, -0.001, 0.02)],
name: 'Other Academy',
};
const park = { ...school, class: 'park' };
const at = { lat: 0.005, lon: 0.005, name: 'Lincoln High School' };
const asked = {
...at,
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
assert.deepEqual(
enclosingAreaFromTiles([{ areas: [school] }], asked)?.class,
'school',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [school] }], at),
null,
'no class from the ask, no unnamed answer',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [park] }], asked),
null,
'another class never answers',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [otherSchool] }], asked),
null,
'a differently named school never answers',
);
const named = {
...school,
rings: [square(-0.002, -0.002, 0.03)],
name: 'Lincoln High School',
};
assert.equal(
enclosingAreaFromTiles([{ areas: [school, named] }], asked).name,
'Lincoln High School',
'a named match answers first',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [{ ...school, clipped: true }] }], asked),
null,
'clipped unnamed grounds are not grounds',
);
});
test('an unnamed class match is only the part holding the point', () => {
// A school feature with a second, distant part: only a name could tie them.
const near = square(0, 0, 0.002); // about 49,000 m²
const far = square(0.02, 0.02, 0.002);
const tiny = square(0, 0, 0.0005); // about 3,000 m², below the floor
const ask = {
lat: 0.001,
lon: 0.001,
name: 'Lincoln High School',
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
// As decoded: `rings` is the same array as its entry in `featureParts`.
const area = (rings, others) => ({
rings,
featureParts: [rings, ...others],
clipped: false,
class: 'school',
name: null,
});
const found = enclosingAreaFromTiles(
[{ areas: [area([near], [[far]])] }],
ask,
);
assert.deepEqual(
found.polygons,
[[near]],
'the distant part is not included',
);
assert.equal(
enclosingAreaFromTiles(
[{ areas: [area([tiny], [[far], [square(-0.02, 0, 0.003)]])] }],
{ ...ask, lat: 0.0002, lon: 0.0002 },
),
null,
'other parts cannot lift a small part over the size floor',
);
const named = enclosingAreaFromTiles(
[
{
areas: [{ ...area([near], [[far]]), name: 'Lincoln High School' }],
},
],
ask,
);
assert.equal(named.polygons.length, 2, 'a named feature keeps its parts');
});
test('grounds names keep their own grounds words; possessives and part cuts', () => {
assert.deepEqual(
[...groundsClassesFor("Lincoln School's grounds")],
['school'],
);
const complex = {
rings: [square(0, 0, 0.002)],
clipped: false,
class: 'pitch',
name: 'Mueller Sports Complex',
};
const named = (name) => ({
rings: [square(0, 0, 0.002)],
clipped: false,
class: 'park',
name,
});
const at = { lat: 0.001, lon: 0.001 };
for (const [ask, area] of [
["Lincoln School's", 'Lincoln School'],
['Martius Park', 'Campus Martius Park'],
['Mueller Sports Field', 'Mueller Sports Complex Field'],
])
assert.equal(
enclosingAreaFromTiles([{ areas: [named(area)] }], { ...at, name: ask })
?.name,
area,
`${ask} → ${area}`,
);
assert.equal(
enclosingAreaFromTiles([{ areas: [named('Lincoln Park Zoo')] }], {
...at,
name: 'Lincoln Park',
}),
null,
'a longer, different name never answers',
);
// The ask arrives with its grounds words cut by the rung.
assert.equal(
enclosingAreaFromTiles([{ areas: [complex] }], {
lat: 0.001,
lon: 0.001,
name: 'Mueller Sports',
})?.name,
'Mueller Sports Complex',
);
// A sibling part cut at the tile edge does not refuse a class match's own part.
const school = {
rings: [square(0, 0, 0.002)],
clipped: true,
partClipped: false,
class: 'school',
name: null,
};
const ask = {
lat: 0.001,
lon: 0.001,
name: 'Lincoln High School',
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
assert.ok(enclosingAreaFromTiles([{ areas: [school] }], ask));
assert.equal(
enclosingAreaFromTiles(
[{ areas: [{ ...school, partClipped: true }] }],
ask,
),
null,
'its own cut part is still refused',
);
});
+3
View File
@@ -89,6 +89,9 @@ export function createOverpassFeatureSource({
}
}
return {
// Only this concrete OSM adapter declares outline origin; generic injected
// feature sources do not acquire attribution from their feature IDs.
outlineCredit: 'osm',
getFocusFootprints({ lat, lon }, options = {}) {
validPoint(lat, lon);
return query(
+46 -2
View File
@@ -1,9 +1,53 @@
/** Optional development phase measurements; no provider payloads or credentials. */
export function phaseTiming(phase, start, detail = {}) {
export function phaseTiming(phase, start, detail = {}, scope = 'roads') {
if (import.meta.env?.DEV)
performance.measure(`roads:${phase}`, {
performance.measure(`${scope}:${phase}`, {
start,
end: performance.now(),
detail,
});
}
/** Bounded development evidence for one physical outline installation. */
export function createWorkTrace() {
if (!import.meta.env?.DEV) return null;
try {
return {
timeOrigin: performance.timeOrigin,
slices: [],
dropped: 0,
probes: 0,
samplerStart: null,
samplerHeadAt: null,
samplerStatus: null,
rendererStatus: null,
status: 'pending',
completedAt: null,
};
} catch {
return null;
}
}
/** Never let optional diagnostic recording change an installation outcome. */
export function recordWorkSlice(trace, phase, start, end, detail) {
if (!trace) return;
try {
if (trace.slices.length >= 16) {
trace.dropped += 1;
return;
}
trace.slices.push({
phase,
start,
end: end ?? performance.now(),
...(detail === undefined ? {} : { detail }),
});
} catch {
try {
trace.dropped += 1;
} catch {
// Missing or incomplete evidence fails the measurement, not the product.
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWorkTrace, recordWorkSlice } from './phaseTiming.js';
test('unsupported DEV does not create a recorder and absent recording is inert', () => {
assert.equal(createWorkTrace(), null);
assert.doesNotThrow(() => recordWorkSlice(null, 'unused', 0));
});
test('work intervals preserve raw endpoints and fail closed on bounded-record loss', () => {
const trace = { slices: [], dropped: 0 };
recordWorkSlice(trace, 'ground-step', 100, 123);
assert.deepEqual(trace.slices[0], {
phase: 'ground-step',
start: 100,
end: 123,
});
for (let i = 1; i <= 16; i += 1)
recordWorkSlice(trace, 'ground-step', 200 + i, 201 + i);
assert.equal(trace.slices.length, 16);
assert.equal(trace.dropped, 1);
assert.deepEqual(trace.slices[0], {
phase: 'ground-step',
start: 100,
end: 123,
});
const detail = { index: 0, clampStart: 2, clampEnd: 3 };
const nested = { slices: [], dropped: 0 };
recordWorkSlice(nested, 'ground-step', 1, 4, detail);
assert.deepEqual(nested.slices[0], {
phase: 'ground-step',
start: 1,
end: 4,
detail,
});
const unavailable = { slices: Object.freeze([]), dropped: 0 };
assert.doesNotThrow(() =>
recordWorkSlice(unavailable, 'ground-step', 1, 2, detail),
);
assert.equal(unavailable.dropped, 1);
});
+13
View File
@@ -72,3 +72,16 @@ export function tleCatalogNumber(line) {
const field = String(line).slice(2, 7);
return decodeCatalogField(field.replace(/^ +/, ''));
}
/**
* Launch year from TLE line 1's international designator (columns 10-11);
* null when the designator is blank.
* @param {string} line1
* @returns {number|null}
*/
export function tleLineLaunchYear(line1) {
const digits = String(line1 || '').slice(9, 11);
if (!/^\d{2}$/.test(digits)) return null;
const shortYear = Number(digits);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
+236 -39
View File
@@ -37,6 +37,150 @@ function tileJsonOrigin(tileJsonUrl) {
}
}
const DECODER_IDS = new WeakMap();
let nextDecoderId = 1;
/** Give the page a turn; shared completion must not run every consumer's selection in one task. */
export 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 decoder identity, immutable tile template and XYZ;
* callers still keep their own consumer-shaped caches and request lifetimes.
*/
export function createSharedDecodedTileCache({
maxEntries = 192,
maxCacheBytes = 64 * 1024 * 1024,
} = {}) {
const cache = new Map();
const flights = new Map();
let cacheBytes = 0;
let generation = 0;
const release = (entry) => {
if (!entry || entry.released) return;
entry.released = true;
entry.value?.releaseSharedCacheEntry?.();
};
const remove = (key, expected = null) => {
const entry = cache.get(key);
if (!entry || (expected && entry !== expected)) return false;
cache.delete(key);
cacheBytes -= entry.size;
release(entry);
return true;
};
const evict = () => {
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
remove(cache.keys().next().value);
}
};
const resize = (key, value, entry, size) => {
if (!Number.isFinite(size) || size < 0) return;
const previousSize = entry.size;
entry.size = size;
if (cache.get(key) !== entry || entry.value !== value) return;
cacheBytes += size - previousSize;
evict();
};
const store = (key, value, size) => {
const entry = { value, size, released: false };
if (!Number.isFinite(size) || size < 0 || size > maxCacheBytes)
return entry;
remove(key);
cache.set(key, entry);
cacheBytes += size;
try {
value?.bindSharedCacheEntry?.(
(nextSize) => resize(key, value, entry, nextSize),
size,
);
} catch (error) {
remove(key, entry);
throw error;
}
evict();
return entry;
};
const subscribe = (flight, signal, key) => {
signal?.throwIfAborted();
flight.users += 1;
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
flight.promise
.then(resolve, reject)
.finally(() => signal?.removeEventListener('abort', abort));
}).finally(() => {
flight.users -= 1;
if (!flight.users && flights.get(key) === flight) {
flights.delete(key);
flight.controller.abort();
}
});
};
return {
get(key) {
const hit = cache.get(key);
if (!hit) return null;
cache.delete(key);
cache.set(key, hit);
return hit;
},
set(key, value, size) {
return store(key, value, size);
},
load(key, signal, loader) {
const hit = this.get(key);
if (hit) return Promise.resolve(hit);
let flight = flights.get(key);
if (!flight) {
flight = {
controller: new AbortController(),
users: 0,
promise: null,
generation,
};
flights.set(key, flight);
flight.promise = Promise.resolve()
.then(() => loader(flight.controller.signal))
.then(({ value, size }) => {
flight.controller.signal.throwIfAborted();
if (flight.generation !== generation)
throw new DOMException('Shared tile cache cleared', 'AbortError');
return store(key, value, size);
})
.finally(() => {
if (flights.get(key) === flight) flights.delete(key);
});
}
return subscribe(flight, signal, key);
},
clear() {
generation += 1;
for (const flight of flights.values()) flight.controller.abort();
flights.clear();
for (const entry of cache.values()) release(entry);
cache.clear();
cacheBytes = 0;
},
stats: () => ({ entries: cache.size, bytes: cacheBytes }),
};
}
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
export function createVectorTileSource({
tileJsonUrl,
@@ -52,6 +196,9 @@ export function createVectorTileSource({
maxResponseBytes = 4 * 1024 * 1024,
metadataCooldownMs = 5000,
now = Date.now,
sharedDecodedCache = null,
selectDecoded = (value) => value,
selectedSize = null,
}) {
const cache = new Map();
const active = new Set();
@@ -66,12 +213,12 @@ export function createVectorTileSource({
let activeWorkers = 0;
const waiters = [];
async function request(url, parentSignal, read) {
async function request(url, parentSignal, read, trackAsSource = true) {
const controller = new AbortController();
const abort = () => controller.abort(parentSignal?.reason);
parentSignal?.throwIfAborted();
parentSignal?.addEventListener('abort', abort, { once: true });
active.add(controller);
if (trackAsSource) active.add(controller);
const timer = setTimeout(
() =>
controller.abort(
@@ -113,7 +260,7 @@ export function createVectorTileSource({
} finally {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
active.delete(controller);
if (trackAsSource) active.delete(controller);
}
}
@@ -271,59 +418,109 @@ 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 hit.value;
}
if (hit) {
cache.delete(key);
cacheBytes -= hit.size;
}
const loadDecoded = async (ownedSignal, sharedOwnership = false) => {
await acquire(ownedSignal);
try {
ownedSignal.throwIfAborted();
fetched++;
const url = meta.template
.replace('{z}', tile.z)
.replace('{x}', tile.x)
.replace('{y}', tile.y);
const fetchStart = performance.now();
const bytes = await request(
url,
ownedSignal,
async (res, requestSignal) => {
const bodyStart = performance.now();
const value = await readResponseBytesCapped(
res,
maxResponseBytes,
requestSignal,
);
phaseTiming('tile-body-read', bodyStart, {
source: allowedOrigin,
key,
bytes: value.byteLength,
});
return value;
},
!sharedOwnership,
);
phaseTiming('tile-fetch', fetchStart, {
source: allowedOrigin,
key,
bytes: bytes.byteLength,
});
const decodeStart = performance.now();
const decoded = decode(bytes, tile.z, tile.x, tile.y);
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
ownedSignal.throwIfAborted();
// Body-based decoded-storage estimate; projections may increase it.
return { value: decoded, size: bytes.byteLength * 4 };
} finally {
release();
}
};
let flight = flights.get(key);
if (!flight) {
flight = { controller: new AbortController(), users: 0 };
const ownedSignal = flight.controller.signal;
flights.set(key, flight);
flight.promise = (async () => {
await acquire(ownedSignal);
try {
const decodedEntry = sharedDecodedCache
? 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');
fetched++;
const url = meta.template
.replace('{z}', tile.z)
.replace('{x}', tile.x)
.replace('{y}', tile.y);
const fetchStart = performance.now();
const bytes = await request(url, ownedSignal, (res, requestSignal) =>
readResponseBytesCapped(res, maxResponseBytes, requestSignal),
);
phaseTiming('tile-fetch', fetchStart, {
source: allowedOrigin,
key,
bytes: bytes.byteLength,
});
const decodeStart = performance.now();
const value = decode(bytes, tile.z, tile.x, tile.y);
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
ownedSignal.throwIfAborted();
// Body-based decoded-storage estimate; never stringify geometry on the load path.
const size = bytes.byteLength * 4;
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
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 value;
} finally {
release();
}
const selected = selectDecoded(decodedEntry.value, tile);
// Shared entries stay authoritative; consumers own their nested arrays.
const value = sharedDecodedCache ? structuredClone(selected) : selected;
// A consumer of a shared tile retains only its own slice; charge that,
// not the whole decoded tile, which the shared cache already counts.
let size = decodedEntry.size;
if (sharedDecodedCache && selectedSize) {
try {
size = selectedSize(value);
} catch {
size = decodedEntry.size;
}
}
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
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 value;
})().finally(() => {
if (flights.get(key) === flight) flights.delete(key);
});
+294 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
const meta = { tiles: ['https://tiles.example/{z}/{x}/{y}.pbf'] };
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
const options = {
@@ -245,3 +248,293 @@ test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
);
});
test('shared decoded tiles are isolated from consumer mutation', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
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,
selectDecoded: (value) => value.roads,
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const first = createVectorTileSource(common);
const second = createVectorTileSource(common);
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const firstResult = await first.fetchBounds(box, { zoom: 14, tiles });
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);
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;
let decodes = 0;
let releaseResponse;
let requestStarted;
const responseReady = new Promise((resolve) => {
releaseResponse = resolve;
});
const started = new Promise((resolve) => {
requestStarted = resolve;
});
const decode = () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
};
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode,
fetchImpl: async () => {
requests++;
requestStarted();
await responseReady;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const roads = createVectorTileSource({
...common,
selectDecoded: (value) => value.roads,
});
const outlines = createVectorTileSource({
...common,
selectDecoded: (value) => value.outlines,
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const roadResult = roads.fetchBounds(box, { zoom: 14, tiles });
const roadRejected = assert.rejects(roadResult, { name: 'AbortError' });
await started;
const outlineResult = outlines.fetchBounds(box, { zoom: 14, tiles });
await new Promise((resolve) => setImmediate(resolve));
roads.clear();
releaseResponse();
await roadRejected;
assert.deepEqual((await outlineResult).tiles, [['outline']]);
assert.equal(requests, 1);
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;
let releases = 0;
const decoded = {
bindSharedCacheEntry(update) {
resize = update;
},
releaseSharedCacheEntry() {
releases++;
},
};
const entry = shared.set('tile', decoded, 12);
assert.deepEqual(shared.stats(), { entries: 1, bytes: 12 });
resize(80);
assert.deepEqual(shared.stats(), { entries: 1, bytes: 80 });
resize(101);
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(entry.size, 101);
assert.equal(releases, 1);
shared.clear();
assert.equal(releases, 1);
});
test('shared clear releases entries and prevents a pending loader from refilling', async () => {
const shared = createSharedDecodedTileCache();
let releases = 0;
shared.set(
'resident',
{
releaseSharedCacheEntry() {
releases++;
},
},
12,
);
shared.clear();
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(releases, 1);
let loaderStarted;
let resolveLoader;
const started = new Promise((resolve) => {
loaderStarted = resolve;
});
const loaderResult = new Promise((resolve) => {
resolveLoader = resolve;
});
const pending = shared.load('late', null, async () => {
loaderStarted();
return loaderResult;
});
await started;
resolveLoader({ value: {}, size: 16 });
shared.clear();
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);
});