mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +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,510 @@
|
||||
import { normalizePlaceName } from './nominatimCache.js';
|
||||
|
||||
/**
|
||||
* Which Nominatim results may answer which voice ask, and how their polygons
|
||||
* are bounded. Pure: no I/O.
|
||||
*
|
||||
* A search returns whatever matches the words best. "Mission District" can
|
||||
* come back as a car-charging station and "Bandra" as a railway station, so a
|
||||
* result is accepted only when its OSM class (and, for broad classes, its
|
||||
* type) suits the kind of thing that was asked for, it carries a polygon, and
|
||||
* its name actually answers the words.
|
||||
*/
|
||||
|
||||
/** Ask kinds the outline route answers. */
|
||||
export const OUTLINE_KINDS = Object.freeze([
|
||||
'city',
|
||||
'admin',
|
||||
'neighborhood',
|
||||
'landmark',
|
||||
'building',
|
||||
]);
|
||||
|
||||
/** Simplification tolerance (degrees) sent upstream, per ask kind. */
|
||||
export const OUTLINE_POLYGON_THRESHOLD = Object.freeze({
|
||||
city: 0.0005,
|
||||
admin: 0.0005,
|
||||
neighborhood: 0.0005,
|
||||
landmark: 0.00005,
|
||||
building: 0.00005,
|
||||
});
|
||||
|
||||
/** Total vertices returned for one outline, and parts/holes kept. */
|
||||
export const OUTLINE_MAX_VERTICES = 6000;
|
||||
const OUTLINE_MAX_PARTS = 24;
|
||||
const OUTLINE_MAX_HOLES = 16;
|
||||
|
||||
const ANY = '*';
|
||||
|
||||
const SETTLEMENT_PLACES = [
|
||||
'city',
|
||||
'town',
|
||||
'village',
|
||||
'municipality',
|
||||
'borough',
|
||||
'hamlet',
|
||||
];
|
||||
|
||||
const CIVIC_AMENITIES = [
|
||||
'university',
|
||||
'college',
|
||||
'school',
|
||||
'kindergarten',
|
||||
'hospital',
|
||||
'clinic',
|
||||
'place_of_worship',
|
||||
'monastery',
|
||||
'townhall',
|
||||
'courthouse',
|
||||
'library',
|
||||
'theatre',
|
||||
'cinema',
|
||||
'arts_centre',
|
||||
'community_centre',
|
||||
'conference_centre',
|
||||
'events_venue',
|
||||
'exhibition_centre',
|
||||
'music_venue',
|
||||
'planetarium',
|
||||
'marketplace',
|
||||
'prison',
|
||||
'grave_yard',
|
||||
'fire_station',
|
||||
'police',
|
||||
'embassy',
|
||||
'public_building',
|
||||
];
|
||||
|
||||
const LANDMARK_TOURISM = [
|
||||
'attraction',
|
||||
'theme_park',
|
||||
'zoo',
|
||||
'aquarium',
|
||||
'museum',
|
||||
'gallery',
|
||||
'viewpoint',
|
||||
'camp_site',
|
||||
'hotel',
|
||||
'resort',
|
||||
];
|
||||
|
||||
const MAN_MADE_STRUCTURES = [
|
||||
'tower',
|
||||
'lighthouse',
|
||||
'pier',
|
||||
'bridge',
|
||||
'works',
|
||||
'dam',
|
||||
'observatory',
|
||||
'wastewater_plant',
|
||||
'water_works',
|
||||
'breakwater',
|
||||
];
|
||||
|
||||
/**
|
||||
* Accepted `class → types` per ask kind. `*` accepts every type of that
|
||||
* class. Anything absent is rejected: roads, railways, stations, shops,
|
||||
* guideposts and single facilities never answer an area ask.
|
||||
*/
|
||||
export const OUTLINE_ACCEPT = Object.freeze({
|
||||
city: {
|
||||
boundary: ['administrative'],
|
||||
place: SETTLEMENT_PLACES,
|
||||
},
|
||||
admin: {
|
||||
boundary: ['administrative'],
|
||||
place: ['country', 'state', 'province', 'region', 'county', 'district'],
|
||||
},
|
||||
neighborhood: {
|
||||
boundary: ['administrative'],
|
||||
place: ['suburb', 'quarter', 'neighbourhood', 'borough', 'city_block'],
|
||||
},
|
||||
landmark: {
|
||||
leisure: ANY,
|
||||
landuse: ANY,
|
||||
amenity: CIVIC_AMENITIES,
|
||||
tourism: LANDMARK_TOURISM,
|
||||
building: ANY,
|
||||
natural: ANY,
|
||||
water: ANY,
|
||||
waterway: ['riverbank', 'dock'],
|
||||
historic: ANY,
|
||||
man_made: MAN_MADE_STRUCTURES,
|
||||
office: ['government', 'diplomatic'],
|
||||
aeroway: ['aerodrome', 'heliport', 'terminal'],
|
||||
military: ANY,
|
||||
boundary: ['national_park', 'protected_area'],
|
||||
place: ['island', 'islet', 'square'],
|
||||
},
|
||||
// Single structures only: no campuses, grounds, parks or camp sites.
|
||||
building: {
|
||||
building: ANY,
|
||||
amenity: [
|
||||
'place_of_worship',
|
||||
'townhall',
|
||||
'courthouse',
|
||||
'library',
|
||||
'theatre',
|
||||
'cinema',
|
||||
'arts_centre',
|
||||
'community_centre',
|
||||
'conference_centre',
|
||||
'exhibition_centre',
|
||||
'music_venue',
|
||||
'planetarium',
|
||||
'fire_station',
|
||||
'police',
|
||||
'embassy',
|
||||
'public_building',
|
||||
],
|
||||
tourism: ['museum', 'gallery', 'hotel', 'attraction'],
|
||||
historic: ['building', 'castle', 'church', 'fort', 'palace', 'monastery'],
|
||||
man_made: ['tower', 'lighthouse', 'observatory'],
|
||||
office: ANY,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Administrative evidence per ask kind: an address type, or a Nominatim
|
||||
* place_rank band (country 4, state 8, county 10-12, city 12-16, suburb 18-20,
|
||||
* neighbourhood 20-22). An administrative boundary with neither is refused.
|
||||
*/
|
||||
const ADMIN_EVIDENCE = Object.freeze({
|
||||
city: {
|
||||
types: ['city', 'town', 'village', 'municipality', 'borough', 'hamlet'],
|
||||
ranks: [12, 18],
|
||||
},
|
||||
admin: {
|
||||
types: [
|
||||
'country',
|
||||
'state',
|
||||
'province',
|
||||
'region',
|
||||
'county',
|
||||
'district',
|
||||
'state_district',
|
||||
],
|
||||
ranks: [2, 14],
|
||||
},
|
||||
neighborhood: {
|
||||
types: ['suburb', 'quarter', 'neighbourhood', 'city_district', 'borough'],
|
||||
ranks: [17, 24],
|
||||
},
|
||||
});
|
||||
|
||||
/** Whether an administrative boundary is at the level the ask names. */
|
||||
export function adminLevelFits(kind, row) {
|
||||
const evidence = ADMIN_EVIDENCE[kind];
|
||||
if (!evidence) return true;
|
||||
const type = String(row?.addresstype || '').toLowerCase();
|
||||
if (type && evidence.types.includes(type)) return true;
|
||||
const rank = Number(row?.place_rank);
|
||||
if (Number.isFinite(rank))
|
||||
return rank >= evidence.ranks[0] && rank <= evidence.ranks[1];
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Largest footprint (m²) a non-`building` result may have for a building ask. */
|
||||
const BUILDING_MAX_AREA_M2 = 150_000;
|
||||
|
||||
/** Approximate area (m²) of a [lon, lat] ring. */
|
||||
function ringAreaM2(ring) {
|
||||
const lat0 = ring[0][1];
|
||||
const k = 111_320 * 111_320 * Math.cos((lat0 * Math.PI) / 180);
|
||||
return ringArea(ring) * k;
|
||||
}
|
||||
|
||||
/** Whether a result's class/type suits the ask kind. */
|
||||
export function acceptsOutlineClass(kind, osmClass, osmType) {
|
||||
const rules = OUTLINE_ACCEPT[kind];
|
||||
if (!rules) return false;
|
||||
const types = rules[String(osmClass || '').toLowerCase()];
|
||||
if (!types) return false;
|
||||
return types === ANY || types.includes(String(osmType || '').toLowerCase());
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set(['the', 'of', 'de', 'la', 'le', 'el', 'and']);
|
||||
|
||||
/** Scripts written without spaces between words. */
|
||||
const UNSPACED_SCRIPT_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
||||
|
||||
/** Whether `needle` occurs in `hay` as whole consecutive words. */
|
||||
function containsWords(hay, needle) {
|
||||
for (let i = 0; i + needle.length <= hay.length; i++)
|
||||
if (needle.every((word, j) => hay[i + j] === word)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Significant words of the part of a query before the first comma. */
|
||||
function queryWords(query) {
|
||||
const head = String(query ?? '').split(',')[0];
|
||||
return normalizePlaceName(head)
|
||||
.split(' ')
|
||||
.filter((word) => word && !STOP_WORDS.has(word));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a result's names answer the query. Most of the query's words must
|
||||
* appear among one name's words, or one name must contain the whole query
|
||||
* (for scripts written without spaces).
|
||||
*/
|
||||
export function outlineNameMatches(row, query) {
|
||||
const words = queryWords(query);
|
||||
if (!words.length) return false;
|
||||
const whole = words.join(' ');
|
||||
const names = new Set();
|
||||
const add = (value) => {
|
||||
const name = normalizePlaceName(value);
|
||||
if (name) names.add(name);
|
||||
};
|
||||
add(row?.name);
|
||||
add(String(row?.display_name || '').split(',')[0]);
|
||||
if (row?.namedetails && typeof row.namedetails === 'object')
|
||||
for (const value of Object.values(row.namedetails)) add(value);
|
||||
const unspaced = UNSPACED_SCRIPT_RE.test(whole);
|
||||
for (const name of names) {
|
||||
const nameWords = name.split(' ');
|
||||
// Whole words only: "York" never matches "Yorkshire Park".
|
||||
if (containsWords(nameWords, words)) return true;
|
||||
// Scripts without spaces compare by characters instead.
|
||||
if (unspaced && name.replace(/ /g, '').includes(whole.replace(/ /g, '')))
|
||||
return true;
|
||||
const nameSet = new Set(nameWords);
|
||||
const hits = words.filter((word) => nameSet.has(word)).length;
|
||||
if (words.length > 1 && hits >= Math.ceil(words.length * 0.6)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const finiteLon = (value) =>
|
||||
Number.isFinite(value) && value >= -180 && value <= 180;
|
||||
const finiteLat = (value) =>
|
||||
Number.isFinite(value) && value >= -90 && value <= 90;
|
||||
|
||||
/** A valid closed ring of [lon, lat] numbers, or null. */
|
||||
function cleanRing(ring) {
|
||||
if (!Array.isArray(ring)) return null;
|
||||
const out = [];
|
||||
for (const point of ring) {
|
||||
if (!Array.isArray(point)) return null;
|
||||
const lon = Number(point[0]);
|
||||
const lat = Number(point[1]);
|
||||
if (!finiteLon(lon) || !finiteLat(lat)) return null;
|
||||
out.push([lon, lat]);
|
||||
}
|
||||
if (out.length < 4) return null;
|
||||
const [first, last] = [out[0], out.at(-1)];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) out.push([...first]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Planar ring area in squared degrees (only used to rank parts). */
|
||||
function ringArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
|
||||
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
|
||||
export function polygonsFromGeoJson(geometry) {
|
||||
const raw =
|
||||
geometry?.type === 'Polygon'
|
||||
? [geometry.coordinates]
|
||||
: geometry?.type === 'MultiPolygon'
|
||||
? geometry.coordinates
|
||||
: null;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const polygons = [];
|
||||
for (const polygon of raw) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
const outer = cleanRing(polygon[0]);
|
||||
if (!outer) continue;
|
||||
const holes = polygon
|
||||
.slice(1)
|
||||
.map(cleanRing)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => ringArea(b) - ringArea(a))
|
||||
.slice(0, OUTLINE_MAX_HOLES);
|
||||
polygons.push({ rings: [outer, ...holes], area: ringArea(outer) });
|
||||
}
|
||||
if (!polygons.length) return null;
|
||||
polygons.sort((a, b) => b.area - a.area);
|
||||
return polygons.slice(0, OUTLINE_MAX_PARTS).map((entry) => entry.rings);
|
||||
}
|
||||
|
||||
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
|
||||
function simplifyClosedRing(ring, tolerance) {
|
||||
if (ring.length <= 8 || tolerance <= 0) return ring;
|
||||
const keep = new Uint8Array(ring.length);
|
||||
keep[0] = keep[ring.length - 1] = 1;
|
||||
const stack = [[0, ring.length - 1]];
|
||||
const tol2 = tolerance * tolerance;
|
||||
while (stack.length) {
|
||||
const [start, end] = stack.pop();
|
||||
const [ax, ay] = ring[start];
|
||||
const [bx, by] = ring[end];
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let best = -1;
|
||||
let bestD = tol2;
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
const [px, py] = ring[i];
|
||||
let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const ex = ax + t * dx - px;
|
||||
const ey = ay + t * dy - py;
|
||||
const d = ex * ex + ey * ey;
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best > 0) {
|
||||
keep[best] = 1;
|
||||
stack.push([start, best], [best, end]);
|
||||
}
|
||||
}
|
||||
const out = ring.filter((_point, index) => keep[index]);
|
||||
return out.length >= 4 ? out : ring;
|
||||
}
|
||||
|
||||
const countVertices = (polygons) =>
|
||||
polygons.reduce(
|
||||
(sum, rings) => sum + rings.reduce((n, ring) => n + ring.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
/** Simplify until the whole outline fits the vertex cap. */
|
||||
export function capOutlineVertices(
|
||||
polygons,
|
||||
maxVertices = OUTLINE_MAX_VERTICES,
|
||||
) {
|
||||
let current = polygons;
|
||||
let tolerance = 0.00002;
|
||||
for (let round = 0; round < 12; round++) {
|
||||
if (countVertices(current) <= maxVertices) return current;
|
||||
current = polygons.map((rings) =>
|
||||
rings.map((ring) => simplifyClosedRing(ring, tolerance)),
|
||||
);
|
||||
tolerance *= 2;
|
||||
}
|
||||
// Still over: keep the largest parts that fit, outer rings only.
|
||||
const kept = [];
|
||||
let total = 0;
|
||||
for (const rings of current) {
|
||||
const outer = rings[0];
|
||||
if (total + outer.length > maxVertices) break;
|
||||
kept.push([outer]);
|
||||
total += outer.length;
|
||||
}
|
||||
return kept.length ? kept : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first result within the limit that suits the ask, names the place and
|
||||
* carries a polygon — or null with the reason each result was passed over.
|
||||
*
|
||||
* @param {object[]} rows Nominatim jsonv2 results, in upstream order.
|
||||
* @param {{kind: string, query: string}} ask
|
||||
*/
|
||||
export function selectOutlineResult(rows, { kind, query }) {
|
||||
const skipped = [];
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
const osmClass = row?.category || row?.class;
|
||||
const osmType = row?.type;
|
||||
const label = `${osmClass}=${osmType}`;
|
||||
if (!acceptsOutlineClass(kind, osmClass, osmType)) {
|
||||
skipped.push(`${label}: wrong type`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
String(osmClass).toLowerCase() === 'boundary' &&
|
||||
String(osmType).toLowerCase() === 'administrative' &&
|
||||
!adminLevelFits(kind, row)
|
||||
) {
|
||||
skipped.push(`${label}: level`);
|
||||
continue;
|
||||
}
|
||||
if (!outlineNameMatches(row, query)) {
|
||||
skipped.push(`${label}: name`);
|
||||
continue;
|
||||
}
|
||||
const parsed = polygonsFromGeoJson(row?.geojson);
|
||||
if (!parsed) {
|
||||
skipped.push(`${label}: no polygon`);
|
||||
continue;
|
||||
}
|
||||
// A building ask needs building evidence: the building class, or a
|
||||
// footprint no larger than a large building.
|
||||
if (
|
||||
kind === 'building' &&
|
||||
String(osmClass).toLowerCase() !== 'building' &&
|
||||
ringAreaM2(parsed[0][0]) > BUILDING_MAX_AREA_M2
|
||||
) {
|
||||
skipped.push(`${label}: not a building`);
|
||||
continue;
|
||||
}
|
||||
const polygons = capOutlineVertices(parsed);
|
||||
if (!polygons) {
|
||||
skipped.push(`${label}: too large`);
|
||||
continue;
|
||||
}
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
return {
|
||||
outline: {
|
||||
name: String(row.name || '').slice(0, 160) || null,
|
||||
displayName: String(row.display_name || '').slice(0, 300) || null,
|
||||
class: String(osmClass),
|
||||
type: String(osmType),
|
||||
osm:
|
||||
row.osm_type && row.osm_id != null
|
||||
? `${row.osm_type}/${row.osm_id}`
|
||||
: null,
|
||||
center: finiteLat(lat) && finiteLon(lon) ? { lat, lon } : null,
|
||||
polygons,
|
||||
},
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
return { outline: null, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse view bias: the centre is snapped to a grid (1° for places, 0.05°
|
||||
* for small grounds) so small camera moves reuse the same cache entry, and
|
||||
* the viewbox sent upstream is built from the snapped centre.
|
||||
*/
|
||||
export function outlineBias(kind, lat, lon) {
|
||||
// Missing is missing: Number(null) and Number('') would read as (0, 0).
|
||||
const given = (value) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
(typeof value === 'number' || String(value).trim() !== '');
|
||||
if (!given(lat) || !given(lon)) return null;
|
||||
const latitude = Number(lat);
|
||||
const longitude = Number(lon);
|
||||
if (!finiteLat(latitude) || !finiteLon(longitude)) return null;
|
||||
const small = kind === 'landmark' || kind === 'building';
|
||||
const step = small ? 0.05 : 1;
|
||||
const half = small ? 0.25 : 2;
|
||||
const snap = (value) => Math.round(value / step) * step;
|
||||
const cLat = Number(snap(latitude).toFixed(2));
|
||||
const cLon = Number(snap(longitude).toFixed(2));
|
||||
const clampLat = (value) => Math.max(-90, Math.min(90, value));
|
||||
const clampLon = (value) => Math.max(-180, Math.min(180, value));
|
||||
return {
|
||||
key: `${cLat},${cLon}`,
|
||||
viewbox: [
|
||||
clampLon(cLon - half),
|
||||
clampLat(cLat + half),
|
||||
clampLon(cLon + half),
|
||||
clampLat(cLat - half),
|
||||
]
|
||||
.map((value) => Number(value.toFixed(4)))
|
||||
.join(','),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user