// Degrees/minutes/seconds parsing and formatting. The parser is deliberately
// strict: a value with minutes or seconds outside their range is rejected
// instead of being silently normalised, because a coordinate with a typo is
// worse than an error.
///|
/// Parse one angle written as decimal degrees or as degrees/minutes/seconds.
///
/// Accepted forms include `51.5`, `-51.5`, `51 30 26.5 N`, `51°30'26.5"N`,
/// `51d30m26.5sN`, `51:30:26.5N` and a leading sign in place of a hemisphere.
/// `is_lat` selects the latitude range and the accepted hemisphere letters.
pub fn parse_dms(input : String, is_lat : Bool) -> Double? {
let s = ascii_upper(trim_ascii(input))
let chars = chars_of(s)
let mut end = chars.length()
if end == 0 {
return None
}
let mut sign = 1.0
let last = chars[end - 1]
if last == 'N' || last == 'S' || last == 'E' || last == 'W' {
if is_lat && (last == 'E' || last == 'W') {
return None
}
if !is_lat && (last == 'N' || last == 'S') {
return None
}
if last == 'S' || last == 'W' {
sign = -1.0
}
end = end - 1
}
let mut start = 0
while start < end && is_space_char(chars[start]) {
start = start + 1
}
if start < end && (chars[start] == '-' || chars[start] == '+') {
if chars[start] == '-' {
sign = -sign
}
start = start + 1
}
let values = number_tokens(chars, start, end)
if values.length() == 0 || values.length() > 3 {
return None
}
let deg = values[0]
let min = if values.length() > 1 { values[1] } else { 0.0 }
let sec = if values.length() > 2 { values[2] } else { 0.0 }
if !is_finite(deg) || !is_finite(min) || !is_finite(sec) {
return None
}
if deg < 0.0 || min < 0.0 || sec < 0.0 {
return None
}
if min >= 60.0 || sec >= 60.0 {
return None
}
let limit = if is_lat { 90.0 } else { 180.0 }
if deg > limit {
return None
}
if deg == limit && (min > 0.0 || sec > 0.0) {
return None
}
let value = deg + min / 60.0 + sec / 3600.0
Some(sign * value)
}
///|
fn is_space_char(c : Char) -> Bool {
c == ' ' || c == '\t'
}
///|
/// Split a run of characters into numeric tokens, treating the usual degree
/// symbols as separators. Returns the parsed values.
fn number_tokens(chars : Array[Char], start : Int, end : Int) -> Array[Double] {
let out : Array[Double] = []
let mut token : Array[Char] = []
let mut i = start
while i < end {
let c = chars[i]
if is_number_char(c) {
token.push(c)
} else if is_separator(c) {
if token.length() > 0 {
match parse_token(token) {
Some(v) => out.push(v)
None => return []
}
token = []
}
} else {
// An unknown character means the whole spelling is malformed.
return []
}
i = i + 1
}
if token.length() > 0 {
match parse_token(token) {
Some(v) => out.push(v)
None => return []
}
}
out
}
///|
fn is_number_char(c : Char) -> Bool {
(c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+'
}
///|
/// Characters that may separate the degree, minute and second fields. The
/// degree symbols are the Unicode ones as well as the ASCII spellings `d`,
/// `m` and `s`.
fn is_separator(c : Char) -> Bool {
c == ' ' ||
c == '\t' ||
c == ',' ||
c == ':' ||
c == '\'' ||
c == '"' ||
c == 'D' ||
c == 'M' ||
c == 'S' ||
c.to_int() == 0x00B0 ||
c.to_int() == 0x2032 ||
c.to_int() == 0x2033
}
///|
/// Parse a plain decimal number without exceptions. Coordinate tokens never
/// need an exponent, so this intentionally accepts only `[+-]?digits[.digits]`.
fn parse_token(chars : Array[Char]) -> Double? {
parse_decimal(string_of(chars))
}
///|
/// Format an angle as `D°M′S″H` with two decimal places on the seconds.
pub fn format_dms(value : Double, is_lat : Bool) -> String {
if !is_finite(value) {
return "--"
}
let limit = if is_lat { 90.0 } else { 180.0 }
let total = value.abs()
if total > limit {
return "--"
}
let d0 = total.floor()
let rem = (total - d0) * 60.0
let m0 = rem.floor()
let mut sec = round_to((rem - m0) * 60.0, 2)
let mut min = m0.to_int()
let mut deg = d0.to_int()
if sec >= 60.0 {
sec = sec - 60.0
min = min + 1
}
if min >= 60 {
min = min - 60
deg = deg + 1
}
let sec_text = format_fixed(sec, 2)
let sec_padded = if sec_text.length() < 5 {
"0".repeat(5 - sec_text.length()) + sec_text
} else {
sec_text
}
let min_text = if min < 10 { "0" + min.to_string() } else { min.to_string() }
let suffix = hemisphere_suffix(value, is_lat)
deg.to_string() +
DEGREE +
min_text +
PRIME +
sec_padded +
DOUBLE_PRIME +
suffix
}