Skip to main content

lat_long/
lat.rs

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