diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index cf1269250..4d60f5944 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": 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]]}"#), @@ -329,12 +329,251 @@ fn zoom(s: &mut Session, factor: f64) -> CmdResult { Ok(Value::Null) } +/// 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. 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()); + } + 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) { + // Without N, S, E or W the order is the reader's assumption, so it is said. + 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 + // range, not misspelled. + let plain = |w: &&str| { + let digits = w.strip_prefix(['-', '+']).unwrap_or(w); + 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) { + 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() { + // 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) => 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']) }; + // 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) { + 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(); + } + } + // 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 usage = || format!("write the {name} in degrees (-74.006) or degrees, minutes and seconds (74°0′22″W)"); + // 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()); + } + 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) { + return Err(usage()); + } + 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")); + } + // 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 }; + 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 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)); + // What the reading assumed is said once the map has moved there, not before a refusal. + 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()); + } + 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 { + // 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()); } + // 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")?), @@ -345,7 +584,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 { @@ -356,6 +595,9 @@ pub(crate) fn go_to_xy(s: &mut Session, p: &Value) -> CmdResult { } m.camera.clamp(); s.record_extent(); + if let Some(said) = notice { + s.notify_text(said, false); + } Ok(Value::Null) } diff --git a/crates/engine/src/cmd/search.rs b/crates/engine/src/cmd/search.rs index 47ba56cd8..6b58a60a8 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. @@ -644,11 +644,15 @@ 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; } + // 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 6fe64a55d..2f8990c57 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47368,6 +47368,135 @@ 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'22\"W", + "40°42′46″N 74°0′22″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); + // 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), "{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")] + { + // 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}"); + 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")); + // 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")); + 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(); + 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(); + 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(); + 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}"); + } + // 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")); + 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(); + 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}"); +} + /// A mobile map package says what simplifying its layers changed or left alone (#1811): a line /// reaching 95° (bad data) has its vertex taken as the pole, and a part of one vertex left out; /// lines with a longitude too far out are left as they were; one notice per kind. A clean layer, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d000098c0..e1248788e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -216,8 +216,10 @@ impl Dialogs { self.flag = true; } "goToXY" => { + // The map's center, the longitude within ±180 even when the view has been panned + // past the antimeridian. let c = s.map().center_lonlat(); - self.a = format!("{:.4}", c.x); + 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()), @@ -394,21 +396,48 @@ 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!("Longitude (X)")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(200.0)).labelled_by(input_label.id); + 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(egui::TextEdit::singleline(&mut app.dialogs.b).desired_width(200.0)).labelled_by(input_label.id); + 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(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. + 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!("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!({"lon": app.dialogs.a.trim(), "lat": app.dialogs.b.trim()}); - if let Ok(s) = app.dialogs.c.trim().replace(',', "").parse::() { - p["scale"] = json!(s); + 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: 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); } app.run("map.goToXY", p).map(|_| ()) }); @@ -2637,6 +2666,23 @@ 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() } +} + /// The columns a name-based edit can reach. Keep both the initial choice and the picker in /// step with the command's case-insensitive lookup, including duplicate names in imported data. fn calculation_fields(dataset: &rustgis_engine::data::Dataset) -> impl Iterator { @@ -2647,6 +2693,29 @@ fn calculation_fields(dataset: &rustgis_engine::data::Dataset) -> impl Iterator< 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}"); + } + /// A field dialog opened on a standalone table acts on that table, and on no other once /// another project is open: table ids start again in each project. #[test] diff --git a/crates/ui-egui/src/mapmenu.rs b/crates/ui-egui/src/mapmenu.rs index f5a01341a..442979c32 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 123a11e0f..c26963d1e 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4625,3 +4625,70 @@ 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 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(); + 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.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.text = "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(); + // 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(); + 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:?}"); +} + +/// 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.as_str(), h.state().dialogs.b.as_str()), ("-170.0000", "10.0000")); + 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 7f7685548..5abb0ca19 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -11,7 +11,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 longitude -and latitude; **By Place or Address…** finds a place or address with a geocoder. **New Bookmark…** +(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` file. Imported bookmarks show the same place on a map in another projection; places that projection