Skip to main content

sofie_values/
coordinate.rs

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