powerio_dist/glm/
coordinates.rs1use std::collections::BTreeMap;
2
3use powerio_core::Error;
4
5use super::{GlmPropertyValue, ObjectIndex, ObjectKey, Statement, TokenKind};
6
7#[derive(Clone, Copy, Debug, Default, PartialEq)]
11pub struct GlmCoordinates {
12 pub latitude: Option<f64>,
13 pub longitude: Option<f64>,
14}
15
16impl ObjectIndex<'_> {
17 pub fn declared_coordinates(&self) -> Result<Vec<GlmCoordinates>, Error> {
26 let mut result = vec![GlmCoordinates::default(); self.objects().len()];
27 let mut events = Vec::new();
28 for object in self.objects() {
29 for statement in object.other_statements() {
30 if let Statement::Simple { tokens, span } = statement
31 && tokens
32 .first()
33 .is_some_and(|token| matches!(token.text(), "latitude" | "longitude"))
34 {
35 return Err(self.fail(span, "coordinate assignment is missing its value"));
36 }
37 }
38 for property in object.properties() {
39 if matches!(property.name, "name" | "latitude" | "longitude") {
40 events.push((object, property));
41 }
42 }
43 }
44 events.sort_by_key(|(_, property)| property.span.byte_start());
47 let mut named: BTreeMap<&str, ObjectKey> = BTreeMap::new();
48 for (object, property) in events {
49 if property.name == "name" {
50 if let Some(name) = object.name() {
51 named.insert(name, object.key());
52 }
53 continue;
54 }
55 let fail = |message| self.fail(property.span, message);
56 let GlmPropertyValue::Tokens(tokens) = &property.value else {
57 return Err(fail("coordinate cannot be an inline object"));
58 };
59 let [token] = *tokens else {
60 return Err(fail(
61 "coordinate requires one literal or object-axis reference",
62 ));
63 };
64 let text = if token.kind() == TokenKind::Quoted {
65 token
66 .text()
67 .strip_prefix('"')
68 .and_then(|s| s.strip_suffix('"'))
69 .ok_or_else(|| fail("coordinate strings require double quotes"))?
70 } else {
71 token.text()
72 };
73 let latitude = property.name == "latitude";
74 let value = if let Some(reference) = text.strip_prefix('(') {
75 let (name, axis) = reference
76 .strip_suffix(')')
77 .and_then(|s| s.split_once('.'))
78 .ok_or_else(|| fail("expected (object.latitude) or (object.longitude)"))?;
79 if axis != property.name {
80 return Err(fail("coordinate reference must use the same axis"));
81 }
82 let key = named.get(name).ok_or_else(|| {
83 fail("coordinate reference requires an object named before this assignment")
84 })?;
85 let point = result[key.ordinal()];
86 if latitude {
87 point.latitude
88 } else {
89 point.longitude
90 }
91 } else {
92 Some(parse_coordinate(text, latitude).map_err(fail)?)
93 };
94 let point = &mut result[object.key().ordinal()];
95 if latitude {
96 point.latitude = value;
97 } else {
98 point.longitude = value;
99 }
100 }
101 Ok(result)
102 }
103}
104
105fn parse_coordinate(text: &str, latitude: bool) -> Result<f64, &'static str> {
106 let hemispheres = if latitude { ['N', 'S'] } else { ['E', 'W'] };
107 let value = if let Some(position) = text.find(hemispheres) {
108 let (degrees, tail) = text.split_at(position);
109 if degrees.is_empty() || !degrees.bytes().all(|c| c.is_ascii_digit()) {
110 return Err("hemisphere coordinates require unsigned integer degrees");
111 }
112 let degrees = number(degrees)?;
113 let (minutes, seconds) = if let Some((minutes, seconds)) = tail[1..].split_once(':') {
114 if minutes.is_empty() || !minutes.bytes().all(|c| c.is_ascii_digit()) {
115 return Err("DMS coordinates require unsigned integer minutes");
116 }
117 (number(minutes)?, number(seconds)?)
118 } else {
119 (number(&tail[1..])?, 0.0)
120 };
121 if !(0.0..60.0).contains(&minutes) || !(0.0..60.0).contains(&seconds) {
122 return Err("coordinate minutes and seconds must be in [0, 60)");
123 }
124 let magnitude = degrees + minutes / 60.0 + seconds / 3600.0;
125 if tail.starts_with(hemispheres[1]) {
126 -magnitude
127 } else {
128 magnitude
129 }
130 } else {
131 number(text)?
132 };
133 let limit = if latitude { 90.0 } else { 180.0 };
134 if !(-limit..=limit).contains(&value) {
135 return Err("coordinate is outside the geographic axis range");
136 }
137 Ok(value)
138}
139
140fn number(text: &str) -> Result<f64, &'static str> {
141 let value = text
142 .parse::<f64>()
143 .map_err(|_| "invalid coordinate number")?;
144 if !value.is_finite() {
145 return Err("coordinate must be finite");
146 }
147 Ok(value)
148}