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