Files
gods-eye-view/server/providers/regional/outlineSelect.js
T
Manjunath 451be8e3b7 fix(voice): treat a Zone as retired only in Nepal
Every name ending in "Zone" was kept without a level and refused as a retired
administrative identity. That is right for Nepal, which replaced its zones in
2015, but zones are current units elsewhere: Ethiopia's "Gamo Zone" was
refused. The selector now keeps the verified level unless display_name places
the zone in Nepal or names no country, and the client no longer reads "Zone"
as a retired level when the source verified one.
2026-11-03 17:27:26 +05:30

747 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
},
});
/**
* Nominatim address types whose meaning is stable enough for the area consumer's
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
* vary by country, so they remain unverified instead of being guessed.
*/
const VERIFIED_ADMIN_LEVEL = Object.freeze({
country: 'country',
state: 'admin1',
province: 'admin1',
county: 'admin2',
state_district: 'admin2',
});
const EXPLICIT_NAME_LEVEL = Object.freeze({
country: 'country',
nation: 'country',
province: 'admin1',
state: 'admin1',
region: 'admin1',
oblast: 'admin1',
prefecture: 'admin1',
canton: 'admin1',
governorate: 'admin1',
county: 'admin2',
district: 'admin2',
department: 'admin2',
zone: 'retired',
});
function explicitNameEvidence(value) {
const normalized = String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
const prefix = normalized.match(
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
)?.[1];
const suffix = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
)?.[1];
const levels = new Set(
[prefix, suffix]
.filter(Boolean)
.map((type) => EXPLICIT_NAME_LEVEL[type])
.filter(Boolean),
);
if (levels.size > 1) return { contradictory: true, level: null };
return { contradictory: false, level: levels.values().next().value || null };
}
/**
* Verified administrative identity carried by the selected result itself.
* Never infer it from the request, desired kind, rank, or a separate geocoder
* anchor. A retired name is preserved without a level so downstream policy can
* reject it explicitly; every other contradiction stays wholly unverified.
*/
function selectedAdminIdentity(kind, row) {
if (kind !== 'admin') return null;
const adminLevel =
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
const authoritativeAdminArea = String(row?.name || '').trim();
if (!adminLevel || !authoritativeAdminArea) return null;
// Validate the complete authoritative name. `adminArea` is truncated only
// for display after every prefix/suffix signal has been considered.
const { contradictory, level: nameLevel } = explicitNameEvidence(
authoritativeAdminArea,
);
if (contradictory) return null;
const adminArea = authoritativeAdminArea.slice(0, 160);
if (nameLevel === 'retired') {
// Nepal replaced its zones in 2015, but zones are current units elsewhere
// (Ethiopia). Without a country the name stays retired.
if (!zoneIsCurrentIn(row)) return { adminArea };
return { adminArea, adminLevel };
}
if (nameLevel && nameLevel !== adminLevel) return null;
return { adminArea, adminLevel };
}
/** Whether the row names a country (last display_name part) other than Nepal. */
function zoneIsCurrentIn(row) {
const country = String(row?.display_name || '')
.split(',')
.slice(1)
.pop()
?.trim()
.toLocaleLowerCase();
return Boolean(country) && country !== 'nepal';
}
/** 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;
/** 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));
}
/**
* Words naming what a building is, which OSM rarely carries in the name or
* type ("Burj Khalifa", building=yes). Enclosures (zoo, garden, arch,
* conservatory) are left out: those name a feature inside a park.
*/
const BUILDING_WORDS = [
'building',
'tower',
'church',
'cathedral',
'basilica',
'chapel',
'temple',
'mosque',
'synagogue',
'shrine',
'museum',
'palace',
'castle',
'hall',
'library',
];
/** 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"), what it is ("… Bridge"), or
// a building word on a single-structure result ("Burj Khalifa Tower").
const context = new Set(
normalizePlaceName(
[
String(row?.display_name || '')
.split(',')
.slice(1)
.join(' '),
row?.type,
row?.category || row?.class,
]
.join(' ')
.replace(/_/g, ' '),
).split(' '),
);
// Only a single structure, as a building ask would accept: never a campus,
// zoo or district whose name merely starts the ask ("Duke University").
if (acceptsOutlineClass('building', row?.category || row?.class, row?.type))
for (const word of BUILDING_WORDS) context.add(word);
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;
}
/** 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,
...(selectedAdminIdentity(kind, row) || {}),
polygons,
},
});
}
// Stable: equal matches keep upstream order.
found.sort((a, b) => b.nameMatch - a.nameMatch);
const outlines = found.map((entry) => entry.outline);
const ranks = found.map((entry) => entry.nameMatch);
return { outline: outlines[0] || null, outlines, ranks, skipped };
}
/**
* Among the best-named outlines (`ranks` from selectOutlineResult; all of
* them without), the one holding `lat, lon` (the resolved anchor), else the
* first. A worse name never wins by containment alone: an exact building
* that misses a pin by metres is not replaced by the plaza around it.
* Without an anchor the first answers, as before.
*/
export function outlineAt(outlines, lat, lon, ranks = null) {
const list = Array.isArray(outlines) ? outlines : [];
if (!finiteLat(lat) || !finiteLon(lon)) return list[0] || null;
const best = Array.isArray(ranks) ? ranks[0] : undefined;
return (
list.find(
(outline, index) =>
(best === undefined || ranks[index] === best) &&
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(','),
};
}