Files
gods-eye-view/server/providers/regional/outlineSelect.js
T
Sameh Khamis a61d0f4c2a tighten the remaining voice outline review items
- One place-name key for server and tiles, so "St. Paul" and "Saint Paul"
  agree in Nominatim name checks.
- Tile street names: a lone letter is a direction only beside another name
  word ("E Street" is not "East Street"), and a trailing quadrant ("E St NW")
  may go unsaid in the ask.
- A building cut at the tile edge that is nearer the point than any whole
  one leaves the point instead of a farther neighbour standing in; an edge
  without a tile buffer counts as a cut, so a cut area never answers grounds.
- A batch building install re-judges only results that installed; a late
  timeout during caption conversion keeps the pending point drawn; a failed
  plain recaption keeps the old mark addressable.
- An interrupted Nominatim dispatch is reclaimed by the next lock owner ten
  minutes after the start its marker names; node:sqlite loads on first use
  without its experimental warning.
- PHOTON_URL accepts only https, which the page's connect-src allows.
- Shared OpenFreeMap tiles decode each layer when a consumer first selects
  it; outline selection reuses the shared ring area and simplification, and
  ground probes the shared ring centroid; the grounds word list lives once.
2026-11-02 08:50:05 -08:00

623 lines
19 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],
},
});
/** 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(','),
};
}