Skip to main content

maps_engine_rust/
projection.rs

1//! Web Mercator projection — port of `projection.ts`.
2//!
3//! Converts between geographic coordinates (longitude/latitude in degrees)
4//! and world/pixel coordinates used by slippy-map tile systems.
5
6use std::f64::consts::PI;
7
8/// Maximum latitude representable in Web Mercator (≈85.05112878°).
9pub const MAX_LAT: f64 = 85.05112878;
10/// Earth radius used by Web Mercator in meters.
11pub const EARTH_RADIUS: f64 = 6_378_137.0;
12/// Tile size in pixels.
13pub const TILE_SIZE: u32 = 256;
14
15/// Clamp latitude to the Web Mercator valid range.
16#[inline]
17pub fn clamp_lat(lat: f64) -> f64 {
18    lat.clamp(-MAX_LAT, MAX_LAT)
19}
20
21/// Project longitude/latitude (degrees) to normalized world coordinates in `[0, 1)`.
22#[inline]
23pub fn project(lon: f64, lat: f64) -> (f64, f64) {
24    let lat = clamp_lat(lat);
25    let x = (lon + 180.0) / 360.0;
26    let sin = (lat * PI / 180.0).sin();
27    let y = 0.5 - ((1.0 + sin) / (1.0 - sin)).ln() / (4.0 * PI);
28    (x, y)
29}
30
31/// Inverse-project normalized world coordinates in `[0, 1)` back to lon/lat degrees.
32#[inline]
33pub fn unproject(x: f64, y: f64) -> (f64, f64) {
34    let lon = x * 360.0 - 180.0;
35    let n = PI * (1.0 - 2.0 * y);
36    let lat = 180.0 / PI * (0.5 * (n.exp() - (-n).exp())).atan();
37    (lon, lat)
38}
39
40/// Project lon/lat to pixel coordinates at a given zoom level.
41#[inline]
42pub fn to_pixels(lon: f64, lat: f64, zoom: u8) -> (f64, f64) {
43    let scale = (TILE_SIZE as f64) * 2f64.powi(zoom as i32);
44    let (x, y) = project(lon, lat);
45    (x * scale, y * scale)
46}
47
48/// Convert pixel coordinates at a zoom level back to lon/lat.
49#[inline]
50pub fn from_pixels(px: f64, py: f64, zoom: u8) -> (f64, f64) {
51    let scale = (TILE_SIZE as f64) * 2f64.powi(zoom as i32);
52    unproject(px / scale, py / scale)
53}
54
55/// Meters per pixel at a given latitude and zoom.
56#[inline]
57pub fn meters_per_pixel(lat: f64, zoom: u8) -> f64 {
58    let lat_rad = clamp_lat(lat) * PI / 180.0;
59    2.0 * PI * EARTH_RADIUS * lat_rad.cos() / ((TILE_SIZE as f64) * 2f64.powi(zoom as i32))
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn origin_projects_to_center() {
68        let (x, y) = project(0.0, 0.0);
69        assert!((x - 0.5).abs() < 1e-12);
70        assert!((y - 0.5).abs() < 1e-12);
71    }
72
73    #[test]
74    fn roundtrip() {
75        for &(lon, lat) in &[(105.85, 21.02), (-73.99, 40.73), (139.69, 35.68)] {
76            let (x, y) = project(lon, lat);
77            let (lon2, lat2) = unproject(x, y);
78            assert!((lon - lon2).abs() < 1e-9, "lon {lon}");
79            assert!((lat - lat2).abs() < 1e-9, "lat {lat}");
80        }
81    }
82
83    #[test]
84    fn clamps_extreme_latitude() {
85        let (_, y1) = project(0.0, 90.0);
86        let (_, y2) = project(0.0, MAX_LAT);
87        assert!((y1 - y2).abs() < 1e-12);
88    }
89
90    #[test]
91    fn pixel_roundtrip_at_zoom() {
92        let (px, py) = to_pixels(105.85, 21.02, 14);
93        let (lon, lat) = from_pixels(px, py, 14);
94        assert!((lon - 105.85).abs() < 1e-9);
95        assert!((lat - 21.02).abs() < 1e-9);
96    }
97}