Repository navigation
feat(navigate): By Coordinates takes DMS, UTM and MGRS #2002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cdf52ff
de083db
2667471
1e5c380
5771a6c
3cdd98c
183b641
ca90a6e
9d5c8de
09e47cd
753c5b0
00d6db4
682831a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,7 +42,7 @@ pub fn commands() -> Vec<C> { | |
| .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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quality (confidence ~0.6): a minus sign with N or E is silently read as the other hemisphere.
The By Coordinates boxes refuse the same contradiction: Suggested direction: in |
||
| // 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<f64, String> { | ||
| 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" })); | ||
|
giswqs marked this conversation as resolved.
Resolved
|
||
| } | ||
| 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<f64>; 3] = [None; 3]; | ||
| let mut next = 0usize; | ||
| let mut number = String::new(); | ||
| let mut last: Option<usize> = 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<f64> = None; | ||
| let mut place = |v: f64, at: Option<usize>, next: &mut usize, last: &mut Option<usize>| -> 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<Option<f64>, 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<f64> = 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<f64, String> { | ||
| 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::<f64>().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) | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.