feat(outlines): voice outlines without Overpass — bundled US places and neighborhoods, guarded Nominatim, OpenFreeMap streets and buildings

This commit is contained in:
Bilawal Sidhu committed 2026-09-28 22:49:23 -05:00
1 parent e7707d9a0f
commit 9559e7a282
333 files changed
+13203 -271

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
import { readResponseJsonCapped } from './httpBody.js';
/**
* Browser client for the server's guarded outline route. The server owns the
* endpoint, pacing, caps and caches; this client only asks once per explicit
* voice ask and reads the tri-state answer the annotation resolver expects:
*
* - `{ polygons, name, class, type, osm }` — an accepted outline;
* - `null` — the service answered and has no suitable polygon;
* - `undefined` — a transient failure; a later retry may succeed;
* - `{ rateLimited: true, retryAfterMs }` — busy; retry no sooner;
* - `{ unavailable: true, retryable: false }` — disabled, capped for the
* day, or no route: stop asking this session.
*/
const DEFAULT_ENDPOINT = '/api/geocode/outline';
const MAX_RESPONSE_BYTES = 3 * 1024 * 1024;
const TIMEOUT_MS = 15_000;
/** Ask kinds the route accepts. */
export const NOMINATIM_OUTLINE_KINDS = Object.freeze([
'city',
'admin',
'neighborhood',
'landmark',
'building',
]);
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return null;
const out = [];
for (const point of ring) {
const lon = Number(point?.[0]);
const lat = Number(point?.[1]);
if (!(Math.abs(lon) <= 180 && Math.abs(lat) <= 90)) return null;
out.push([lon, lat]);
}
return out;
}
function validPolygons(value) {
if (!Array.isArray(value) || !value.length) return null;
const polygons = [];
for (const rings of value) {
if (!Array.isArray(rings)) continue;
const clean = rings.map(validRing);
// The outer ring must be valid; invalid holes are dropped.
if (clean[0]) polygons.push(clean.filter(Boolean));
}
return polygons.length ? polygons : null;
}
export function createNominatimOutlineClient({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = DEFAULT_ENDPOINT,
maxRemembered = 64,
} = {}) {
let unavailable = null;
const remembered = new Map();
function remember(key, value) {
remembered.delete(key);
remembered.set(key, value);
while (remembered.size > maxRemembered)
remembered.delete(remembered.keys().next().value);
}
/**
* @param {{query: string, kind: string, lat?: number, lon?: number}} ask
* @param {{signal?: AbortSignal}} [options]
*/
async function lookup({ query, kind, lat, lon }, { signal } = {}) {
if (unavailable) return unavailable;
const text = String(query || '').trim();
if (!text || text.length > 200 || !NOMINATIM_OUTLINE_KINDS.includes(kind))
return null;
const params = new URLSearchParams({ q: text, kind });
if (Number.isFinite(lat) && Number.isFinite(lon)) {
params.set('lat', lat.toFixed(3));
params.set('lon', lon.toFixed(3));
}
const key = params.toString();
if (remembered.has(key)) return remembered.get(key);
// A cancelled ask spends nothing.
signal?.throwIfAborted();
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(abort, TIMEOUT_MS);
try {
const response = await fetchImpl(`${endpoint}?${key}`, {
signal: controller.signal,
});
let body = null;
try {
body = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
controller.signal,
);
} catch {
body = null;
}
if (response.status === 404 || body?.retryable === false) {
// No outline route here, disabled, or today's allowance is used.
unavailable = { unavailable: true, retryable: false, code: body?.code };
return unavailable;
}
if (response.status === 429) {
const seconds = Number(response.headers?.get?.('retry-after'));
return {
rateLimited: true,
retryAfterMs: Number.isFinite(seconds) ? seconds * 1000 : null,
};
}
if (!response.ok || !body) return undefined;
if (body.status === 'ZERO_RESULTS') {
remember(key, null);
return null;
}
const polygons = validPolygons(body.outline?.polygons);
if (body.status !== 'OK' || !polygons) return undefined;
const outline = {
polygons,
name: body.outline.name || null,
class: body.outline.class || null,
type: body.outline.type || null,
osm: body.outline.osm || null,
};
remember(key, outline);
return outline;
} catch {
signal?.throwIfAborted();
return undefined;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}
return { lookup };
}
+93
View File
@@ -0,0 +1,93 @@
// Browser client for /api/geocode/outline: tri-state answers, session stop.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNominatimOutlineClient } from './nominatimOutlines.js';
const ring = [
[2.3, 48.8],
[2.4, 48.8],
[2.4, 48.9],
[2.3, 48.9],
[2.3, 48.8],
];
function client(respond) {
const calls = [];
const lookup = createNominatimOutlineClient({
fetchImpl: async (url) => {
calls.push(String(url));
return respond(calls.length);
},
}).lookup;
return { calls, lookup };
}
const reply = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers });
test('an accepted outline comes back with its polygons and is remembered', async () => {
const { calls, lookup } = client(() =>
reply(200, {
status: 'OK',
outline: { name: 'Paris', class: 'boundary', type: 'administrative', polygons: [[ring]] },
}),
);
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
const outline = await lookup(ask);
assert.deepEqual(outline.polygons, [[ring]]);
assert.equal(outline.class, 'boundary');
assert.match(calls[0], /^\/api\/geocode\/outline\?q=Paris&kind=city&lat=48\.850&lon=2\.350$/);
await lookup(ask);
assert.equal(calls.length, 1, 'one request per ask in a session');
});
test('no result is a definitive null; failures are transient undefined', async () => {
const { lookup } = client((n) =>
n === 1 ? reply(200, { status: 'ZERO_RESULTS', outline: null }) : reply(503, { error: 'x' }),
);
assert.equal(await lookup({ query: 'Nowhere', kind: 'landmark' }), null);
assert.equal(await lookup({ query: 'Down', kind: 'landmark' }), undefined);
});
test('busy is rate-limited with its Retry-After', async () => {
const { lookup } = client(() => reply(429, { error: 'busy' }, { 'Retry-After': '20' }));
assert.deepEqual(await lookup({ query: 'Paris', kind: 'city' }), {
rateLimited: true,
retryAfterMs: 20_000,
});
});
test('disabled, capped or missing routes stop asking for the session', async () => {
for (const [status, body] of [
[503, { code: 'NOMINATIM_DISABLED', retryable: false }],
[429, { code: 'NOMINATIM_DAILY_CAP', retryable: false }],
[404, { error: 'Not Found' }],
]) {
const { calls, lookup } = client(() => reply(status, body));
const first = await lookup({ query: 'Paris', kind: 'city' });
assert.equal(first.unavailable, true);
assert.equal(first.retryable, false);
const second = await lookup({ query: 'Tokyo', kind: 'city' });
assert.equal(second.unavailable, true);
assert.equal(calls.length, 1, `${status}: no further requests`);
}
});
test('bad input and malformed polygons never become outlines', async () => {
const { calls, lookup } = client(() =>
reply(200, { status: 'OK', outline: { polygons: [[[[999, 0], [1, 1], [2, 2], [999, 0]]]] } }),
);
assert.equal(await lookup({ query: '', kind: 'city' }), null);
assert.equal(await lookup({ query: 'Paris', kind: 'street' }), null);
assert.equal(calls.length, 0);
assert.equal(await lookup({ query: 'Paris', kind: 'city' }), undefined);
});
test('a cancelled ask sends nothing', async () => {
const { calls, lookup } = client(() => reply(200, { status: 'ZERO_RESULTS' }));
await assert.rejects(
lookup({ query: 'Paris', kind: 'city' }, { signal: AbortSignal.abort() }),
{ name: 'AbortError' },
);
assert.equal(calls.length, 0);
});
+481
View File
@@ -0,0 +1,481 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { clipTileLine, createOpenFreeMapSource } from './openFreeMap.js';
import { tileToBBox } from '../data/tomtomTiles.js';
import {
approximateDistanceM,
ringAreaM2,
closeRing,
} from './featureGeometry.js';
/**
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
* traffic layer reads. No new tile provider: the source below points at the
* same immutable tile URLs, so tiles traffic already loaded come from the
* browser cache. Nothing here is fetched without an explicit ask.
*/
/** Tile zoom used for outlines (the highest OpenMapTiles zoom with buildings). */
export const OUTLINE_TILE_ZOOM = 14;
/** Radius around the geocoded point searched for a named street. */
export const STREET_RADIUS_M = 1500;
/** Bounds on what one street ask can return. */
const STREET_MAX_CHAINS = 12;
const STREET_MAX_POINTS = 4000;
const STREET_MIN_CHAIN_M = 20;
const JOIN_TOLERANCE_M = 3;
/** How far from the point a building may be and still be "this building". */
export const BUILDING_MAX_DISTANCE_M = 40;
const LAYER_FEATURE_LIMIT = 40_000;
/** Area classes (OpenMapTiles `class`, or `subclass` for landcover) that can be grounds. */
const GROUNDS_AREA_CLASSES = Object.freeze({
park: new Set(['park', 'nature_reserve', 'protected_area', 'national_park']),
landuse: new Set([
'school',
'university',
'college',
'hospital',
'cemetery',
'stadium',
'zoo',
'theme_park',
'military',
'religious',
'pitch',
'track',
'playground',
]),
landcover: new Set(['park', 'garden', 'grass', 'recreation_ground']),
});
/** Lines of one tile's `transportation_name` layer and polygons of `building`. */
export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
const box = tileToBBox(z, x, y);
const streets = [];
const names = tile.layers.transportation_name;
if (names) {
if (names.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
for (let i = 0; i < names.length; i++) {
const feature = names.feature(i);
const props = feature.properties;
const name = props.name || props['name:latin'] || props.name_en;
if (!name) continue;
const geometry = feature.toGeoJSON(x, y, z).geometry;
const lines =
geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.type === 'MultiLineString'
? geometry.coordinates
: [];
// Clip to the tile core so neighbouring tiles' buffers never duplicate.
for (const line of lines)
for (const clipped of clipTileLine(line, box))
streets.push({
name: String(name),
nameEn: props.name_en ? String(props.name_en) : null,
class: props.class || null,
coordinates: clipped,
});
}
}
const buildings = decodePolygons(
tile.layers.building,
z,
x,
y,
null,
(props) => {
const height = Number(props.render_height);
return { heightM: Number.isFinite(height) && height > 0 ? height : null };
},
);
// Open areas that can enclose grounds: parks, landuse and park-like cover.
const areas = [];
for (const name of ['park', 'landuse', 'landcover']) {
areas.push(
...decodePolygons(
tile.layers[name],
z,
x,
y,
(props) =>
GROUNDS_AREA_CLASSES[name].has(String(props.subclass || props.class)),
(props) => ({
layer: name,
class: String(props.subclass || props.class),
name: props.name ? String(props.name) : null,
}),
),
);
}
return { streets, buildings, areas };
}
/** Tile pixel -> longitude/latitude, exactly as vector-tile's toGeoJSON projects. */
function pixelToLonLat(px, py, extent, z, x, y) {
const size = extent * 2 ** z;
const lon = ((extent * x + px) * 360) / size - 180;
const y2 = 180 - ((extent * y + py) * 360) / size;
const lat = (360 / Math.PI) * Math.atan(Math.exp((y2 * Math.PI) / 180)) - 90;
return [lon, lat];
}
/**
* Polygons of one layer with their identity and completeness.
*
* Tiles cut geometry at a buffer beyond the tile edge. A polygon with a vertex
* on that cut line is a fragment of something larger: it keeps its feature id
* and tile, and is marked `clipped` so it is never used as a whole outline. A
* neighbouring tile may carry the same feature complete within its buffer.
*/
function decodePolygons(layer, z, x, y, keep, describe) {
if (!layer) return [];
if (layer.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
const extent = layer.extent || 4096;
// The cut lines are the layer's extreme coordinates beyond the tile core.
let minX = 0;
let minY = 0;
let maxX = extent;
let maxY = extent;
for (let i = 0; i < layer.length; i++)
for (const ring of layer.feature(i).loadGeometry())
for (const point of ring) {
if (point.x < minX) minX = point.x;
if (point.y < minY) minY = point.y;
if (point.x > maxX) maxX = point.x;
if (point.y > maxY) maxY = point.y;
}
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, extent, z, x, y);
const eps = 1e-9;
const onCut = ([lon, lat]) =>
(minX < 0 && Math.abs(lon - westCut) < eps) ||
(maxX > extent && Math.abs(lon - eastCut) < eps) ||
(minY < 0 && Math.abs(lat - northCut) < eps) ||
(maxY > extent && Math.abs(lat - southCut) < eps);
const out = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const props = feature.properties;
if (keep && !keep(props)) continue;
const geometry = feature.toGeoJSON(x, y, z).geometry;
const polygons =
geometry.type === 'Polygon'
? [geometry.coordinates]
: geometry.type === 'MultiPolygon'
? geometry.coordinates
: [];
for (let part = 0; part < polygons.length; part++) {
const rings = polygons[part];
if (!Array.isArray(rings?.[0]) || rings[0].length < 4) continue;
out.push({
rings,
id: feature.id ?? null,
part,
tile: `${z}/${x}/${y}`,
clipped: rings[0].some(onCut),
...describe(props),
});
}
}
return out;
}
/** Construct the outline tile source: same OpenFreeMap tiles, outline decode. */
export function createOpenFreeMapOutlineSource(options = {}) {
return createOpenFreeMapSource({
decode: decodeOpenFreeMapOutlineTile,
maxEntries: 32,
maxCacheBytes: 16 * 1024 * 1024,
...options,
});
}
/** Geographic box of `radiusM` around a point. */
export function boxAround(lat, lon, radiusM) {
const dLat = radiusM / 111_320;
const dLon =
radiusM / (111_320 * Math.max(0.05, Math.cos((lat * Math.PI) / 180)));
return {
south: Math.max(-85, lat - dLat),
north: Math.min(85, lat + dLat),
west: Math.max(-180, lon - dLon),
east: Math.min(180, lon + dLon),
};
}
const STREET_WORDS = Object.freeze({
st: 'street',
str: 'street',
ave: 'avenue',
av: 'avenue',
blvd: 'boulevard',
rd: 'road',
dr: 'drive',
ln: 'lane',
hwy: 'highway',
pkwy: 'parkway',
pl: 'place',
ct: 'court',
sq: 'square',
n: 'north',
s: 'south',
e: 'east',
w: 'west',
});
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
/** Comparable street name: accents off, abbreviations expanded. */
export function normalizeStreetName(value) {
return String(value ?? '')
.split(',')[0]
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.toLocaleLowerCase('en-US')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.filter((word, index) => !(index === 0 && word === 'the'))
.map((word) => STREET_WORDS[word] || word)
.join(' ');
}
/**
* Whether a tile street name answers the asked street. Exact after
* normalization; otherwise the same name with only a leading direction added
* ("North Congress Avenue" for "Congress Avenue") — never a different street
* that merely shares a word.
*/
export function streetNameMatches(candidate, asked) {
const want = normalizeStreetName(asked);
if (!want) return false;
const have = normalizeStreetName(candidate);
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
}
const lengthM = (line) => {
let total = 0;
for (let i = 1; i < line.length; i++)
total += approximateDistanceM(
line[i - 1][1],
line[i - 1][0],
line[i][1],
line[i][0],
);
return total;
};
const near = (a, b) =>
approximateDistanceM(a[1], a[0], b[1], b[0]) <= JOIN_TOLERANCE_M;
/** Join line pieces whose ends meet into as few chains as possible. */
export function mergeLineSegments(segments) {
const pending = segments.filter((s) => s.length >= 2).map((s) => s.slice());
const chains = [];
while (pending.length) {
let chain = pending.shift();
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < pending.length; i++) {
const s = pending[i];
const head = chain[0];
const tail = chain[chain.length - 1];
if (near(tail, s[0])) chain = chain.concat(s.slice(1));
else if (near(tail, s[s.length - 1]))
chain = chain.concat(s.slice(0, -1).reverse());
else if (near(head, s[s.length - 1]))
chain = s.slice(0, -1).concat(chain);
else if (near(head, s[0])) chain = s.slice(1).reverse().concat(chain);
else continue;
pending.splice(i, 1);
grew = true;
break;
}
}
chains.push(chain);
}
return chains;
}
/**
* The asked street near a point as a bounded set of merged polylines, longest
* first, or null when no tile street carries that name.
*
* @param {Array<{tiles?: object[], streets?: object[]}>|object[]} tiles Decoded tiles.
* @param {{name: string, lat: number, lon: number, radiusM?: number}} ask
* @returns {null | {name: string, lines: Array<Array<[number, number]>>}}
*/
export function streetFromTiles(
tiles,
{ name, lat, lon, radiusM = STREET_RADIUS_M },
) {
const pieces = [];
let matchedName = null;
const extent = boxAround(lat, lon, radiusM);
for (const tile of tiles || []) {
for (const street of tile?.streets || []) {
if (
!streetNameMatches(street.name, name) &&
!(street.nameEn && streetNameMatches(street.nameEn, name))
)
continue;
// Segment distance, not vertex distance: a long straight piece through
// the point has no vertex near it.
if (distanceToLineM(lon, lat, street.coordinates) > radiusM) continue;
// Only what lies within the search extent is drawn.
const clipped = clipTileLine(street.coordinates, extent);
if (!clipped.length) continue;
matchedName ||= street.name;
pieces.push(...clipped);
}
}
if (!pieces.length) return null;
const chains = mergeLineSegments(pieces)
.map((line) => ({ line, length: lengthM(line) }))
.filter((entry) => entry.length >= STREET_MIN_CHAIN_M)
.sort((a, b) => b.length - a.length)
.slice(0, STREET_MAX_CHAINS);
const lines = [];
let points = 0;
for (const { line } of chains) {
if (points + line.length > STREET_MAX_POINTS) break;
lines.push(line);
points += line.length;
}
return lines.length ? { name: matchedName, lines } : null;
}
function insideRing(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi
)
inside = !inside;
}
return inside;
}
/** Inside the outer ring and outside every hole. */
function insidePolygon(lon, lat, rings) {
if (!insideRing(lon, lat, rings[0])) return false;
for (let i = 1; i < rings.length; i++)
if (insideRing(lon, lat, rings[i])) return false;
return true;
}
/** Distance (m) from a point to the nearest edge of any ring of a polygon. */
function distanceToPolygonM(lon, lat, rings) {
return Math.min(...rings.map((ring) => distanceToLineM(lon, lat, ring)));
}
/** Distance (m) from a point to the nearest segment of a line or ring. */
function distanceToLineM(lon, lat, ring) {
const mLat = 111_320;
const mLon = mLat * Math.cos((lat * Math.PI) / 180);
let best = Infinity;
for (let i = 1; i < ring.length; i++) {
const ax = (ring[i - 1][0] - lon) * mLon;
const ay = (ring[i - 1][1] - lat) * mLat;
const bx = (ring[i][0] - lon) * mLon;
const by = (ring[i][1] - lat) * mLat;
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
const t = len2 ? Math.max(0, Math.min(1, -(ax * dx + ay * dy) / len2)) : 0;
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
}
return best;
}
/**
* The building at a point: the smallest footprint containing it, else the
* nearest within `maxDistanceM`, else null.
*
* @returns {null | {ring: Array<[number, number]>, rings: Array<Array<[number, number]>>, heightM: number|null, contains: boolean}}
*/
export function buildingFromTiles(
tiles,
{ lat, lon, maxDistanceM = BUILDING_MAX_DISTANCE_M },
) {
let containing = null;
let fragmentContains = false;
let nearest = null;
for (const tile of tiles || []) {
for (const building of tile?.buildings || []) {
if (insidePolygon(lon, lat, building.rings)) {
// A fragment cut at a tile edge is never the whole footprint.
if (building.clipped) {
fragmentContains = true;
continue;
}
const area = ringAreaM2(building.rings[0]);
if (!containing || area < containing.area)
containing = { building, area };
} else if (!building.clipped) {
const distance = distanceToPolygonM(lon, lat, building.rings);
if (
distance <= maxDistanceM &&
(!nearest || distance < nearest.distance)
)
nearest = { building, distance };
}
}
}
// The point is on a building only seen in pieces: no neighbour stands in.
const pick =
containing?.building || (fragmentContains ? null : nearest?.building);
if (!pick) return null;
const rings = pick.rings.map((ring) =>
closeRing(ring.map((p) => [p[0], p[1]])),
);
return {
ring: rings[0],
rings,
heightM: pick.heightM,
contains: Boolean(containing),
};
}
/**
* The open area enclosing grounds at a point: the smallest park, landuse or
* park-like cover polygon that contains it and is at least grounds-sized.
* Tiles carry no names for these, so this is only used after a named lookup
* found nothing better.
*/
export function enclosingAreaFromTiles(
tiles,
{ lat, lon, minAreaM2 = 20_000, maxAreaM2 = 60e6 },
) {
let best = null;
for (const tile of tiles || []) {
for (const area of tile?.areas || []) {
// Grounds ENCLOSE: the outer boundary counts, since a hole in grounds is
// typically the building the ask is about (the Capitol inside its lawn).
if (area.clipped || !insideRing(lon, lat, area.rings[0])) continue;
const size = ringAreaM2(area.rings[0]);
if (size < minAreaM2 || size > maxAreaM2) continue;
if (!best || size < best.size) best = { area, size };
}
}
if (!best) return null;
const rings = best.area.rings.map((ring) =>
closeRing(ring.map((p) => [p[0], p[1]])),
);
return { ring: rings[0], rings, class: best.area.class, areaM2: best.size };
}
+112
View File
@@ -0,0 +1,112 @@
// Street and building outlines from OpenFreeMap z14 tiles (fixtures: trimmed
// real tiles around the Texas Capitol, see src/data/fixtures/README.md).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildingFromTiles,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
mergeLineSegments,
normalizeStreetName,
streetFromTiles,
streetNameMatches,
} from './openFreeMapOutlines.js';
import { approximateDistanceM } from './featureGeometry.js';
const fixture = (y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
y,
);
const TILES = [fixture(6745), fixture(6746)];
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
test('tiles decode named street lines and building polygons', () => {
const names = new Set(TILES.flatMap((t) => t.streets.map((s) => s.name)));
assert.ok(names.has('Congress Avenue'));
assert.ok(names.has('South Congress Avenue'));
assert.ok(TILES[0].buildings.length > 0);
for (const street of TILES.flatMap((t) => t.streets))
for (const [lon, lat] of street.coordinates) {
assert.ok(lon > -97.76 && lon < -97.72, 'clipped to the tile core');
assert.ok(lat > 30.24 && lat < 30.29);
}
});
test('street names compare with abbreviations and a leading direction only', () => {
assert.equal(normalizeStreetName('Congress Ave.'), 'congress avenue');
assert.equal(normalizeStreetName('Lombard St, San Francisco'), 'lombard street');
assert.ok(streetNameMatches('Congress Avenue', 'congress ave'));
assert.ok(streetNameMatches('North Congress Avenue', 'Congress Avenue'));
assert.ok(!streetNameMatches('Congress Avenue', 'South Congress Avenue'));
assert.ok(!streetNameMatches('Congress Street', 'Congress Avenue'));
assert.ok(!streetNameMatches('East 11th Street', '11th'));
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
const street = streetFromTiles(TILES, { name: 'Congress Avenue', ...CAPITOL });
assert.equal(street.name, 'Congress Avenue');
assert.equal(street.lines.length, 1, 'the tile pieces join into one line');
const line = street.lines[0];
const [start, end] = [line[0], line.at(-1)];
const spanM = approximateDistanceM(start[1], start[0], end[1], end[0]);
assert.ok(spanM > 1200, `Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`);
// South Congress is a different street and stays out.
const south = streetFromTiles(TILES, { name: 'South Congress Avenue', ...CAPITOL });
assert.ok(south);
assert.ok(south.lines.every((l) => l.every(([, lat]) => lat < 30.265)));
});
test('street lookups are bounded by radius and answer null for unknown names', () => {
assert.equal(streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }), null);
assert.equal(
streetFromTiles(TILES, { name: 'Congress Avenue', lat: 30.4, lon: -97.74 }),
null,
'no piece within the radius of a far point',
);
});
test('merge joins reversed and out-of-order pieces and keeps gaps apart', () => {
const a = [[0, 0], [0.001, 0]];
const b = [[0.002, 0], [0.001, 0]]; // reversed, shares a's end
const c = [[0.01, 0], [0.011, 0]]; // separate
const chains = mergeLineSegments([b, c, a]);
assert.equal(chains.length, 2);
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
});
test('the building containing a point is picked (Texas Capitol)', () => {
const building = buildingFromTiles(TILES, CAPITOL);
assert.ok(building.contains);
assert.ok(building.ring.length >= 8);
assert.deepEqual(building.ring[0], building.ring.at(-1), 'closed ring');
assert.ok(building.heightM > 20, 'render height carried for extrusion');
});
test('a point beside a building picks the nearest within 40 m, else none', () => {
const box = [[0, 0], [0.0002, 0], [0.0002, 0.0002], [0, 0.0002], [0, 0]];
const tiles = [{ buildings: [{ rings: [box], heightM: null }] }];
const near = buildingFromTiles(tiles, { lat: 0.0001, lon: 0.00045 }); // ~28 m east
assert.ok(near && !near.contains);
assert.equal(buildingFromTiles(tiles, { lat: 0.0001, lon: 0.0008 }), null);
});
test('the open area enclosing the Capitol is its grounds, not a building', () => {
const grounds = enclosingAreaFromTiles(TILES, CAPITOL);
assert.ok(grounds, 'the park-like cover around the Capitol is found');
assert.ok(grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000, `${grounds.areaM2} m²`);
assert.equal(grounds.class, 'park');
assert.equal(
enclosingAreaFromTiles(TILES, { ...CAPITOL, minAreaM2: 1e6 }),
null,
'nothing grounds-sized above the floor',
);
});
@@ -0,0 +1,144 @@
// Tile fragments, holes and street extents for the OpenFreeMap outline rungs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildingFromTiles,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
streetFromTiles,
} from './openFreeMapOutlines.js';
const TILE = decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
),
14,
3743,
6745,
);
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
const square = (lon, lat, d) => [
[lon, lat],
[lon + d, lat],
[lon + d, lat + d],
[lon, lat + d],
[lon, lat],
];
test('decoded polygons keep identity and mark tile-cut fragments', () => {
assert.ok(
TILE.buildings.every((b) => b.tile === '14/3743/6745' && 'id' in b),
);
assert.ok(
TILE.buildings.some((b) => b.clipped),
'fragments at the cut line are marked',
);
assert.ok(
buildingFromTiles([TILE], CAPITOL)?.contains,
'the Capitol is complete',
);
});
test('a clipped fragment is never the building, and no neighbour stands in', () => {
const fragment = {
rings: [square(0, 0, 0.001)],
clipped: true,
heightM: null,
};
const neighbour = {
rings: [square(0.0011, 0, 0.0002)],
clipped: false,
heightM: null,
};
const point = { lat: 0.0005, lon: 0.0008 };
assert.equal(
buildingFromTiles([{ buildings: [fragment, neighbour] }], point),
null,
);
// A complete copy from the neighbouring tile's buffer is used.
const complete = { ...fragment, clipped: false };
assert.ok(
buildingFromTiles(
[{ buildings: [fragment, neighbour] }, { buildings: [complete] }],
point,
)?.contains,
);
const lawn = { rings: [square(0, 0, 0.01)], clipped: true, class: 'park' };
assert.equal(
enclosingAreaFromTiles([{ areas: [lawn] }], { lat: 0.005, lon: 0.005 }),
null,
'clipped grounds are not grounds',
);
});
test('a point in a courtyard is not inside the building', () => {
const block = {
rings: [square(0, 0, 0.001), square(0.0004, 0.0004, 0.0002).reverse()],
clipped: false,
heightM: 20,
};
const courtyard = { lat: 0.0005, lon: 0.0005 };
assert.equal(
buildingFromTiles([{ buildings: [block] }], {
...courtyard,
maxDistanceM: 0,
}),
null,
);
const near = buildingFromTiles([{ buildings: [block] }], courtyard);
assert.ok(near && !near.contains, 'near the courtyard walls, not inside');
});
test('grounds enclose the building sitting in their hole', () => {
const lawn = {
rings: [square(0, 0, 0.004), square(0.0015, 0.0015, 0.001).reverse()],
clipped: false,
class: 'park',
};
const grounds = enclosingAreaFromTiles([{ areas: [lawn] }], {
lat: 0.002,
lon: 0.002,
});
assert.ok(grounds, 'the Capitol-in-its-lawn case');
});
test('street proximity uses segments and clips to the search extent', () => {
// One straight 4 km piece whose vertices are both 2 km from the point.
const through = {
name: 'Long Road',
coordinates: [
[-0.018, 0],
[0.018, 0],
],
};
const street = streetFromTiles([{ streets: [through] }], {
name: 'Long Road',
lat: 0,
lon: 0,
radiusM: 500,
});
assert.ok(street, 'a street passing through the point is found');
for (const [lon] of street.lines.flat())
assert.ok(Math.abs(lon) <= 500 / 111_320 + 1e-9, 'within the extent');
const far = {
name: 'Long Road',
coordinates: [
[-0.018, 0.02],
[0.018, 0.02],
],
};
assert.equal(
streetFromTiles([{ streets: [far] }], {
name: 'Long Road',
lat: 0,
lon: 0,
radiusM: 500,
}),
null,
);
});