From d60a4313c5d1a4eaf523bb06b99d79eda7cde01f Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 17:58:48 +0200 Subject: [PATCH 1/6] fix: include web tile layers in map search --- crates/engine/src/cmd/search.rs | 66 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/tiles.rs | 8 +++- crates/engine/src/tests.rs | 6 +++ crates/ui-egui/src/searchbox.rs | 5 ++- 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 9f85322d0..b216a2f0b 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -38,6 +38,8 @@ pub enum Hit { Coordinate { lon: f64, lat: f64 }, /// A layer whose name matches. Layer { id: u32, name: String }, + /// A web tile layer whose name matches; `index` is its position in the map's tile stack. + Tile { index: usize, name: String, overlay: bool }, /// A bookmark whose name matches. Bookmark { name: String }, /// A feature with a matching value. @@ -51,6 +53,7 @@ impl Hit { match self { Hit::Coordinate { lon, lat } => json!({"kind": "coordinate", "lon": lon, "lat": lat, "label": format_lat_lon(*lat, *lon)}), Hit::Layer { id, name } => json!({"kind": "layer", "layer": id, "label": name}), + Hit::Tile { index, name, overlay } => json!({"kind": "tile", "index": index, "label": name, "overlay": overlay}), Hit::Bookmark { name } => json!({"kind": "bookmark", "label": name}), Hit::Feature { layer, layer_name, row, field, value, name } => { json!({"kind": "feature", "layer": layer, "layerName": layer_name, "row": row, "field": field, "label": value, "name": name}) @@ -83,6 +86,11 @@ pub fn search(s: &Session, query: &str, limit: usize) -> Results { out.hits.push(Hit::Layer { id: l.id, name: l.name.clone() }); } } + for (index, t) in map.tiles.iter().enumerate() { + if contains_ci(&t.name, &needle) { + out.hits.push(Hit::Tile { index, name: t.name.clone(), overlay: t.overlay }); + } + } for b in &map.bookmarks { if contains_ci(&b.name, &needle) { out.hits.push(Hit::Bookmark { name: b.name.clone() }); @@ -207,6 +215,7 @@ pub fn go_to(s: &mut Session, hit: &Hit) -> CmdResult { } Ok(Value::Null) } + Hit::Tile { index, .. } => super::tiles::zoom_to_index(s, *index).map(|_| Value::Null), Hit::Bookmark { name } => { let cam = s.map().bookmarks.iter().find(|b| b.name == *name).map(|b| b.camera).ok_or_else(|| format!("no bookmark '{name}'"))?; @@ -501,6 +510,63 @@ mod tests { assert!(search(&s, " ", 50).hits.is_empty()); } + #[test] + fn finds_tile_layers_without_bounds_or_visibility() { + let mut s = Session::new(crate::Project::default()); + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/wms?BBOX={bbox-epsg-3857}", + "name": "OSM-WMS", "overlay": true + }), + ) + .unwrap(); + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/{z}/{x}/{y}.png", + "name": "OSM streets" + }), + ) + .unwrap(); + s.run("map.setTiles", &json!({"name": "OSM streets", "visible": false})).unwrap(); + let r = s.run("map.search", &json!({"query": " oSm "})).unwrap(); + assert_eq!( + r["hits"], + json!([ + {"kind": "tile", "index": 0, "label": "OSM-WMS", "overlay": true}, + {"kind": "tile", "index": 1, "label": "OSM streets", "overlay": false} + ]) + ); + let r = s.run("map.search", &json!({"query": "OSM-WMS", "limit": 1})).unwrap(); + assert_eq!(r["hits"][0]["index"], 0); + let before = s.map().camera; + let e = s.run("map.search", &json!({"query": "OSM-WMS", "go": true})).unwrap_err(); + assert!(e.contains("doesn't say what it covers"), "{e}"); + assert_eq!(s.map().camera, before); + } + + #[test] + fn going_to_tile_hits_uses_their_bounds() { + let mut s = Session::new(crate::Project::default()); + for (name, bounds) in [("Coverage west", [-20.0, -5.0, -10.0, 5.0]), ("Coverage east", [10.0, -5.0, 20.0, 5.0])] { + s.run( + "map.addTiles", + &json!({ + "url": format!("https://example.invalid/{}/{{z}}/{{x}}/{{y}}.png", name.replace(' ', "-")), + "name": name, "bounds": bounds, "overlay": true + }), + ) + .unwrap(); + } + for (go, lon) in [(json!(true), 15.0), (json!(1), -15.0)] { + s.run("map.search", &json!({"query": "Coverage", "go": go})).unwrap(); + let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); + assert!((c.x - lon).abs() < 1e-6 && c.y.abs() < 1e-6, "{c:?}"); + assert!(s.map().camera.res < 10_000.0, "{:?}", s.map().camera); + } + } + #[test] fn going_to_a_feature_selects_it() { let mut s = Session::new(crate::sample::world_project()); diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs index 1cad96931..dd5ae6a3e 100644 --- a/crates/engine/src/cmd/tiles.rs +++ b/crates/engine/src/cmd/tiles.rs @@ -284,10 +284,16 @@ fn whole_world(e: &rustgis_geom::Extent) -> bool { fn zoom_to(s: &mut Session, p: &Value) -> CmdResult { let i = find(s, p)?.ok_or("give the tile layer's name or index")?; + let e = zoom_to_index(s, i)?; + Ok(json!({"bounds": [e.min_x, e.min_y, e.max_x, e.max_y]})) +} + +/// Zooms to the same bounds from the Layers pane or a search hit. +pub(super) fn zoom_to_index(s: &mut Session, i: usize) -> Result { let t = s.map().tiles.get(i).ok_or("no tile layer at that index")?; let e = extent_of(t)?.ok_or_else(|| format!("{}: still being read; try again in a moment", t.name))?; s.zoom_to(&e); - Ok(json!({"bounds": [e.min_x, e.min_y, e.max_x, e.max_y]})) + Ok(e) } /// The date a dated tile layer starts at: the time slider's step. Its `start` (and `end`, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5306b6a56..c3cf226d6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7075,6 +7075,12 @@ fn pmtiles_archives_frame_the_map_on_their_bounds() { assert_eq!(r["bounds"][0].as_f64().map(|w| (w + 24.5).abs() < 1e-6), Some(true), "{r}"); let c = center(&s); assert!((c.x + 19.0).abs() < 0.5, "{c:?}"); + // Search finds archives too and uses the header, even without service metadata bounds. + s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); + let r = s.run("map.search", &json!({"query": "iceland", "go": true})).unwrap(); + assert_eq!(r["hits"][0]["kind"], "tile"); + let c = center(&s); + assert!((c.x + 19.0).abs() < 0.5 && (c.y - 65.0).abs() < 1.5, "{c:?}"); s.run("map.addTiles", &json!({"url": "https://tile.example.com/{z}/{x}/{y}.png", "name": "xyz"})).unwrap(); assert!(s.run("map.zoomToTiles", &json!({"name": "xyz"})).is_err()); assert!(s.run("map.zoomToTiles", &json!({})).is_err()); diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index 184da673f..333eb8eb9 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -332,6 +332,9 @@ fn describe(st: &State, row: &Row) -> (String, &'static str, String, Option (tl!("Layers").to_string(), "contents", name.clone(), None), + Some(Hit::Tile { name, overlay, .. }) => { + (tl!("Layers").to_string(), "basemap", if *overlay { format!("{} · {}", name, tl!("overlay")) } else { name.clone() }, None) + } Some(Hit::Bookmark { name }) => (tl!("Bookmarks").to_string(), "bookmark", name.clone(), None), Some(Hit::Feature { layer, layer_name, field, value, name, .. }) => { let partial = st.results.partial.contains(layer); @@ -434,7 +437,7 @@ fn pick(app: &mut RustGisApp, row: Row) { let label = search::format_lat_lon(*lat, *lon); (label.clone(), Some((Coord::new(*lon, *lat), label))) } - Hit::Layer { name, .. } | Hit::Bookmark { name } => (name.clone(), None), + Hit::Layer { name, .. } | Hit::Tile { name, .. } | Hit::Bookmark { name } => (name.clone(), None), Hit::Feature { value, name, .. } => (name.clone().unwrap_or_else(|| value.clone()), None), }; settle(app, text, marker); From b19cb0a3f3c2146c8cb695e1c5ff6467c827b5c9 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 18:46:00 +0200 Subject: [PATCH 2/6] fix: resolve retained tile search hits safely --- crates/engine/src/cmd/search.rs | 75 ++++++++++++++++++++++++++++++--- crates/engine/src/cmd/tiles.rs | 7 +++ crates/ui-egui/src/searchbox.rs | 14 +++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index b216a2f0b..c75d0236e 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -31,6 +31,44 @@ pub const LIMIT: usize = 30; /// Most characters of a query, and of a value shown. const MAX_CHARS: usize = 200; +/// Identifies a tile layer across stack reordering without relying on catalog metadata. +#[derive(Clone, Debug, PartialEq)] +pub struct TileIdentity { + name: String, + kind: rustgis_map::tiles::TileKind, + url: String, + overlay: bool, + bounds: Option<[f64; 4]>, + layers: Vec, + style_layers: String, +} + +impl TileIdentity { + fn of(tile: &rustgis_map::tiles::TileLayer) -> Self { + Self { + name: tile.name.clone(), + kind: tile.kind, + url: tile.time_url.as_ref().unwrap_or(&tile.url).clone(), + overlay: tile.overlay, + bounds: tile.bounds, + layers: tile.layers.clone(), + style_layers: tile.style_layers.clone(), + } + } +} + +impl TileIdentity { + pub(crate) fn matches(&self, tile: &rustgis_map::tiles::TileLayer) -> bool { + self.name == tile.name + && self.kind == tile.kind + && self.url == *tile.time_url.as_ref().unwrap_or(&tile.url) + && self.overlay == tile.overlay + && self.bounds == tile.bounds + && self.layers == tile.layers + && self.style_layers == tile.style_layers + } +} + /// What a search found. #[derive(Clone, Debug, PartialEq)] pub enum Hit { @@ -38,8 +76,8 @@ pub enum Hit { Coordinate { lon: f64, lat: f64 }, /// A layer whose name matches. Layer { id: u32, name: String }, - /// A web tile layer whose name matches; `index` is its position in the map's tile stack. - Tile { index: usize, name: String, overlay: bool }, + /// A web tile layer whose name matches; `index` is its current stack position. + Tile { index: usize, identity: TileIdentity, name: String, overlay: bool }, /// A bookmark whose name matches. Bookmark { name: String }, /// A feature with a matching value. @@ -53,7 +91,7 @@ impl Hit { match self { Hit::Coordinate { lon, lat } => json!({"kind": "coordinate", "lon": lon, "lat": lat, "label": format_lat_lon(*lat, *lon)}), Hit::Layer { id, name } => json!({"kind": "layer", "layer": id, "label": name}), - Hit::Tile { index, name, overlay } => json!({"kind": "tile", "index": index, "label": name, "overlay": overlay}), + Hit::Tile { index, name, overlay, .. } => json!({"kind": "tile", "index": index, "label": name, "overlay": overlay}), Hit::Bookmark { name } => json!({"kind": "bookmark", "label": name}), Hit::Feature { layer, layer_name, row, field, value, name } => { json!({"kind": "feature", "layer": layer, "layerName": layer_name, "row": row, "field": field, "label": value, "name": name}) @@ -88,7 +126,7 @@ pub fn search(s: &Session, query: &str, limit: usize) -> Results { } for (index, t) in map.tiles.iter().enumerate() { if contains_ci(&t.name, &needle) { - out.hits.push(Hit::Tile { index, name: t.name.clone(), overlay: t.overlay }); + out.hits.push(Hit::Tile { index, identity: TileIdentity::of(t), name: t.name.clone(), overlay: t.overlay }); } } for b in &map.bookmarks { @@ -215,7 +253,11 @@ pub fn go_to(s: &mut Session, hit: &Hit) -> CmdResult { } Ok(Value::Null) } - Hit::Tile { index, .. } => super::tiles::zoom_to_index(s, *index).map(|_| Value::Null), + Hit::Tile { identity, name, .. } => { + let index = + super::tiles::index_of_identity(s, identity).ok_or_else(|| format!("tile layer '{name}' has changed; search again"))?; + super::tiles::zoom_to_index(s, index).map(|_| Value::Null) + } Hit::Bookmark { name } => { let cam = s.map().bookmarks.iter().find(|b| b.name == *name).map(|b| b.camera).ok_or_else(|| format!("no bookmark '{name}'"))?; @@ -567,6 +609,29 @@ mod tests { } } + #[test] + fn retained_tile_hits_follow_reordering_and_reject_removed_layers() { + let mut s = Session::new(crate::Project::default()); + for (name, bounds) in [("Coverage west", [-20.0, -5.0, -10.0, 5.0]), ("Coverage east", [10.0, -5.0, 20.0, 5.0])] { + s.run( + "map.addTiles", + &json!({ + "url": format!("https://example.invalid/{}/{{z}}/{{x}}/{{y}}.png", name.replace(' ', "-")), + "name": name, "bounds": bounds, "overlay": true + }), + ) + .unwrap(); + } + let hit = search(&s, "Coverage east", LIMIT).hits.remove(0); + s.run("map.setTiles", &json!({"name": "Coverage east", "move": "bottom"})).unwrap(); + go_to(&mut s, &hit).unwrap(); + let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); + assert!((c.x - 15.0).abs() < 1e-6, "{c:?}"); + s.run("map.removeTiles", &json!({"name": "Coverage east"})).unwrap(); + let e = go_to(&mut s, &hit).unwrap_err(); + assert!(e.contains("has changed; search again"), "{e}"); + } + #[test] fn going_to_a_feature_selects_it() { let mut s = Session::new(crate::sample::world_project()); diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs index dd5ae6a3e..31e745ddf 100644 --- a/crates/engine/src/cmd/tiles.rs +++ b/crates/engine/src/cmd/tiles.rs @@ -296,6 +296,13 @@ pub(super) fn zoom_to_index(s: &mut Session, i: usize) -> Result Option { + let mut found = s.map().tiles.iter().enumerate().filter(|(_, tile)| identity.matches(tile)).map(|(i, _)| i); + let index = found.next()?; + found.next().is_none().then_some(index) +} + /// The date a dated tile layer starts at: the time slider's step. Its `start` (and `end`, /// `unit`, `interval` or `dates`) set the slider up, turning it on when it is off. Should a later /// check of the layer fail, the session restores the project, the slider with it (tested). diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index 333eb8eb9..1165134bb 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -428,7 +428,19 @@ fn pick(app: &mut RustGisApp, row: Row) { // The query the list was found for (the box may have been typed in since), with the // list's limit, so the index is the row picked. let query = st.searched.clone(); - if let Err(e) = app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": i})) { + let go = if let Hit::Tile { identity, .. } = &hit { + search::search(&app.session, &query, search::LIMIT) + .hits + .iter() + .position(|current| matches!(current, Hit::Tile { identity: found, .. } if found == identity)) + } else { + Some(i) + }; + let Some(go) = go else { + app.session.notify(tl!("Tile layer has changed; search again"), true); + return; + }; + if let Err(e) = app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": go})) { app.session.notify(e, true); return; } From c2d3c25d84523214213f13ec38d993a8b9876844 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 20:15:58 +0200 Subject: [PATCH 3/6] fix: resolve a retained tile search hit against the live tile stack Key `TileIdentity` on the source template rather than on the URL the time slider rewrites, so a dated layer's hit stays valid as the slider moves, and merge the two `impl` blocks into one. The search box resolved a tile hit by re-running the limited search and comparing positions, so a `map.setTiles` reorder could navigate to another layer. Resolve the retained identity to its index in the live stack and run `map.zoomToTiles` with it; a layer that has gone still reports the stale hit. --- crates/engine/src/cmd/search.rs | 88 ++++++++++++++++++++++++++++++--- crates/engine/src/cmd/tiles.rs | 2 +- crates/ui-egui/src/searchbox.rs | 21 +++----- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index c75d0236e..116a2baf7 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -31,12 +31,16 @@ pub const LIMIT: usize = 30; /// Most characters of a query, and of a value shown. const MAX_CHARS: usize = 200; -/// Identifies a tile layer across stack reordering without relying on catalog metadata. +/// Identifies a tile layer across stack reordering without relying on catalog metadata. It uses +/// temporal source metadata rather than the current URL, which changes with the time slider. #[derive(Clone, Debug, PartialEq)] pub struct TileIdentity { name: String, kind: rustgis_map::tiles::TileKind, - url: String, + /// Static URL, present only for non-dated sources. + url: Option, + time_url: Option, + has_time_urls: bool, overlay: bool, bounds: Option<[f64; 4]>, layers: Vec, @@ -48,20 +52,22 @@ impl TileIdentity { Self { name: tile.name.clone(), kind: tile.kind, - url: tile.time_url.as_ref().unwrap_or(&tile.url).clone(), + url: (tile.time_url.is_none() && tile.time_urls.is_empty()).then(|| tile.url.clone()), + time_url: tile.time_url.clone(), + has_time_urls: !tile.time_urls.is_empty(), overlay: tile.overlay, bounds: tile.bounds, layers: tile.layers.clone(), style_layers: tile.style_layers.clone(), } } -} -impl TileIdentity { pub(crate) fn matches(&self, tile: &rustgis_map::tiles::TileLayer) -> bool { self.name == tile.name && self.kind == tile.kind - && self.url == *tile.time_url.as_ref().unwrap_or(&tile.url) + && self.url.as_ref().is_none_or(|url| url == &tile.url) + && self.time_url == tile.time_url + && self.has_time_urls == !tile.time_urls.is_empty() && self.overlay == tile.overlay && self.bounds == tile.bounds && self.layers == tile.layers @@ -632,6 +638,76 @@ mod tests { assert!(e.contains("has changed; search again"), "{e}"); } + #[test] + fn retained_tile_hit_resolves_past_the_limited_search_results() { + let mut s = Session::new(crate::Project::default()); + for i in 0..rustgis_map::tiles::MAX_TILE_LAYERS - 1 { + s.run( + "map.addTiles", + &json!({ + "url": format!("https://example.invalid/filler-{i}/{{z}}/{{x}}/{{y}}.png"), + "name": format!("Coverage filler {i}"), "overlay": true, + "bounds": [-5.0, -5.0, 5.0, 5.0] + }), + ) + .unwrap(); + } + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/target/{z}/{x}/{y}.png", + "name": "Coverage target", "overlay": true, + "bounds": [10.0, -5.0, 20.0, 5.0] + }), + ) + .unwrap(); + let hit = search(&s, "Coverage", LIMIT).hits.remove(0); + s.run("map.setTiles", &json!({"name": "Coverage target", "move": "bottom"})).unwrap(); + let Hit::Tile { identity, .. } = &hit else { + panic!("expected tile hit"); + }; + assert_eq!(super::super::tiles::index_of_identity(&s, identity), Some(rustgis_map::tiles::MAX_TILE_LAYERS - 1)); + assert!(!search(&s, "Coverage", LIMIT).hits.iter().any(|h| matches!(h, Hit::Tile { name, .. } if name == "Coverage target"))); + go_to(&mut s, &hit).unwrap(); + let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); + assert!((c.x - 15.0).abs() < 1e-6, "{c:?}"); + } + + #[test] + fn retained_dated_tile_hits_survive_time_url_changes() { + let mut s = Session::new(crate::Project::default()); + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/template/{z}/{x}/{y}.png", + "name": "Dated template", "bounds": [10.0, -5.0, 20.0, 5.0] + }), + ) + .unwrap(); + s.map_mut().tiles[0].time_url = Some("https://example.invalid/{date:YYYY}/{z}/{x}/{y}.png".into()); + s.map_mut().tiles[0].url = "https://example.invalid/2024/{z}/{x}/{y}.png".into(); + let template_hit = search(&s, "Dated template", LIMIT).hits.remove(0); + s.map_mut().tiles[0].url = "https://example.invalid/2025/{z}/{x}/{y}.png".into(); + go_to(&mut s, &template_hit).unwrap(); + + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/cube/2024/{z}/{x}/{y}.png", + "name": "Dated cube", "bounds": [10.0, -5.0, 20.0, 5.0] + }), + ) + .unwrap(); + let cube = s.map_mut().tiles.get_mut(0).unwrap(); + cube.time_urls = vec![ + (2024.0, "https://example.invalid/cube/2024/{z}/{x}/{y}.png".into()), + (2025.0, "https://example.invalid/cube/2025/{z}/{x}/{y}.png".into()), + ]; + let cube_hit = search(&s, "Dated cube", LIMIT).hits.remove(0); + s.map_mut().tiles[0].url = "https://example.invalid/cube/2025/{z}/{x}/{y}.png".into(); + go_to(&mut s, &cube_hit).unwrap(); + } + #[test] fn going_to_a_feature_selects_it() { let mut s = Session::new(crate::sample::world_project()); diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs index 31e745ddf..851af3d4b 100644 --- a/crates/engine/src/cmd/tiles.rs +++ b/crates/engine/src/cmd/tiles.rs @@ -297,7 +297,7 @@ pub(super) fn zoom_to_index(s: &mut Session, i: usize) -> Result Option { +pub fn index_of_identity(s: &Session, identity: &super::search::TileIdentity) -> Option { let mut found = s.map().tiles.iter().enumerate().filter(|(_, tile)| identity.matches(tile)).map(|(i, _)| i); let index = found.next()?; found.next().is_none().then_some(index) diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index 1165134bb..c8f2e99e8 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -425,22 +425,17 @@ fn pick(app: &mut RustGisApp, row: Row) { } Row::Hit(i) => { let Some(hit) = st.results.hits.get(i).cloned() else { return }; - // The query the list was found for (the box may have been typed in since), with the - // list's limit, so the index is the row picked. let query = st.searched.clone(); - let go = if let Hit::Tile { identity, .. } = &hit { - search::search(&app.session, &query, search::LIMIT) - .hits - .iter() - .position(|current| matches!(current, Hit::Tile { identity: found, .. } if found == identity)) + let result = if let Hit::Tile { identity, .. } = &hit { + let Some(index) = rustgis_engine::cmd::tiles::index_of_identity(&app.session, identity) else { + app.session.notify(tl!("Tile layer has changed; search again"), true); + return; + }; + app.run("map.zoomToTiles", json!({"index": index})) } else { - Some(i) + app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": i})) }; - let Some(go) = go else { - app.session.notify(tl!("Tile layer has changed; search again"), true); - return; - }; - if let Err(e) = app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": go})) { + if let Err(e) = result { app.session.notify(e, true); return; } From 25f869e2bab6f59bf9faf416f1a56973b71ed9ae Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 20:43:38 +0200 Subject: [PATCH 4/6] fix: tell retained tile hits apart by their dated sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TileIdentity` kept a `has_time_urls` flag, so two dated layers sharing a name and bounds matched each other and neither hit resolved. It now compares a digest of the per-date addresses — a dated cube lists thousands, too many to keep a copy of — so a hit names the layer it came from. `index_of_identity` rejected any hit matching more than one layer and reported the layer as changed; layers that are wholly alike (the same source added twice) always match each other. It resolves to the match nearest the stack position the hit recorded instead, so the row picked is the layer picked. --- crates/engine/src/cmd/search.rs | 94 +++++++++++++++++++++++++++++---- crates/engine/src/cmd/tiles.rs | 10 ++-- crates/ui-egui/src/searchbox.rs | 6 +-- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 116a2baf7..9eacba4e0 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -2,6 +2,9 @@ //! bookmarks and the values in its layers' attribute tables. Places (geocoding) are the UI's, //! asked for on Enter: the public servers don't allow searching as one types. +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + use rustgis_data::Value as Attr; use rustgis_geom::{Coord, Extent}; use serde_json::{Value, json}; @@ -40,7 +43,10 @@ pub struct TileIdentity { /// Static URL, present only for non-dated sources. url: Option, time_url: Option, - has_time_urls: bool, + /// Digest of the per-date addresses, which stay put while the time slider moves. A dated cube + /// lists thousands of them, too many to keep a copy of (so a digest, compared within a + /// session, the only place an identity is used). + dated_sources: u64, overlay: bool, bounds: Option<[f64; 4]>, layers: Vec, @@ -54,7 +60,7 @@ impl TileIdentity { kind: tile.kind, url: (tile.time_url.is_none() && tile.time_urls.is_empty()).then(|| tile.url.clone()), time_url: tile.time_url.clone(), - has_time_urls: !tile.time_urls.is_empty(), + dated_sources: digest(&tile.time_urls), overlay: tile.overlay, bounds: tile.bounds, layers: tile.layers.clone(), @@ -67,7 +73,7 @@ impl TileIdentity { && self.kind == tile.kind && self.url.as_ref().is_none_or(|url| url == &tile.url) && self.time_url == tile.time_url - && self.has_time_urls == !tile.time_urls.is_empty() + && self.dated_sources == digest(&tile.time_urls) && self.overlay == tile.overlay && self.bounds == tile.bounds && self.layers == tile.layers @@ -75,6 +81,18 @@ impl TileIdentity { } } +/// Digest of a layer's per-date addresses and dates, which stand in for the list itself: two +/// dated layers that share a name are told apart by their sources, not just by having some. +fn digest(time_urls: &[(f64, String)]) -> u64 { + let mut h = DefaultHasher::new(); + time_urls.len().hash(&mut h); + for (date, url) in time_urls { + date.to_bits().hash(&mut h); + url.hash(&mut h); + } + h.finish() +} + /// What a search found. #[derive(Clone, Debug, PartialEq)] pub enum Hit { @@ -82,7 +100,8 @@ pub enum Hit { Coordinate { lon: f64, lat: f64 }, /// A layer whose name matches. Layer { id: u32, name: String }, - /// A web tile layer whose name matches; `index` is its current stack position. + /// A web tile layer whose name matches; `index` is the stack position it held when the search + /// ran, which tells alike layers apart when a hit is picked. Tile { index: usize, identity: TileIdentity, name: String, overlay: bool }, /// A bookmark whose name matches. Bookmark { name: String }, @@ -259,10 +278,10 @@ pub fn go_to(s: &mut Session, hit: &Hit) -> CmdResult { } Ok(Value::Null) } - Hit::Tile { identity, name, .. } => { - let index = - super::tiles::index_of_identity(s, identity).ok_or_else(|| format!("tile layer '{name}' has changed; search again"))?; - super::tiles::zoom_to_index(s, index).map(|_| Value::Null) + Hit::Tile { index, identity, name, .. } => { + let at = super::tiles::index_of_identity(s, identity, *index) + .ok_or_else(|| format!("tile layer '{name}' has changed; search again"))?; + super::tiles::zoom_to_index(s, at).map(|_| Value::Null) } Hit::Bookmark { name } => { let cam = @@ -663,10 +682,10 @@ mod tests { .unwrap(); let hit = search(&s, "Coverage", LIMIT).hits.remove(0); s.run("map.setTiles", &json!({"name": "Coverage target", "move": "bottom"})).unwrap(); - let Hit::Tile { identity, .. } = &hit else { + let Hit::Tile { index, identity, .. } = &hit else { panic!("expected tile hit"); }; - assert_eq!(super::super::tiles::index_of_identity(&s, identity), Some(rustgis_map::tiles::MAX_TILE_LAYERS - 1)); + assert_eq!(super::super::tiles::index_of_identity(&s, identity, *index), Some(rustgis_map::tiles::MAX_TILE_LAYERS - 1)); assert!(!search(&s, "Coverage", LIMIT).hits.iter().any(|h| matches!(h, Hit::Tile { name, .. } if name == "Coverage target"))); go_to(&mut s, &hit).unwrap(); let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); @@ -708,6 +727,61 @@ mod tests { go_to(&mut s, &cube_hit).unwrap(); } + #[test] + fn alike_tile_hits_still_resolve() { + let mut s = Session::new(crate::Project::default()); + s.run( + "map.addTiles", + &json!({ + "url": "https://example.invalid/twin/{z}/{x}/{y}.png", + "name": "Twin", "bounds": [10.0, -5.0, 20.0, 5.0] + }), + ) + .unwrap(); + // The same layer twice; `map.addTiles` would replace it, so the copy goes in by hand. + let twin = s.map().tiles[0].clone(); + s.map_mut().tiles.insert(0, twin); + let hits = search(&s, "Twin", LIMIT).hits; + assert_eq!(hits.len(), 2, "{hits:?}"); + for hit in &hits { + let Hit::Tile { index, identity, .. } = hit else { + panic!("expected a tile hit, got {hit:?}"); + }; + // Alike layers are told apart by where the row was listed, not left to go stale. + assert_eq!(super::super::tiles::index_of_identity(&s, identity, *index), Some(*index)); + go_to(&mut s, hit).unwrap(); + } + let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); + assert!((c.x - 15.0).abs() < 1e-6, "{c:?}"); + } + + #[test] + fn tile_hits_tell_dated_sources_apart() { + let mut s = Session::new(crate::Project::default()); + for i in 0..2 { + s.run( + "map.addTiles", + &json!({ + "url": format!("https://example.invalid/cube-{i}/2024/{{z}}/{{x}}/{{y}}.png"), + "name": "Coverage", "bounds": [10.0, -5.0, 20.0, 5.0] + }), + ) + .unwrap(); + // A dated cube: the current address is the slider's, the list is the source. + let t = s.map_mut().tiles.get_mut(0).unwrap(); + t.time_urls = vec![(2024.0, format!("https://example.invalid/cube-{i}/{{z}}/{{x}}/{{y}}.png"))]; + } + let hits = search(&s, "Coverage", LIMIT).hits; + assert_eq!(hits.len(), 2, "{hits:?}"); + // Each identity names its own layer, even asked for from the other's place in the stack. + for hit in &hits { + let Hit::Tile { index, identity, .. } = hit else { + panic!("expected a tile hit, got {hit:?}"); + }; + assert_eq!(super::super::tiles::index_of_identity(&s, identity, 1 - *index), Some(*index), "{hit:?}"); + } + } + #[test] fn going_to_a_feature_selects_it() { let mut s = Session::new(crate::sample::world_project()); diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs index 851af3d4b..de52a085e 100644 --- a/crates/engine/src/cmd/tiles.rs +++ b/crates/engine/src/cmd/tiles.rs @@ -296,11 +296,11 @@ pub(super) fn zoom_to_index(s: &mut Session, i: usize) -> Result Option { - let mut found = s.map().tiles.iter().enumerate().filter(|(_, tile)| identity.matches(tile)).map(|(i, _)| i); - let index = found.next()?; - found.next().is_none().then_some(index) +/// The tile layer a retained search hit names: the match nearest the stack position the hit was +/// listed at, so layers that are wholly alike (the same one added twice) still resolve. Zero +/// matches mean the layer has gone. +pub fn index_of_identity(s: &Session, identity: &super::search::TileIdentity, near: usize) -> Option { + s.map().tiles.iter().enumerate().filter(|(_, tile)| identity.matches(tile)).map(|(i, _)| i).min_by_key(|i| i.abs_diff(near)) } /// The date a dated tile layer starts at: the time slider's step. Its `start` (and `end`, diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index c8f2e99e8..15056d8de 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -426,12 +426,12 @@ fn pick(app: &mut RustGisApp, row: Row) { Row::Hit(i) => { let Some(hit) = st.results.hits.get(i).cloned() else { return }; let query = st.searched.clone(); - let result = if let Hit::Tile { identity, .. } = &hit { - let Some(index) = rustgis_engine::cmd::tiles::index_of_identity(&app.session, identity) else { + let result = if let Hit::Tile { index, identity, .. } = &hit { + let Some(at) = rustgis_engine::cmd::tiles::index_of_identity(&app.session, identity, *index) else { app.session.notify(tl!("Tile layer has changed; search again"), true); return; }; - app.run("map.zoomToTiles", json!({"index": index})) + app.run("map.zoomToTiles", json!({"index": at})) } else { app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": i})) }; From a25e35a297e5b5fccac20d8e0423e95d88045e35 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 21:01:53 +0200 Subject: [PATCH 5/6] test: say why an alike tile layer is in the stack `map.addTiles` replaces a layer added twice, but a project load repairs and truncates the tile stack without deduplicating it, so two alike layers do reach a session. --- crates/engine/src/cmd/search.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 9eacba4e0..9c5ec3b90 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -738,7 +738,8 @@ mod tests { }), ) .unwrap(); - // The same layer twice; `map.addTiles` would replace it, so the copy goes in by hand. + // The same layer twice: `map.addTiles` replaces it, but a project load only repairs each + // layer and truncates the stack, duplicates and all, so two alike layers do occur. let twin = s.map().tiles[0].clone(); s.map_mut().tiles.insert(0, twin); let hits = search(&s, "Twin", LIMIT).hits; From 95a4ab91a4ce3db440eb92061a6b89e163df849d Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Sat, 24 Oct 2026 21:17:53 +0200 Subject: [PATCH 6/6] refactor: clone the searched query only where the search reruns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a tile hit resolves the layer by identity and never runs `map.search`, so the clone belongs in the branch that does. Its comment — why the searched query with the list's limit is passed, rather than the box's text with its default — comes back with it. --- crates/ui-egui/src/searchbox.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index 15056d8de..9661a39e3 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -425,7 +425,6 @@ fn pick(app: &mut RustGisApp, row: Row) { } Row::Hit(i) => { let Some(hit) = st.results.hits.get(i).cloned() else { return }; - let query = st.searched.clone(); let result = if let Hit::Tile { index, identity, .. } = &hit { let Some(at) = rustgis_engine::cmd::tiles::index_of_identity(&app.session, identity, *index) else { app.session.notify(tl!("Tile layer has changed; search again"), true); @@ -433,6 +432,9 @@ fn pick(app: &mut RustGisApp, row: Row) { }; app.run("map.zoomToTiles", json!({"index": at})) } else { + // The query the list was found for (the box may have been typed in since), with the + // list's limit, so the index picked is the row picked. + let query = st.searched.clone(); app.run("map.search", json!({"query": query, "limit": search::LIMIT, "go": i})) }; if let Err(e) = result {