From cdf52ffa24435cd3edb1cf08e4f6c7a95e9da95a Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:18:36 -0600 Subject: [PATCH 01/11] feat(navigate): By Coordinates takes DMS, UTM and MGRS, as the search box does --- crates/engine/src/cmd/navigate.rs | 55 +++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 49 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 20 ++++++----- crates/ui-egui/tests/smoke.rs | 26 +++++++++++++++ docs/user-guide/maps.md | 7 ++-- 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index cf1269250e..53924583b6 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -42,7 +42,7 @@ pub fn commands() -> Vec { .params(r#"{"extent": [minLon, minLat, maxLon, maxLat]}"#), C::new("map.goToXY", "By Coordinates…", "Home › Navigate › Locate", go_to_xy).roles(&[]).effects(crate::policy::Effects::NONE) .view() - .params(r#"{"lon" | "x": number, "lat" | "y": number, "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), + .params(r#"{"lon" | "x": number, "lat" | "y": number | "position": text as the search box reads it (40.7128, -74.006; 40°42'46"N 74°0'21"W; 18north 583959 4507351; 18TWL8395907350), "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), C::new("map.goToLink", "Go To Map Link", "Home › Navigate", go_to_link).roles(&[]).effects(crate::policy::Effects::NONE.with(crate::policy::Effect::Files)) .view() .params(r#"{"url": a map link ending in #zoom/lat/lon[/bearing[/pitch]] (as MapLibre writes them; also #map=…), or just zoom/lat/lon[/bearing[/pitch]]}"#), @@ -329,9 +329,58 @@ fn zoom(s: &mut Session, factor: f64) -> CmdResult { Ok(Value::Null) } +/// The most characters of a typed position read; no form the search box reads is longer. +const MAX_POSITION: usize = 200; + +/// A place typed as the search box reads one: latitude and longitude in decimal degrees, degrees +/// and minutes or degrees, minutes and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'21"W`), or +/// on the Earth's map a UTM position or an MGRS reference (`18north 583959 4507351`, +/// `18TWL8395907350`, the center of the square it names). A reference that names no place says +/// why, as the search box does. +pub(crate) fn read_position(map: &Map, text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err("the position is empty".into()); + } + if text.chars().count() > MAX_POSITION { + return Err(format!("the position is longer than {MAX_POSITION} characters")); + } + if let Some(c) = super::search::parse_lat_lon(text) { + return Ok(c); + } + let usage = "write a position as latitude and longitude (40.7128, -74.006 or 40°42'46\"N 74°0'21\"W), UTM (18north 583959 4507351) or MGRS (18TWL8395907350)"; + if !map.body().is_earth() { + return Err( + "write a position as latitude and longitude (40.7128, -74.006 or 40°42'46\"N 74°0'21\"W); UTM and MGRS are the Earth's grids" + .into(), + ); + } + match super::search::read_grid_reference(text) { + Ok(c) => Ok(c), + Err(rustgis_geom::GridError::Syntax) => Err(usage.into()), + Err(e) => Err(e.to_string()), + } +} + pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { - let lon = f(p, "lon").or_else(|| f(p, "x")).ok_or("lon is required")?; - let lat = f(p, "lat").or_else(|| f(p, "y")).ok_or("lat is required")?; + let position = match p.get("position") { + None | Some(Value::Null) => None, + Some(Value::String(t)) => Some(t.as_str()), + Some(_) => return Err("position must be text, such as \"40.7128, -74.006\" or \"18TWL8395907350\"".into()), + }; + let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null)); + let (lon, lat) = if let Some(text) = position { + if ["lon", "lat", "x", "y"].iter().any(|k| given(k)) { + return Err("give a position or lon and lat, not both".into()); + } + let c = read_position(s.map(), text)?; + (c.x, c.y) + } else { + ( + f(p, "lon").or_else(|| f(p, "x")).ok_or("lon is required (or a position)")?, + f(p, "lat").or_else(|| f(p, "y")).ok_or("lat is required (or a position)")?, + ) + }; if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) { return Err("longitude must be within ±180 and latitude within ±90".into()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6721a4ca25..71ff680cfb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45366,3 +45366,52 @@ fn native_date_source_repair_and_reload_use_finished_candidates() { } std::fs::remove_dir_all(dir).unwrap(); } + +/// `map.goToXY` takes a `position` written as the search box reads one (#1042, discussion +/// #1157): decimal degrees, degrees, minutes and seconds, UTM and MGRS; one that names no place +/// says why, and the Earth's grids name no place on another body. +#[test] +fn go_to_xy_reads_a_position_as_the_search_box_does() { + let mut s = world(); + let at = |s: &Session| { + let c = s.map().center_lonlat(); + ((c.y * 1e3).round() / 1e3, (c.x * 1e3).round() / 1e3) + }; + // The forms the dialog's hint shows, all New York (GeographicLib's `GeoConvert` for the grids). + assert_eq!(rustgis_geom::format_utm(rustgis_geom::Coord::new(-74.006, 40.7128)).as_deref(), Some("18north 583959 4507351")); + assert_eq!( + rustgis_geom::to_mgrs(rustgis_geom::Coord::new(-74.006, 40.7128), 5).map(|m| m.compact()).as_deref(), + Some("18TWL8395907350") + ); + for q in [ + "40.7128, -74.006", + "40°42'46\"N 74°0'21\"W", + "40°42′46″N 74°0′21″W", + "18north 583959 4507351", + "18T WL 83959 07350", + "18TWL8395907350", + ] { + s.run("map.goToXY", &json!({"lon": 0, "lat": 0})).unwrap(); + s.run("map.goToXY", &json!({"position": q, "scale": 50_000})).unwrap(); + assert_eq!(at(&s), (40.713, -74.006), "{q}"); + } + assert!((s.map().scale() - 50_000.0).abs() < 1.0); + // Numbers still go as before. + s.run("map.goToXY", &json!({"lon": 10, "lat": 50})).unwrap(); + assert_eq!(at(&s), (50.0, 10.0)); + // Refused, with the reason the search box gives, and the map stays where it was. + let refused = |s: &mut Session, p: Value| s.run("map.goToXY", &p).unwrap_err(); + assert!(refused(&mut s, json!({"position": "33UZZ1234512345"})).contains("100 km square")); + assert!(refused(&mut s, json!({"position": "Paris"})).contains("write a position as")); + assert!(refused(&mut s, json!({"position": " "})).contains("empty")); + assert!(refused(&mut s, json!({"position": "4".repeat(10_000)})).contains("longer than")); + assert!(refused(&mut s, json!({"position": 40.7})).contains("must be text")); + assert!(refused(&mut s, json!({"position": "40.7, -74", "lon": 1})).contains("not both")); + assert!(refused(&mut s, json!({})).contains("lon is required")); + assert_eq!(at(&s), (50.0, 10.0)); + // On another body degrees still name a place; the Earth's grids don't. + s.run("map.body", &json!({"name": "Mars"})).unwrap(); + s.run("map.goToXY", &json!({"position": "18.4, 77.5"})).unwrap(); + assert_eq!(at(&s), (18.4, 77.5)); + assert!(refused(&mut s, json!({"position": "18TWL8395907350"})).contains("Earth's grids")); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c3b6e65312..b07b5c6359 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -154,9 +154,9 @@ impl Dialogs { self.a = if is_code { self.text.trim_start_matches(|c: char| !c.is_ascii_digit()).to_string() } else { String::new() }; } "goToXY" => { + // The map's center as the search box writes a place: `lat, lon`. let c = s.map().center_lonlat(); - self.a = format!("{:.4}", c.x); - self.b = format!("{:.4}", c.y); + self.a = rustgis_engine::cmd::search::format_lat_lon((c.y * 1e4).round() / 1e4, (c.x * 1e4).round() / 1e4); } "bookmark" => self.text = rustgis_engine::cmd::navigate::new_bookmark_name(s.map()), "camera" => { @@ -327,18 +327,20 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "selectByAttributes" => close = select_by_attributes(app, ui, &t), "goToXY" => { egui::Grid::new("xy").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let input_label = ui.label(tl!("Longitude (X)")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(200.0)).labelled_by(input_label.id); - ui.end_row(); - let input_label = ui.label(tl!("Latitude (Y)")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).desired_width(200.0)).labelled_by(input_label.id); + let input_label = ui.label(tl!("Position")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); let input_label = ui.label(tl!("Scale 1:")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(200.0)).labelled_by(input_label.id); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); }); + // Below the boxes, wrapped to the dialog's width rather than widening it. + ui.label( + egui::RichText::new(tl!("Latitude and longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′21″W · 18north 583959 4507351 · 18TWL8395907350")) + .weak(), + ); close = buttons(app, ui, "Go", |app| { - let mut p = json!({"lon": app.dialogs.a.trim(), "lat": app.dialogs.b.trim()}); + let mut p = json!({"position": app.dialogs.a.trim()}); if let Ok(s) = app.dialogs.c.trim().replace(',', "").parse::() { p["scale"] = json!(s); } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index f8da5b5cb6..c0327d69af 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4426,3 +4426,29 @@ fn zonal_statistics_shows_available_band_range() { assert!(h.query_by_label("Band must be a positive integer, list of bands, or 'all'.").is_some()); assert!(h.get_by_label("Calculate Statistics").accesskit_node().is_disabled()); } + +/// By Coordinates… opens on the map's center as `lat, lon`, and Go reads its one box as the +/// search box does: MGRS here; a reference that names no place keeps the dialog open with the +/// reason. +#[test] +fn by_coordinates_reads_one_position_box() { + let mut h = harness(); + h.state_mut().run("map.goToXY", json!({"lon": 10, "lat": 50})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "goToXY"})).unwrap(); + h.step(); + assert_eq!(h.state().dialogs.open, Some("goToXY")); + assert_eq!(h.state().dialogs.a, "50, 10"); + h.state_mut().dialogs.a = "33UZZ1234512345".into(); + h.step(); + h.get_by_label("Go").click(); + h.step(); + assert_eq!(h.state().dialogs.open, Some("goToXY")); + assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("100 km square")), "{:?}", h.state().dialogs.error); + h.state_mut().dialogs.a = "18TWL8395907350".into(); + h.step(); + h.get_by_label("Go").click(); + h.step(); + assert_eq!(h.state().dialogs.open, None); + let c = h.state().session.map().center_lonlat(); + assert!((c.y - 40.7128).abs() < 1e-3 && (c.x + 74.006).abs() < 1e-3, "{c:?}"); +} diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index ebf747a632..3e5123f281 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -10,8 +10,11 @@ For selecting, grouping, reordering and copying layers between maps, see Drag to pan and scroll to zoom; ++home++ (or **Full Extent**) shows all the data. **Home › Navigate** has fixed zoom steps, **Previous** and **Next** extents, **Bookmarks** (the World Atlas has World, -Europe, Africa, Americas and East Asia), and **Locate**: **By Coordinates…** pans to a longitude -and latitude; **By Place or Address…** finds a place or address with a geocoder. **New Bookmark…** +Europe, Africa, Americas and East Asia), and **Locate**: **By Coordinates…** pans to a position +written as the search box reads one: latitude and longitude in decimal degrees or degrees, minutes +and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'21"W`), or a UTM or MGRS reference +(`18north 583959 4507351`, `18TWL8395907350`; `map.goToXY {"position": "18TWL8395907350"}`), and +one that names no place says why; **By Place or Address…** finds a place or address with a geocoder. **New Bookmark…** saves the current view under a name, with an optional description of the place. The Bookmarks menu removes a bookmark with the button beside it, and exports or imports them as a `.rgisbookmarks` file. Imported bookmarks show the same place on a map in another projection; places that projection From de083db80f1f57b28de0cf10ac7b9b0aba13d3a4 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:40:54 -0600 Subject: [PATCH 02/11] fix(navigate): By Coordinates says what it can't read, wraps the longitude, notes a square's center --- crates/engine/src/cmd/navigate.rs | 60 ++++++++++++++++++------------- crates/engine/src/cmd/search.rs | 2 +- crates/engine/src/tests.rs | 22 ++++++++++-- crates/ui-egui/src/dialogs.rs | 8 +++-- crates/ui-egui/tests/smoke.rs | 19 ++++++++++ docs/user-guide/maps.md | 10 +++--- 6 files changed, 86 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 53924583b6..b1b0ae18ce 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -42,7 +42,7 @@ pub fn commands() -> Vec { .params(r#"{"extent": [minLon, minLat, maxLon, maxLat]}"#), C::new("map.goToXY", "By Coordinates…", "Home › Navigate › Locate", go_to_xy).roles(&[]).effects(crate::policy::Effects::NONE) .view() - .params(r#"{"lon" | "x": number, "lat" | "y": number | "position": text as the search box reads it (40.7128, -74.006; 40°42'46"N 74°0'21"W; 18north 583959 4507351; 18TWL8395907350), "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), + .params(r#"{"lon" | "x": number, "lat" | "y": number, "position"?: text in place of lon and lat, as the search box reads it (40.7128, -74.006; 40°42′46″N 74°0′22″W; 18north 583959 4507351; 18TWL8395907350), "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), C::new("map.goToLink", "Go To Map Link", "Home › Navigate", go_to_link).roles(&[]).effects(crate::policy::Effects::NONE.with(crate::policy::Effect::Files)) .view() .params(r#"{"url": a map link ending in #zoom/lat/lon[/bearing[/pitch]] (as MapLibre writes them; also #map=…), or just zoom/lat/lon[/bearing[/pitch]]}"#), @@ -329,36 +329,43 @@ fn zoom(s: &mut Session, factor: f64) -> CmdResult { Ok(Value::Null) } -/// The most characters of a typed position read; no form the search box reads is longer. -const MAX_POSITION: usize = 200; - -/// A place typed as the search box reads one: latitude and longitude in decimal degrees, degrees -/// and minutes or degrees, minutes and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'21"W`), or -/// on the Earth's map a UTM position or an MGRS reference (`18north 583959 4507351`, -/// `18TWL8395907350`, the center of the square it names). A reference that names no place says -/// why, as the search box does. -pub(crate) fn read_position(map: &Map, text: &str) -> Result { +/// A place typed as the search box reads one: latitude, then longitude, in decimal degrees, +/// degrees and minutes or degrees, minutes and seconds (`40.7128, -74.006`, +/// `40°42'46"N 74°0'22"W`), or on the Earth's map a UTM position or an MGRS reference +/// (`18north 583959 4507351`, `18TWL8395907350`). A reference that names no place says why, as +/// the search box does. An MGRS reference coarser than a meter names a square, not a point: the +/// place is its center, and the second value says so, for a notice. +pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option), String> { let text = text.trim(); if text.is_empty() { return Err("the position is empty".into()); } - if text.chars().count() > MAX_POSITION { - return Err(format!("the position is longer than {MAX_POSITION} characters")); + if text.chars().nth(super::search::MAX_CHARS).is_some() { + return Err(format!("the position is longer than {} characters", super::search::MAX_CHARS)); } if let Some(c) = super::search::parse_lat_lon(text) { - return Ok(c); + return Ok((c, None)); + } + // Two plain numbers that aren't a latitude and a longitude: out of range, not misspelled. + let words: Vec<&str> = text.split(|c: char| c == ',' || c.is_whitespace()).filter(|w| !w.is_empty()).collect(); + if words.len() == 2 && words.iter().all(|w| w.parse::().is_ok_and(f64::is_finite)) { + return Err("latitude must be within ±90 and longitude within ±180".into()); } - let usage = "write a position as latitude and longitude (40.7128, -74.006 or 40°42'46\"N 74°0'21\"W), UTM (18north 583959 4507351) or MGRS (18TWL8395907350)"; + let degrees = "write a position as latitude, then longitude (40.7128, -74.006 or 40°42'46\"N 74°0'22\"W)"; if !map.body().is_earth() { - return Err( - "write a position as latitude and longitude (40.7128, -74.006 or 40°42'46\"N 74°0'21\"W); UTM and MGRS are the Earth's grids" - .into(), - ); + return Err(format!("{degrees}; UTM and MGRS are the Earth's grids")); } match super::search::read_grid_reference(text) { - Ok(c) => Ok(c), - Err(rustgis_geom::GridError::Syntax) => Err(usage.into()), - Err(e) => Err(e.to_string()), + Ok(c) => { + let square = rustgis_geom::parse_mgrs(text).ok().filter(|m| m.digits < 5).map(|m| { + let side = 10u32.pow(5 - u32::from(m.digits)); + let side = if side >= 1000 { format!("{} km", side / 1000) } else { format!("{side} m") }; + format!("{} names a {side} square, not a point: went to its center", text) + }); + Ok((c, square)) + } + Err(rustgis_geom::GridError::Syntax) => Err(format!("{degrees}, UTM (18north 583959 4507351) or MGRS (18TWL8395907350)")), + Err(e) => Err(format!("no place at this grid reference: {e}")), } } @@ -369,16 +376,18 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { Some(_) => return Err("position must be text, such as \"40.7128, -74.006\" or \"18TWL8395907350\"".into()), }; let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null)); - let (lon, lat) = if let Some(text) = position { + // An MGRS square's center is said once the map has moved there, not before a refusal. + let (lon, lat, square) = if let Some(text) = position { if ["lon", "lat", "x", "y"].iter().any(|k| given(k)) { return Err("give a position or lon and lat, not both".into()); } - let c = read_position(s.map(), text)?; - (c.x, c.y) + let (c, square) = read_position(s.map(), text)?; + (c.x, c.y, square) } else { ( f(p, "lon").or_else(|| f(p, "x")).ok_or("lon is required (or a position)")?, f(p, "lat").or_else(|| f(p, "y")).ok_or("lat is required (or a position)")?, + None, ) }; if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) { @@ -405,6 +414,9 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { } m.camera.clamp(); s.record_extent(); + if let Some(said) = square { + s.notify(said, false); + } Ok(Value::Null) } diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 952f66737d..25202c6f87 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -33,7 +33,7 @@ const MAX_ROWS: usize = 50_000; /// index it picks is one the command finds. pub const LIMIT: usize = 30; /// Most characters of a query, and of a value shown. -const MAX_CHARS: usize = 200; +pub(crate) const MAX_CHARS: usize = 200; /// 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. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 71ff680cfb..8df8bf7188 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45385,8 +45385,8 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { ); for q in [ "40.7128, -74.006", - "40°42'46\"N 74°0'21\"W", - "40°42′46″N 74°0′21″W", + "40°42'46\"N 74°0'22\"W", + "40°42′46″N 74°0′22″W", "18north 583959 4507351", "18T WL 83959 07350", "18TWL8395907350", @@ -45396,13 +45396,29 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert_eq!(at(&s), (40.713, -74.006), "{q}"); } assert!((s.map().scale() - 50_000.0).abs() < 1.0); + // An MGRS reference coarser than a meter goes to its square's center and says so; one to the + // meter, or degrees, say nothing. + for (q, said) in [("18TWL", Some("100 km square")), ("18TWL8307", Some("1 km square")), ("18TWL8395907350", None), ("40.7, -74", None)] + { + let mark = s.notice_mark(); + s.run("map.goToXY", &json!({"position": q})).unwrap(); + let notices: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); + match said { + Some(t) => assert!(notices.len() == 1 && notices[0].contains(t) && notices[0].contains("center"), "{q}: {notices:?}"), + None => assert!(notices.is_empty(), "{q}: {notices:?}"), + } + } // Numbers still go as before. s.run("map.goToXY", &json!({"lon": 10, "lat": 50})).unwrap(); assert_eq!(at(&s), (50.0, 10.0)); // Refused, with the reason the search box gives, and the map stays where it was. let refused = |s: &mut Session, p: Value| s.run("map.goToXY", &p).unwrap_err(); - assert!(refused(&mut s, json!({"position": "33UZZ1234512345"})).contains("100 km square")); + let e = refused(&mut s, json!({"position": "33UZZ1234512345"})); + assert!(e.starts_with("no place at this grid reference") && e.contains("100 km square"), "{e}"); assert!(refused(&mut s, json!({"position": "Paris"})).contains("write a position as")); + for q in ["40.7, 200", "95, 100", "-91, 200"] { + assert!(refused(&mut s, json!({"position": q})).contains("within ±90"), "{q}"); + } assert!(refused(&mut s, json!({"position": " "})).contains("empty")); assert!(refused(&mut s, json!({"position": "4".repeat(10_000)})).contains("longer than")); assert!(refused(&mut s, json!({"position": 40.7})).contains("must be text")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b07b5c6359..82dec9e796 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -154,9 +154,11 @@ impl Dialogs { self.a = if is_code { self.text.trim_start_matches(|c: char| !c.is_ascii_digit()).to_string() } else { String::new() }; } "goToXY" => { - // The map's center as the search box writes a place: `lat, lon`. + // The map's center as the search box writes a place: `lat, lon`, the longitude + // within ±180 even when the view has been panned past the antimeridian. let c = s.map().center_lonlat(); - self.a = rustgis_engine::cmd::search::format_lat_lon((c.y * 1e4).round() / 1e4, (c.x * 1e4).round() / 1e4); + let lon = (c.x + 180.0).rem_euclid(360.0) - 180.0; + self.a = rustgis_engine::cmd::search::format_lat_lon((c.y * 1e4).round() / 1e4, (lon * 1e4).round() / 1e4); } "bookmark" => self.text = rustgis_engine::cmd::navigate::new_bookmark_name(s.map()), "camera" => { @@ -336,7 +338,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { }); // Below the boxes, wrapped to the dialog's width rather than widening it. ui.label( - egui::RichText::new(tl!("Latitude and longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′21″W · 18north 583959 4507351 · 18TWL8395907350")) + egui::RichText::new(tl!("Latitude, then longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′22″W · 18north 583959 4507351 · 18TWL8395907350")) .weak(), ); close = buttons(app, ui, "Go", |app| { diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index c0327d69af..20e650fc3f 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4452,3 +4452,22 @@ fn by_coordinates_reads_one_position_box() { let c = h.state().session.map().center_lonlat(); assert!((c.y - 40.7128).abs() < 1e-3 && (c.x + 74.006).abs() < 1e-3, "{c:?}"); } + +/// Panned past the antimeridian, By Coordinates still opens on a longitude within ±180, so Go on +/// the text it wrote goes there. +#[test] +fn by_coordinates_wraps_the_longitude_past_the_antimeridian() { + let mut h = harness(); + h.state_mut().run("map.goToXY", json!({"lon": 170, "lat": 10, "scale": 5_000_000})).unwrap(); + // 20° further east in Web Mercator meters: 190° E. + h.state_mut().session.map_mut().camera.center.x += 20.0_f64.to_radians() * 6_378_137.0; + let lon = h.state().session.map().center_lonlat().x; + assert!(lon > 180.0, "the camera is past the antimeridian: {lon}"); + h.state_mut().run("ui.dialog", json!({"name": "goToXY"})).unwrap(); + h.step(); + assert_eq!(h.state().dialogs.a, "10, -170"); + h.step(); + h.get_by_label("Go").click(); + h.step(); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); +} diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index 3e5123f281..874320a88a 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -11,10 +11,12 @@ For selecting, grouping, reordering and copying layers between maps, see Drag to pan and scroll to zoom; ++home++ (or **Full Extent**) shows all the data. **Home › Navigate** has fixed zoom steps, **Previous** and **Next** extents, **Bookmarks** (the World Atlas has World, Europe, Africa, Americas and East Asia), and **Locate**: **By Coordinates…** pans to a position -written as the search box reads one: latitude and longitude in decimal degrees or degrees, minutes -and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'21"W`), or a UTM or MGRS reference -(`18north 583959 4507351`, `18TWL8395907350`; `map.goToXY {"position": "18TWL8395907350"}`), and -one that names no place says why; **By Place or Address…** finds a place or address with a geocoder. **New Bookmark…** +written as the search box reads one: latitude, then longitude, in decimal degrees or degrees, +minutes and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'22"W`), or a UTM or MGRS reference +(`18north 583959 4507351`, `18TWL8395907350`; `map.goToXY {"position": "18TWL8395907350"}`). One +that names no place says why; an MGRS reference coarser than a meter (`18TWL`) goes to the center +of its square and says so. **By Place or Address…** finds a place or address with a geocoder. +**New Bookmark…** saves the current view under a name, with an optional description of the place. The Bookmarks menu removes a bookmark with the button beside it, and exports or imports them as a `.rgisbookmarks` file. Imported bookmarks show the same place on a map in another projection; places that projection From 266747104b8121eb68bb6976558f3642b918ae83 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:58:23 -0600 Subject: [PATCH 03/11] fix(navigate): a scale By Coordinates can't read is an error; the square notice translates --- crates/engine/src/cmd/navigate.rs | 39 +++++++++++++++++++------------ crates/engine/src/tests.rs | 5 ++++ crates/ui-egui/src/dialogs.rs | 19 +++++++++------ crates/ui-egui/src/mapmenu.rs | 2 +- crates/ui-egui/tests/smoke.rs | 8 +++++++ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index b1b0ae18ce..10ed482391 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -334,8 +334,8 @@ fn zoom(s: &mut Session, factor: f64) -> CmdResult { /// `40°42'46"N 74°0'22"W`), or on the Earth's map a UTM position or an MGRS reference /// (`18north 583959 4507351`, `18TWL8395907350`). A reference that names no place says why, as /// the search box does. An MGRS reference coarser than a meter names a square, not a point: the -/// place is its center, and the second value says so, for a notice. -pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option), String> { +/// place is its center, and the second value is the square's side in meters, for a notice. +pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option), String> { let text = text.trim(); if text.is_empty() { return Err("the position is empty".into()); @@ -346,24 +346,26 @@ pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option = text.split(|c: char| c == ',' || c.is_whitespace()).filter(|w| !w.is_empty()).collect(); - if words.len() == 2 && words.iter().all(|w| w.parse::().is_ok_and(f64::is_finite)) { + if words.len() == 2 && words.iter().all(plain) { return Err("latitude must be within ±90 and longitude within ±180".into()); } let degrees = "write a position as latitude, then longitude (40.7128, -74.006 or 40°42'46\"N 74°0'22\"W)"; if !map.body().is_earth() { - return Err(format!("{degrees}; UTM and MGRS are the Earth's grids")); + // A grid reference is told why; anything else how a place is written here. + return Err(match super::search::read_grid_reference(text) { + Err(rustgis_geom::GridError::Syntax) => degrees.into(), + _ => "UTM and MGRS are the Earth's grids and name no place on this map; write a position as latitude, then longitude".into(), + }); } match super::search::read_grid_reference(text) { - Ok(c) => { - let square = rustgis_geom::parse_mgrs(text).ok().filter(|m| m.digits < 5).map(|m| { - let side = 10u32.pow(5 - u32::from(m.digits)); - let side = if side >= 1000 { format!("{} km", side / 1000) } else { format!("{side} m") }; - format!("{} names a {side} square, not a point: went to its center", text) - }); - Ok((c, square)) - } + Ok(c) => Ok((c, rustgis_geom::parse_mgrs(text).ok().filter(|m| m.digits < 5).map(|m| m.precision_m()))), Err(rustgis_geom::GridError::Syntax) => Err(format!("{degrees}, UTM (18north 583959 4507351) or MGRS (18TWL8395907350)")), Err(e) => Err(format!("no place at this grid reference: {e}")), } @@ -381,7 +383,14 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { if ["lon", "lat", "x", "y"].iter().any(|k| given(k)) { return Err("give a position or lon and lat, not both".into()); } - let (c, square) = read_position(s.map(), text)?; + let (c, side) = read_position(s.map(), text)?; + let square = side.map(|side| { + let side = if side >= 1000.0 { format!("{} km", side / 1000.0) } else { format!("{side} m") }; + crate::notice_text::NoticeText::format( + "{reference} names a {side} square, not a point: went to its center", + [("reference", text.trim().to_string().into()), ("side", side.into())], + ) + }); (c.x, c.y, square) } else { ( @@ -415,7 +424,7 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { m.camera.clamp(); s.record_extent(); if let Some(said) = square { - s.notify(said, false); + s.notify_text(said, false); } Ok(Value::Null) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8df8bf7188..49dc96a8f4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45419,6 +45419,9 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { for q in ["40.7, 200", "95, 100", "-91, 200"] { assert!(refused(&mut s, json!({"position": q})).contains("within ±90"), "{q}"); } + // Not plain decimals, so not a range: `1e1` isn't how a latitude is written. + let e = refused(&mut s, json!({"position": "1e1, 2"})); + assert!(!e.contains("within ±90"), "{e}"); assert!(refused(&mut s, json!({"position": " "})).contains("empty")); assert!(refused(&mut s, json!({"position": "4".repeat(10_000)})).contains("longer than")); assert!(refused(&mut s, json!({"position": 40.7})).contains("must be text")); @@ -45430,4 +45433,6 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { s.run("map.goToXY", &json!({"position": "18.4, 77.5"})).unwrap(); assert_eq!(at(&s), (18.4, 77.5)); assert!(refused(&mut s, json!({"position": "18TWL8395907350"})).contains("Earth's grids")); + let e = refused(&mut s, json!({"position": "Olympus"})); + assert!(e.contains("write a position as") && !e.contains("UTM"), "{e}"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 82dec9e796..074451ba48 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -157,7 +157,7 @@ impl Dialogs { // The map's center as the search box writes a place: `lat, lon`, the longitude // within ±180 even when the view has been panned past the antimeridian. let c = s.map().center_lonlat(); - let lon = (c.x + 180.0).rem_euclid(360.0) - 180.0; + let lon = crate::mapmenu::wrap_lon(c.x); self.a = rustgis_engine::cmd::search::format_lat_lon((c.y * 1e4).round() / 1e4, (lon * 1e4).round() / 1e4); } "bookmark" => self.text = rustgis_engine::cmd::navigate::new_bookmark_name(s.map()), @@ -336,14 +336,19 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); }); - // Below the boxes, wrapped to the dialog's width rather than widening it. - ui.label( - egui::RichText::new(tl!("Latitude, then longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′22″W · 18north 583959 4507351 · 18TWL8395907350")) - .weak(), - ); + // Below the boxes, wrapped to the dialog's width rather than widening it. UTM and + // MGRS are the Earth's grids, so another body's map offers degrees alone. + let hint = if app.session.map().body().is_earth() { + tl!("Latitude, then longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′22″W · 18north 583959 4507351 · 18TWL8395907350") + } else { + tl!("Latitude, then longitude: 18.4, 77.5 · 18°24′N 77°30′E") + }; + ui.label(egui::RichText::new(hint).weak()); close = buttons(app, ui, "Go", |app| { let mut p = json!({"position": app.dialogs.a.trim()}); - if let Ok(s) = app.dialogs.c.trim().replace(',', "").parse::() { + let scale = app.dialogs.c.trim().replace(',', ""); + if !scale.is_empty() { + let s = scale.parse::().ok().filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| tl!("The scale must be a number, such as 50000").to_string())?; p["scale"] = json!(s); } app.run("map.goToXY", p).map(|_| ()) diff --git a/crates/ui-egui/src/mapmenu.rs b/crates/ui-egui/src/mapmenu.rs index df7fceb130..422d281db9 100644 --- a/crates/ui-egui/src/mapmenu.rs +++ b/crates/ui-egui/src/mapmenu.rs @@ -72,7 +72,7 @@ fn point_geojson(c: Coord) -> String { } /// A longitude within ±180 (a view can run past the antimeridian). -fn wrap_lon(lon: f64) -> f64 { +pub(crate) fn wrap_lon(lon: f64) -> f64 { (lon + 180.0).rem_euclid(360.0) - 180.0 } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 20e650fc3f..d815a70de6 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4444,7 +4444,15 @@ fn by_coordinates_reads_one_position_box() { h.step(); assert_eq!(h.state().dialogs.open, Some("goToXY")); assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("100 km square")), "{:?}", h.state().dialogs.error); + // A scale it can't read is said, not dropped. h.state_mut().dialogs.a = "18TWL8395907350".into(); + h.state_mut().dialogs.c = "50k".into(); + h.step(); + h.get_by_label("Go").click(); + h.step(); + assert_eq!(h.state().dialogs.open, Some("goToXY")); + assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("scale")), "{:?}", h.state().dialogs.error); + h.state_mut().dialogs.c = "50,000".into(); h.step(); h.get_by_label("Go").click(); h.step(); From 1e5c3803f7629b47290755c50f18635b66e712c4 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:16:13 -0600 Subject: [PATCH 04/11] fix(navigate): map.goToXY refuses a scale it can't use --- crates/engine/src/cmd/navigate.rs | 11 +++++++++-- crates/engine/src/tests.rs | 6 ++++++ crates/ui-egui/src/dialogs.rs | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 10ed482391..2ba52d4f61 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -350,7 +350,9 @@ pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option // range, not misspelled. let plain = |w: &&str| { let digits = w.strip_prefix(['-', '+']).unwrap_or(w); - !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit() || c == '.') && digits.chars().filter(|&c| c == '.').count() <= 1 + digits.chars().any(|c| c.is_ascii_digit()) + && digits.chars().all(|c| c.is_ascii_digit() || c == '.') + && digits.chars().filter(|&c| c == '.').count() <= 1 }; let words: Vec<&str> = text.split(|c: char| c == ',' || c.is_whitespace()).filter(|w| !w.is_empty()).collect(); if words.len() == 2 && words.iter().all(plain) { @@ -402,6 +404,11 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) { return Err("longitude must be within ±180 and latitude within ±90".into()); } + // A scale that can't be used is refused, not dropped. + let scale = match p.get("scale") { + None | Some(Value::Null) => None, + Some(_) => Some(f(p, "scale").filter(|s| *s > 0.0).ok_or("scale must be a positive number, such as 50000 for 1:50,000")?), + }; let bearing = match p.get("bearing") { None | Some(Value::Null) => None, Some(_) => Some(f(p, "bearing").ok_or("bearing must be a number of degrees")?), @@ -412,7 +419,7 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { }; let m = s.map_mut(); m.camera.center = m.to_map(Coord::new(lon, lat)).ok_or("that point is outside the map's projection")?; - if let Some(sc) = f(p, "scale") { + if let Some(sc) = scale { m.set_scale(sc); } if let Some(b) = bearing { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 49dc96a8f4..c5b45d6c8c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45427,6 +45427,12 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert!(refused(&mut s, json!({"position": 40.7})).contains("must be text")); assert!(refused(&mut s, json!({"position": "40.7, -74", "lon": 1})).contains("not both")); assert!(refused(&mut s, json!({})).contains("lon is required")); + // A scale that can't be used is refused, not dropped, whether a position or numbers are given. + for scale in [json!("50k"), json!(0), json!(-5), json!(true)] { + assert!(refused(&mut s, json!({"position": "40.7, -74", "scale": scale})).contains("scale must be"), "{scale}"); + assert!(refused(&mut s, json!({"lon": 1, "lat": 1, "scale": scale})).contains("scale must be"), "{scale}"); + } + assert!(refused(&mut s, json!({"position": "., 5"})).contains("write a position as")); assert_eq!(at(&s), (50.0, 10.0)); // On another body degrees still name a place; the Earth's grids don't. s.run("map.body", &json!({"name": "Mars"})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 074451ba48..7bff5f97ee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -346,10 +346,10 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.label(egui::RichText::new(hint).weak()); close = buttons(app, ui, "Go", |app| { let mut p = json!({"position": app.dialogs.a.trim()}); + // The command refuses a scale it can't use. let scale = app.dialogs.c.trim().replace(',', ""); if !scale.is_empty() { - let s = scale.parse::().ok().filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| tl!("The scale must be a number, such as 50000").to_string())?; - p["scale"] = json!(s); + p["scale"] = json!(scale); } app.run("map.goToXY", p).map(|_| ()) }); From 5771a6ca8862f63c6d563807c012c3af50f98de6 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:54:42 -0600 Subject: [PATCH 05/11] feat(navigate): By Coordinates keeps X and Y boxes, each taking DMS, with a grid reference box --- crates/engine/src/cmd/navigate.rs | 147 +++++++++++++++++++++++++----- crates/engine/src/tests.rs | 33 ++++++- crates/ui-egui/src/dialogs.rs | 43 ++++++--- crates/ui-egui/tests/smoke.rs | 22 ++++- docs/user-guide/maps.md | 15 +-- 5 files changed, 211 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 2ba52d4f61..11ad416d5e 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -42,7 +42,7 @@ pub fn commands() -> Vec { .params(r#"{"extent": [minLon, minLat, maxLon, maxLat]}"#), C::new("map.goToXY", "By Coordinates…", "Home › Navigate › Locate", go_to_xy).roles(&[]).effects(crate::policy::Effects::NONE) .view() - .params(r#"{"lon" | "x": number, "lat" | "y": number, "position"?: text in place of lon and lat, as the search box reads it (40.7128, -74.006; 40°42′46″N 74°0′22″W; 18north 583959 4507351; 18TWL8395907350), "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), + .params(r#"{"lon" | "x": degrees (-74.006, or text such as 74°0′22″W), "lat" | "y": degrees (40.7128, or text such as 40°42′46″N), "position"?: text in place of lon and lat, as the search box reads it, latitude first (40.7128, -74.006; 18north 583959 4507351; 18TWL8395907350), "scale"?: number, "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85)}"#), C::new("map.goToLink", "Go To Map Link", "Home › Navigate", go_to_link).roles(&[]).effects(crate::policy::Effects::NONE.with(crate::policy::Effect::Files)) .view() .params(r#"{"url": a map link ending in #zoom/lat/lon[/bearing[/pitch]] (as MapLibre writes them; also #map=…), or just zoom/lat/lon[/bearing[/pitch]]}"#), @@ -333,9 +333,9 @@ fn zoom(s: &mut Session, factor: f64) -> CmdResult { /// degrees and minutes or degrees, minutes and seconds (`40.7128, -74.006`, /// `40°42'46"N 74°0'22"W`), or on the Earth's map a UTM position or an MGRS reference /// (`18north 583959 4507351`, `18TWL8395907350`). A reference that names no place says why, as -/// the search box does. An MGRS reference coarser than a meter names a square, not a point: the -/// place is its center, and the second value is the square's side in meters, for a notice. -pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option), String> { +/// the search box does. What the reading assumed comes with the place, for a notice +/// ([`PositionRead`]). +pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, PositionRead), String> { let text = text.trim(); if text.is_empty() { return Err("the position is empty".into()); @@ -344,7 +344,9 @@ pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option return Err(format!("the position is longer than {} characters", super::search::MAX_CHARS)); } if let Some(c) = super::search::parse_lat_lon(text) { - return Ok((c, None)); + // Without N, S, E or W the order is the reader's assumption, so it is said. + let lettered = text.chars().any(char::is_alphabetic); + return Ok((c, if lettered { PositionRead::AsWritten } else { PositionRead::Ordered })); } // Two plain decimal numbers (`40.7, 200`) that aren't a latitude and a longitude: out of // range, not misspelled. @@ -367,12 +369,103 @@ pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, Option }); } match super::search::read_grid_reference(text) { - Ok(c) => Ok((c, rustgis_geom::parse_mgrs(text).ok().filter(|m| m.digits < 5).map(|m| m.precision_m()))), + Ok(c) => Ok(( + c, + rustgis_geom::parse_mgrs(text) + .ok() + .filter(|m| m.digits < 5) + .map_or(PositionRead::AsWritten, |m| PositionRead::Square(m.precision_m())), + )), Err(rustgis_geom::GridError::Syntax) => Err(format!("{degrees}, UTM (18north 583959 4507351) or MGRS (18TWL8395907350)")), Err(e) => Err(format!("no place at this grid reference: {e}")), } } +/// What reading a typed position assumed. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PositionRead { + /// Nothing: hemisphere letters, a UTM position or an MGRS reference to the meter. + AsWritten, + /// Two numbers without N, S, E or W, taken as latitude, then longitude (or the other way + /// round when the first can only be a longitude): the order is said, so `2.35, 48.85` typed + /// as x, y shows where it went. + Ordered, + /// An MGRS reference coarser than a meter, which names a square of this side in meters: the + /// place is its center. + Square(f64), +} + +/// One axis typed on its own, as By Coordinates' Longitude (X) and Latitude (Y) boxes take it: +/// decimal degrees (`-74.006`), or degrees, minutes and seconds with or without marks and a +/// hemisphere letter before or after (`74°0′22″W`, `W 74 0 22`, `40 42 46.1 N`). A letter of the +/// other axis (N in the longitude) is refused rather than taken as the other axis. +pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { + let name = if latitude { "latitude" } else { "longitude" }; + let text = text.trim(); + if text.is_empty() { + return Err(format!("the {name} is empty")); + } + if text.chars().nth(64).is_some() { + return Err(format!("the {name} is longer than 64 characters")); + } + let (own, other) = if latitude { (['N', 'S'], ['E', 'W']) } else { (['E', 'W'], ['N', 'S']) }; + let mut hemisphere = None; + let mut rest = text; + for at_end in [false, true] { + let c = if at_end { rest.chars().next_back() } else { rest.chars().next() }; + if let Some(c) = c.map(|c| c.to_ascii_uppercase()) + && (own.contains(&c) || other.contains(&c)) + { + if other.contains(&c) { + return Err(format!("{c} belongs to the {}; write the {name} here", if latitude { "longitude" } else { "latitude" })); + } + if hemisphere.replace(c).is_some() { + return Err(format!("the {name} has two hemisphere letters")); + } + // The letter is ASCII, one byte. + rest = if at_end { rest.get(..rest.len().saturating_sub(1)) } else { rest.get(1..) }.unwrap_or("").trim(); + } + } + let marks = |c: char| c.is_whitespace() || matches!(c, '°' | 'º' | '\'' | '′' | '"' | '″' | ','); + let parts: Vec<&str> = rest.split(marks).filter(|w| !w.is_empty()).collect(); + let usage = || format!("write the {name} in degrees (-74.006) or degrees, minutes and seconds (74°0′22″W)"); + if parts.is_empty() || parts.len() > 3 { + return Err(usage()); + } + let mut nums = Vec::with_capacity(3); + for p in &parts { + let v: f64 = p.replace('−', "-").parse().map_err(|_| usage())?; + if !v.is_finite() { + return Err(usage()); + } + nums.push(v); + } + let (d, ms) = nums.split_first().ok_or_else(usage)?; + if ms.iter().any(|v| !(0.0..60.0).contains(v)) { + return Err(format!("the {name}'s minutes and seconds must be from 0 to under 60")); + } + if hemisphere.is_some() && d.is_sign_negative() { + return Err(format!("the {name} has both a minus sign and a hemisphere letter")); + } + let m = ms.first().copied().unwrap_or(0.0); + let sec = ms.get(1).copied().unwrap_or(0.0); + let mut v = d.abs() + m / 60.0 + sec / 3600.0; + if d.is_sign_negative() || matches!(hemisphere, Some('S' | 'W')) { + v = -v; + } + let limit = if latitude { 90.0 } else { 180.0 }; + if v.abs() > limit { + return Err(format!("the {name} must be within ±{limit}")); + } + Ok(v) +} + +/// A number of degrees as typed back to the user: six decimals at most, no trailing zeros. +fn degrees_text(v: f64) -> String { + let v = (v * 1e6).round() / 1e6; + if v == 0.0 { "0".into() } else { format!("{v}") } +} + pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { let position = match p.get("position") { None | Some(Value::Null) => None, @@ -380,26 +473,38 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { Some(_) => return Err("position must be text, such as \"40.7128, -74.006\" or \"18TWL8395907350\"".into()), }; let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null)); - // An MGRS square's center is said once the map has moved there, not before a refusal. + // What the reading assumed is said once the map has moved there, not before a refusal. let (lon, lat, square) = if let Some(text) = position { if ["lon", "lat", "x", "y"].iter().any(|k| given(k)) { return Err("give a position or lon and lat, not both".into()); } - let (c, side) = read_position(s.map(), text)?; - let square = side.map(|side| { - let side = if side >= 1000.0 { format!("{} km", side / 1000.0) } else { format!("{side} m") }; - crate::notice_text::NoticeText::format( - "{reference} names a {side} square, not a point: went to its center", - [("reference", text.trim().to_string().into()), ("side", side.into())], - ) - }); - (c.x, c.y, square) + let (c, read) = read_position(s.map(), text)?; + let reference = || text.trim().to_string().into(); + let said = match read { + PositionRead::AsWritten => None, + PositionRead::Ordered => Some(crate::notice_text::NoticeText::format( + "Read {position} as latitude {lat}, longitude {lon}", + [("position", reference()), ("lat", degrees_text(c.y).into()), ("lon", degrees_text(c.x).into())], + )), + PositionRead::Square(side) => { + let side = if side >= 1000.0 { format!("{} km", side / 1000.0) } else { format!("{side} m") }; + Some(crate::notice_text::NoticeText::format( + "{reference} names a {side} square, not a point: went to its center", + [("reference", reference()), ("side", side.into())], + )) + } + }; + (c.x, c.y, said) } else { - ( - f(p, "lon").or_else(|| f(p, "x")).ok_or("lon is required (or a position)")?, - f(p, "lat").or_else(|| f(p, "y")).ok_or("lat is required (or a position)")?, - None, - ) + // A number, or text in degrees or degrees, minutes and seconds (`74°0′22″W`). + let axis = |keys: [&str; 2], latitude: bool| -> Result { + let key = keys.iter().find(|k| given(k)).ok_or_else(|| format!("{} is required (or a position)", keys[0]))?; + match p.get(*key) { + Some(Value::String(t)) => t.trim().parse::().ok().filter(|v| v.is_finite()).map_or_else(|| read_axis(t, latitude), Ok), + _ => f(p, key).ok_or_else(|| format!("{key} must be a number of degrees, or text such as \"74°0′22″W\"")), + } + }; + (axis(["lon", "x"], false)?, axis(["lat", "y"], true)?, None) }; if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) { return Err("longitude must be within ±180 and latitude within ±90".into()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c5b45d6c8c..f713004b4c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45396,18 +45396,41 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert_eq!(at(&s), (40.713, -74.006), "{q}"); } assert!((s.map().scale() - 50_000.0).abs() < 1.0); - // An MGRS reference coarser than a meter goes to its square's center and says so; one to the - // meter, or degrees, say nothing. - for (q, said) in [("18TWL", Some("100 km square")), ("18TWL8307", Some("1 km square")), ("18TWL8395907350", None), ("40.7, -74", None)] - { + // What the reading assumed is said: an MGRS square's center, or the order of two bare numbers + // (so `2.35, 48.85` typed as x, y shows where it went, and a swap shows too). A reference to + // the meter, or degrees with hemisphere letters, say nothing. + for (q, said) in [ + ("18TWL", Some("names a 100 km square, not a point: went to its center")), + ("18TWL8307", Some("names a 1 km square")), + ("2.35, 48.85", Some("Read 2.35, 48.85 as latitude 2.35, longitude 48.85")), + ("-122.4, 37.8", Some("as latitude 37.8, longitude -122.4")), + ("18TWL8395907350", None), + ("40°42′46″N 74°0′22″W", None), + ] { let mark = s.notice_mark(); s.run("map.goToXY", &json!({"position": q})).unwrap(); let notices: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); match said { - Some(t) => assert!(notices.len() == 1 && notices[0].contains(t) && notices[0].contains("center"), "{q}: {notices:?}"), + Some(t) => assert!(notices.len() == 1 && notices[0].contains(t), "{q}: {notices:?}"), None => assert!(notices.is_empty(), "{q}: {notices:?}"), } } + // Longitude and latitude each take degrees, minutes and seconds, as the dialog's boxes send + // them; a letter of the other axis is refused, not swapped; numbers say nothing. + for (lon, lat) in [("74°0′22″W", "40°42′46″N"), ("W 74 0 22", "N 40 42 46"), ("-74 0 22", "40 42 46"), ("-74.006", "40.7128")] + { + let mark = s.notice_mark(); + s.run("map.goToXY", &json!({"lon": lon, "lat": lat})).unwrap(); + assert_eq!(at(&s), (40.713, -74.006), "{lon} {lat}"); + assert!(s.notices_since(mark).is_empty()); + } + let axis_refused = |s: &mut Session, lon: &str, lat: &str| s.run("map.goToXY", &json!({"lon": lon, "lat": lat})).unwrap_err(); + assert!(axis_refused(&mut s, "40°42′46″N", "74°0′22″W").contains("belongs to the latitude")); + assert!(axis_refused(&mut s, "74 60 0", "40").contains("under 60")); + assert!(axis_refused(&mut s, "-74W", "40").contains("both a minus sign")); + assert!(axis_refused(&mut s, "74", "91 N").contains("within ±90")); + assert!(axis_refused(&mut s, "Paris", "40").contains("write the longitude")); + assert!(axis_refused(&mut s, &"9".repeat(500), "40").contains("longer than")); // Numbers still go as before. s.run("map.goToXY", &json!({"lon": 10, "lat": 50})).unwrap(); assert_eq!(at(&s), (50.0, 10.0)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7bff5f97ee..e03a6434ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -154,11 +154,11 @@ impl Dialogs { self.a = if is_code { self.text.trim_start_matches(|c: char| !c.is_ascii_digit()).to_string() } else { String::new() }; } "goToXY" => { - // The map's center as the search box writes a place: `lat, lon`, the longitude - // within ±180 even when the view has been panned past the antimeridian. + // The map's center, the longitude within ±180 even when the view has been panned + // past the antimeridian. let c = s.map().center_lonlat(); - let lon = crate::mapmenu::wrap_lon(c.x); - self.a = rustgis_engine::cmd::search::format_lat_lon((c.y * 1e4).round() / 1e4, (lon * 1e4).round() / 1e4); + self.a = format!("{:.4}", crate::mapmenu::wrap_lon(c.x)); + self.b = format!("{:.4}", c.y); } "bookmark" => self.text = rustgis_engine::cmd::navigate::new_bookmark_name(s.map()), "camera" => { @@ -328,24 +328,43 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { match which { "selectByAttributes" => close = select_by_attributes(app, ui, &t), "goToXY" => { + // UTM and MGRS are the Earth's grids, so another body's map offers degrees alone. + // A grid reference, once typed, is where Go goes: the degree boxes gray out. + let earth = app.session.map().body().is_earth(); + let by_grid = earth && !app.dialogs.text.trim().is_empty(); egui::Grid::new("xy").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let input_label = ui.label(tl!("Position")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(300.0)).labelled_by(input_label.id); + let input_label = ui.label(tl!("Longitude (X)")); + ui.add_enabled(!by_grid, egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("-74.006 · 74°0′22″W").desired_width(300.0)) + .labelled_by(input_label.id); + ui.end_row(); + let input_label = ui.label(tl!("Latitude (Y)")); + ui.add_enabled(!by_grid, egui::TextEdit::singleline(&mut app.dialogs.b).hint_text("40.7128 · 40°42′46″N").desired_width(300.0)) + .labelled_by(input_label.id); ui.end_row(); + if earth { + let input_label = ui.label(tl!("Or grid reference")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("18TWL8395907350 · 18north 583959 4507351").desired_width(300.0)) + .labelled_by(input_label.id); + ui.end_row(); + } let input_label = ui.label(tl!("Scale 1:")); ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); }); - // Below the boxes, wrapped to the dialog's width rather than widening it. UTM and - // MGRS are the Earth's grids, so another body's map offers degrees alone. - let hint = if app.session.map().body().is_earth() { - tl!("Latitude, then longitude, or a UTM or MGRS reference: 40.7128, -74.006 · 40°42′46″N 74°0′22″W · 18north 583959 4507351 · 18TWL8395907350") + // Below the boxes, wrapped to the dialog's width rather than widening it. + let hint = if earth { + tl!("Degrees as decimals or degrees, minutes and seconds; or a UTM or MGRS reference in place of both.") } else { - tl!("Latitude, then longitude: 18.4, 77.5 · 18°24′N 77°30′E") + tl!("Degrees as decimals or degrees, minutes and seconds.") }; ui.label(egui::RichText::new(hint).weak()); close = buttons(app, ui, "Go", |app| { - let mut p = json!({"position": app.dialogs.a.trim()}); + let grid = app.dialogs.text.trim(); + let mut p = if app.session.map().body().is_earth() && !grid.is_empty() { + json!({"position": grid}) + } else { + json!({"lon": app.dialogs.a.trim(), "lat": app.dialogs.b.trim()}) + }; // The command refuses a scale it can't use. let scale = app.dialogs.c.trim().replace(',', ""); if !scale.is_empty() { diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index d815a70de6..bcdd48597a 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4431,21 +4431,33 @@ fn zonal_statistics_shows_available_band_range() { /// search box does: MGRS here; a reference that names no place keeps the dialog open with the /// reason. #[test] -fn by_coordinates_reads_one_position_box() { +fn by_coordinates_reads_x_y_and_a_grid_reference() { let mut h = harness(); h.state_mut().run("map.goToXY", json!({"lon": 10, "lat": 50})).unwrap(); h.state_mut().run("ui.dialog", json!({"name": "goToXY"})).unwrap(); h.step(); assert_eq!(h.state().dialogs.open, Some("goToXY")); - assert_eq!(h.state().dialogs.a, "50, 10"); - h.state_mut().dialogs.a = "33UZZ1234512345".into(); + assert_eq!((h.state().dialogs.a.as_str(), h.state().dialogs.b.as_str()), ("10.0000", "50.0000")); + // Each box takes degrees, minutes and seconds. + h.state_mut().dialogs.a = "74°0′22″W".into(); + h.state_mut().dialogs.b = "40°42′46″N".into(); + h.step(); + h.get_by_label("Go").click(); + h.step(); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + let c = h.state().session.map().center_lonlat(); + assert!((c.y - 40.7128).abs() < 1e-3 && (c.x + 74.006).abs() < 1e-3, "{c:?}"); + // A grid reference goes in place of both; one that names no place says why. + h.state_mut().run("ui.dialog", json!({"name": "goToXY"})).unwrap(); + h.step(); + h.state_mut().dialogs.text = "33UZZ1234512345".into(); h.step(); h.get_by_label("Go").click(); h.step(); assert_eq!(h.state().dialogs.open, Some("goToXY")); assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("100 km square")), "{:?}", h.state().dialogs.error); // A scale it can't read is said, not dropped. - h.state_mut().dialogs.a = "18TWL8395907350".into(); + h.state_mut().dialogs.text = "18TWL8395907350".into(); h.state_mut().dialogs.c = "50k".into(); h.step(); h.get_by_label("Go").click(); @@ -4473,7 +4485,7 @@ fn by_coordinates_wraps_the_longitude_past_the_antimeridian() { assert!(lon > 180.0, "the camera is past the antimeridian: {lon}"); h.state_mut().run("ui.dialog", json!({"name": "goToXY"})).unwrap(); h.step(); - assert_eq!(h.state().dialogs.a, "10, -170"); + assert_eq!((h.state().dialogs.a.as_str(), h.state().dialogs.b.as_str()), ("-170.0000", "10.0000")); h.step(); h.get_by_label("Go").click(); h.step(); diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index 874320a88a..6d1ca55182 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -10,12 +10,15 @@ For selecting, grouping, reordering and copying layers between maps, see Drag to pan and scroll to zoom; ++home++ (or **Full Extent**) shows all the data. **Home › Navigate** has fixed zoom steps, **Previous** and **Next** extents, **Bookmarks** (the World Atlas has World, -Europe, Africa, Americas and East Asia), and **Locate**: **By Coordinates…** pans to a position -written as the search box reads one: latitude, then longitude, in decimal degrees or degrees, -minutes and seconds (`40.7128, -74.006`, `40°42'46"N 74°0'22"W`), or a UTM or MGRS reference -(`18north 583959 4507351`, `18TWL8395907350`; `map.goToXY {"position": "18TWL8395907350"}`). One -that names no place says why; an MGRS reference coarser than a meter (`18TWL`) goes to the center -of its square and says so. **By Place or Address…** finds a place or address with a geocoder. +Europe, Africa, Americas and East Asia), and **Locate**: **By Coordinates…** pans to a longitude +(X) and latitude (Y), each in decimal degrees or degrees, minutes and seconds (`-74.006` or +`74°0'22"W`; `40.7128` or `40°42'46"N`; an N or S in the longitude is refused, not swapped), or on +the Earth's map to a UTM or MGRS grid reference in place of both (`18north 583959 4507351`, +`18TWL8395907350`). `map.goToXY` takes the same: `lon` and `lat` as numbers or text, or a +`position` read as the search box reads it, latitude first; two bare numbers there are said back +as the latitude and longitude they were read as. A reference that names no place says why; an +MGRS reference coarser than a meter (`18TWL`) goes to the center of its square and says so. +**By Place or Address…** finds a place or address with a geocoder. **New Bookmark…** saves the current view under a name, with an optional description of the place. The Bookmarks menu removes a bookmark with the button beside it, and exports or imports them as a `.rgisbookmarks` From 3cdd98cdae650f3bee87f70d217f31cd118a5f6a Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:16:55 -0600 Subject: [PATCH 06/11] fix(navigate): one value a box; a decimal comma or a pasted pair is refused --- crates/engine/src/cmd/navigate.rs | 17 +++++++++++++---- crates/engine/src/tests.rs | 7 +++++++ crates/ui-egui/tests/smoke.rs | 6 +++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 11ad416d5e..f7165016f8 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -345,7 +345,7 @@ pub(crate) fn read_position(map: &Map, text: &str) -> Result<(Coord, PositionRea } if let Some(c) = super::search::parse_lat_lon(text) { // Without N, S, E or W the order is the reader's assumption, so it is said. - let lettered = text.chars().any(char::is_alphabetic); + let lettered = text.chars().any(|c| matches!(c.to_ascii_uppercase(), 'N' | 'S' | 'E' | 'W')); return Ok((c, if lettered { PositionRead::AsWritten } else { PositionRead::Ordered })); } // Two plain decimal numbers (`40.7, 200`) that aren't a latitude and a longitude: out of @@ -426,7 +426,11 @@ pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { rest = if at_end { rest.get(..rest.len().saturating_sub(1)) } else { rest.get(1..) }.unwrap_or("").trim(); } } - let marks = |c: char| c.is_whitespace() || matches!(c, '°' | 'º' | '\'' | '′' | '"' | '″' | ','); + // One value a box: a comma is a decimal comma or a second value, neither of which is read here. + if rest.contains(',') { + return Err(format!("the {name} box takes one value, with a point for decimals (-74.006, not -74,006 or two numbers)")); + } + let marks = |c: char| c.is_whitespace() || matches!(c, '°' | 'º' | '\'' | '′' | '’' | '"' | '″' | '”'); let parts: Vec<&str> = rest.split(marks).filter(|w| !w.is_empty()).collect(); let usage = || format!("write the {name} in degrees (-74.006) or degrees, minutes and seconds (74°0′22″W)"); if parts.is_empty() || parts.len() > 3 { @@ -441,6 +445,11 @@ pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { nums.push(v); } let (d, ms) = nums.split_first().ok_or_else(usage)?; + // Minutes follow whole degrees and seconds whole minutes (`48.85 2.35` is two values, not + // degrees and minutes). + if nums.iter().rev().skip(1).any(|v| v.fract() != 0.0) { + return Err(usage()); + } if ms.iter().any(|v| !(0.0..60.0).contains(v)) { return Err(format!("the {name}'s minutes and seconds must be from 0 to under 60")); } @@ -474,7 +483,7 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { }; let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null)); // What the reading assumed is said once the map has moved there, not before a refusal. - let (lon, lat, square) = if let Some(text) = position { + let (lon, lat, notice) = if let Some(text) = position { if ["lon", "lat", "x", "y"].iter().any(|k| given(k)) { return Err("give a position or lon and lat, not both".into()); } @@ -535,7 +544,7 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { } m.camera.clamp(); s.record_extent(); - if let Some(said) = square { + if let Some(said) = notice { s.notify_text(said, false); } Ok(Value::Null) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f713004b4c..f5bd8fe503 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45430,6 +45430,13 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert!(axis_refused(&mut s, "-74W", "40").contains("both a minus sign")); assert!(axis_refused(&mut s, "74", "91 N").contains("within ±90")); assert!(axis_refused(&mut s, "Paris", "40").contains("write the longitude")); + // A decimal comma or a pasted pair in one box is refused, not read as degrees and minutes. + assert!(axis_refused(&mut s, "2,35", "48").contains("one value")); + assert!(axis_refused(&mut s, "48.85, 2.35", "48").contains("one value")); + assert!(axis_refused(&mut s, "48.85 2.35", "48").contains("write the longitude")); + // Typographic quotes, as pasted from a document, are marks like any other. + s.run("map.goToXY", &json!({"lon": "74°0’22”W", "lat": "40°42’46”N"})).unwrap(); + assert_eq!(at(&s), (40.713, -74.006)); assert!(axis_refused(&mut s, &"9".repeat(500), "40").contains("longer than")); // Numbers still go as before. s.run("map.goToXY", &json!({"lon": 10, "lat": 50})).unwrap(); diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index bcdd48597a..132b30f40a 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4427,9 +4427,9 @@ fn zonal_statistics_shows_available_band_range() { assert!(h.get_by_label("Calculate Statistics").accesskit_node().is_disabled()); } -/// By Coordinates… opens on the map's center as `lat, lon`, and Go reads its one box as the -/// search box does: MGRS here; a reference that names no place keeps the dialog open with the -/// reason. +/// By Coordinates… opens on the map's center in its Longitude (X) and Latitude (Y) boxes, each of +/// which takes degrees, minutes and seconds; a grid reference goes in place of both, and one that +/// names no place, or a scale it can't use, keeps the dialog open with the reason. #[test] fn by_coordinates_reads_x_y_and_a_grid_reference() { let mut h = harness(); From ca90a6e2f9889bc3b7c89ab361ea61daa73734ff Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:11:38 -0600 Subject: [PATCH 07/11] fix(navigate): By Coordinates sums degrees, minutes and seconds with the search box's reader; its test starts each case elsewhere --- crates/engine/src/cmd/navigate.rs | 13 ++++--------- crates/engine/src/cmd/search.rs | 2 +- crates/engine/src/tests.rs | 3 +++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index f7165016f8..6f5eca6514 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -444,22 +444,17 @@ pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { } nums.push(v); } - let (d, ms) = nums.split_first().ok_or_else(usage)?; // Minutes follow whole degrees and seconds whole minutes (`48.85 2.35` is two values, not // degrees and minutes). if nums.iter().rev().skip(1).any(|v| v.fract() != 0.0) { return Err(usage()); } - if ms.iter().any(|v| !(0.0..60.0).contains(v)) { - return Err(format!("the {name}'s minutes and seconds must be from 0 to under 60")); - } - if hemisphere.is_some() && d.is_sign_negative() { + if hemisphere.is_some() && nums.first().is_some_and(|d| d.is_sign_negative()) { return Err(format!("the {name} has both a minus sign and a hemisphere letter")); } - let m = ms.first().copied().unwrap_or(0.0); - let sec = ms.get(1).copied().unwrap_or(0.0); - let mut v = d.abs() + m / 60.0 + sec / 3600.0; - if d.is_sign_negative() || matches!(hemisphere, Some('S' | 'W')) { + // The degrees, minutes and seconds as the search box sums them. + let mut v = super::search::axis(&nums).ok_or_else(|| format!("the {name}'s minutes and seconds must be from 0 to under 60"))?; + if matches!(hemisphere, Some('S' | 'W')) { v = -v; } let limit = if latitude { 90.0 } else { 180.0 }; diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 647371ef5a..16c60a9cd8 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -644,7 +644,7 @@ fn tokens(text: &str) -> Option> { } /// One axis: degrees, and maybe minutes and seconds; `None` for anything else. -fn axis(nums: &[f64]) -> Option { +pub(crate) fn axis(nums: &[f64]) -> Option { let (d, rest) = nums.split_first()?; if rest.len() > 2 || rest.iter().any(|v| !(0.0..60.0).contains(v)) { return None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 83f375a219..364dbcf5a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45865,6 +45865,8 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { // them; a letter of the other axis is refused, not swapped; numbers say nothing. for (lon, lat) in [("74°0′22″W", "40°42′46″N"), ("W 74 0 22", "N 40 42 46"), ("-74 0 22", "40 42 46"), ("-74.006", "40.7128")] { + // From elsewhere each time, so the text is what moves the map. + s.run("map.goToXY", &json!({"lon": 0, "lat": 0})).unwrap(); let mark = s.notice_mark(); s.run("map.goToXY", &json!({"lon": lon, "lat": lat})).unwrap(); assert_eq!(at(&s), (40.713, -74.006), "{lon} {lat}"); @@ -45881,6 +45883,7 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert!(axis_refused(&mut s, "48.85, 2.35", "48").contains("one value")); assert!(axis_refused(&mut s, "48.85 2.35", "48").contains("write the longitude")); // Typographic quotes, as pasted from a document, are marks like any other. + s.run("map.goToXY", &json!({"lon": 0, "lat": 0})).unwrap(); s.run("map.goToXY", &json!({"lon": "74°0’22”W", "lat": "40°42’46”N"})).unwrap(); assert_eq!(at(&s), (40.713, -74.006)); assert!(axis_refused(&mut s, &"9".repeat(500), "40").contains("longer than")); From 9d5c8de4d521cce8ccfa6be1ebbb978aa8bbc9d1 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:49:29 -0600 Subject: [PATCH 08/11] fix(navigate): a box's marks say which number is degrees, minutes or seconds --- crates/engine/src/cmd/navigate.rs | 69 +++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 11 +++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 6f5eca6514..c2efe0ab06 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -430,20 +430,71 @@ pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { if rest.contains(',') { return Err(format!("the {name} box takes one value, with a point for decimals (-74.006, not -74,006 or two numbers)")); } - let marks = |c: char| c.is_whitespace() || matches!(c, '°' | 'º' | '\'' | '′' | '’' | '"' | '″' | '”'); - let parts: Vec<&str> = rest.split(marks).filter(|w| !w.is_empty()).collect(); let usage = || format!("write the {name} in degrees (-74.006) or degrees, minutes and seconds (74°0′22″W)"); - if parts.is_empty() || parts.len() > 3 { - return Err(usage()); - } - let mut nums = Vec::with_capacity(3); - for p in &parts { - let v: f64 = p.replace('−', "-").parse().map_err(|_| usage())?; + // Each number goes where its mark says (° degrees, ′ minutes, ″ seconds), so `40°30″` is 40° + // and 30 seconds, not 30 minutes; a number without a mark takes the place after the last. + let mark = |c: char| match c { + '°' | 'º' => Some(0), + '\'' | '′' | '’' => Some(1), + '"' | '″' | '”' => Some(2), + _ => None, + }; + let mut places: [Option; 3] = [None; 3]; + let mut next = 0usize; + let mut number = String::new(); + let mut last: Option = None; + // A number, once read, waits for a mark that may follow it; it is placed when the next number + // starts or the text ends. + let mut pending: Option = None; + let mut place = |v: f64, at: Option, next: &mut usize, last: &mut Option| -> Result<(), String> { + let at = at.unwrap_or(*next); + let slot = places.get_mut(at).ok_or_else(usage)?; + if slot.is_some() || last.is_some_and(|l| at <= l) { + return Err(format!("the {name}'s degrees, minutes and seconds are out of order")); + } + *slot = Some(v); + *last = Some(at); + *next = at + 1; + Ok(()) + }; + let finish = |number: &mut String| -> Result, String> { + if number.is_empty() { + return Ok(None); + } + let v: f64 = number.replace('−', "-").parse().map_err(|_| usage())?; + number.clear(); if !v.is_finite() { return Err(usage()); } - nums.push(v); + Ok(Some(v)) + }; + for c in rest.chars().chain(std::iter::once(' ')) { + if c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '−') { + number.push(c); + continue; + } + if let Some(v) = finish(&mut number)? { + if let Some(p) = pending.take() { + place(p, None, &mut next, &mut last)?; + } + pending = Some(v); + } + if let Some(at) = mark(c) { + let v = pending.take().ok_or_else(usage)?; + place(v, Some(at), &mut next, &mut last)?; + } else if !c.is_whitespace() { + return Err(usage()); + } + } + if let Some(p) = pending.take() { + place(p, None, &mut next, &mut last)?; + } + // Degrees come first; a place skipped before a later one is zero (`40°30″` is 40° 0′ 30″). + let filled = last.ok_or_else(usage)? + 1; + if places.first().is_none_or(Option::is_none) { + return Err(usage()); } + let nums: Vec = places.iter().take(filled).map(|v| v.unwrap_or(0.0)).collect(); // Minutes follow whole degrees and seconds whole minutes (`48.85 2.35` is two values, not // degrees and minutes). if nums.iter().rev().skip(1).any(|v| v.fract() != 0.0) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 364dbcf5a3..ae27299038 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45882,6 +45882,17 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert!(axis_refused(&mut s, "2,35", "48").contains("one value")); assert!(axis_refused(&mut s, "48.85, 2.35", "48").contains("one value")); assert!(axis_refused(&mut s, "48.85 2.35", "48").contains("write the longitude")); + // A mark says which place a number fills: seconds after degrees with no minutes are seconds, + // not minutes; marks out of order, or no degrees, are refused. + for (lat, want) in [("40°30″N", 40.008), ("40 30″", 40.008), ("40°30′N", 40.5), ("40°30", 40.5), ("40°0′30″", 40.008)] { + s.run("map.goToXY", &json!({"lon": 0, "lat": 0})).unwrap(); + s.run("map.goToXY", &json!({"lon": "10", "lat": lat})).unwrap(); + assert_eq!(at(&s).0, want, "{lat}"); + } + assert!(axis_refused(&mut s, "10", "30″40°").contains("out of order")); + assert!(axis_refused(&mut s, "10", "40′30′").contains("out of order")); + assert!(axis_refused(&mut s, "10", "30′").contains("write the latitude")); + assert!(axis_refused(&mut s, "10", "°40").contains("write the latitude")); // Typographic quotes, as pasted from a document, are marks like any other. s.run("map.goToXY", &json!({"lon": 0, "lat": 0})).unwrap(); s.run("map.goToXY", &json!({"lon": "74°0’22”W", "lat": "40°42’46”N"})).unwrap(); From 09e47cd03f2be6134f9b34b83df0e0ccf303947e Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:45:29 -0600 Subject: [PATCH 09/11] fix(navigate): the Scale box takes commas only between thousands; a decimal comma is refused, not read as a bigger scale --- crates/ui-egui/src/dialogs.rs | 45 +++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5f1d70c3b6..cb846b147d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -365,8 +365,9 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { } else { json!({"lon": app.dialogs.a.trim(), "lat": app.dialogs.b.trim()}) }; - // The command refuses a scale it can't use. - let scale = app.dialogs.c.trim().replace(',', ""); + // The command refuses a scale it can't use: commas go only where they group + // thousands (50,000), so a decimal comma (1,5) is refused, not read as 15. + let scale = scale_text(&app.dialogs.c); if !scale.is_empty() { p["scale"] = json!(scale); } @@ -2438,10 +2439,50 @@ fn crs_details(ui: &mut egui::Ui, chosen: &str, current: &str, t: &Tokens) { } } +/// A scale box's text as the command reads it: commas that group thousands (`50,000`, +/// `1,000,000.5`) taken out, any other comma (a decimal comma, `1,5`) left in, so the command +/// refuses it rather than reading `15`. +fn scale_text(text: &str) -> String { + let text = text.trim(); + let whole = text.split_once('.').map_or(text, |(w, _)| w); + let grouped = whole.contains(',') && { + let mut groups = whole.split(','); + let first = groups.next().unwrap_or(""); + (1..=3).contains(&first.len()) + && first.chars().all(|c| c.is_ascii_digit()) + && groups.all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit())) + }; + let decimals = text.split_once('.').is_none_or(|(_, d)| !d.contains(',')); + if grouped && decimals { text.replace(',', "") } else { text.to_string() } +} + #[cfg(test)] mod tests { use super::*; + /// A scale's commas go only where they group thousands: a decimal comma is left for the + /// command to refuse, not read as a bigger number. + #[test] + fn a_scale_box_takes_commas_only_between_thousands() { + for (typed, sent) in [ + ("50,000", "50000"), + (" 1,000,000 ", "1000000"), + ("1,000,000.5", "1000000.5"), + ("50000", "50000"), + ("1,5", "1,5"), + ("50,00", "50,00"), + ("1234,567", "1234,567"), + (",500", ",500"), + ("1,000.5,5", "1,000.5,5"), + ("", ""), + ] { + assert_eq!(scale_text(typed), sent, "{typed}"); + } + let mut s = rustgis_engine::Session::new(rustgis_engine::Project::default()); + let refused = s.run("map.goToXY", &json!({"lon": 0, "lat": 0, "scale": scale_text("1,5")})).unwrap_err(); + assert!(refused.contains("scale must be a positive number"), "{refused}"); + } + /// The app in a harness, its fonts installed on the first pass (the dialog draws on the next). fn app_harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { let mut fonts = false; From 00d6db4a534fe3a132f30725e1aacd47c641ef9f Mon Sep 17 00:00:00 2001 From: giswqs <5016453+giswqs@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:36:44 +0000 Subject: [PATCH 10/11] fix(navigate): read only hemisphere letters in a number A word ending in N, S, E or W (Paris, Lisbon) in a coordinate box was refused as the other axis's hemisphere ("S belongs to the latitude"). A hemisphere letter now counts only where every letter in the text is one of N, S, E or W; other words get the usage message. The test covers both words and checks the message no longer says "belongs to". Co-Authored-By: Claude Haiku 5.5 Applied by the claude-autofix workflow. --- crates/engine/src/cmd/navigate.rs | 5 +++++ crates/engine/src/tests.rs | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index c2efe0ab06..4d60f5944e 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -409,11 +409,16 @@ pub(crate) fn read_axis(text: &str, latitude: bool) -> Result { return Err(format!("the {name} is longer than 64 characters")); } let (own, other) = if latitude { (['N', 'S'], ['E', 'W']) } else { (['E', 'W'], ['N', 'S']) }; + // A letter at either end is a hemisphere only in text that is otherwise a number: a word + // ending in N, S, E or W (`Paris`, `Lisbon`) gets the usage message, not a refusal as the + // other axis. + let hemispheres_only = text.chars().all(|c| !c.is_ascii_alphabetic() || matches!(c.to_ascii_uppercase(), 'N' | 'S' | 'E' | 'W')); let mut hemisphere = None; let mut rest = text; for at_end in [false, true] { let c = if at_end { rest.chars().next_back() } else { rest.chars().next() }; if let Some(c) = c.map(|c| c.to_ascii_uppercase()) + && hemispheres_only && (own.contains(&c) || other.contains(&c)) { if other.contains(&c) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6ffd858c7..d7fb992419 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47432,7 +47432,11 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { assert!(axis_refused(&mut s, "74 60 0", "40").contains("under 60")); assert!(axis_refused(&mut s, "-74W", "40").contains("both a minus sign")); assert!(axis_refused(&mut s, "74", "91 N").contains("within ±90")); - assert!(axis_refused(&mut s, "Paris", "40").contains("write the longitude")); + // A word that ends in N, S, E or W is no hemisphere: it gets the usage message. + for word in ["Paris", "Lisbon"] { + let refused = axis_refused(&mut s, word, "40"); + assert!(refused.contains("write the longitude") && !refused.contains("belongs to"), "{refused}"); + } // A decimal comma or a pasted pair in one box is refused, not read as degrees and minutes. assert!(axis_refused(&mut s, "2,35", "48").contains("one value")); assert!(axis_refused(&mut s, "48.85, 2.35", "48").contains("one value")); From 682831a10dcd2eeec2ecc05167a47745229184e1 Mon Sep 17 00:00:00 2001 From: Cory Douglas Martin <4108748+flight1973@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:26:38 -0600 Subject: [PATCH 11/11] fix(navigate): a position's minutes follow whole degrees, as in the X and Y boxes; the grid case starts elsewhere --- crates/engine/src/cmd/search.rs | 4 ++++ crates/engine/src/tests.rs | 5 +++++ crates/ui-egui/tests/smoke.rs | 2 ++ 3 files changed, 11 insertions(+) diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 16c60a9cd8..6b58a60a80 100644 --- a/crates/engine/src/cmd/search.rs +++ b/crates/engine/src/cmd/search.rs @@ -649,6 +649,10 @@ pub(crate) fn axis(nums: &[f64]) -> Option { if rest.len() > 2 || rest.iter().any(|v| !(0.0..60.0).contains(v)) { return None; } + // Minutes follow whole degrees and seconds whole minutes: `40.5°30′` says two things at once. + if nums.iter().rev().skip(1).any(|v| v.fract() != 0.0) { + return None; + } let m = rest.first().copied().unwrap_or(0.0); let sec = rest.get(1).copied().unwrap_or(0.0); let v = d.abs() + m / 60.0 + sec / 3600.0; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d7fb992419..2f8990c570 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47465,6 +47465,11 @@ fn go_to_xy_reads_a_position_as_the_search_box_does() { let e = refused(&mut s, json!({"position": "33UZZ1234512345"})); assert!(e.starts_with("no place at this grid reference") && e.contains("100 km square"), "{e}"); assert!(refused(&mut s, json!({"position": "Paris"})).contains("write a position as")); + // Minutes follow whole degrees and seconds whole minutes, as in the X and Y boxes: `40.5°30′` + // isn't read as 41°. + for q in ["40.5°30′N 74°W", "40°30.5′15″N 74°W"] { + assert!(refused(&mut s, json!({"position": q})).contains("write a position as"), "{q}"); + } for q in ["40.7, 200", "95, 100", "-91, 200"] { assert!(refused(&mut s, json!({"position": q})).contains("within ±90"), "{q}"); } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 34c864293f..c26963d1eb 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4664,6 +4664,8 @@ fn by_coordinates_reads_x_y_and_a_grid_reference() { assert_eq!(h.state().dialogs.open, Some("goToXY")); assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("scale")), "{:?}", h.state().dialogs.error); h.state_mut().dialogs.c = "50,000".into(); + // From elsewhere, so the grid reference is what brings the map to New York. + h.state_mut().session.run("map.goToXY", &json!({"lon": 10, "lat": 50})).unwrap(); h.step(); h.get_by_label("Go").click(); h.step();