From a38a065c5cfb2f32c14739b4935d23689a96333a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 08:37:45 -0800 Subject: [PATCH] keep bent streets, enclosed landmarks and same-name outlines honest A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point when the street runs within 100 m. Nominatim outline selection ranks an exact name, then one holding every asked word, then an overlap, and refuses an overlap whose name lacks the ask's last word unless that word is in the result's address or type, so "Central Park Zoo" is no longer Central Park. The server caches every accepted outline and answers each ask with the one holding its resolved anchor (sent as alat/alon), read across the antimeridian. A duplicate mark that fails to clean up no longer tears down the new outline it duplicates. --- changelog.d/842-voice-outlines.md | 5 + docs/CURRENT-STATE.md | 59 +++++---- server/providers/regional/outlineSelect.js | 107 ++++++++++++---- server/providers/regional/place.js | 44 +++++-- src/annotations/annotationEngine.js | 9 +- src/annotations/outlineIdentity.test.mjs | 42 +++++++ src/annotations/outlineRungs.js | 53 ++------ src/annotations/outlineRungs.test.mjs | 2 + src/annotations/placeOutline.test.mjs | 49 ++++++++ src/annotations/resolver.js | 18 ++- src/data/adminBoundaries.js | 40 ++++++ src/sources/featureGeometry.js | 31 +++++ src/sources/nominatimOutlines.js | 15 ++- src/sources/nominatimOutlines.test.mjs | 18 +++ src/sources/openFreeMapOutlines.js | 17 +-- src/tooling/nominatimOutlineRoute.test.mjs | 140 +++++++++++++++++++++ 16 files changed, 516 insertions(+), 133 deletions(-) diff --git a/changelog.d/842-voice-outlines.md b/changelog.d/842-voice-outlines.md index 62ebb5eaf..620578ce0 100644 --- a/changelog.d/842-voice-outlines.md +++ b/changelog.d/842-voice-outlines.md @@ -37,3 +37,8 @@ - Outline nothing from tiles when the tile under the point failed to load, so a neighbouring building or area never stands in for it, and match each of a Nominatim name tag's ";"-separated names (manjunath22466, #842). +- Keep bent streets outlined when the geocoded point sits beside the + centreline, never answer a landmark ask with the park or square around it, + pick the same-name Nominatim outline that holds the resolved place, and keep + a new outline when an identical older mark fails to clean up + (samehkhamis, #842). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5a98c0702..03f471f19 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3875,7 +3875,7 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, * [`docs/TRANSLATORS.md`](./TRANSLATORS.md). - **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera', - '{count} cameras')`, and `msg('Live Flights')` for text stored in data and +'{count} cameras')`, and `msg('Live Flights')` for text stored in data and translated later. Markup opts in with `data-i18n` (element text) and `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English value). English has no catalog; every other locale is @@ -3945,31 +3945,31 @@ nearby place—always use station selection. Unqualified “turn on/start the ra requests use Play; a qualified Play-shaped tool call is normalized to Select so its criteria cannot be silently ignored. -| Layer | Source | File | Proxy | Update Interval | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | -| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | -| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | -| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | -| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | -| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | -| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | -| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | -| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +| Layer | Source | File | Proxy | Update Interval | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | +| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | +| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | +| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | +| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; @@ -3995,7 +3995,6 @@ MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception. anchors, recovery, keyboard and mobile states; emergency repainting is covered by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. - Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, @@ -4161,8 +4160,7 @@ that does not answer in 20 s is asked once more, and one that is not a line list is not cached. A click on a line reaches `handlePick(id, {groundPoint})`, and the provider asks the core (`actions.openNearest`) for its nearest showable photo within 50 m, without -framing: many photos are archived (their file answers 409, while a HEAD says -200) or have no file, so the six nearest are probed at once with a GET cut +framing: many photos are archived (their file answers 409, while a HEAD says 200) or have no file, so the six nearest are probed at once with a GET cut off at the headers, and where all are gone the panel says KartaView's photos there are archived. Only a definite answer (403/404/409/410, or 200) is remembered; a storage timeout or 5xx is asked again. The open photo's @@ -5465,7 +5463,7 @@ request viewbox and continues searching without that geographic preference. - Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner. - Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch. -- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade. +- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Guarded Nominatim keeps every accepted result for an ask, ranks names holding every asked word first, refuses a partial landmark match whose name lacks the ask's last word (the place around a feature, such as a park around its zoo, is not the feature), and answers each ask with the cached result whose filled area holds the resolved anchor, else the first. A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point to the anchor. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade. - With no detailed-feature capability, annotations retain their pins and say "Detailed outline unavailable"; location focus reports the same result without retrying. Missing capability never synthesizes an outline from a radius. @@ -5673,7 +5671,6 @@ Vite's development filesystem deny list excludes local `.gev-logs/` and dependency `dist/` assets. Checkout glob characters are escaped. This does not apply to `public/` assets or built-preview serving. - - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: diff --git a/server/providers/regional/outlineSelect.js b/server/providers/regional/outlineSelect.js index 401d9aeb9..76b57a525 100644 --- a/server/providers/regional/outlineSelect.js +++ b/server/providers/regional/outlineSelect.js @@ -1,5 +1,8 @@ import { normalizePlaceName } from './nominatimCache.js'; -import { pointInRing } from '../../../src/data/adminBoundaries.js'; +import { + containsAnchor, + pointInRing, +} from '../../../src/data/adminBoundaries.js'; /** * Which Nominatim results may answer which voice ask, and how their polygons @@ -249,13 +252,6 @@ const STOP_WORDS = new Set(['the', 'of', 'de', 'la', 'le', 'el', 'and']); 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]; @@ -264,11 +260,19 @@ function queryWords(query) { .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. + * 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 outlineNameMatches(row, query, kind) { +function outlineNameMatch(row, query, kind) { const names = new Set(); const add = (value) => { const name = normalizePlaceName(value); @@ -282,24 +286,54 @@ function outlineNameMatches(row, query, kind) { for (const part of String(value ?? '').split(';')) add(part); if (kind === 'city' || kind === 'admin' || kind === 'neighborhood') { const head = normalizePlaceName(String(query ?? '').split(',')[0]); - return Boolean(head) && names.has(head); + return head && names.has(head) ? NAME_EXACT : NAME_MISS; } const words = queryWords(query); - if (!words.length) return false; + 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) { - const nameWords = name.split(' '); - // Whole words only: "York" never matches "Yorkshire Park". - if (containsWords(nameWords, words)) return true; + 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, ''))) - return true; - const nameSet = new Set(nameWords); + 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 (words.length > 1 && hits >= Math.ceil(words.length * 0.6)) return true; + 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 false; + return match; } const finiteLon = (value) => @@ -500,14 +534,16 @@ export function capOutlineVertices( } /** - * 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. + * 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; @@ -525,7 +561,8 @@ export function selectOutlineResult(rows, { kind, query }) { skipped.push(`${label}: level`); continue; } - if (!outlineNameMatches(row, query, kind)) { + const nameMatch = outlineNameMatch(row, query, kind); + if (nameMatch === NAME_MISS) { skipped.push(`${label}: name`); continue; } @@ -549,7 +586,8 @@ export function selectOutlineResult(rows, { kind, query }) { skipped.push(`${label}: too large`); continue; } - return { + found.push({ + nameMatch, outline: { name: String(row.name || '').slice(0, 160) || null, displayName: String(row.display_name || '').slice(0, 300) || null, @@ -561,10 +599,27 @@ export function selectOutlineResult(rows, { kind, query }) { : null, polygons, }, - skipped, - }; + }); } - return { outline: null, skipped }; + // 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 + ); } /** diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js index 4479c1579..82768ca75 100644 --- a/server/providers/regional/place.js +++ b/server/providers/regional/place.js @@ -19,6 +19,7 @@ import { import { OUTLINE_KINDS, OUTLINE_POLYGON_THRESHOLD, + outlineAt, outlineBias, selectOutlineResult, } from './outlineSelect.js'; @@ -52,6 +53,11 @@ function requestKey(gate, kind, params) { return `${CACHE_SCHEMA}|${endpoint}|${kind}|${params}`; } +/** A decimal coordinate query value, or NaN: never "" or " " as 0. */ +function coordinateParam(value) { + return /^-?\d+(\.\d+)?$/.test(value ?? '') ? Number(value) : NaN; +} + function malformed() { return Object.assign(new Error('Place answer was not understood'), { code: 'NOMINATIM_MALFORMED', @@ -222,8 +228,10 @@ export function createNominatimSearchProvider({ /** * Construct the outline lookup: one guarded search with polygons for one - * explicit ask, accepting only a result whose class suits the ask kind. - * Found outlines are kept 90 days and genuine no-results 24 hours. + * explicit ask, accepting only results whose class suits the ask kind. + * Every accepted result is kept (90 days; genuine no-results 24 hours), and + * each ask is answered with the one holding its anchor, so a same-name place + * elsewhere neither answers nor repeats from the cache. */ export function createNominatimOutlineProvider({ gate = () => sharedNominatimGate(), @@ -232,8 +240,15 @@ export function createNominatimOutlineProvider({ const inFlight = new Map(); const currentGate = () => (typeof gate === 'function' ? gate() : gate); + const answer = (payload, anchorLat, anchorLon, cached) => ({ + status: payload.status, + outline: outlineAt(payload.outlines, anchorLat, anchorLon), + ...(payload.skipped ? { skipped: payload.skipped } : {}), + ...(cached ? { cached: true } : {}), + }); + return async function fetchNominatimOutline( - { query, kind, lat, lon }, + { query, kind, lat, lon, anchorLat = null, anchorLon = null }, { signal } = {}, ) { const bias = outlineBias(kind, lat, lon); @@ -249,10 +264,10 @@ export function createNominatimOutlineProvider({ }); if (bias) params.set('viewbox', bias.viewbox); const gateNow = currentGate(); - const cacheKey = requestKey(gateNow, `outline-${kind}`, params); + const cacheKey = requestKey(gateNow, `outlines-${kind}`, params); const cached = await cache.get(cacheKey); - if (cached) return { ...cached.payload, cached: true }; - return await subscribeSharedRequest( + if (cached) return answer(cached.payload, anchorLat, anchorLon, true); + const payload = await subscribeSharedRequest( inFlight, cacheKey, signal, @@ -262,23 +277,24 @@ export function createNominatimOutlineProvider({ { signal: sharedSignal, maxBytes: OUTLINE_MAX_RESPONSE_BYTES }, ); if (!Array.isArray(rows)) throw malformed(); - const { outline, skipped } = selectOutlineResult(rows, { + const { outlines, skipped } = selectOutlineResult(rows, { kind, query: sent, }); - const payload = outline - ? { status: 'OK', outline } - : { status: 'ZERO_RESULTS', outline: null, skipped }; + const found = outlines.length + ? { status: 'OK', outlines } + : { status: 'ZERO_RESULTS', outlines: [], skipped }; await cache.set( cacheKey, - payload, - outline + found, + outlines.length ? NOMINATIM_CACHE_TTL_MS.outline : NOMINATIM_CACHE_TTL_MS.notFound, ); - return payload; + return found; }, ); + return answer(payload, anchorLat, anchorLon, false); }; } @@ -428,6 +444,8 @@ export function geocodeProxy({ kind, lat: url.searchParams.get('lat'), lon: url.searchParams.get('lon'), + anchorLat: coordinateParam(url.searchParams.get('alat')), + anchorLon: coordinateParam(url.searchParams.get('alon')), }, { signal }, ); diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 6c429b283..28c0e814b 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1128,8 +1128,13 @@ export function createAnnotationEngine({ if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue; if (!outlinesEqual(other, anno)) continue; - removeRendered(other); - annotations.delete(other.id); + try { + removeRendered(other); + annotations.delete(other.id); + } catch { + // The duplicate's failure is its own: leave it addressable for + // later cleanup and keep this mark's installed outline. + } } } renderer.sync(annotations); diff --git a/src/annotations/outlineIdentity.test.mjs b/src/annotations/outlineIdentity.test.mjs index 1addaf1fa..8cd2ec54d 100644 --- a/src/annotations/outlineIdentity.test.mjs +++ b/src/annotations/outlineIdentity.test.mjs @@ -120,3 +120,45 @@ test('the engine resolves a bare street highlight with its footprint', async (t) assert.equal(seen[0].footprint, true); assert.equal(seen[0].entityKind, 'street'); }); + +test('a duplicate that fails to clean up never costs the new outline', async (t) => { + installAnimationFrameStubs(t); + const polygons = [[outer]]; + const outline = { ring: outer, polygons, footprintKind: 'area' }; + const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' }; + // "Old" draws at once; "New" resolves the same polygon later. + const resolveTarget = async ({ target }) => + target === 'old' + ? { ...point, label: target, ...outline } + : { + ...point, + label: target, + resolveOutline: async () => ({ ...point, ...outline }), + }; + const stuck = new Set(); + const outcomes = []; + const engine = createAnnotationEngine({ + viewer: {}, + renderer: { + ...renderer, + add(anno) { + if (anno.label === 'Old') stuck.add(anno); + }, + remove(anno) { + if (stuck.has(anno)) throw new Error('renderer refused'); + }, + }, + resolveTarget, + }); + engine.onOutlineEvent((event) => outcomes.push(event)); + await engine.annotate({ type: 'area', target: 'old', label: 'Old' }); + await engine.annotate({ type: 'area', target: 'new', label: 'New' }); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); + const fresh = engine.list().find((mark) => mark.label === 'New'); + assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline'); + assert.notEqual(fresh.outlineUnavailable, true); + assert.deepEqual( + outcomes.filter((event) => event.label === 'New').map((e) => e.status), + ['resolved'], + ); +}); diff --git a/src/annotations/outlineRungs.js b/src/annotations/outlineRungs.js index ad80b9f27..ac56d8316 100644 --- a/src/annotations/outlineRungs.js +++ b/src/annotations/outlineRungs.js @@ -4,7 +4,7 @@ import { ringCentroid, } from '../sources/featureGeometry.js'; import { isUnavailableCapability } from '../sources/capability.js'; -import { polygonsContain } from '../data/adminBoundaries.js'; +import { containsAnchor } from '../data/adminBoundaries.js'; import { lonLatToTile } from '../data/tomtomTiles.js'; import { BUILDING_MAX_DISTANCE_M, @@ -19,6 +19,8 @@ import { } from '../sources/openFreeMapOutlines.js'; import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js'; +export { containsAnchor }; + /** * Outline rungs that need no Overpass, around the resolver's existing ladder: * @@ -82,46 +84,6 @@ export function nominatimKindFor({ scope, groundsLike, pointLike, around }) { return null; } -/** - * Whether polygons contain a point. A part whose outer ring crosses the - * antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own - * longitude frame, with the point moved into that frame; every other part, - * including one genuinely wider than 180°, is tested as stored. Crossing is - * read from an edge jumping more than 180°, which assumes densely noded rings - * (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or - * a non-crossing ring with a single edge over 180°, is not supported. - */ -export function containsAnchor(polygons, lat, lon) { - if (!Number.isFinite(lon)) return false; - for (const part of polygons || []) { - const outer = part?.[0]; - if (!Array.isArray(outer) || !outer.length) continue; - const reference = outer[0][0]; - const shift = (x) => x - 360 * Math.round((x - reference) / 360); - const span = (ring, f) => { - let west = Infinity; - let east = -Infinity; - for (const [x] of ring) { - west = Math.min(west, f(x)); - east = Math.max(east, f(x)); - } - return east - west; - }; - // An edge jumping more than 180° wraps the line; a polar ring whose - // edges run along ±180 collapses when reframed and is read as stored. - const jumps = outer.some( - ([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180, - ); - const framedSpan = jumps ? span(outer, shift) : 0; - const crosses = framedSpan > 0 && framedSpan < 180; - const framed = crosses - ? part.map((ring) => ring.map(([x, y]) => [shift(x), y])) - : part; - if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true; - } - return false; -} - /** * Parts with the one holding the anchor outside its holes first. A boundary * can be largest at sea (Tokyo's island municipalities), so size alone does @@ -275,7 +237,14 @@ export function createOutlineRungs({ if (!isNamedAsk(query)) return null; const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon }; const outline = await nominatim.lookup( - { query, kind, lat: bias.lat, lon: bias.lon }, + { + query, + kind, + lat: bias.lat, + lon: bias.lon, + anchorLat: ctx.lat, + anchorLon: ctx.lon, + }, { signal: ctx.signal }, ); if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited diff --git a/src/annotations/outlineRungs.test.mjs b/src/annotations/outlineRungs.test.mjs index 820952cb8..8b4defe4e 100644 --- a/src/annotations/outlineRungs.test.mjs +++ b/src/annotations/outlineRungs.test.mjs @@ -420,6 +420,8 @@ test('a city with no configured Overpass is outlined by Nominatim', async () => kind: 'city', lat: CAPITOL.lat, lon: CAPITOL.lon, + anchorLat: 43.6, + anchorLon: 1.44, }); assert.deepEqual( run.calls.finished, diff --git a/src/annotations/placeOutline.test.mjs b/src/annotations/placeOutline.test.mjs index 70e3bc3ad..1de4b6da5 100644 --- a/src/annotations/placeOutline.test.mjs +++ b/src/annotations/placeOutline.test.mjs @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createAnnotationResolver } from './resolver.js'; import { polygonsContain } from '../data/adminBoundaries.js'; +import { streetFootprint } from './outlineRungs.js'; function viewerAt(lat, lon, height = 20_000) { return { @@ -338,6 +339,54 @@ test('a courtyard outline with no filled candidate anchor remains a point', asyn } }); +test('a bent street keeps its outline when the anchor sits beside the centreline', async () => { + // 500 m east then 500 m north: the ring mean lands off the 22 m ribbon. + const lat0 = 30.27; + const lon0 = -97.74; + const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180)); + const dLat = 500 / 111320; + const line = [ + [lon0, lat0], + [lon0 + dLon / 2, lat0], + [lon0 + dLon, lat0], + [lon0 + dLon, lat0 + dLat / 2], + [lon0 + dLon, lat0 + dLat], + ]; + const footprint = streetFootprint({ lines: [line] }); + // A geocoded point 15 m north of the first leg, outside the ribbon. + const lat = lat0 + 15 / 111320; + const lon = lon0 + dLon / 2; + assert.equal(polygonsContain(footprint.polygons, lat, lon), false); + const { resolve } = harness( + { lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] }, + { outlineRungs: rungsAnswering(footprint) }, + ); + const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), { + entityKind: 'street', + }); + const outline = await pending.resolveOutline(); + assert.equal(outline?.ring, footprint.ring, 'the street is still drawn'); + assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon)); + assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street'); + assert.ok(Math.abs(outline.lon - lon) < 1e-7); + // A same-name street that never comes near the anchor is not that street. + const far = lat0 + 300 / 111320; + const away = harness( + { + lat: far, + lng: lon, + name: 'Bent Street', + label: 'Bent St', + types: ['route'], + }, + { outlineRungs: rungsAnswering(footprint) }, + ); + const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), { + entityKind: 'street', + }); + assert.equal((await distant.resolveOutline())?.ring ?? null, null); +}); + test('local dateline outlines share a frame for area, centering and filled anchors', async () => { const ring = [ [179.9995, -0.0005], diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 4730f1ab2..fad27e55d 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -11,6 +11,7 @@ import { approximateAreaM2, approximateDistanceM, pointInPolygon, + nearestPointOnLines, } from '../sources/featureGeometry.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; @@ -79,6 +80,9 @@ function localOutlineFrame(fp) { /** Ms the analyst region lookup waits on geocode + admin boundary. */ const REGION_FALLBACK_BUDGET_MS = 3_000; +/** How far a street may run from its anchor when the ribbon holds neither. */ +const STREET_SNAP_MAX_M = 100; + /** * Names that end like a landmark rather than a district: "Central Park", * "Lake Merritt"-style suffixes are checked on the last word only, so "Park @@ -830,8 +834,18 @@ export function createAnnotationResolver({ frame.reference === null ? lon : lon - 360 * Math.round((lon - frame.reference) / 360); - if (!polygonsContainPoint(frame.polygons, lat, anchorLon)) return null; - centroid = { lat, lon }; + if (polygonsContainPoint(frame.polygons, lat, anchorLon)) { + centroid = { lat, lon }; + } else if (Array.isArray(fp.lines)) { + // A bent street's ribbon rarely holds its ring mean, and a geocoded + // anchor can sit beside the centreline: centre on the street itself, + // only when it runs beside the anchor. + const onStreet = nearestPointOnLines(fp.lines, lat, lon); + if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null; + centroid = { lat: onStreet.lat, lon: onStreet.lon }; + } else { + return null; + } } if ( frame.reference !== null && diff --git a/src/data/adminBoundaries.js b/src/data/adminBoundaries.js index 58fe34fc2..7468513d8 100644 --- a/src/data/adminBoundaries.js +++ b/src/data/adminBoundaries.js @@ -150,6 +150,46 @@ export function polygonsContain(polygons, lat, lon) { return false; } +/** + * Whether polygons contain a point. A part whose outer ring crosses the + * antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own + * longitude frame, with the point moved into that frame; every other part, + * including one genuinely wider than 180°, is tested as stored. Crossing is + * read from an edge jumping more than 180°, which assumes densely noded rings + * (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or + * a non-crossing ring with a single edge over 180°, is not supported. + */ +export function containsAnchor(polygons, lat, lon) { + if (!Number.isFinite(lon)) return false; + for (const part of polygons || []) { + const outer = part?.[0]; + if (!Array.isArray(outer) || !outer.length) continue; + const reference = outer[0][0]; + const shift = (x) => x - 360 * Math.round((x - reference) / 360); + const span = (ring, f) => { + let west = Infinity; + let east = -Infinity; + for (const [x] of ring) { + west = Math.min(west, f(x)); + east = Math.max(east, f(x)); + } + return east - west; + }; + // An edge jumping more than 180° wraps the line; a polar ring whose + // edges run along ±180 collapses when reframed and is read as stored. + const jumps = outer.some( + ([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180, + ); + const framedSpan = jumps ? span(outer, shift) : 0; + const crosses = framedSpan > 0 && framedSpan < 180; + const framed = crosses + ? part.map((ring) => ring.map(([x, y]) => [shift(x), y])) + : part; + if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true; + } + return false; +} + /** * A unit's geometry, decoded on first use. Longitudes are unwrapped around * the largest part for the box so units cut at the antimeridian (Alaska, diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index 52ebfcefa..3ef7fa993 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -253,6 +253,37 @@ export function ringCentroid(ring) { return { lat: sumLat / ring.length, lon: sumLon / ring.length }; } +/** + * The point on any of `lines` ([lon, lat] chains) nearest `lat, lon`, with + * its distance in metres, or null when no line has a segment. + */ +export function nearestPointOnLines(lines, lat, lon) { + const mLat = 111320; + const mLon = mLat * Math.cos((lat * Math.PI) / 180); + let best = null; + let bestD2 = Infinity; + for (const line of lines || []) { + for (let i = 0; i + 1 < line.length; i++) { + const ax = (line[i][0] - lon) * mLon; + const ay = (line[i][1] - lat) * mLat; + const dx = (line[i + 1][0] - line[i][0]) * mLon; + const dy = (line[i + 1][1] - line[i][1]) * mLat; + const len2 = dx * dx + dy * dy; + const t = len2 + ? Math.max(0, Math.min(1, -(ax * dx + ay * dy) / len2)) + : 0; + const x = ax + t * dx; + const y = ay + t * dy; + if (x * x + y * y < bestD2) { + bestD2 = x * x + y * y; + best = { lat: lat + y / mLat, lon: lon + x / mLon, distanceM: 0 }; + } + } + } + if (best) best.distanceM = Math.sqrt(bestD2); + return best; +} + export function approximateAreaM2(coords) { // Shoelace in a local equirectangular projection (good enough for buildings). if (coords.length < 3) return 0; diff --git a/src/sources/nominatimOutlines.js b/src/sources/nominatimOutlines.js index ebc10ffb0..b040c8d9b 100644 --- a/src/sources/nominatimOutlines.js +++ b/src/sources/nominatimOutlines.js @@ -79,10 +79,16 @@ export function createNominatimOutlineClient({ } /** - * @param {{query: string, kind: string, lat?: number, lon?: number}} ask + * `lat, lon` bias the search; `anchorLat, anchorLon` (the resolved place) + * pick which same-name result answers. + * + * @param {{query: string, kind: string, lat?: number, lon?: number, anchorLat?: number, anchorLon?: number}} ask * @param {{signal?: AbortSignal}} [options] */ - async function lookup({ query, kind, lat, lon }, { signal } = {}) { + async function lookup( + { query, kind, lat, lon, anchorLat, anchorLon }, + { signal } = {}, + ) { if (unavailable) return unavailable; const text = String(query || '').trim(); if (!text || text.length > 200 || !NOMINATIM_OUTLINE_KINDS.includes(kind)) @@ -92,6 +98,11 @@ export function createNominatimOutlineClient({ params.set('lat', lat.toFixed(3)); params.set('lon', lon.toFixed(3)); } + if (Number.isFinite(anchorLat) && Number.isFinite(anchorLon)) { + // Full anchor precision: rounding could cross a building's edge. + params.set('alat', anchorLat.toFixed(6)); + params.set('alon', anchorLon.toFixed(6)); + } const key = params.toString(); if (remembered.has(key)) return remembered.get(key); // A cancelled ask spends nothing. diff --git a/src/sources/nominatimOutlines.test.mjs b/src/sources/nominatimOutlines.test.mjs index d756702e1..cef4c6cb2 100644 --- a/src/sources/nominatimOutlines.test.mjs +++ b/src/sources/nominatimOutlines.test.mjs @@ -210,3 +210,21 @@ test('an ask cancelled while the capability probe is pending spends nothing', as assert.equal(calls.length, 0, `${label}: no outline request`); } }); + +test('the resolved anchor is sent at full precision, apart from the bias', async () => { + const { calls, lookup } = client(() => + reply(200, { status: 'OK', outline: { polygons: [[ring]] } }), + ); + await lookup({ + query: 'Paris', + kind: 'city', + lat: 48.85, + lon: 2.35, + anchorLat: 48.8566, + anchorLon: 2.35221, + }); + assert.match( + calls[0], + /&lat=48\.850&lon=2\.350&alat=48\.856600&alon=2\.352210$/, + ); +}); diff --git a/src/sources/openFreeMapOutlines.js b/src/sources/openFreeMapOutlines.js index 87d3a778e..6bd1d65d1 100644 --- a/src/sources/openFreeMapOutlines.js +++ b/src/sources/openFreeMapOutlines.js @@ -11,6 +11,7 @@ import { ringAreaM2, closeRing, stitchLines, + nearestPointOnLines, } from './featureGeometry.js'; import { phaseTiming } from './phaseTiming.js'; import { normalizePlaceName } from '../data/placeBoundaries.js'; @@ -406,21 +407,7 @@ function distanceToPolygonM(lon, lat, rings) { /** 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; + return nearestPointOnLines([ring], lat, lon)?.distanceM ?? Infinity; } /** diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index f00c25ba7..e18711499 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -27,6 +27,7 @@ import { import { acceptsOutlineClass, capOutlineVertices, + outlineAt, outlineBias, selectOutlineResult, } from '../../server/providers/regional/outlineSelect.js'; @@ -1092,6 +1093,16 @@ test('the outline route answers OK with polygons and validates its input', async assert.match(calls[0], /polygon_geojson=1/); assert.match(calls[0], /polygon_threshold=0\.0005/); assert.match(calls[0], /limit=3/); + // The resolved anchor picks among cached same-name results. + const anchored = await request( + '/outline?q=Paris&kind=city&lat=48.85&lon=2.35&alat=10&alon=10', + ); + assert.equal( + anchored.body.outline.osm, + ok.body.outline.osm, + 'else the first', + ); + assert.equal(calls.length, 1, 'the anchor is not part of the upstream ask'); for (const bad of [ '/outline?q=Paris', '/outline?kind=city', @@ -1271,3 +1282,132 @@ test('the outline route reports disabled and capped as permanent, busy as retrya assert.equal(refused.status, 429); assert.equal(refused.headers['Retry-After'], '90'); }); + +test('a landmark ask is not answered by the place around it', () => { + const park = (name) => + row({ category: 'leisure', type: 'park', name, display_name: name }); + const landmark = (query) => ({ kind: 'landmark', query }); + for (const query of [ + 'Central Park Zoo', + 'Washington Square Arch', + 'Lincoln Park Conservatory', + ]) + assert.equal( + selectOutlineResult( + [park(query.split(' ').slice(0, -1).join(' '))], + landmark(query), + ).outline, + null, + query, + ); + // The last word may say where the result is, or what it is. + const at = (name, display_name, overrides = {}) => + row({ + category: 'tourism', + type: 'attraction', + name, + display_name, + ...overrides, + }); + for (const [query, result] of [ + [ + 'Space Needle Seattle', + at('Space Needle', 'Space Needle, Seattle, King County, Washington'), + ], + [ + 'Golden Gate Bridge San Francisco', + at('Golden Gate Bridge', 'Golden Gate Bridge, San Francisco, California'), + ], + [ + 'Notre Dame Cathedral', + at('Notre-Dame de Paris', 'Notre-Dame de Paris, Paris, France', { + category: 'building', + type: 'cathedral', + }), + ], + ]) + assert.ok(selectOutlineResult([result], landmark(query)).outline, query); + // An exact name, then one holding every asked word, outranks earlier + // partial matches. + const exact = selectOutlineResult( + [park('Zilker Metropolitan Park'), park('Zilker Park')], + landmark('Zilker Park'), + ); + assert.deepEqual( + exact.outlines.map((outline) => outline.name), + ['Zilker Park', 'Zilker Metropolitan Park'], + ); + const every = selectOutlineResult( + [park('Royal Park'), park('Royal Botanic Historic Park')], + landmark('Royal Botanic Park'), + ); + assert.equal(every.outline.name, 'Royal Botanic Historic Park'); +}); + +test('outlineAt reads a ring crossing the antimeridian in its own frame', () => { + const band = { + osm: 'way/1', + polygons: [ + [ + [ + [170, -17], + [175, -17], + [175, -16], + [170, -16], + [170, -17], + ], + ], + ], + }; + const fiji = { + osm: 'way/2', + polygons: [ + [ + [ + [179.9, -17], + [-179.9, -17], + [-179.9, -16], + [179.9, -16], + [179.9, -17], + ], + ], + ], + }; + assert.equal(outlineAt([band, fiji], -16.5, 179.995).osm, 'way/2'); + assert.equal(outlineAt([band, fiji], -16.5, 172).osm, 'way/1'); + assert.equal( + outlineAt([fiji, band], -16.5, 0).osm, + 'way/2', + 'else the first', + ); +}); + +test('a same-name outline answers the ask whose anchor it holds, cached or not', async (t) => { + const london = (lon, lat, osm_id) => + row({ + category: 'place', + type: 'city', + addresstype: 'city', + name: 'London', + osm_id, + geojson: { type: 'Polygon', coordinates: square(lon, lat, 0.2) }, + }); + const { calls, fetchImpl } = stubFetch(async () => + json([london(-0.2, 51.4, 1), london(-81.35, 42.9, 2)]), + ); + const gate = publicGate(t, { fetchImpl, ...clock() }); + const outline = createNominatimOutlineProvider({ gate }); + const ask = { query: 'London', kind: 'city', lat: 43, lon: -81 }; + const ontario = await outline({ + ...ask, + anchorLat: 42.98, + anchorLon: -81.25, + }); + assert.equal(ontario.outline.osm, 'relation/2'); + const england = await outline({ ...ask, anchorLat: 51.5, anchorLon: -0.12 }); + assert.equal(england.outline.osm, 'relation/1'); + assert.equal(england.cached, true); + const unanchored = await outline(ask); + assert.equal(unanchored.outline.osm, 'relation/1', 'first without an anchor'); + assert.equal(calls.length, 1, 'one upstream request for every anchor'); +});