mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge current upstream and resolve PR 581 conflicts
This commit is contained in:
commit
d866f9b01c
211 files changed
+28120
-1988
No files matched your search
@@ -0,0 +1,21 @@
|
||||
/** A permanent session capability miss; clients must not schedule background retries. */
|
||||
export function isUnavailableCapability(value) {
|
||||
return (
|
||||
value?.code === 'OVERPASS_NOT_CONFIGURED' ||
|
||||
value?.unavailable === true ||
|
||||
value?.retryable === false
|
||||
);
|
||||
}
|
||||
|
||||
/** Preserve the server's explicit capability and cooldown fields without upstream text. */
|
||||
export function sourceResponseError(body, response, message) {
|
||||
const seconds = Number(response?.headers?.get('retry-after'));
|
||||
return Object.assign(new Error(message), {
|
||||
code: body?.code,
|
||||
retryable: body?.retryable !== false,
|
||||
retryAfterMs: Math.max(
|
||||
Number(body?.retryAfterMs) || 0,
|
||||
Number.isFinite(seconds) ? seconds * 1000 : 0,
|
||||
),
|
||||
});
|
||||
}
|
||||
+13
-14
@@ -61,14 +61,13 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||
const tooLarge = () => {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
return err;
|
||||
};
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error('Upstream response too large'), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw tooLarge();
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
@@ -95,20 +94,20 @@ export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||
if (total > maxBytes) throw tooLarge();
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
} catch (error) {
|
||||
cancel();
|
||||
await reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
reader.releaseLock();
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/** Decode the Planetiler typed-id convention; unknown schemes stay unnamed. */
|
||||
export function militaryOsmKey(featureKey) {
|
||||
const id = Number(featureKey);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
const prefix = ['', 'n', 'w', 'r'][id % 10];
|
||||
return prefix ? `${prefix}${Math.floor(id / 10)}` : null;
|
||||
}
|
||||
|
||||
const groupNames = new WeakMap();
|
||||
|
||||
/** Clip a polygon ring to a tile core, retaining winding and a closed ring. */
|
||||
export function clipTileRing(ring, box) {
|
||||
let points = ring.slice(0, -1);
|
||||
for (const [axis, edge, sign] of [
|
||||
[0, box.west, 1],
|
||||
[0, box.east, -1],
|
||||
[1, box.south, 1],
|
||||
[1, box.north, -1],
|
||||
]) {
|
||||
const output = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[(i + points.length - 1) % points.length],
|
||||
b = points[i];
|
||||
const ai = (a[axis] - edge) * sign >= 0,
|
||||
bi = (b[axis] - edge) * sign >= 0;
|
||||
if (ai !== bi) {
|
||||
const t = (edge - a[axis]) / (b[axis] - a[axis]);
|
||||
const p = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
|
||||
p[axis] = edge;
|
||||
output.push(p);
|
||||
}
|
||||
if (bi) output.push(b);
|
||||
}
|
||||
points = output;
|
||||
}
|
||||
return points.length >= 3 ? [...points, points[0]] : [];
|
||||
}
|
||||
|
||||
/** Return only real boundary segments, never the straight edges introduced by tile clipping. */
|
||||
export function militaryOutlineLines(ring, box) {
|
||||
const lines = [];
|
||||
let line = [];
|
||||
const epsilon = 1e-8;
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const a = ring[i - 1],
|
||||
b = ring[i];
|
||||
const seam = [
|
||||
[0, box.west],
|
||||
[0, box.east],
|
||||
[1, box.south],
|
||||
[1, box.north],
|
||||
].some(
|
||||
([axis, edge]) =>
|
||||
Math.abs(a[axis] - edge) < epsilon &&
|
||||
Math.abs(b[axis] - edge) < epsilon,
|
||||
);
|
||||
if (seam) {
|
||||
if (line.length > 1) lines.push(line);
|
||||
line = [];
|
||||
} else {
|
||||
if (!line.length) line.push(a);
|
||||
line.push(b);
|
||||
}
|
||||
}
|
||||
if (line.length > 1) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function inside(point, ring) {
|
||||
let hit = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a = ring[i],
|
||||
b = ring[j];
|
||||
if (
|
||||
a[1] > point[1] !== b[1] > point[1] &&
|
||||
point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0]
|
||||
)
|
||||
hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
const inPolygon = (point, rings) =>
|
||||
inside(point, rings[0]) &&
|
||||
!rings.slice(1).some((ring) => inside(point, ring));
|
||||
function bounds(ring) {
|
||||
const x = ring.map((p) => p[0]),
|
||||
y = ring.map((p) => p[1]);
|
||||
return {
|
||||
west: Math.min(...x),
|
||||
east: Math.max(...x),
|
||||
south: Math.min(...y),
|
||||
north: Math.max(...y),
|
||||
};
|
||||
}
|
||||
function overlaps(a, b, eps) {
|
||||
return (
|
||||
a.west <= b.east + eps &&
|
||||
b.west <= a.east + eps &&
|
||||
a.south <= b.north + eps &&
|
||||
b.south <= a.north + eps
|
||||
);
|
||||
}
|
||||
function distance2(p, a, b) {
|
||||
const dx = b[0] - a[0],
|
||||
dy = b[1] - a[1];
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy) || 0,
|
||||
),
|
||||
);
|
||||
return (p[0] - a[0] - t * dx) ** 2 + (p[1] - a[1] - t * dy) ** 2;
|
||||
}
|
||||
function touches(a, b, epsilon) {
|
||||
if (!overlaps(a.bbox, b.bbox, epsilon)) return false;
|
||||
if (
|
||||
a.rings[0].some((p) => inPolygon(p, b.rings)) ||
|
||||
b.rings[0].some((p) => inPolygon(p, a.rings))
|
||||
)
|
||||
return true;
|
||||
const cross = (p, q, r) =>
|
||||
(q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
for (const ar of a.rings)
|
||||
for (const br of b.rings) {
|
||||
for (let i = 1; i < ar.length; i++)
|
||||
for (let j = 1; j < br.length; j++) {
|
||||
const p = ar[i - 1],
|
||||
q = ar[i],
|
||||
r = br[j - 1],
|
||||
s = br[j];
|
||||
if (
|
||||
cross(p, q, r) * cross(p, q, s) < 0 &&
|
||||
cross(r, s, p) * cross(r, s, q) < 0
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
Math.min(
|
||||
distance2(p, r, s),
|
||||
distance2(q, r, s),
|
||||
distance2(r, p, q),
|
||||
distance2(s, p, q),
|
||||
) <=
|
||||
epsilon ** 2
|
||||
)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function ringMoment(ring) {
|
||||
const [ox, oy] = ring[0];
|
||||
let sum = 0,
|
||||
x = 0,
|
||||
y = 0;
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const a = [ring[i - 1][0] - ox, ring[i - 1][1] - oy],
|
||||
b = [ring[i][0] - ox, ring[i][1] - oy];
|
||||
const c = a[0] * b[1] - b[0] * a[1];
|
||||
sum += c;
|
||||
x += (a[0] + b[0]) * c;
|
||||
y += (a[1] + b[1]) * c;
|
||||
}
|
||||
return {
|
||||
area: Math.abs(sum / 2),
|
||||
point: sum ? [ox + x / (3 * sum), oy + y / (3 * sum)] : ring[0],
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge touching mapped parcels and tile fragments, preserving one session-stable installation id. */
|
||||
export function mergeMilitaryFragments(records, aliases = new Map()) {
|
||||
let titles = groupNames.get(aliases);
|
||||
if (!titles) groupNames.set(aliases, (titles = new Map()));
|
||||
const fragments = records
|
||||
.filter((r) => r.footprint?.length >= 4)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
rings: r.rings || [r.footprint],
|
||||
bbox: bounds(r.footprint),
|
||||
}));
|
||||
const parents = fragments.map((_, i) => i);
|
||||
const root = (i) => {
|
||||
while (parents[i] !== i) {
|
||||
parents[i] = parents[parents[i]];
|
||||
i = parents[i];
|
||||
}
|
||||
return i;
|
||||
};
|
||||
for (let i = 0; i < fragments.length; i++)
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (root(i) === root(j)) continue;
|
||||
const a = fragments[i],
|
||||
b = fragments[j];
|
||||
const scope = a.tileZoom ?? 'unknown';
|
||||
const known = aliases.get(`${scope}:${a.featureKey || a.id}`);
|
||||
if (
|
||||
(scope === (b.tileZoom ?? 'unknown') &&
|
||||
known &&
|
||||
known === aliases.get(`${scope}:${b.featureKey || b.id}`)) ||
|
||||
(a.featureKey && a.featureKey === b.featureKey) ||
|
||||
touches(a, b, 1e-10)
|
||||
)
|
||||
parents[root(i)] = root(j);
|
||||
}
|
||||
const groups = new Map();
|
||||
fragments.forEach((f, i) => {
|
||||
const key = root(i);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(f);
|
||||
});
|
||||
const usedIds = new Set();
|
||||
const merged = [...groups.values()].map((group) => {
|
||||
const keys = [...new Set(group.map((r) => r.featureKey || r.id))].sort();
|
||||
// Aliases are scoped to zoom. Old coarse associations are never union evidence.
|
||||
const zoom = group[0].tileZoom ?? 'unknown';
|
||||
const scoped = keys.map((key) => `${zoom}:${key}`);
|
||||
const prior = scoped
|
||||
.map((key) => aliases.get(key))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const namedMembers = [
|
||||
...new Map(
|
||||
[
|
||||
...prior.flatMap((id) => titles.get(`${zoom}:${id}`) || []),
|
||||
...group.filter((r) => r.nameRecord).map((r) => r.nameRecord),
|
||||
].map((r) => [r.osmKey, r]),
|
||||
).values(),
|
||||
].sort((a, b) => b.areaM2 - a.areaM2 || a.osmKey.localeCompare(b.osmKey));
|
||||
const primary = namedMembers[0];
|
||||
const typedKey = militaryOsmKey(keys[0]);
|
||||
let id =
|
||||
primary?.id ||
|
||||
prior[0] ||
|
||||
(typedKey ? `osm:military:${typedKey}` : `ofm:installation:${keys[0]}`);
|
||||
if (usedIds.has(id)) id = `ofm:installation:${keys[0]}`;
|
||||
usedIds.add(id);
|
||||
const aliasIds = [
|
||||
...new Set([
|
||||
...prior,
|
||||
...keys.map((key) => {
|
||||
const typed = militaryOsmKey(key);
|
||||
return typed ? `osm:military:${typed}` : `ofm:installation:${key}`;
|
||||
}),
|
||||
]),
|
||||
].filter((alias) => alias !== id);
|
||||
const priorIds = new Set(prior);
|
||||
for (const [key, previous] of aliases)
|
||||
if (key.startsWith(`${zoom}:`) && priorIds.has(previous))
|
||||
aliases.set(key, id);
|
||||
for (const key of scoped) aliases.set(key, id);
|
||||
for (const previous of prior) titles.delete(`${zoom}:${previous}`);
|
||||
if (namedMembers.length)
|
||||
titles.set(`${zoom}:${id}`, namedMembers.slice(0, 2048));
|
||||
let area = 0,
|
||||
x = 0,
|
||||
y = 0;
|
||||
for (const f of group)
|
||||
for (let i = 0; i < f.rings.length; i++) {
|
||||
const m = ringMoment(f.rings[i]),
|
||||
weight = m.area * (i ? -1 : 1);
|
||||
area += weight;
|
||||
x += m.point[0] * weight;
|
||||
y += m.point[1] * weight;
|
||||
}
|
||||
const labelPoint = primary && [primary.longitude, primary.latitude];
|
||||
let point =
|
||||
labelPoint && group.some((f) => inPolygon(labelPoint, f.rings))
|
||||
? labelPoint
|
||||
: area > 0
|
||||
? [x / area, y / area]
|
||||
: group[0].footprint[0];
|
||||
if (!group.some((f) => inPolygon(point, f.rings))) {
|
||||
// Find the nearest interior horizontal interval at a fragment's mid-latitude.
|
||||
const candidates = [];
|
||||
for (const f of group) {
|
||||
const lat = (f.bbox.south + f.bbox.north) / 2,
|
||||
hits = [];
|
||||
for (const ring of f.rings)
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const a = ring[i - 1],
|
||||
b = ring[i];
|
||||
if (a[1] > lat !== b[1] > lat)
|
||||
hits.push(a[0] + ((lat - a[1]) * (b[0] - a[0])) / (b[1] - a[1]));
|
||||
}
|
||||
hits.sort((a, b) => a - b);
|
||||
for (let i = 1; i < hits.length; i++) {
|
||||
const p = [(hits[i - 1] + hits[i]) / 2, lat];
|
||||
if (inPolygon(p, f.rings)) candidates.push(p);
|
||||
}
|
||||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
Math.hypot(a[0] - point[0], a[1] - point[1]) -
|
||||
Math.hypot(b[0] - point[0], b[1] - point[1]),
|
||||
);
|
||||
point = candidates[0] || group[0].footprint[0];
|
||||
}
|
||||
return {
|
||||
...group[0],
|
||||
id,
|
||||
aliasIds,
|
||||
...(primary
|
||||
? {
|
||||
name: primary.name,
|
||||
class: primary.class,
|
||||
namedArea: true,
|
||||
osmKey: primary.osmKey,
|
||||
areaM2: primary.areaM2,
|
||||
namedMembers,
|
||||
memberNames: [
|
||||
...new Set(namedMembers.slice(1).map((r) => r.name)),
|
||||
].filter((name) => name !== primary.name),
|
||||
}
|
||||
: {}),
|
||||
longitude: point[0],
|
||||
latitude: point[1],
|
||||
footprints: group.map((f) => f.rings),
|
||||
outlineLines: group.flatMap((f) => f.outlineLines || [f.footprint]),
|
||||
sources: [
|
||||
...new Map(
|
||||
group.flatMap((f) => f.sources).map((s) => [s.id, s]),
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
});
|
||||
while (aliases.size > 4096) aliases.delete(aliases.keys().next().value);
|
||||
while (titles.size > 4096) titles.delete(titles.keys().next().value);
|
||||
return [...merged, ...records.filter((r) => !r.footprint?.length)];
|
||||
}
|
||||
|
||||
/** Keep a named polygon readable when its usual label lies beyond the close view. */
|
||||
export function militaryNameInView(record, box) {
|
||||
if (
|
||||
!record.namedArea ||
|
||||
!record.footprints?.length ||
|
||||
(record.longitude >= box.west &&
|
||||
record.longitude <= box.east &&
|
||||
record.latitude >= box.south &&
|
||||
record.latitude <= box.north)
|
||||
)
|
||||
return record;
|
||||
for (const x of [0.5, 0.25, 0.75])
|
||||
for (const y of [0.5, 0.25, 0.75]) {
|
||||
const point = [
|
||||
box.west + (box.east - box.west) * x,
|
||||
box.south + (box.north - box.south) * y,
|
||||
];
|
||||
if (record.footprints.some((rings) => inPolygon(point, rings)))
|
||||
return { ...record, longitude: point[0], latitude: point[1] };
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
clipTileRing,
|
||||
militaryOutlineLines,
|
||||
mergeMilitaryFragments,
|
||||
} from './militaryTileGeometry.js';
|
||||
const ring = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
const record = (id, footprint, extra = {}) => ({
|
||||
id,
|
||||
featureKey: id,
|
||||
footprint,
|
||||
sources: [{ name: 'OpenStreetMap', id }],
|
||||
...extra,
|
||||
});
|
||||
test('tile cores discard buffers and their artificial outline edges', () => {
|
||||
const box = { west: 0, east: 1, south: 0, north: 1 };
|
||||
const clipped = clipTileRing(ring(-1, 0.2, 2, 0.8), box);
|
||||
assert.deepEqual(militaryOutlineLines(clipped, box), [
|
||||
[
|
||||
[0, 0.2],
|
||||
[1, 0.2],
|
||||
],
|
||||
[
|
||||
[1, 0.8],
|
||||
[0, 0.8],
|
||||
],
|
||||
]);
|
||||
});
|
||||
test('touching parcels and tile fragments merge with weighted interior centroid and stable pan ids', () => {
|
||||
const aliases = new Map();
|
||||
const a = record('a', ring(0, 0, 1, 1)),
|
||||
b = record('b', ring(1, 0, 3, 1));
|
||||
const [first] = mergeMilitaryFragments([a, b], aliases);
|
||||
assert.equal(first.longitude, 1.5);
|
||||
assert.equal(first.latitude, 0.5);
|
||||
assert.equal(first.footprints.length, 2);
|
||||
const [pan] = mergeMilitaryFragments(
|
||||
[b, record('c', ring(3, 0, 4, 1))],
|
||||
aliases,
|
||||
);
|
||||
assert.equal(pan.id, first.id);
|
||||
assert.equal(
|
||||
mergeMilitaryFragments([a, record('far', ring(10, 10, 11, 11))]).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
test('centroid outside a concave footprint snaps inside instead of landing in the gap', () => {
|
||||
const r = [
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[3, 1],
|
||||
[1, 1],
|
||||
[1, 3],
|
||||
[0, 3],
|
||||
[0, 0],
|
||||
];
|
||||
const [m] = mergeMilitaryFragments([record('u', r)]);
|
||||
assert.ok(m.longitude < 1 || m.latitude < 1);
|
||||
});
|
||||
test('holes remain holes and disconnected pieces of one mapped feature have one id', () => {
|
||||
const outer = ring(0, 0, 4, 4),
|
||||
hole = ring(1, 1, 3, 3);
|
||||
const a = record('a', outer, { rings: [outer, hole] });
|
||||
const [m] = mergeMilitaryFragments([a, record('a', ring(5, 0, 6, 1))]);
|
||||
assert.equal(m.footprints.length, 2);
|
||||
assert.equal(m.footprints[0].length, 2);
|
||||
assert.ok(
|
||||
!(m.longitude > 1 && m.longitude < 3 && m.latitude > 1 && m.latitude < 3),
|
||||
);
|
||||
});
|
||||
|
||||
test('a pan that hides connecting parcels keeps one marker with its known installation id', () => {
|
||||
const aliases = new Map();
|
||||
const a = record('a', ring(0, 0, 1, 1));
|
||||
const bridge = record('bridge', ring(1, 0, 2, 1));
|
||||
const b = record('b', ring(2, 0, 3, 1));
|
||||
const [full] = mergeMilitaryFragments([a, bridge, b], aliases);
|
||||
const pan = mergeMilitaryFragments([a, b], aliases);
|
||||
assert.equal(pan.length, 1);
|
||||
assert.equal(pan[0].id, full.id);
|
||||
assert.equal(pan[0].footprints.length, 2);
|
||||
});
|
||||
|
||||
test('joining known groups also updates aliases for their currently offscreen parcels', () => {
|
||||
const aliases = new Map();
|
||||
const a = record('a', ring(0, 0, 1, 1));
|
||||
const b = record('b', ring(2, 0, 3, 1));
|
||||
const c = record('c', ring(3, 0, 4, 1));
|
||||
assert.equal(mergeMilitaryFragments([a, b, c], aliases).length, 2);
|
||||
const [joined] = mergeMilitaryFragments(
|
||||
[a, record('bridge', ring(1, 0, 2, 1)), b],
|
||||
aliases,
|
||||
);
|
||||
assert.equal(aliases.get('unknown:c'), joined.id);
|
||||
const pan = mergeMilitaryFragments([a, c], aliases);
|
||||
assert.equal(pan.length, 1);
|
||||
assert.equal(pan[0].id, joined.id);
|
||||
});
|
||||
|
||||
test('quantization gaps within a tile stay separate; coarse identities do not fuse detailed sites', () => {
|
||||
const aliases = new Map();
|
||||
const a = record('a', ring(0, 0, 1, 1), { tileZoom: 10, tileEpsilon: 0.1 });
|
||||
const b = record('b', ring(1.05, 0, 2, 1), {
|
||||
tileZoom: 10,
|
||||
tileEpsilon: 0.1,
|
||||
});
|
||||
assert.equal(mergeMilitaryFragments([a, b], aliases).length, 2);
|
||||
mergeMilitaryFragments([a, { ...b, footprint: ring(1, 0, 2, 1) }], aliases);
|
||||
const detail = mergeMilitaryFragments(
|
||||
[
|
||||
{ ...a, tileZoom: 14 },
|
||||
{ ...b, tileZoom: 14 },
|
||||
],
|
||||
aliases,
|
||||
);
|
||||
assert.equal(detail.length, 2);
|
||||
assert.notEqual(detail[0].id, detail[1].id);
|
||||
const left = { ...a, tileBounds: { west: 0, east: 1, south: 0, north: 1 } };
|
||||
const right = {
|
||||
...b,
|
||||
footprint: ring(1, 0.05, 2, 1),
|
||||
tileBounds: { west: 1, east: 2, south: 0, north: 1 },
|
||||
};
|
||||
assert.equal(mergeMilitaryFragments([left, right]).length, 1);
|
||||
});
|
||||
|
||||
test('distinct named bases separated by one seam quantization step retain separate identities on revisit', () => {
|
||||
const aliases = new Map();
|
||||
const epsilon = 360 / 512 / 4096;
|
||||
const named = (n, footprint, tileBounds, areaM2) =>
|
||||
record(String(n * 10 + 2), footprint, {
|
||||
tileZoom: 9,
|
||||
tileEpsilon: epsilon,
|
||||
tileBounds,
|
||||
osmKey: `w${n}`,
|
||||
nameRecord: {
|
||||
id: `osm:military:w${n}`,
|
||||
osmKey: `w${n}`,
|
||||
name: `Site ${n}`,
|
||||
areaM2,
|
||||
longitude: footprint[0][0],
|
||||
latitude: footprint[0][1],
|
||||
},
|
||||
});
|
||||
const a = named(
|
||||
3,
|
||||
ring(0.99, 0.1, 1, 0.4),
|
||||
{ west: 0, east: 1, south: 0, north: 1 },
|
||||
100,
|
||||
);
|
||||
const b = named(
|
||||
4,
|
||||
ring(1, 0.4 + epsilon, 1.01, 0.5),
|
||||
{ west: 1, east: 2, south: 0, north: 1 },
|
||||
10,
|
||||
);
|
||||
for (const records of [[a, b], [b], [a, b]]) {
|
||||
const merged = mergeMilitaryFragments(records, aliases);
|
||||
assert.equal(merged.length, records.length);
|
||||
assert.deepEqual(
|
||||
merged.map((r) => r.name),
|
||||
records.map((r) => r.nameRecord.name),
|
||||
);
|
||||
}
|
||||
assert.notEqual(aliases.get('9:32'), aliases.get('9:42'));
|
||||
assert.equal(
|
||||
mergeMilitaryFragments([
|
||||
a,
|
||||
{
|
||||
...b,
|
||||
featureKey: a.featureKey,
|
||||
osmKey: a.osmKey,
|
||||
nameRecord: a.nameRecord,
|
||||
},
|
||||
]).length,
|
||||
1,
|
||||
'same identity stitches even across quantization',
|
||||
);
|
||||
});
|
||||
|
||||
test('named A+B to B to A+B preserves canonical identity, parent title and selection', async () => {
|
||||
const C = await import('cesium');
|
||||
const { createRendering } =
|
||||
await import('../layers/installations/rendering.js');
|
||||
const aliases = new Map();
|
||||
const named = (n, footprint, areaM2) =>
|
||||
record(String(n * 10 + 2), footprint, {
|
||||
tileZoom: 9,
|
||||
osmKey: `w${n}`,
|
||||
kind: 'installation',
|
||||
class: 'military_land',
|
||||
nameRecord: {
|
||||
id: `osm:military:w${n}`,
|
||||
osmKey: `w${n}`,
|
||||
name: `Site ${n}`,
|
||||
areaM2,
|
||||
longitude: footprint[0][0] + 0.1,
|
||||
latitude: 0.5,
|
||||
},
|
||||
});
|
||||
const a = named(1, ring(0, 0, 1, 1), 100),
|
||||
b = named(2, ring(1, 0, 2, 1), 10);
|
||||
let selected = { id: 'osm:military:w1' };
|
||||
const state = {
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: selected.id,
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new C.CustomDataSource(),
|
||||
};
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
source: {},
|
||||
parts: {
|
||||
model: {
|
||||
colorFor: () => C.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
},
|
||||
services: {
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer() {},
|
||||
registerEntityContext() {},
|
||||
selectEntityContext: (entity) => {
|
||||
selected = entity;
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
anchors: {},
|
||||
},
|
||||
});
|
||||
for (const members of [[a, b], [b], [a, b]]) {
|
||||
state.records = mergeMilitaryFragments(members, aliases);
|
||||
state.recordById = new Map(state.records.map((r) => [r.id, r]));
|
||||
assert.equal(state.records[0].id, selected.id);
|
||||
assert.equal(state.records[0].name, 'Site 1');
|
||||
assert.deepEqual(state.records[0].memberNames, ['Site 2']);
|
||||
rendering.renderRecords();
|
||||
assert.equal(state.selectedId, 'osm:military:w1');
|
||||
assert.ok(state.dataSource.entities.getById(state.selectedId));
|
||||
}
|
||||
const [detail] = mergeMilitaryFragments([{ ...b, tileZoom: 12 }], aliases);
|
||||
assert.equal(detail.id, 'osm:military:w2');
|
||||
assert.equal(
|
||||
detail.name,
|
||||
'Site 2',
|
||||
'coarse parent metadata cannot override a detail split',
|
||||
);
|
||||
});
|
||||
|
||||
test('cold names reconcile selection through enrichment, identical groups at another zoom and wide-point handoff', async () => {
|
||||
const C = await import('cesium');
|
||||
const { createRendering } =
|
||||
await import('../layers/installations/rendering.js');
|
||||
const { retainNamedHandoff } =
|
||||
await import('../layers/installations/ingestion.js');
|
||||
const aliases = new Map();
|
||||
const unnamed = [1, 2].map((n) =>
|
||||
record(String(n * 10 + 2), ring(n - 1, 0, n, 1), {
|
||||
tileZoom: 12,
|
||||
name: 'Military area',
|
||||
kind: 'installation',
|
||||
class: 'military_land',
|
||||
}),
|
||||
);
|
||||
const named = unnamed.map((r, i) => ({
|
||||
...r,
|
||||
nameRecord: {
|
||||
id: `osm:military:w${i + 1}`,
|
||||
osmKey: `w${i + 1}`,
|
||||
name: `Site ${i + 1}`,
|
||||
areaM2: (i + 1) * 100,
|
||||
longitude: i + 0.5,
|
||||
latitude: 0.5,
|
||||
class: 'military_land',
|
||||
},
|
||||
}));
|
||||
const [warm] = mergeMilitaryFragments(named);
|
||||
let selected = null;
|
||||
const contexts = new Map();
|
||||
const state = {
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: null,
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new C.CustomDataSource(),
|
||||
};
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
source: {},
|
||||
parts: {
|
||||
model: {
|
||||
colorFor: () => C.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
},
|
||||
services: {
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer: (_id, { retainIds } = {}) => {
|
||||
for (const id of contexts.keys())
|
||||
if (!retainIds?.has(id)) contexts.delete(id);
|
||||
},
|
||||
registerEntityContext: (_entity, context) =>
|
||||
contexts.set(context.id, context),
|
||||
selectEntityContext: (entity) => {
|
||||
selected = contexts.get(entity.id);
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
anchors: {},
|
||||
},
|
||||
});
|
||||
const publish = (records, claimSelection = false) => {
|
||||
state.records = records;
|
||||
state.recordById = new Map(records.map((r) => [r.id, r]));
|
||||
rendering.renderRecords({ claimSelection });
|
||||
assert.equal(state.selectedId, records[0].id);
|
||||
assert.equal(selected.id, records[0].id);
|
||||
assert.ok(state.dataSource.entities.getById(state.selectedId));
|
||||
assert.ok(contexts.has(selected.id));
|
||||
};
|
||||
const cold = mergeMilitaryFragments(unnamed, aliases);
|
||||
state.selectedId = cold[0].id;
|
||||
publish(cold, true);
|
||||
const enriched = mergeMilitaryFragments(named, aliases);
|
||||
assert.equal(
|
||||
enriched[0].id,
|
||||
warm.id,
|
||||
'names timing must not choose the final canonical identity',
|
||||
);
|
||||
publish(enriched);
|
||||
assert.equal(selected.label, 'Site 2');
|
||||
for (const zoom of [10, 12]) {
|
||||
const records = mergeMilitaryFragments(
|
||||
named.map((r) => ({ ...r, tileZoom: zoom })),
|
||||
aliases,
|
||||
);
|
||||
assert.equal(records[0].id, warm.id);
|
||||
assert.equal(records[0].footprints.length, 2);
|
||||
publish(records);
|
||||
}
|
||||
const wide = {
|
||||
...named[1].nameRecord,
|
||||
namedArea: true,
|
||||
pointOnly: true,
|
||||
sources: named[1].sources,
|
||||
};
|
||||
publish([wide]);
|
||||
const fresh = mergeMilitaryFragments(unnamed, new Map());
|
||||
publish(
|
||||
retainNamedHandoff([wide], fresh, {
|
||||
west: -1,
|
||||
east: 3,
|
||||
south: -1,
|
||||
north: 2,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
state.records[0].name,
|
||||
'Site 2',
|
||||
'the unnamed group retains the wide parent label',
|
||||
);
|
||||
publish(enriched);
|
||||
selected = { id: 'aircraft:other', layerId: 'flights' };
|
||||
rendering.renderRecords();
|
||||
assert.equal(
|
||||
state.selectedId,
|
||||
null,
|
||||
'identity migration cannot reclaim another layer selection',
|
||||
);
|
||||
assert.equal(selected.id, 'aircraft:other');
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
|
||||
import { tileToBBox } from '../data/tomtomTiles.js';
|
||||
|
||||
const ROAD_TYPES = Object.freeze({
|
||||
motorway: 'motorway',
|
||||
trunk: 'trunk',
|
||||
primary: 'primary',
|
||||
secondary: 'secondary',
|
||||
tertiary: 'tertiary',
|
||||
minor: 'residential',
|
||||
});
|
||||
|
||||
/**
|
||||
* Surface traffic uses public motor-road classes only. Untyped service ways
|
||||
* include car parks and grounds access, so none are used as through traffic.
|
||||
* OpenMapTiles folds private access into `no`. Tunnels cannot be placed on
|
||||
* the surface above them; ramps/bridges remain valid only on a motor road.
|
||||
*/
|
||||
export function isDrivableOpenMapRoad(properties = {}) {
|
||||
return (
|
||||
Object.hasOwn(ROAD_TYPES, properties.class) &&
|
||||
!properties.subclass &&
|
||||
!properties.service &&
|
||||
![
|
||||
'no',
|
||||
'private',
|
||||
'destination',
|
||||
'customers',
|
||||
'delivery',
|
||||
'agricultural',
|
||||
'forestry',
|
||||
].includes(properties.access) &&
|
||||
properties.brunnel !== 'tunnel' &&
|
||||
!properties.indoor
|
||||
);
|
||||
}
|
||||
|
||||
/** Translate public motor roads; reverse negative one-way geometry. */
|
||||
export function openMapRoad(coordinates, properties = {}) {
|
||||
const type = ROAD_TYPES[properties.class];
|
||||
if (!isDrivableOpenMapRoad(properties) || coordinates.length < 2) return null;
|
||||
const reverse = Number(properties.oneway) === -1;
|
||||
return {
|
||||
coordinates: reverse ? coordinates.slice().reverse() : coordinates,
|
||||
type,
|
||||
drivable: true,
|
||||
roadClass: properties.class,
|
||||
roadProperties: { ...properties },
|
||||
oneway: reverse || Number(properties.oneway) === 1 ? 1 : 0,
|
||||
ramp: properties.ramp === 1,
|
||||
brunnel: properties.brunnel || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Area-weighted centroid in tile-scale longitude/latitude coordinates. */
|
||||
export function polygonCentroid(ring) {
|
||||
if (!ring?.length) return null;
|
||||
const [ox, oy] = ring[0];
|
||||
let area = 0,
|
||||
x = 0,
|
||||
y = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const ax = ring[j][0] - ox,
|
||||
ay = ring[j][1] - oy;
|
||||
const bx = ring[i][0] - ox,
|
||||
by = ring[i][1] - oy;
|
||||
const cross = ax * by - bx * ay;
|
||||
area += cross;
|
||||
x += (ax + bx) * cross;
|
||||
y += (ay + by) * cross;
|
||||
}
|
||||
return Math.abs(area) > 1e-15
|
||||
? [ox + x / (3 * area), oy + y / (3 * area)]
|
||||
: ring[0];
|
||||
}
|
||||
|
||||
/** Clip a line to its tile core so buffered copies never animate duplicate dots. */
|
||||
export function clipTileLine(coords, box) {
|
||||
const lines = [];
|
||||
let line = [];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const a = coords[i - 1],
|
||||
b = coords[i];
|
||||
const dx = b[0] - a[0],
|
||||
dy = b[1] - a[1];
|
||||
let lo = 0,
|
||||
hi = 1;
|
||||
const p = [-dx, dx, -dy, dy],
|
||||
q = [
|
||||
a[0] - box.west,
|
||||
box.east - a[0],
|
||||
a[1] - box.south,
|
||||
box.north - a[1],
|
||||
];
|
||||
for (let k = 0; k < 4; k++) {
|
||||
if (p[k] === 0) {
|
||||
if (q[k] < 0) hi = -1;
|
||||
} else if (p[k] < 0) lo = Math.max(lo, q[k] / p[k]);
|
||||
else hi = Math.min(hi, q[k] / p[k]);
|
||||
}
|
||||
if (lo > hi) {
|
||||
if (line.length > 1) lines.push(line);
|
||||
line = [];
|
||||
continue;
|
||||
}
|
||||
const start = [a[0] + lo * dx, a[1] + lo * dy],
|
||||
end = [a[0] + hi * dx, a[1] + hi * dy];
|
||||
const last = line.at(-1);
|
||||
if (
|
||||
!last ||
|
||||
Math.abs(last[0] - start[0]) + Math.abs(last[1] - start[1]) > 1e-10
|
||||
) {
|
||||
if (line.length > 1) lines.push(line);
|
||||
line = [start];
|
||||
}
|
||||
line.push(end);
|
||||
}
|
||||
if (line.length > 1) lines.push(line);
|
||||
return lines.filter((points) => {
|
||||
let metres = 0;
|
||||
for (let i = 1; i < points.length; i++)
|
||||
metres +=
|
||||
Math.hypot(
|
||||
(points[i][0] - points[i - 1][0]) *
|
||||
Math.cos((points[i][1] * Math.PI) / 180),
|
||||
points[i][1] - points[i - 1][1],
|
||||
) * 111320;
|
||||
return metres >= 12;
|
||||
});
|
||||
}
|
||||
|
||||
/** Decode road lines and unnamed military polygons from an OpenMapTiles tile. */
|
||||
export function decodeOpenFreeMapTile(bytes, z, x, y) {
|
||||
return decodeLayers(bytes, z, x, y, ['transportation', 'landuse']);
|
||||
}
|
||||
|
||||
/** Decode only the unnamed military polygons; installations never use roads. */
|
||||
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));
|
||||
const roads = [],
|
||||
military = [];
|
||||
const box = tileToBBox(z, x, y);
|
||||
for (const name of layerNames) {
|
||||
const layer = tile.layers[name];
|
||||
if (!layer) continue;
|
||||
if (layer.length > 40_000)
|
||||
throw new Error('Vector tile feature limit exceeded');
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const feature = layer.feature(i),
|
||||
props = feature.properties;
|
||||
if (
|
||||
name === 'transportation'
|
||||
? !isDrivableOpenMapRoad(props)
|
||||
: props.class !== 'military'
|
||||
)
|
||||
continue;
|
||||
const geometry = feature.toGeoJSON(x, y, z).geometry;
|
||||
if (name === 'transportation') {
|
||||
const lines =
|
||||
geometry.type === 'LineString'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'MultiLineString'
|
||||
? geometry.coordinates
|
||||
: [];
|
||||
for (const coords of lines)
|
||||
for (const clipped of clipTileLine(coords, box))
|
||||
roads.push(openMapRoad(clipped, props));
|
||||
} else {
|
||||
const polygons =
|
||||
geometry.type === 'Polygon'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'MultiPolygon'
|
||||
? geometry.coordinates
|
||||
: [];
|
||||
for (let p = 0; p < polygons.length; p++) {
|
||||
const rings = polygons[p]
|
||||
.map((r) => clipTileRing(r, box))
|
||||
.filter((r) => r.length >= 4);
|
||||
const ring = rings[0],
|
||||
centroid = polygonCentroid(ring);
|
||||
if (!centroid || ring.length < 4) continue;
|
||||
const [longitude, latitude] = centroid;
|
||||
military.push({
|
||||
id: `ofm:${z}/${x}/${y}:${feature.id ?? i}:${p}`,
|
||||
kind: 'installation',
|
||||
class: 'military_land',
|
||||
name: 'Military area',
|
||||
latitude,
|
||||
longitude,
|
||||
footprint: ring,
|
||||
rings,
|
||||
featureKey:
|
||||
feature.id == null
|
||||
? `tile:${z}/${x}/${y}:${i}`
|
||||
: String(feature.id),
|
||||
tileEpsilon: (box.east - box.west) / feature.extent,
|
||||
tileBounds: box,
|
||||
tileZoom: z,
|
||||
outlineLines: rings.flatMap((r) => militaryOutlineLines(r, box)),
|
||||
validation: 'unreviewed',
|
||||
sources: [{ name: 'OpenStreetMap', id: `tile:${z}/${x}/${y}` }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { roads, military };
|
||||
}
|
||||
|
||||
/** Construct an immutable-version road/military tile source without starting I/O. */
|
||||
export function createOpenFreeMapSource(options = {}) {
|
||||
return createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||
allowedOrigin: 'https://tiles.openfreemap.org',
|
||||
decode: decodeOpenFreeMapTile,
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
decodeOpenFreeMapTile,
|
||||
openMapRoad,
|
||||
polygonCentroid,
|
||||
clipTileLine,
|
||||
createOpenFreeMapSource,
|
||||
} from './openFreeMap.js';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
const fixture = (name) =>
|
||||
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
|
||||
|
||||
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'],
|
||||
[12, 935, 1686, 'ofm-austin-12-935-1686.pbf'],
|
||||
]) {
|
||||
const { roads } = decodeOpenFreeMapTile(fixture(name), z, x, y);
|
||||
assert.ok(roads.length > 0);
|
||||
assert.ok(
|
||||
roads.every((r) =>
|
||||
[
|
||||
'motorway',
|
||||
'trunk',
|
||||
'primary',
|
||||
'secondary',
|
||||
'tertiary',
|
||||
'residential',
|
||||
'unclassified',
|
||||
].includes(r.type),
|
||||
),
|
||||
);
|
||||
assert.ok(roads.some((r) => r.oneway === 1));
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenMapTiles class mapping and reverse oneway preserve the animated-road contract', () => {
|
||||
const coords = [
|
||||
[-97, 30],
|
||||
[-97.001, 30.001],
|
||||
];
|
||||
assert.deepEqual(
|
||||
openMapRoad(coords, { class: 'minor', oneway: -1 }).coordinates,
|
||||
coords.slice().reverse(),
|
||||
);
|
||||
assert.equal(openMapRoad(coords, { class: 'minor', oneway: -1 }).oneway, 1);
|
||||
assert.equal(openMapRoad(coords, { class: 'minor' }).type, 'residential');
|
||||
assert.equal(openMapRoad(coords, { class: 'service' }), null);
|
||||
assert.equal(openMapRoad(coords, { class: 'primary', oneway: 0 }).oneway, 0);
|
||||
for (const klass of [
|
||||
'track',
|
||||
'path',
|
||||
'rail',
|
||||
'pier',
|
||||
'motorway_construction',
|
||||
'bridge',
|
||||
])
|
||||
assert.equal(openMapRoad(coords, { class: klass }), null);
|
||||
});
|
||||
|
||||
test('tile buffer clipping rejects slivers and does not join disconnected lines', () => {
|
||||
const box = { west: 0, east: 1, south: 0, north: 1 };
|
||||
assert.deepEqual(
|
||||
clipTileLine(
|
||||
[
|
||||
[-1, 0.5],
|
||||
[2, 0.5],
|
||||
],
|
||||
box,
|
||||
),
|
||||
[
|
||||
[
|
||||
[0, 0.5],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
clipTileLine(
|
||||
[
|
||||
[0, 0],
|
||||
[0.00001, 0],
|
||||
],
|
||||
box,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Camp Mabry tile emits an unnamed polygon and its centroid marker', () => {
|
||||
const { military } = decodeOpenFreeMapTile(
|
||||
fixture('ofm-camp-mabry-12-935-1685.pbf'),
|
||||
12,
|
||||
935,
|
||||
1685,
|
||||
);
|
||||
assert.ok(military.length > 0);
|
||||
const camp = military.find(
|
||||
(record) =>
|
||||
Math.abs(record.latitude - 30.314) < 0.03 &&
|
||||
Math.abs(record.longitude + 97.763) < 0.03,
|
||||
);
|
||||
assert.ok(camp);
|
||||
assert.equal(camp.name, 'Military area');
|
||||
assert.equal(camp.kind, 'installation');
|
||||
assert.equal(camp.class, 'military_land');
|
||||
assert.ok(camp.footprint.length > 3);
|
||||
assert.deepEqual(
|
||||
polygonCentroid([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[0, 4],
|
||||
[0, 0],
|
||||
]),
|
||||
[4 / 3, 4 / 3],
|
||||
);
|
||||
});
|
||||
|
||||
test('TileJSON is resolved once and decoded tile LRU prevents per-pan refetch', async () => {
|
||||
const calls = [];
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url.endsWith('/planet')
|
||||
? Response.json({
|
||||
tiles: [
|
||||
'https://tiles.openfreemap.org/planet/version/{z}/{x}/{y}.pbf',
|
||||
],
|
||||
})
|
||||
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
|
||||
},
|
||||
});
|
||||
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
|
||||
await source.fetchBounds(box, { zoom: 14 });
|
||||
await source.fetchBounds({ ...box, north: 30.269 }, { zoom: 14 });
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(source.getStats().cacheEntries, 1);
|
||||
source.clear();
|
||||
assert.equal(source.getStats().cacheBytes, 0);
|
||||
});
|
||||
|
||||
test('tile reads cap view size, concurrency and retained bytes, and reject oversized bodies', async () => {
|
||||
let concurrent = 0,
|
||||
peak = 0;
|
||||
const source = createVectorTileSource({
|
||||
template: 'https://tiles.example/{z}/{x}/{y}',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
maxTiles: 8,
|
||||
concurrency: 2,
|
||||
maxEntries: 2,
|
||||
maxCacheBytes: 100,
|
||||
decode: () => [1, 2, 3],
|
||||
fetchImpl: async () => {
|
||||
peak = Math.max(peak, ++concurrent);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
concurrent--;
|
||||
return new Response('bytes');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.fetchBounds(
|
||||
{ south: 20, north: 40, west: -100, east: -80 },
|
||||
{ zoom: 14 },
|
||||
),
|
||||
{ code: 'TILE_VIEW_TOO_WIDE' },
|
||||
);
|
||||
await source.fetchBounds(
|
||||
{ south: 30.26, north: 30.3, west: -97.8, east: -97.7 },
|
||||
{ zoom: 12 },
|
||||
);
|
||||
assert.ok(peak <= 2);
|
||||
assert.ok(source.getStats().cacheEntries <= 2);
|
||||
assert.ok(source.getStats().cacheBytes <= 100);
|
||||
const huge = createVectorTileSource({
|
||||
template: 'https://tiles.example/{z}/{x}/{y}',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
maxResponseBytes: 4,
|
||||
decode: () => assert.fail('oversize decoded'),
|
||||
fetchImpl: async () => new Response('too many bytes'),
|
||||
});
|
||||
await assert.rejects(
|
||||
huge.fetchBounds(
|
||||
{ south: 30.267, north: 30.268, west: -97.744, east: -97.743 },
|
||||
{ zoom: 12 },
|
||||
),
|
||||
{ code: 'RESPONSE_TOO_LARGE' },
|
||||
);
|
||||
});
|
||||
|
||||
test('shared TileJSON survives one camera cancellation and aborts when its last owner leaves', async () => {
|
||||
let finish,
|
||||
calls = 0,
|
||||
upstreamSignal;
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls++;
|
||||
upstreamSignal = signal;
|
||||
return new Promise((resolve, reject) => {
|
||||
finish = () =>
|
||||
resolve(
|
||||
Response.json({
|
||||
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
);
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const a = new AbortController(),
|
||||
b = new AbortController();
|
||||
const first = source.getMetadata(a.signal),
|
||||
second = source.getMetadata(b.signal);
|
||||
a.abort();
|
||||
await assert.rejects(first, { name: 'AbortError' });
|
||||
assert.equal(upstreamSignal.aborted, false);
|
||||
finish();
|
||||
await second;
|
||||
assert.equal(calls, 1);
|
||||
const cancelled = createOpenFreeMapSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
upstreamSignal = signal;
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const c = new AbortController();
|
||||
const waiting = cancelled.getMetadata(c.signal);
|
||||
c.abort();
|
||||
await assert.rejects(waiting, { name: 'AbortError' });
|
||||
assert.equal(upstreamSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('destroying a tile source aborts in-flight bodies and prevents stale cache refill', async () => {
|
||||
let started;
|
||||
const ready = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const source = createVectorTileSource({
|
||||
template: 'https://tiles.example/{z}/{x}/{y}',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
decode: () => [],
|
||||
fetchImpl: (url, { signal }) => {
|
||||
started();
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const work = source.fetchBounds(
|
||||
{ south: 30.267, north: 30.268, west: -97.744, east: -97.743 },
|
||||
{ zoom: 12 },
|
||||
);
|
||||
await ready;
|
||||
source.clear();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(source.getStats().cacheEntries, 0);
|
||||
});
|
||||
|
||||
// Representative provider properties from the Capitol, Camp Mabry greenbelt
|
||||
// and commercial car parks. Tiny property fixtures retain the rejection cause.
|
||||
test('public road admission rejects paths, private access, service areas and tunnels', () => {
|
||||
const coordinates = [
|
||||
[-97.7404, 30.2747],
|
||||
[-97.7404, 30.2757],
|
||||
];
|
||||
for (const properties of [
|
||||
{ class: 'minor', access: 'no' },
|
||||
{ class: 'minor', access: 'private' },
|
||||
{ class: 'path', subclass: 'footway' },
|
||||
{ class: 'path', subclass: 'cycleway' },
|
||||
{ class: 'track' },
|
||||
{ class: 'minor', subclass: 'pedestrian' },
|
||||
{ class: 'minor', subclass: 'steps', ramp: 1 },
|
||||
{ class: 'minor', subclass: 'platform' },
|
||||
{ class: 'service' },
|
||||
{ class: 'service', service: 'parking_aisle' },
|
||||
{ class: 'service', service: 'driveway' },
|
||||
{ class: 'service', service: 'drive-through' },
|
||||
{ class: 'service', service: 'alley' },
|
||||
{ class: 'primary', brunnel: 'tunnel' },
|
||||
])
|
||||
assert.equal(
|
||||
openMapRoad(coordinates, properties),
|
||||
null,
|
||||
JSON.stringify(properties),
|
||||
);
|
||||
for (const properties of [
|
||||
{ class: 'minor' },
|
||||
{ class: 'primary', ramp: 1 },
|
||||
{ class: 'tertiary', brunnel: 'bridge' },
|
||||
{ class: 'minor', brunnel: 'ford' },
|
||||
]) {
|
||||
const road = openMapRoad(coordinates, properties);
|
||||
assert.equal(road.drivable, true);
|
||||
assert.deepEqual(road.roadProperties, properties);
|
||||
}
|
||||
});
|
||||
|
||||
test('Capitol, greenbelt and commercial property fixtures exclude non-public driving space', () => {
|
||||
const records = JSON.parse(fixture('traffic-road-access.json'));
|
||||
for (const record of records) {
|
||||
const road = openMapRoad(record.coordinates, record.properties);
|
||||
assert.equal(
|
||||
Boolean(road),
|
||||
record.drivable,
|
||||
`${record.area}: ${JSON.stringify(record.properties)}`,
|
||||
);
|
||||
}
|
||||
for (const area of ['capitol', 'greenbelt', 'commercial']) {
|
||||
assert.ok(records.some((r) => r.area === area && r.drivable));
|
||||
assert.ok(records.some((r) => r.area === area && !r.drivable));
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from './capability.js';
|
||||
export {
|
||||
FEATURE_SOURCE_METHODS,
|
||||
requireFeatureSource,
|
||||
@@ -14,9 +15,27 @@ function validPoint(lat, lon) {
|
||||
throw new TypeError('Valid feature coordinates are required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output box for `out geom(s,w,n,e)`: `out geom` prints every member of a
|
||||
* matched relation, and one borough can run to tens of thousands of nodes.
|
||||
* Geometry is printed only inside this box; a relation cut by it no longer
|
||||
* closes and is rejected by ring stitching, so nothing partial is drawn.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} halfSizeM - Half the box side in metres.
|
||||
* @returns {string} `(south,west,north,east)` in degrees, 5 decimals.
|
||||
*/
|
||||
export function geometryOutputBox(lat, lon, halfSizeM) {
|
||||
const dLat = halfSizeM / 111320;
|
||||
const dLon =
|
||||
halfSizeM / (111320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180)));
|
||||
const f = (value) => Number(value.toFixed(5));
|
||||
return `(${f(Math.max(-90, lat - dLat))},${f(Math.max(-180, lon - dLon))},${f(Math.min(90, lat + dLat))},${f(Math.min(180, lon + dLon))})`;
|
||||
}
|
||||
|
||||
/** Query bounded feature candidates; ranking and rendering belong to callers.
|
||||
* Array = definitive response (possibly empty), null = retryable failure,
|
||||
* {rateLimited, retryAfterMs} = admission delay.
|
||||
* {rateLimited, retryAfterMs} = admission delay; {unavailable, retryable:false} = no capability.
|
||||
*/
|
||||
export function createOverpassFeatureSource({
|
||||
boundarySource,
|
||||
@@ -24,11 +43,13 @@ export function createOverpassFeatureSource({
|
||||
} = {}) {
|
||||
if (typeof boundarySource?.query !== 'function')
|
||||
throw new TypeError('A boundary query transport is required');
|
||||
let unavailable = null;
|
||||
async function query(
|
||||
text,
|
||||
timeoutMs,
|
||||
{ signal, focus = false, relationsOnly = false } = {},
|
||||
) {
|
||||
if (unavailable) return unavailable;
|
||||
const controller = new AbortController();
|
||||
const signals = [lifetime, signal, controller.signal].filter(Boolean);
|
||||
const combined = AbortSignal.any(signals);
|
||||
@@ -37,6 +58,14 @@ export function createOverpassFeatureSource({
|
||||
combined.throwIfAborted();
|
||||
const elements = await boundarySource.query(text, { signal: combined });
|
||||
combined.throwIfAborted();
|
||||
if (isUnavailableCapability(elements)) {
|
||||
unavailable = {
|
||||
unavailable: true,
|
||||
code: elements.code || 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
};
|
||||
return unavailable;
|
||||
}
|
||||
return Array.isArray(elements)
|
||||
? normalizeOverpassFeatures(
|
||||
relationsOnly
|
||||
@@ -45,7 +74,15 @@ export function createOverpassFeatureSource({
|
||||
{ focus },
|
||||
)
|
||||
: elements;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isUnavailableCapability(error)) {
|
||||
unavailable = {
|
||||
unavailable: true,
|
||||
code: error.code || 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
};
|
||||
return unavailable;
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
@@ -65,7 +102,7 @@ export function createOverpassFeatureSource({
|
||||
way(around:180,${lat},${lon})["tourism"="attraction"];
|
||||
relation(around:180,${lat},${lon})["tourism"="attraction"];
|
||||
);
|
||||
out tags center geom;
|
||||
out center geom${geometryOutputBox(lat, lon, 1500)};
|
||||
`,
|
||||
6000,
|
||||
{ ...options, focus: true },
|
||||
@@ -88,6 +125,10 @@ export function createOverpassFeatureSource({
|
||||
{ ...options, relationsOnly: true },
|
||||
);
|
||||
},
|
||||
// Polygon lookups print with `out geom`, not `out tags geom`: at `tags`
|
||||
// verbosity a relation carries no members, so it arrives with bounds and
|
||||
// no outline and is dropped. The output box bounds member geometry (6 km
|
||||
// for neighbourhoods, 3 km for street areas, 1.5 km for focus footprints).
|
||||
getNeighborhoodAreas({ lat, lon }, options = {}) {
|
||||
validPoint(lat, lon);
|
||||
return query(
|
||||
@@ -95,7 +136,7 @@ export function createOverpassFeatureSource({
|
||||
`way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
|
||||
`relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
|
||||
`relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` +
|
||||
`);out tags geom;`,
|
||||
`);out geom${geometryOutputBox(lat, lon, 6000)};`,
|
||||
14000,
|
||||
options,
|
||||
);
|
||||
@@ -108,7 +149,7 @@ export function createOverpassFeatureSource({
|
||||
`relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` +
|
||||
`way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` +
|
||||
`relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` +
|
||||
`);out tags geom;`,
|
||||
`);out geom${geometryOutputBox(lat, lon, 3000)};`,
|
||||
14000,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createOverpassFeatureSource,
|
||||
geometryOutputBox,
|
||||
FEATURE_SOURCE_METHODS,
|
||||
} from './overpassFeatures.js';
|
||||
import { normalizeOverpassFeatures } from './overpassFeaturesRecords.js';
|
||||
@@ -190,3 +191,156 @@ test('annotation retries transient feature failures but caches definitive misses
|
||||
assert.equal(await anchor.resolveOutline(), null);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
// A multipolygon relation as Overpass prints it with `out geom`: the outer
|
||||
// boundary split across two ways, an inner ring, and a label node.
|
||||
const relationRing = [
|
||||
{ lat: 30, lon: -97 },
|
||||
{ lat: 30, lon: -96.994 },
|
||||
{ lat: 30.005, lon: -96.994 },
|
||||
{ lat: 30.005, lon: -97 },
|
||||
{ lat: 30, lon: -97 },
|
||||
];
|
||||
const relationFixture = (name, tags) => ({
|
||||
type: 'relation',
|
||||
id: 7,
|
||||
bounds: { minlat: 30, minlon: -97, maxlat: 30.005, maxlon: -96.994 },
|
||||
members: [
|
||||
{ type: 'way', ref: 71, role: 'outer', geometry: relationRing.slice(0, 3) },
|
||||
{ type: 'way', ref: 72, role: 'outer', geometry: relationRing.slice(2) },
|
||||
{
|
||||
type: 'way',
|
||||
ref: 73,
|
||||
role: 'inner',
|
||||
geometry: relationRing.map((p) => ({ lat: p.lat + 0.001, lon: p.lon })),
|
||||
},
|
||||
{ type: 'node', ref: 74, role: 'label', lat: 30.0025, lon: -96.997 },
|
||||
],
|
||||
tags: { name, ...tags },
|
||||
});
|
||||
|
||||
// Overpass leaves members out at `tags` verbosity, so `geom` has nothing to
|
||||
// attach to and a relation keeps only its bounds.
|
||||
const overpassPrinting = (element) => ({
|
||||
query: async (text) =>
|
||||
/\bout\s+tags\b/.test(text)
|
||||
? [
|
||||
{
|
||||
type: element.type,
|
||||
id: element.id,
|
||||
bounds: element.bounds,
|
||||
tags: element.tags,
|
||||
},
|
||||
]
|
||||
: [element],
|
||||
});
|
||||
|
||||
test('neighborhood and street-area lookups keep relation member geometry', async () => {
|
||||
const calls = [];
|
||||
const source = createOverpassFeatureSource({
|
||||
boundarySource: {
|
||||
query: async (text) => {
|
||||
calls.push(text);
|
||||
return overpassPrinting(
|
||||
relationFixture('Fixture Quarter', { place: 'quarter' }),
|
||||
).query(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const method of ['getNeighborhoodAreas', 'getStreetAreas']) {
|
||||
const [record] = await source[method]({ lat: 30.002, lon: -96.997 });
|
||||
assert.deepEqual(record.coordinates, relationRing, method);
|
||||
}
|
||||
// Member geometry is printed only inside a box around the point.
|
||||
assert.ok(
|
||||
calls[0].endsWith(');out geom(29.9481,-97.05924,30.0559,-96.93476);'),
|
||||
);
|
||||
assert.ok(
|
||||
calls[1].endsWith(');out geom(29.97505,-97.02812,30.02895,-96.96588);'),
|
||||
);
|
||||
for (const text of calls) assert.doesNotMatch(text, /\bout\s+tags\b/);
|
||||
});
|
||||
|
||||
test('the geometry output box is a bounded square around the point', () => {
|
||||
const box = geometryOutputBox(30, -97, 6000)
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
const [south, west, north, east] = box;
|
||||
assert.ok(Math.abs((north - south) * 111320 - 12000) < 2);
|
||||
assert.ok(
|
||||
Math.abs((east - west) * 111320 * Math.cos((30 * Math.PI) / 180) - 12000) <
|
||||
2,
|
||||
);
|
||||
assert.equal(geometryOutputBox(89.99, 179.99, 6000).includes('90,180'), true);
|
||||
});
|
||||
|
||||
test('annotations outline a neighborhood or street area mapped as a relation', async () => {
|
||||
const ring = relationRing.map((p) => [p.lon, p.lat]);
|
||||
const areas = (tags, name) => {
|
||||
const source = createOverpassFeatureSource({
|
||||
boundarySource: overpassPrinting(relationFixture(name, tags)),
|
||||
});
|
||||
return (point, options) =>
|
||||
source[tags.place ? 'getNeighborhoodAreas' : 'getStreetAreas'](
|
||||
point,
|
||||
options,
|
||||
);
|
||||
};
|
||||
const neighborhood = await createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getNeighborhoodAreas: areas(
|
||||
{ place: 'neighbourhood' },
|
||||
'Fixture Heights',
|
||||
),
|
||||
}),
|
||||
}).resolveAnnotationTarget({
|
||||
latitude: 30.002,
|
||||
longitude: -96.997,
|
||||
target: 'Fixture Heights',
|
||||
entityKind: 'district',
|
||||
footprint: true,
|
||||
});
|
||||
assert.equal(neighborhood.footprintKind, 'area');
|
||||
assert.deepEqual(neighborhood.ring, ring);
|
||||
|
||||
const street = await createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getStreetAreas: areas(
|
||||
{ landuse: 'retail', type: 'multipolygon' },
|
||||
'Fixture Street Market',
|
||||
),
|
||||
}),
|
||||
}).resolveAnnotationTarget({
|
||||
latitude: 30.002,
|
||||
longitude: -96.997,
|
||||
target: 'Fixture Street',
|
||||
entityKind: 'street',
|
||||
footprint: true,
|
||||
});
|
||||
assert.equal(street.footprintKind, 'area');
|
||||
assert.deepEqual(street.ring, ring);
|
||||
});
|
||||
|
||||
test('focus footprints keep relation members and centers', async () => {
|
||||
let query;
|
||||
const element = relationFixture('Fixture Building', { building: 'yes' });
|
||||
const source = createOverpassFeatureSource({
|
||||
boundarySource: {
|
||||
query: async (text) => {
|
||||
query = text;
|
||||
return overpassPrinting(element).query(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
const [record] = await source.getFocusFootprints({
|
||||
lat: 30.002,
|
||||
lon: -96.997,
|
||||
});
|
||||
assert.deepEqual(
|
||||
record.coordinates,
|
||||
element.members.flatMap((member) => member.geometry || []),
|
||||
);
|
||||
assert.match(query, /out center geom\([-\d.,]+\);/);
|
||||
assert.doesNotMatch(query, /out tags/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Optional development phase measurements; no provider payloads or credentials. */
|
||||
export function phaseTiming(phase, start, detail = {}) {
|
||||
if (import.meta.env?.DEV)
|
||||
performance.measure(`roads:${phase}`, {
|
||||
start,
|
||||
end: performance.now(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Bound acquisition and body consumption, including transports that ignore abort. */
|
||||
export async function requestWithDeadline(operation, { signal, timeoutMs }) {
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) cancel();
|
||||
else signal?.addEventListener('abort', cancel, { once: true });
|
||||
let rejectWait;
|
||||
const aborted = new Promise((_, reject) => {
|
||||
rejectWait = reject;
|
||||
});
|
||||
const rejectAbort = () => rejectWait(controller.signal.reason);
|
||||
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(new DOMException('Request timed out', 'TimeoutError')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
return await Promise.race([operation(controller.signal), aborted]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
controller.signal.removeEventListener('abort', rejectAbort);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel one consumer's wait without cancelling a shared acquisition. */
|
||||
export async function waitForSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
let cancel;
|
||||
const aborted = new Promise((_, reject) => {
|
||||
cancel = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, aborted]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { phaseTiming } from './phaseTiming.js';
|
||||
import { tilesForBounds } from '../data/tomtomTiles.js';
|
||||
import { readResponseBytesCapped, readResponseJsonCapped } from './httpBody.js';
|
||||
|
||||
/** Validate a non-dateline geographic viewport before selecting a bounded grid. */
|
||||
export function validTileBounds(box) {
|
||||
return (
|
||||
box &&
|
||||
[box.south, box.west, box.north, box.east].every(Number.isFinite) &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90 &&
|
||||
box.west >= -180 &&
|
||||
box.east <= 180 &&
|
||||
box.north > box.south &&
|
||||
box.east > box.west
|
||||
);
|
||||
}
|
||||
|
||||
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
||||
export function createVectorTileSource({
|
||||
tileJsonUrl,
|
||||
template,
|
||||
allowedOrigin,
|
||||
decode,
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
maxTiles = 16,
|
||||
concurrency = 4,
|
||||
ttlMs = Infinity,
|
||||
maxEntries = 64,
|
||||
maxCacheBytes = 24 * 1024 * 1024,
|
||||
maxResponseBytes = 4 * 1024 * 1024,
|
||||
metadataCooldownMs = 5000,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const cache = new Map();
|
||||
const active = new Set();
|
||||
const flights = new Map();
|
||||
let cacheBytes = 0;
|
||||
let metadata = null;
|
||||
let metadataError = null;
|
||||
let metadataFlight = null;
|
||||
let metadataRetryAt = 0;
|
||||
let generation = 0;
|
||||
let fetched = 0;
|
||||
let activeWorkers = 0;
|
||||
const waiters = [];
|
||||
|
||||
async function request(url, parentSignal, read) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(parentSignal?.reason);
|
||||
parentSignal?.throwIfAborted();
|
||||
parentSignal?.addEventListener('abort', abort, { once: true });
|
||||
active.add(controller);
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new DOMException('Vector tile request timed out', 'TimeoutError'),
|
||||
),
|
||||
12_000,
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const status = Number.isFinite(response.status)
|
||||
? response.status
|
||||
: null;
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
status === null
|
||||
? 'Vector tiles unavailable'
|
||||
: `Vector tiles unavailable (HTTP ${status})`,
|
||||
),
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
const value = await read(response, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
return value;
|
||||
} catch (error) {
|
||||
// Retain request ownership until every failed read has been cancelled.
|
||||
controller.abort(error);
|
||||
await response?.body?.cancel().catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abort);
|
||||
active.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMetadata(signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (metadata) return metadata;
|
||||
if (metadataError && (!metadataError.retryable || now() < metadataRetryAt))
|
||||
throw metadataError;
|
||||
metadataError = null;
|
||||
if (!metadataFlight) {
|
||||
const flight = {
|
||||
controller: new AbortController(),
|
||||
users: 0,
|
||||
promise: null,
|
||||
};
|
||||
const metadataStart = performance.now();
|
||||
metadataFlight = flight;
|
||||
flight.promise = (
|
||||
template
|
||||
? Promise.resolve({ tiles: [template] })
|
||||
: request(
|
||||
tileJsonUrl,
|
||||
flight.controller.signal,
|
||||
(res, requestSignal) =>
|
||||
readResponseJsonCapped(res, 256 * 1024, requestSignal),
|
||||
)
|
||||
)
|
||||
.then((value) => {
|
||||
flight.controller.signal.throwIfAborted();
|
||||
const url = value?.tiles?.[0];
|
||||
if (
|
||||
typeof url !== 'string' ||
|
||||
!['{z}', '{x}', '{y}'].every((token) => url.includes(token))
|
||||
)
|
||||
throw Object.assign(new Error('Invalid vector tile metadata'), {
|
||||
retryable: false,
|
||||
});
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(
|
||||
url.replace('{z}', '0').replace('{x}', '0').replace('{y}', '0'),
|
||||
allowedOrigin,
|
||||
);
|
||||
} catch {
|
||||
throw Object.assign(new Error('Invalid vector tile origin'), {
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (
|
||||
parsed.origin !== allowedOrigin ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
)
|
||||
throw Object.assign(new Error('Invalid vector tile origin'), {
|
||||
retryable: false,
|
||||
});
|
||||
if (!template)
|
||||
phaseTiming('tilejson-fetch', metadataStart, {
|
||||
source: allowedOrigin,
|
||||
});
|
||||
metadata = { ...value, template: url };
|
||||
return metadata;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (flight.controller.signal.aborted || error.name === 'AbortError')
|
||||
throw error;
|
||||
metadataError = Object.assign(
|
||||
new Error('Vector tile metadata unavailable', { cause: error }),
|
||||
{
|
||||
status: error.status ?? null,
|
||||
name: error.name === 'TimeoutError' ? 'TimeoutError' : 'Error',
|
||||
retryable:
|
||||
error.retryable !== false && !(error instanceof SyntaxError),
|
||||
},
|
||||
);
|
||||
metadataRetryAt =
|
||||
now() + Math.min(30_000, Math.max(0, metadataCooldownMs));
|
||||
throw metadataError;
|
||||
})
|
||||
.finally(() => {
|
||||
if (metadataFlight === flight) metadataFlight = null;
|
||||
});
|
||||
}
|
||||
const flight = metadataFlight;
|
||||
flight.users += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const cleanup = () => signal?.removeEventListener('abort', abort);
|
||||
flight.promise.then(
|
||||
(value) => {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
}).finally(() => {
|
||||
// Each caller owns its wait; only the last departing caller cancels shared metadata.
|
||||
flight.users -= 1;
|
||||
if (!flight.users && metadataFlight === flight) {
|
||||
flight.controller.abort();
|
||||
metadataFlight = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function acquire(signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (activeWorkers >= concurrency) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const waiter = {
|
||||
resolve: () => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve();
|
||||
},
|
||||
};
|
||||
const abort = () => {
|
||||
const i = waiters.indexOf(waiter);
|
||||
if (i >= 0) waiters.splice(i, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
waiters.push(waiter);
|
||||
});
|
||||
} else activeWorkers += 1;
|
||||
}
|
||||
|
||||
function release() {
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter.resolve();
|
||||
else activeWorkers -= 1;
|
||||
}
|
||||
|
||||
function subscribe(flight, signal, key) {
|
||||
signal?.throwIfAborted();
|
||||
flight.users++;
|
||||
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(() => {
|
||||
if (--flight.users === 0 && flights.get(key) === flight) {
|
||||
flights.delete(key);
|
||||
flight.controller.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getTile(tile, meta, signal, epoch) {
|
||||
signal?.throwIfAborted();
|
||||
const key = `${meta.template}:${tile.z}/${tile.x}/${tile.y}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && now() - hit.at < ttlMs) {
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit.value;
|
||||
}
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
})().finally(() => {
|
||||
if (flights.get(key) === flight) flights.delete(key);
|
||||
});
|
||||
}
|
||||
return subscribe(flight, signal, key);
|
||||
}
|
||||
|
||||
return {
|
||||
getMetadata,
|
||||
async fetchBounds(
|
||||
box,
|
||||
{ zoom, signal, onTile, tiles: selectedTiles } = {},
|
||||
) {
|
||||
if (
|
||||
!validTileBounds(box) ||
|
||||
!Number.isInteger(zoom) ||
|
||||
zoom < 0 ||
|
||||
zoom > 14
|
||||
)
|
||||
throw new TypeError('Invalid tile viewport');
|
||||
signal?.throwIfAborted();
|
||||
const epoch = generation;
|
||||
const metaStart = performance.now();
|
||||
const meta = await getMetadata(signal);
|
||||
phaseTiming('tilejson', metaStart, { source: allowedOrigin });
|
||||
const candidates = selectedTiles
|
||||
? selectedTiles.slice()
|
||||
: tilesForBounds(box, zoom, { maxTiles: maxTiles + 1 });
|
||||
if (
|
||||
candidates.some(
|
||||
(t) =>
|
||||
t.z !== zoom ||
|
||||
!Number.isInteger(t.x) ||
|
||||
!Number.isInteger(t.y) ||
|
||||
t.x < 0 ||
|
||||
t.y < 0 ||
|
||||
t.x >= 2 ** zoom ||
|
||||
t.y >= 2 ** zoom,
|
||||
)
|
||||
)
|
||||
throw new TypeError('Invalid tile selection');
|
||||
const cx = candidates.reduce((n, t) => n + t.x, 0) / candidates.length;
|
||||
const cy = candidates.reduce((n, t) => n + t.y, 0) / candidates.length;
|
||||
if (!selectedTiles)
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy),
|
||||
);
|
||||
const limited = candidates.length > maxTiles;
|
||||
// Refuse over-wide views instead of silently sampling a northwest strip.
|
||||
if (limited)
|
||||
throw Object.assign(new Error('Zoom in for vector tile coverage'), {
|
||||
retryable: false,
|
||||
code: 'TILE_VIEW_TOO_WIDE',
|
||||
});
|
||||
let index = 0;
|
||||
const results = new Array(candidates.length);
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(concurrency, candidates.length) },
|
||||
async () => {
|
||||
while (index < candidates.length) {
|
||||
const i = index++;
|
||||
try {
|
||||
results[i] = {
|
||||
value: await getTile(candidates[i], meta, signal, epoch),
|
||||
tile: candidates[i],
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
onTile?.(results[i].value, candidates[i]);
|
||||
} catch (error) {
|
||||
results[i] = { error };
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (epoch !== generation)
|
||||
throw new DOMException('Source cleared', 'AbortError');
|
||||
const good = results.filter((result) => !result.error);
|
||||
if (!good.length && results.length) throw results[0].error;
|
||||
return {
|
||||
tiles: good.map((result) => result.value),
|
||||
loadedTiles: good.map((result) => result.tile),
|
||||
partial: good.length !== results.length,
|
||||
limited,
|
||||
};
|
||||
},
|
||||
getStats: () => ({
|
||||
tilesFetched: fetched,
|
||||
cacheEntries: cache.size,
|
||||
cacheBytes,
|
||||
}),
|
||||
clear() {
|
||||
generation += 1;
|
||||
metadataFlight?.controller.abort();
|
||||
metadataFlight = null;
|
||||
metadata = null;
|
||||
metadataError = null;
|
||||
metadataRetryAt = 0;
|
||||
for (const flight of flights.values()) flight.controller.abort();
|
||||
flights.clear();
|
||||
for (const controller of active) controller.abort();
|
||||
cache.clear();
|
||||
cacheBytes = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { 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 = {
|
||||
tileJsonUrl: 'https://tiles.example/index.json',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
decode: () => [],
|
||||
};
|
||||
|
||||
for (const failure of ['503', 'network', 'timeout'])
|
||||
test(`TileJSON ${failure} retries after cooldown and caches only success`, async () => {
|
||||
let calls = 0,
|
||||
time = 0;
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
now: () => time,
|
||||
metadataCooldownMs: 5000,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
if (calls > 1) return Response.json(meta);
|
||||
if (failure === '503') return new Response('', { status: 503 });
|
||||
throw failure === 'timeout'
|
||||
? new DOMException('timeout', 'TimeoutError')
|
||||
: new TypeError('network');
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getMetadata(), { retryable: true });
|
||||
await assert.rejects(source.getMetadata(), { retryable: true });
|
||||
assert.equal(calls, 1);
|
||||
time = 5001;
|
||||
assert.equal((await source.getMetadata()).template, meta.tiles[0]);
|
||||
await source.getMetadata();
|
||||
assert.equal(calls, 2);
|
||||
source.clear();
|
||||
await source.getMetadata();
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
for (const invalid of [
|
||||
{ tiles: [] },
|
||||
{ tiles: ['https://evil.example/{z}/{x}/{y}'] },
|
||||
])
|
||||
test('invalid TileJSON is permanent only until clear', async () => {
|
||||
let calls = 0;
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(calls === 1 ? invalid : meta);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getMetadata(), { retryable: false });
|
||||
await assert.rejects(source.getMetadata(), { retryable: false });
|
||||
assert.equal(calls, 1);
|
||||
source.clear();
|
||||
assert.equal((await source.getMetadata()).template, meta.tiles[0]);
|
||||
});
|
||||
for (const declared of [true, false])
|
||||
test(`oversized ${declared ? 'declared' : 'streaming'} tile cancels body and aborts request before release`, async () => {
|
||||
let cancelled = 0,
|
||||
signal;
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
maxResponseBytes: 8,
|
||||
fetchImpl: async (_, opts) => {
|
||||
signal = opts.signal;
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
pull(c) {
|
||||
c.enqueue(new Uint8Array(16));
|
||||
},
|
||||
cancel() {
|
||||
cancelled++;
|
||||
},
|
||||
}),
|
||||
{ headers: declared ? { 'content-length': '16' } : {} },
|
||||
);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.fetchBounds(box, { zoom: 14 }), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(source.getStats().cacheEntries, 0);
|
||||
});
|
||||
|
||||
test('overlapping callers share XYZ; cancelling one keeps the other alive', async () => {
|
||||
let releaseBody,
|
||||
reads = 0,
|
||||
underlying;
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
fetchImpl: async (_, { signal }) => {
|
||||
reads++;
|
||||
underlying = signal;
|
||||
await new Promise((resolve) => {
|
||||
releaseBody = resolve;
|
||||
});
|
||||
return new Response(new Uint8Array([1]));
|
||||
},
|
||||
});
|
||||
const a = new AbortController(),
|
||||
b = new AbortController();
|
||||
const first = source.fetchBounds(box, { zoom: 12, signal: a.signal });
|
||||
const rejection = assert.rejects(first, { name: 'AbortError' });
|
||||
const second = source.fetchBounds(box, { zoom: 12, signal: b.signal });
|
||||
while (!releaseBody) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
a.abort();
|
||||
await rejection;
|
||||
assert.equal(underlying.aborted, false);
|
||||
releaseBody();
|
||||
assert.equal((await second).tiles.length, 1);
|
||||
assert.equal(reads, 1);
|
||||
await source.fetchBounds(box, { zoom: 12 });
|
||||
assert.equal(reads, 1);
|
||||
});
|
||||
|
||||
test('final subscriber cancellation aborts the owned request; completed tiles publish before a straggler', async () => {
|
||||
let owned;
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
fetchImpl: (_, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
owned = signal;
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = source.fetchBounds(box, {
|
||||
zoom: 12,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = assert.rejects(pending, { name: 'AbortError' });
|
||||
while (!owned) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort();
|
||||
await rejection;
|
||||
assert.equal(owned.aborted, true);
|
||||
let release,
|
||||
published = 0,
|
||||
requests = 0;
|
||||
const incremental = createVectorTileSource({
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
fetchImpl: async () => {
|
||||
if (++requests === 2)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return new Response(new Uint8Array([1]));
|
||||
},
|
||||
});
|
||||
const complete = incremental.fetchBounds(
|
||||
{ ...box, east: -97.7 },
|
||||
{ zoom: 12, onTile: () => published++ },
|
||||
);
|
||||
while (!published) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(published, 1);
|
||||
assert.equal(typeof release, 'function');
|
||||
release();
|
||||
await complete;
|
||||
assert.equal(published, 2);
|
||||
});
|
||||
|
||||
test('polygon-selected tiles keep nearest-first order and cached revisits issue zero requests', async () => {
|
||||
const urls = [];
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
template: 'https://tiles.example/{z}/{x}/{y}.pbf',
|
||||
tileJsonUrl: undefined,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return new Response(new Uint8Array([1]));
|
||||
},
|
||||
});
|
||||
const tiles = [
|
||||
{ z: 12, x: 935, y: 1685 },
|
||||
{ z: 12, x: 936, y: 1685 },
|
||||
];
|
||||
await source.fetchBounds(box, { zoom: 12, tiles });
|
||||
assert.ok(urls[0].endsWith('/12/935/1685.pbf'));
|
||||
assert.ok(urls[1].endsWith('/12/936/1685.pbf'));
|
||||
await source.fetchBounds(box, { zoom: 12, tiles: [...tiles].reverse() });
|
||||
assert.equal(urls.length, 2);
|
||||
await assert.rejects(
|
||||
source.fetchBounds(box, {
|
||||
zoom: 12,
|
||||
tiles: Array.from({ length: 17 }, (_, i) => ({
|
||||
z: 12,
|
||||
x: 935 + i,
|
||||
y: 1685,
|
||||
})),
|
||||
}),
|
||||
{ code: 'TILE_VIEW_TOO_WIDE' },
|
||||
);
|
||||
assert.equal(
|
||||
urls.length,
|
||||
2,
|
||||
'over-budget selection is rejected before fetching',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user