Skip to main content

sofie_values/
coordinate.rs

1use crate::{Control, Input, Part, Present};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use sofie_core::show::Fields;
4use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlType, SqlValue};
5
6/// About a centimetre: far below what a float column or JSON number loses, so it round-trips.
7const STEPS_PER_DEGREE: f64 = 1e7;
8const MAXIMUM_LATITUDE: f64 = 90.0;
9const MAXIMUM_LONGITUDE: f64 = 180.0;
10const DEGREES: &[(&str, &str)] = &[("type", "number"), ("step", "any")];
11
12/// Whole ten-millionths of a degree, so two equal points compare equal.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub struct Coordinate {
15    latitude: i32,
16    longitude: i32,
17}
18
19impl Coordinate {
20    pub fn try_new(latitude: f64, longitude: f64) -> Result<Self> {
21        Ok(Self {
22            latitude: steps("latitude", latitude, MAXIMUM_LATITUDE)?,
23            longitude: steps("longitude", longitude, MAXIMUM_LONGITUDE)?,
24        })
25    }
26
27    #[must_use]
28    pub fn latitude(self) -> f64 {
29        f64::from(self.latitude) / STEPS_PER_DEGREE
30    }
31
32    #[must_use]
33    pub fn longitude(self) -> f64 {
34        f64::from(self.longitude) / STEPS_PER_DEGREE
35    }
36}
37
38fn steps(field: &str, degrees: f64, maximum: f64) -> Result<i32> {
39    if !degrees.is_finite() || degrees.abs() > maximum {
40        return Err(Error::invalid(
41            field,
42            format!("must be a number of degrees from -{maximum} to {maximum}"),
43        ));
44    }
45    #[expect(
46        clippy::cast_possible_truncation,
47        reason = "at most 180 degrees is 1.8e9 steps, inside i32"
48    )]
49    let steps = (degrees * STEPS_PER_DEGREE).round() as i32;
50    Ok(steps)
51}
52
53impl Show for Coordinate {
54    fn show(&self) -> Shown<'_> {
55        Shown::Record(self)
56    }
57}
58
59impl Fields for Coordinate {
60    fn name(&self, index: usize) -> Option<&str> {
61        ["latitude", "longitude"].get(index).copied()
62    }
63
64    fn get(&self, name: &str) -> Option<Shown<'_>> {
65        match name {
66            "latitude" => Some(Shown::Float(self.latitude())),
67            "longitude" => Some(Shown::Float(self.longitude())),
68            _ => None,
69        }
70    }
71}
72
73impl Cast for Coordinate {
74    const COLUMNS: &'static [ColumnSpec] = &[
75        ColumnSpec::new("_latitude", SqlType::Float).unordered(),
76        ColumnSpec::new("_longitude", SqlType::Float).unordered(),
77    ];
78
79    fn encode(&self, out: &mut Vec<SqlValue>) {
80        out.push(SqlValue::Float(self.latitude()));
81        out.push(SqlValue::Float(self.longitude()));
82    }
83
84    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
85        let latitude = f64::decode(row)?;
86        let longitude = f64::decode(row)?;
87        Self::try_new(latitude, longitude)
88            .map_err(|error| Error::internal("a stored coordinate is not valid", error))
89    }
90}
91
92#[derive(Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94struct CoordinateFields {
95    #[serde(deserialize_with = "degrees")]
96    latitude: f64,
97    #[serde(deserialize_with = "degrees")]
98    longitude: f64,
99}
100
101/// A form posts each part as text.
102fn degrees<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
103    #[derive(Deserialize)]
104    #[serde(untagged)]
105    enum Degrees {
106        Number(f64),
107        Text(String),
108    }
109    match Degrees::deserialize(deserializer)? {
110        Degrees::Number(degrees) => Ok(degrees),
111        Degrees::Text(text) => text
112            .trim()
113            .parse()
114            .map_err(|_| serde::de::Error::custom("must be a number of degrees")),
115    }
116}
117
118impl Serialize for Coordinate {
119    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
120        let fields = CoordinateFields {
121            latitude: self.latitude(),
122            longitude: self.longitude(),
123        };
124        fields.serialize(serializer)
125    }
126}
127
128impl<'de> Deserialize<'de> for Coordinate {
129    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
130        let fields = CoordinateFields::deserialize(deserializer)?;
131        Self::try_new(fields.latitude, fields.longitude).map_err(Error::into_deserialize_error)
132    }
133}
134
135impl Present for Coordinate {
136    const INPUT: Input = Input::new(Control::Parts(&[
137        Part {
138            name: "latitude",
139            attributes: DEGREES,
140            options: &[],
141        },
142        Part {
143            name: "longitude",
144            attributes: DEGREES,
145            options: &[],
146        },
147    ]));
148    const SEARCH: crate::Search = crate::Search::Skipped;
149
150    fn csv(&self) -> String {
151        format!("{},{}", self.latitude(), self.longitude())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::testing::{round_trip, suffixes};
159
160    #[test]
161    fn accepts_the_poles_and_the_antimeridian() {
162        for (latitude, longitude) in [(90.0, 180.0), (-90.0, -180.0), (0.0, 0.0)] {
163            let point = Coordinate::try_new(latitude, longitude).unwrap();
164            assert_eq!((point.latitude(), point.longitude()), (latitude, longitude));
165        }
166    }
167
168    #[test]
169    fn refuses_degrees_off_the_globe() {
170        for (latitude, longitude) in [
171            (90.000_000_1, 0.0),
172            (0.0, -180.000_000_1),
173            (f64::NAN, 0.0),
174            (0.0, f64::INFINITY),
175        ] {
176            assert!(
177                matches!(
178                    Coordinate::try_new(latitude, longitude),
179                    Err(Error::Invalid(_))
180                ),
181                "{latitude}, {longitude}"
182            );
183        }
184    }
185
186    #[test]
187    fn equal_points_compare_equal_below_the_precision() {
188        assert_eq!(
189            Coordinate::try_new(41.008_238_01, 28.978_359).unwrap(),
190            Coordinate::try_new(41.008_238, 28.978_359_04).unwrap()
191        );
192    }
193
194    #[test]
195    fn round_trips_through_two_float_columns() {
196        let point = Coordinate::try_new(41.008_238_1, 28.978_359_3).unwrap();
197        assert_eq!(
198            round_trip(&point),
199            vec![SqlValue::Float(41.008_238_1), SqlValue::Float(28.978_359_3)]
200        );
201        assert_eq!(suffixes::<Coordinate>(), ["_latitude", "_longitude"]);
202    }
203
204    #[test]
205    fn round_trips_through_json_as_numbers() {
206        let point = Coordinate::try_new(41.008_238, -28.978_359).unwrap();
207        let json = serde_json::to_value(point).unwrap();
208
209        assert_eq!(
210            json,
211            serde_json::json!({"latitude": 41.008_238, "longitude": -28.978_359})
212        );
213        assert_eq!(serde_json::from_value::<Coordinate>(json).unwrap(), point);
214        let off = serde_json::json!({"latitude": 91, "longitude": 0});
215        assert!(serde_json::from_value::<Coordinate>(off).is_err());
216    }
217}