import { normalizePlaceName } from './nominatimCache.js'; import { containsAnchor, pointInRing, } from '../../../src/data/adminBoundaries.js'; import { douglasPeucker, ringAreaM2, } from '../../../src/sources/featureGeometry.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, }); /** * Size limits for one outline. Vertices beyond the budget are simplified away; * more parts, or more holes in one part, than these limits refuse the outline * whole, because a truncated multipart place would misrepresent it. */ 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. */ 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: a recognized address type is required. * A supplied numeric place_rank must also fit the ask's broad band (country 4, * state 8, county 10-12, city 12-16, suburb 18-20, neighbourhood 20-22). */ 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 supplied administrative evidence contradicts the accepted identity. */ function adminLevelFits(kind, row) { const evidence = ADMIN_EVIDENCE[kind]; if (!evidence) return true; const place = String(row?.category || row?.class).toLowerCase() === 'place'; // A place row already has an accepted type. Missing address evidence must // not erase it, but explicit contradictory evidence must not bypass checks. if (!place || Object.hasOwn(row, 'addresstype')) { if (typeof row?.addresstype !== 'string') return false; const type = row.addresstype.toLowerCase(); if ( !evidence.types.includes(type) && !(place && type === String(row.type).toLowerCase()) ) return false; } // Rank can corroborate or contradict an explicit identity, never invent it. // JSONv2 supplies a number; coercing arrays or strings hides malformed data. if (!Object.hasOwn(row, 'place_rank')) return true; const rank = row.place_rank; return ( Number.isInteger(rank) && // Boundary rank bands do not describe every accepted place type. (place || (rank >= evidence.ranks[0] && rank <= evidence.ranks[1])) ); } /** 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. */ /** 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; /** 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)); } /** How well a result's names answer the ask. */ const NAME_MISS = 0; const NAME_OVERLAP = 1; const NAME_WHOLE = 2; const NAME_EXACT = 3; /** * Settlements require one complete normalized name or explicit returned alias. * Landmark/building asks retain their word-overlap and unspaced-script * shorthand. An exact name ranks first, then one holding every asked word, * then an overlap. */ function outlineNameMatch(row, query, kind) { 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]); // OSM name tags may hold several names separated by ";" ("York;Eboracum"). if (row?.namedetails && typeof row.namedetails === 'object') for (const value of Object.values(row.namedetails)) for (const part of String(value ?? '').split(';')) add(part); if (kind === 'city' || kind === 'admin' || kind === 'neighborhood') { const head = normalizePlaceName(String(query ?? '').split(',')[0]); return head && names.has(head) ? NAME_EXACT : NAME_MISS; } const words = queryWords(query); if (!words.length) return NAME_MISS; const whole = words.join(' '); const unspaced = UNSPACED_SCRIPT_RE.test(whole); // The last word usually names the feature ("Zoo", "Arch"): a name without // it is the place around the feature ("Central Park"), unless the word is // where the result is ("Space Needle Seattle") or what it is ("… Bridge"). const context = new Set( normalizePlaceName( [ String(row?.display_name || '') .split(',') .slice(1) .join(' '), row?.type, row?.category || row?.class, ] .join(' ') .replace(/_/g, ' '), ).split(' '), ); let match = NAME_MISS; for (const name of names) { if ( name .split(' ') .filter((word) => !STOP_WORDS.has(word)) .join(' ') === whole ) return NAME_EXACT; // Scripts without spaces compare by characters instead. if (unspaced && name.replace(/ /g, '').includes(whole.replace(/ /g, ''))) match = NAME_WHOLE; // Whole words only: "York" never matches "Yorkshire Park". const nameSet = new Set(name.split(' ')); const hits = words.filter((word) => nameSet.has(word)).length; if (hits === words.length) match = NAME_WHOLE; const last = words.at(-1); if ( words.length > 1 && hits >= Math.ceil(words.length * 0.6) && (unspaced || nameSet.has(last) || context.has(last)) ) match = Math.max(match, NAME_OVERLAP); } return match; } 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, lat] = point; 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). */ /** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */ function polygonsFromGeoJson(geometry) { const raw = geometry?.type === 'Polygon' ? [geometry.coordinates] : geometry?.type === 'MultiPolygon' ? geometry.coordinates : null; if (!Array.isArray(raw) || !raw.length || raw.length > OUTLINE_MAX_PARTS) return null; const polygons = []; for (const polygon of raw) { if ( !Array.isArray(polygon) || !polygon.length || polygon.length - 1 > OUTLINE_MAX_HOLES ) return null; const outer = cleanRing(polygon[0]); if (!outer) return null; const holes = polygon.slice(1).map(cleanRing); if (holes.some((hole) => !hole)) return null; polygons.push({ rings: [outer, ...holes], area: ringAreaM2(outer) }); } polygons.sort((a, b) => b.area - a.area); return polygons.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 out = douglasPeucker(ring, tolerance); return out.length >= 4 ? out : ring; } const countVertices = (polygons) => polygons.reduce( (sum, rings) => sum + rings.reduce((n, ring) => n + ring.length, 0), 0, ); const edgeSide = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]); const onEdge = (p, q, r) => r[0] >= Math.min(p[0], q[0]) && r[0] <= Math.max(p[0], q[0]) && r[1] >= Math.min(p[1], q[1]) && r[1] <= Math.max(p[1], q[1]); /** Intersection, including touching, of two planar closed-ring edges. */ function edgesIntersect(a, b, c, d) { if ( Math.max(a[0], b[0]) < Math.min(c[0], d[0]) || Math.max(c[0], d[0]) < Math.min(a[0], b[0]) || Math.max(a[1], b[1]) < Math.min(c[1], d[1]) || Math.max(c[1], d[1]) < Math.min(a[1], b[1]) ) return false; const ac = edgeSide(a, b, c); const ad = edgeSide(a, b, d); const ca = edgeSide(c, d, a); const cb = edgeSide(c, d, b); return ( (ac === 0 && onEdge(a, b, c)) || (ad === 0 && onEdge(a, b, d)) || (ca === 0 && onEdge(c, d, a)) || (cb === 0 && onEdge(c, d, b)) || (ac > 0 !== ad > 0 && ca > 0 !== cb > 0) ); } function ringsIntersect(a, b) { for (let i = 1; i < a.length; i++) for (let j = 1; j < b.length; j++) if (edgesIntersect(a[i - 1], a[i], b[j - 1], b[j])) return true; return false; } /** * Check only generated bounded output, not a new input-geometry policy. * Touching generated boundaries are conservatively unsupported here. */ function simplifiedTopologyFits(polygons) { for (const [outer, ...holes] of polygons) { for (const ring of [outer, ...holes]) { if (ringAreaM2(ring) === 0) return false; for (let i = 1; i < ring.length; i++) for (let j = i + 2; j < ring.length; j++) { if (i === 1 && j === ring.length - 1) continue; if (edgesIntersect(ring[i - 1], ring[i], ring[j - 1], ring[j])) return false; } } for (let i = 0; i < holes.length; i++) { const hole = holes[i]; if ( !hole.every(([lon, lat]) => pointInRing(outer, lat, lon)) || ringsIntersect(outer, hole) ) return false; for (let j = 0; j < i; j++) { const other = holes[j]; if ( ringsIntersect(hole, other) || pointInRing(other, hole[0][1], hole[0][0]) || pointInRing(hole, other[0][1], other[0][0]) ) return false; } } } return true; } /** 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 === polygons || simplifiedTopologyFits(current) ? current : null; current = polygons.map((rings) => rings.map((ring) => simplifyClosedRing(ring, tolerance)), ); tolerance *= 2; } // Never turn a resource refusal into an apparently complete partial outline. return countVertices(current) <= maxVertices && simplifiedTopologyFits(current) ? current : null; } /** * Every result within the limit that suits the ask, names the place and * carries a polygon — best name match first, then upstream order — with the reason each other result was passed over. `outline` is * the first of them, or null. * * @param {object[]} rows Nominatim jsonv2 results, in upstream order. * @param {{kind: string, query: string}} ask */ export function selectOutlineResult(rows, { kind, query }) { const skipped = []; const found = []; 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() === 'place' || (String(osmClass).toLowerCase() === 'boundary' && String(osmType).toLowerCase() === 'administrative')) && !adminLevelFits(kind, row) ) { skipped.push(`${label}: level`); continue; } const nameMatch = outlineNameMatch(row, query, kind); if (nameMatch === NAME_MISS) { 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; } found.push({ nameMatch, 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, polygons, }, }); } // Stable: equal matches keep upstream order. const outlines = found .sort((a, b) => b.nameMatch - a.nameMatch) .map((entry) => entry.outline); return { outline: outlines[0] || null, outlines, skipped }; } /** * The outline holding `lat, lon` (the resolved anchor), else the first. * Without an anchor the first answers, as before. */ export function outlineAt(outlines, lat, lon) { const list = Array.isArray(outlines) ? outlines : []; if (!finiteLat(lat) || !finiteLon(lon)) return list[0] || null; return ( list.find((outline) => containsAnchor(outline.polygons, lat, lon)) || list[0] || null ); } /** * 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(','), }; }