mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(outlines): voice outlines without Overpass — bundled US places and neighborhoods, guarded Nominatim, OpenFreeMap streets and buildings
This commit is contained in:
1 parent
e7707d9a0f
commit
9559e7a282
333 files changed
+13203
-271
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user