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
6const 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#[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
101fn 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}