maps-engine-rust 0.1.0

Zero-dependency map engine core in Rust — Web Mercator projection, tiles, geofencing, clustering, navigation math.
Documentation
//! Web Mercator projection — port of `projection.ts`.
//!
//! Converts between geographic coordinates (longitude/latitude in degrees)
//! and world/pixel coordinates used by slippy-map tile systems.

use std::f64::consts::PI;

/// Maximum latitude representable in Web Mercator (≈85.05112878°).
pub const MAX_LAT: f64 = 85.05112878;
/// Earth radius used by Web Mercator in meters.
pub const EARTH_RADIUS: f64 = 6_378_137.0;
/// Tile size in pixels.
pub const TILE_SIZE: u32 = 256;

/// Clamp latitude to the Web Mercator valid range.
#[inline]
pub fn clamp_lat(lat: f64) -> f64 {
    lat.clamp(-MAX_LAT, MAX_LAT)
}

/// Project longitude/latitude (degrees) to normalized world coordinates in `[0, 1)`.
#[inline]
pub fn project(lon: f64, lat: f64) -> (f64, f64) {
    let lat = clamp_lat(lat);
    let x = (lon + 180.0) / 360.0;
    let sin = (lat * PI / 180.0).sin();
    let y = 0.5 - ((1.0 + sin) / (1.0 - sin)).ln() / (4.0 * PI);
    (x, y)
}

/// Inverse-project normalized world coordinates in `[0, 1)` back to lon/lat degrees.
#[inline]
pub fn unproject(x: f64, y: f64) -> (f64, f64) {
    let lon = x * 360.0 - 180.0;
    let n = PI * (1.0 - 2.0 * y);
    let lat = 180.0 / PI * (0.5 * (n.exp() - (-n).exp())).atan();
    (lon, lat)
}

/// Project lon/lat to pixel coordinates at a given zoom level.
#[inline]
pub fn to_pixels(lon: f64, lat: f64, zoom: u8) -> (f64, f64) {
    let scale = (TILE_SIZE as f64) * 2f64.powi(zoom as i32);
    let (x, y) = project(lon, lat);
    (x * scale, y * scale)
}

/// Convert pixel coordinates at a zoom level back to lon/lat.
#[inline]
pub fn from_pixels(px: f64, py: f64, zoom: u8) -> (f64, f64) {
    let scale = (TILE_SIZE as f64) * 2f64.powi(zoom as i32);
    unproject(px / scale, py / scale)
}

/// Meters per pixel at a given latitude and zoom.
#[inline]
pub fn meters_per_pixel(lat: f64, zoom: u8) -> f64 {
    let lat_rad = clamp_lat(lat) * PI / 180.0;
    2.0 * PI * EARTH_RADIUS * lat_rad.cos() / ((TILE_SIZE as f64) * 2f64.powi(zoom as i32))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn origin_projects_to_center() {
        let (x, y) = project(0.0, 0.0);
        assert!((x - 0.5).abs() < 1e-12);
        assert!((y - 0.5).abs() < 1e-12);
    }

    #[test]
    fn roundtrip() {
        for &(lon, lat) in &[(105.85, 21.02), (-73.99, 40.73), (139.69, 35.68)] {
            let (x, y) = project(lon, lat);
            let (lon2, lat2) = unproject(x, y);
            assert!((lon - lon2).abs() < 1e-9, "lon {lon}");
            assert!((lat - lat2).abs() < 1e-9, "lat {lat}");
        }
    }

    #[test]
    fn clamps_extreme_latitude() {
        let (_, y1) = project(0.0, 90.0);
        let (_, y2) = project(0.0, MAX_LAT);
        assert!((y1 - y2).abs() < 1e-12);
    }

    #[test]
    fn pixel_roundtrip_at_zoom() {
        let (px, py) = to_pixels(105.85, 21.02, 14);
        let (lon, lat) = from_pixels(px, py, 14);
        assert!((lon - 105.85).abs() < 1e-9);
        assert!((lat - 21.02).abs() < 1e-9);
    }
}