Look up OSM ids in the search box

380834d · Gabriel Morell · 2026-10-28 20:57

10 files +131 -3
Message
{commit_body(@commit)}

Files changed

modified README.md
+6 −0
@@ -169,6 +169,12 @@ Restaurants added before hours were tracked can pick them up with
169 169 once under **Manage** on the area page. Refresh replaces hours (they change)
170 170 but only fills cuisine, website and address where blank.
171 171
172 +The search box also takes an **OSM id**: `29632970` (tries node, way and
173 +relation, keeping whichever is a named place), `w29632970`, `way/29632970`,
174 +or a pasted openstreetmap.org link. It's looked up on the OSM API, placed at
175 +the centre of a way's or relation's outline, and shown however far away or
176 +whatever kind of place it is, since you asked for exactly that one.
177 +
172 178 Search results from an area are sorted nearest its centre first, and
173 179 anything over 50 km away is dropped (Photon will otherwise offer a namesake
174 180 on another continent).
modified src/handlers/places.rs
+16 −0
@@ -47,6 +47,22 @@ pub async fn search(
47 47 let q = qs.q.trim();
48 48 let near = qs.lat.zip(qs.lon);
49 49 let food = qs.food.as_deref() == Some("1");
50 + // An OSM id is an exact request: no eatery filter, no distance cut-off,
51 + // just that element (distance still shown when there's an area).
52 + if let Some((kind, id)) = crate::osm::parse_osm_id(q) {
53 + let (mut places, failed) = match state.osm.lookup(kind, id).await {
54 + Ok(p) => (p, false),
55 + Err(e) => {
56 + warn!(error = %e, "osm id lookup failed");
57 + (vec![], true)
58 + }
59 + };
60 + if let Some((lat, lon)) = near {
61 + crate::osm::by_distance(&mut places, lat, lon, None);
62 + }
63 + return candidates(&state, places, failed, true, qs.area).await;
64 + }
65 +
50 66 let (mut places, failed) = if q.is_empty() {
51 67 (vec![], false)
52 68 } else {
modified src/osm.rs
+106 −0
@@ -103,6 +103,31 @@ impl Place {
103 103 }
104 104 }
105 105
106 +/// An OSM element id typed into a search box: `29632970` (any type),
107 +/// `w29632970`, `way/29632970`, `way 29632970`, or an openstreetmap.org URL.
108 +/// Returns the type, if given (`node`/`way`/`relation`), and the id.
109 +pub fn parse_osm_id(q: &str) -> Option<(Option<&'static str>, i64)> {
110 + let q = q.trim().trim_end_matches('/');
111 + // URLs: take what follows the host — .../way/29632970, possibly with #map=…
112 + let q = q.split_once("openstreetmap.org/").map_or(q, |(_, rest)| rest);
113 + let q = q.split(['#', '?']).next().unwrap_or(q);
114 + let kind = |k: &str| match k.to_ascii_lowercase().as_str() {
115 + "n" | "node" => Some("node"),
116 + "w" | "way" => Some("way"),
117 + "r" | "rel" | "relation" => Some("relation"),
118 + _ => None,
119 + };
120 + if let Ok(id) = q.parse::<i64>() {
121 + return (id > 0).then_some((None, id));
122 + }
123 + let (k, id) = match q.split_once(['/', ' ']) {
124 + Some((k, id)) => (k.trim(), id.trim()),
125 + None => q.split_at(q.find(|c: char| c.is_ascii_digit())?),
126 + };
127 + let id: i64 = id.parse().ok().filter(|i| *i > 0)?;
128 + Some((Some(kind(k)?), id))
129 +}
130 +
106 131 /// `N123` → (`node`, 123), as the OSM API names element types.
107 132 fn split_ref(osm_ref: &str) -> Option<(&'static str, i64)> {
108 133 let kind = match osm_ref.get(..1)? {
@@ -340,6 +365,53 @@ impl OsmClient {
340 365 found
341 366 }
342 367
368 + /// Look elements up by id on the OSM API: one type, or all three when
369 + /// the id came bare (nodes, ways and relations number separately).
370 + /// Only named elements count — a bare number usually also matches some
371 + /// untagged node in a field somewhere. Ways and relations are placed at
372 + /// the centre of their bounding box.
373 + pub async fn lookup(&self, kind: Option<&'static str>, id: i64) -> Result<Vec<Place>> {
374 + let Some(base) = self.osm_api_url.as_deref() else { return Ok(vec![]) };
375 + let kinds: Vec<&str> = kind.map_or(vec!["node", "way", "relation"], |k| vec![k]);
376 + let mut places = vec![];
377 + for kind in kinds {
378 + // `full` brings a way's or relation's nodes, for a position.
379 + let url = match kind {
380 + "node" => format!("{base}/api/0.6/node/{id}.json"),
381 + _ => format!("{base}/api/0.6/{kind}/{id}/full.json"),
382 + };
383 + let resp = self.http.get(url).timeout(Duration::from_secs(6)).send().await?;
384 + // 404 for a type that doesn't have this id, 410 for deleted.
385 + if matches!(resp.status().as_u16(), 404 | 410) {
386 + continue;
387 + }
388 + let body: OsmFull = resp.error_for_status()?.json().await.context("decoding osm element")?;
389 + let Some(element) = body.elements.iter().find(|e| e.kind == kind && e.id == id) else { continue };
390 + let Some(name) = element.tags.get("name").filter(|n| !n.is_empty()).cloned() else { continue };
391 + let (lats, lons): (Vec<f64>, Vec<f64>) =
392 + body.elements.iter().filter_map(|e| Some((e.lat?, e.lon?))).unzip();
393 + if lats.is_empty() {
394 + continue;
395 + }
396 + let mid = |v: &[f64]| (v.iter().copied().fold(f64::INFINITY, f64::min) + v.iter().copied().fold(f64::NEG_INFINITY, f64::max)) / 2.0;
397 + let d = details(&element.tags);
398 + places.push(Place {
399 + osm_ref: Some(format!("{}{id}", kind[..1].to_ascii_uppercase())),
400 + name,
401 + lat: mid(&lats),
402 + lon: mid(&lons),
403 + kind: d.kind.or_else(|| element.tags.get("shop").cloned()),
404 + cuisine: d.cuisine,
405 + address: d.address,
406 + website: d.website,
407 + opening_hours: d.opening_hours,
408 + distance_m: None,
409 + tagged: true,
410 + });
411 + }
412 + Ok(places)
413 + }
414 +
343 415 async fn poi_nearby(&self, lat: f64, lon: f64, radius_m: u32, cuisine: Option<&str>) -> Result<Vec<Place>> {
344 416 let base = self.poi_url.as_deref().unwrap_or_default();
345 417 let mut params: Vec<(&str, String)> = vec![
@@ -466,6 +538,23 @@ struct OsmElement {
466 538 tags: Tags,
467 539 }
468 540
541 +/// A `full` response: the element plus everything it's made of.
542 +#[derive(Deserialize)]
543 +struct OsmFull {
544 + elements: Vec<OsmFullElement>,
545 +}
546 +
547 +#[derive(Deserialize)]
548 +struct OsmFullElement {
549 + #[serde(rename = "type")]
550 + kind: String,
551 + id: i64,
552 + lat: Option<f64>,
553 + lon: Option<f64>,
554 + #[serde(default)]
555 + tags: Tags,
556 +}
557 +
469 558 #[derive(Deserialize)]
470 559 struct PhotonCollection {
471 560 features: Vec<PhotonFeature>,
@@ -632,6 +721,23 @@ mod tests {
632 721 assert!(places[0].distance_m.unwrap() < 100);
633 722 }
634 723
724 + #[test]
725 + fn parses_osm_ids_however_theyre_typed() {
726 + assert_eq!(parse_osm_id("29632970"), Some((None, 29632970)));
727 + assert_eq!(parse_osm_id(" w29632970 "), Some((Some("way"), 29632970)));
728 + assert_eq!(parse_osm_id("N123"), Some((Some("node"), 123)));
729 + assert_eq!(parse_osm_id("way/29632970"), Some((Some("way"), 29632970)));
730 + assert_eq!(parse_osm_id("relation 42"), Some((Some("relation"), 42)));
731 + assert_eq!(
732 + parse_osm_id("https://www.openstreetmap.org/way/29632970#map=19/42.36/-71.06"),
733 + Some((Some("way"), 29632970))
734 + );
735 + assert_eq!(parse_osm_id("https://openstreetmap.org/node/6974241986/"), Some((Some("node"), 6974241986)));
736 + for not_an_id in ["pizza", "7-eleven", "route 66", "w", "x123", "0", "way/abc", "99 bottles"] {
737 + assert_eq!(parse_osm_id(not_an_id), None, "{not_an_id}");
738 + }
739 + }
740 +
635 741 #[test]
636 742 fn haversine_is_sane() {
637 743 // ~111 km per degree of latitude.
modified templates/areas/new.html
+1 −1
@@ -9,7 +9,7 @@
9 9 <form class="join w-full"
10 10 hx-get="/places/search" hx-target="#candidates" hx-select="#candidates" hx-swap="outerHTML">
11 11 <input type="hidden" name="food" value="0"/>
12 − <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Austin, TX · 1 Main St, Somerville" required/>
12 + <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Austin, TX · 1 Main St, Somerville · an OSM id" required/>
13 13 <button class="btn btn-sm join-item" type="submit"><i class="fas fa-magnifying-glass"></i> Search</button>
14 14 </form>
15 15 <div id="candidates"></div>
modified templates/restaurants/new.html
+2 −2
@@ -5,7 +5,7 @@
5 5 <a href="/areas/{{ area.id }}" class="btn btn-ghost btn-sm">&larr; {{ area.name }}</a>
6 6 <h2 class="text-2xl font-bold text-accent">Add a restaurant</h2>
7 7 <p class="text-sm opacity-70">
8 − Search by name, or click a spot on the map to see what's there. Pick one to fill in the
8 + Search by name or OSM id (or paste an openstreetmap.org link), or click a spot on the map to see what's there. Pick one to fill in the
9 9 details, or drop a pin and type them yourself for somewhere OSM doesn't know about.
10 10 </p>
11 11
@@ -15,7 +15,7 @@
15 15 <input type="hidden" name="area" value="{{ area.id }}"/>
16 16 <input type="hidden" name="lat" value="{{ area.lat }}"/>
17 17 <input type="hidden" name="lon" value="{{ area.lon }}"/>
18 − <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Tandoor, burrito, Dunkin'…" required/>
18 + <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Tandoor, burrito, or an OSM id like w29632970" required/>
19 19 <button class="btn btn-sm join-item" type="submit">
20 20 <i class="fas fa-magnifying-glass"></i> Search
21 21 <span class="loading loading-spinner loading-xs htmx-indicator"></span>

Parents: 6c4a5fa