Skip to main content

lat_long/
lat.rs

1//! This module provides the [`Latitude`] type, [`crate::lat!`] macro, and associated constants.
2
3use crate::{
4    Angle, Error,
5    fmt::{FormatOptions, Formatter, formatter_impl},
6    inner,
7    parse::{self, Parsed, Value},
8};
9use core::{
10    fmt::{Debug, Display, Write},
11    str::FromStr,
12};
13use ordered_float::OrderedFloat;
14
15// ---------------------------------------------------------------------------
16// Public Types
17// ---------------------------------------------------------------------------
18
19/// A geographic latitude value, constrained to **−90 ≤ degrees ≤ 90**.
20///
21/// Positive values are north of the equator; negative values are south.
22///
23/// # Construction
24///
25/// Use [`Latitude::new`] to construct from degrees, minutes, and seconds, or
26/// [`TryFrom<inner::Float>`] if you already have a decimal-degree value.
27///
28/// # Examples
29///
30/// ```rust
31/// use lat_long::{Angle, Latitude};
32///
33/// let lat = Latitude::new(45, 30, 0.0).unwrap();
34/// assert!(lat.is_northern());
35///
36/// let equator = Latitude::new(0, 0, 0.0).unwrap();
37/// assert!(equator.is_on_equator());
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct Latitude(OrderedFloat<f64>);
41
42// ---------------------------------------------------------------------------
43// Public Constants
44// ---------------------------------------------------------------------------
45
46/// The geographic North Pole, at 90° N latitude.
47pub const NORTH_POLE: Latitude = Latitude(OrderedFloat(LATITUDE_LIMIT));
48
49/// The Arctic Circle, approximately 66.5° N latitude.
50///
51/// Latitudes at or above this value experience at least one full day of
52/// continuous daylight or darkness per year.
53pub const ARCTIC_CIRCLE: Latitude = Latitude(OrderedFloat(66.5));
54
55/// The Tropic of Cancer, approximately 23.5° N latitude.
56///
57/// The northernmost latitude at which the sun can appear directly overhead
58/// at solar noon (at the June solstice).
59pub const TROPIC_OF_CANCER: Latitude = Latitude(OrderedFloat(23.5));
60
61/// The equator, at 0° latitude.
62///
63/// The circle of latitude equidistant from both poles, dividing the globe
64/// into the northern and southern hemispheres.
65pub const EQUATOR: Latitude = Latitude(inner::ZERO);
66
67/// The Tropic of Capricorn, approximately 23.5° S latitude.
68///
69/// The southernmost latitude at which the sun can appear directly overhead
70/// at solar noon (at the December solstice).
71pub const TROPIC_OF_CAPRICORN: Latitude = Latitude(OrderedFloat(-23.5));
72
73/// The Antarctic Circle, approximately 66.5° S latitude.
74///
75/// Latitudes at or below this value experience at least one full day of
76/// continuous daylight or darkness per year.
77pub const ANTARCTIC_CIRCLE: Latitude = Latitude(OrderedFloat(-66.5));
78
79/// The geographic South Pole, at 90° S latitude.
80pub const SOUTH_POLE: Latitude = Latitude(OrderedFloat(-LATITUDE_LIMIT));
81
82// ---------------------------------------------------------------------------
83// Public Macros
84// ---------------------------------------------------------------------------
85
86#[macro_export]
87macro_rules! lat {
88    (N $degrees:expr, $minutes:expr, $seconds:expr) => {
89        lat!($degrees.abs(), $minutes, $seconds).unwrap()
90    };
91    (S $degrees:expr, $minutes:expr, $seconds:expr) => {
92        lat!(-$degrees.abs(), $minutes, $seconds).unwrap()
93    };
94    ($degrees:expr, $minutes:expr, $seconds:expr) => {
95        Latitude::new($degrees, $minutes, $seconds).unwrap()
96    };
97    (N $degrees:expr, $minutes:expr) => {
98        lat!($degrees.abs(), $minutes).unwrap()
99    };
100    (S $degrees:expr, $minutes:expr) => {
101        lat!(-$degrees.abs(), $minutes).unwrap()
102    };
103    ($degrees:expr, $minutes:expr) => {
104        lat!($degrees, $minutes, 0.0).unwrap()
105    };
106    (N $degrees:expr) => {
107        lat!($degrees.abs()).unwrap()
108    };
109    (S $degrees:expr) => {
110        lat!(-$degrees.abs()).unwrap()
111    };
112    ($degrees:expr) => {
113        lat!($degrees, 0, 0.0).unwrap()
114    };
115}
116
117// ---------------------------------------------------------------------------
118// Implementations
119// ---------------------------------------------------------------------------
120
121const LATITUDE_LIMIT: f64 = 90.0;
122
123impl Default for Latitude {
124    fn default() -> Self {
125        EQUATOR
126    }
127}
128
129impl TryFrom<f64> for Latitude {
130    type Error = Error;
131
132    fn try_from(value: f64) -> Result<Self, Self::Error> {
133        Self::try_from(OrderedFloat(value))
134    }
135}
136
137impl TryFrom<OrderedFloat<f64>> for Latitude {
138    type Error = Error;
139
140    fn try_from(value: OrderedFloat<f64>) -> Result<Self, Self::Error> {
141        if value.0 < -LATITUDE_LIMIT || value.0 > LATITUDE_LIMIT {
142            return Err(Error::InvalidAngle(value.into_inner(), LATITUDE_LIMIT));
143        }
144        Ok(Self(value))
145    }
146}
147
148impl From<Latitude> for OrderedFloat<f64> {
149    fn from(value: Latitude) -> Self {
150        value.0.into()
151    }
152}
153
154impl From<Latitude> for f64 {
155    fn from(value: Latitude) -> Self {
156        value.0.into()
157    }
158}
159
160impl FromStr for Latitude {
161    type Err = Error;
162
163    fn from_str(s: &str) -> Result<Self, Self::Err> {
164        match parse::parse_str(s)? {
165            Parsed::Angle(Value::Unknown(decimal)) => Self::try_from(decimal),
166            Parsed::Angle(Value::Latitude(lat)) => Ok(lat),
167            _ => Err(Error::InvalidAngle(0.0, 0.0)),
168        }
169    }
170}
171
172impl Display for Latitude {
173    /// Formats the latitude as decimal degrees by default, or as
174    /// degrees–minutes–seconds when the alternate flag (`{:#}`) is used.
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        if f.alternate() {
177            let mut buf = String::new();
178            self.format(&mut buf, &FormatOptions::dms_signed())?;
179            f.write_str(&buf)
180        } else {
181            Display::fmt(&(self.0), f)
182        }
183    }
184}
185
186impl Formatter for Latitude {
187    fn format<W: Write>(&self, f: &mut W, fmt: &FormatOptions) -> std::fmt::Result {
188        let fmt = (*fmt).with_labels(('N', 'S'));
189        formatter_impl(self.0, f, &fmt)
190    }
191}
192
193impl Angle for Latitude {
194    const MIN: Self = Self(OrderedFloat(-LATITUDE_LIMIT));
195    const MAX: Self = Self(OrderedFloat(LATITUDE_LIMIT));
196
197    fn new(degrees: i32, minutes: u32, seconds: f32) -> Result<Self, Error> {
198        if degrees < Self::MIN.as_float().0 as i32 || degrees > Self::MAX.as_float().0 as i32 {
199            return Err(Error::InvalidLatitudeDegrees(degrees));
200        }
201        // Delegate to inner helper; it verifies minutes/seconds.
202        // The only remaining failure path from try_from is if the decimal
203        // representation exceeds the limit (e.g. 90°0′0.000001″) — still
204        // report as InvalidLatitudeDegrees.
205        let float = inner::from_degrees_minutes_seconds(degrees, minutes, seconds)?;
206        Self::try_from(float).map_err(|_| Error::InvalidLatitudeDegrees(degrees))
207    }
208
209    fn as_float(&self) -> OrderedFloat<f64> {
210        self.0
211    }
212}
213
214impl Latitude {
215    /// Returns `true` if this latitude is exactly on the equator (0°).
216    #[must_use]
217    pub fn is_on_equator(&self) -> bool {
218        self.is_zero()
219    }
220
221    /// Returns `true` if this latitude is in the northern hemisphere (> 0°).
222    #[must_use]
223    pub fn is_northern(&self) -> bool {
224        self.is_nonzero_positive()
225    }
226
227    /// Returns `true` if this latitude is in the southern hemisphere (< 0°).
228    #[must_use]
229    pub fn is_southern(&self) -> bool {
230        self.is_nonzero_negative()
231    }
232
233    /// Returns `true` if this latitude is within the Arctic region (≥ [`ARCTIC_CIRCLE`], i.e. ≥ 66.5° N).
234    #[must_use]
235    pub fn is_arctic(&self) -> bool {
236        *self >= ARCTIC_CIRCLE
237    }
238
239    /// Returns `true` if this latitude is within the Antarctic region (≤ [`ANTARCTIC_CIRCLE`], i.e. ≤ 66.5° S).
240    #[must_use]
241    pub fn is_antarctic(&self) -> bool {
242        *self <= ANTARCTIC_CIRCLE
243    }
244
245    /// Returns `true` if this latitude is at or north of the [`TROPIC_OF_CANCER`] (≥ 23.5° N).
246    ///
247    /// Together with [`is_tropic_of_capricorn`](Self::is_tropic_of_capricorn) this is used to
248    /// identify locations within the tropical band.
249    #[must_use]
250    pub fn is_tropic_of_cancer(&self) -> bool {
251        *self >= TROPIC_OF_CANCER
252    }
253
254    /// Returns `true` if this latitude is at or south of the [`TROPIC_OF_CAPRICORN`] (≤ 23.5° S).
255    #[must_use]
256    pub fn is_tropic_of_capricorn(&self) -> bool {
257        *self <= TROPIC_OF_CAPRICORN
258    }
259
260    /// Returns `true` if this latitude lies within the tropical band (between the
261    /// [`TROPIC_OF_CANCER`] and [`TROPIC_OF_CAPRICORN`], i.e. within ±23.5°).
262    ///
263    /// Note: this returns `true` for latitudes *outside* the tropical band that
264    /// are ≥ [`TROPIC_OF_CANCER`] in the north or ≤ [`TROPIC_OF_CAPRICORN`] in
265    /// the south — see individual methods for precise semantics.
266    #[must_use]
267    pub fn is_tropical(&self) -> bool {
268        self.is_tropic_of_cancer() || self.is_tropic_of_capricorn()
269    }
270
271    /// Returns `true` if this latitude is within either polar region
272    /// (at or beyond [`ARCTIC_CIRCLE`] north or [`ANTARCTIC_CIRCLE`] south).
273    #[must_use]
274    pub fn is_polar(&self) -> bool {
275        self.is_arctic() || self.is_antarctic()
276    }
277}