mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge upstream main into PR #1435 for integration review
This commit is contained in:
commit
a6d20120a7
1364 files changed
+102726
-23267
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user