Merge current upstream and resolve PR 581 conflicts

This commit is contained in:
Michael Johnston committed 2026-10-01 21:36:44 -04:00
commit d866f9b01c
211 files changed
+28120 -1988

No files matched your search

+21
View File
@@ -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
View File
@@ -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;
}
/**
+352
View File
@@ -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;
}
+383
View File
@@ -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');
});
+227
View File
@@ -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,
});
}
+322
View File
@@ -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));
}
});
+46 -5
View File
@@ -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,
);
+154
View File
@@ -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/);
});
+9
View File
@@ -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,
});
}
+43
View File
@@ -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);
}
}
+408
View File
@@ -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;
},
};
}
+205
View File
@@ -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',
);
});