mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
This commit is contained in:
200 files changed
+26665
-1977
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,
|
||||
),
|
||||
});
|
||||
}
|
||||
+37
-26
@@ -58,43 +58,54 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
|
||||
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes) {
|
||||
const tooLarge = () => {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
return err;
|
||||
};
|
||||
export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error('Upstream response too large'), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge();
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw tooLarge();
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
if (bytes.byteLength > maxBytes) throw tooLarge();
|
||||
return bytes;
|
||||
}
|
||||
const cancel = () => {
|
||||
void reader.cancel().catch(() => {});
|
||||
};
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
throw tooLarge();
|
||||
try {
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
signal?.throwIfAborted();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw tooLarge();
|
||||
chunks.push(value);
|
||||
}
|
||||
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) {
|
||||
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