Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 246 additions & 4 deletions crates/engine/src/cmd/navigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]]}"#),
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

read_position hands the text to search::parse_lat_lon. In its hemisphere branch, the sign closure at crates/engine/src/cmd/search.rs:728 only forces a negative value for S or W. For N or E it returns the value unchanged, so -40 N, 74 W comes out as latitude -40 with no notice.

The By Coordinates boxes refuse the same contradiction: read_axis returns "the latitude has both a minus sign and a hemisphere letter" for -40 N. So the dialog and map.goToXY position now disagree about this input, and position resolves it silently, which the "Never silent" rule forbids.

Suggested direction: in parse_lat_lon, return None when a signed value carries a hemisphere letter, as read_axis does. Then add -40 N, 74 W to non_coordinates_are_refused and to the position test list. This is in search.rs, outside this diff, so I have not written a suggestion block.

// 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" }));
Comment thread
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")?),
Expand All @@ -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 {
Expand All @@ -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)
}

Expand Down
8 changes: 6 additions & 2 deletions crates/engine/src/cmd/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -644,11 +644,15 @@ fn tokens(text: &str) -> Option<Vec<Tok>> {
}

/// One axis: degrees, and maybe minutes and seconds; `None` for anything else.
fn axis(nums: &[f64]) -> Option<f64> {
pub(crate) fn axis(nums: &[f64]) -> Option<f64> {
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;
Expand Down
Loading
Loading