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
9const 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#[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
108fn 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}