maps-engine-rust 0.1.0

Zero-dependency map engine core in Rust — Web Mercator projection, tiles, geofencing, clustering, navigation math.
Documentation
//! Camera / view state — center, zoom, bearing, pitch. Port of `view.ts`.

use crate::geo::LonLat;
use crate::projection::{from_pixels, to_pixels, TILE_SIZE};

/// Map camera state.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct View {
    pub center: LonLat,
    /// Zoom level, fractional allowed.
    pub zoom: f64,
    /// Bearing in degrees clockwise from north.
    pub bearing: f64,
    /// Pitch (tilt) in degrees, 0 = top-down.
    pub pitch: f64,
}

impl Default for View {
    fn default() -> Self {
        View {
            center: LonLat::new(105.85, 21.02), // Hà Nội
            zoom: 12.0,
            bearing: 0.0,
            pitch: 0.0,
        }
    }
}

impl View {
    pub fn new(center: LonLat, zoom: f64) -> Self {
        View {
            center,
            zoom,
            bearing: 0.0,
            pitch: 0.0,
        }
    }

    /// Set pitch in degrees, clamped to `[0, 60]` like the TS engine.
    pub fn set_pitch(&mut self, pitch: f64) {
        self.pitch = pitch.clamp(0.0, 60.0);
    }

    /// Set bearing, normalized to `[0, 360)`.
    pub fn set_bearing(&mut self, bearing: f64) {
        self.bearing = ((bearing % 360.0) + 360.0) % 360.0;
    }

    /// Pixel scale factor: world pixels per screen pixel at this zoom.
    pub fn scale(&self) -> f64 {
        let z_floor = self.zoom.floor();
        let frac = self.zoom - z_floor;
        2f64.powf(frac)
    }

    /// Convert a lon/lat to screen pixels relative to the view center,
    /// given the viewport size. Bearing rotation is applied.
    pub fn project(&self, p: LonLat, viewport_w: f64, viewport_h: f64) -> (f64, f64) {
        let z = self.zoom.floor() as u8;
        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
        let (px, py) = to_pixels(p.lon, p.lat, z);
        let s = self.scale();
        let mut dx = (px - cx) * s;
        let mut dy = (py - cy) * s;
        if self.bearing != 0.0 {
            let r = self.bearing.to_radians();
            let (sin, cos) = r.sin_cos();
            let rx = dx * cos - dy * sin;
            let ry = dx * sin + dy * cos;
            dx = rx;
            dy = ry;
        }
        // Simple pitch foreshortening on the y axis.
        if self.pitch != 0.0 {
            dy *= self.pitch.to_radians().cos().max(0.3);
        }
        (viewport_w / 2.0 + dx, viewport_h / 2.0 + dy)
    }

    /// Inverse of [`View::project`] (ignores pitch foreshortening).
    pub fn unproject(&self, sx: f64, sy: f64, viewport_w: f64, viewport_h: f64) -> LonLat {
        let z = self.zoom.floor() as u8;
        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
        let s = self.scale();
        let mut dx = (sx - viewport_w / 2.0) / s;
        let mut dy = (sy - viewport_h / 2.0) / s;
        if self.bearing != 0.0 {
            let r = (-self.bearing).to_radians();
            let (sin, cos) = r.sin_cos();
            let rx = dx * cos - dy * sin;
            let ry = dx * sin + dy * cos;
            dx = rx;
            dy = ry;
        }
        let (lon, lat) = from_pixels(cx + dx, cy + dy, z);
        LonLat::new(lon, lat)
    }

    /// Visible tile range for a viewport: (x0, y0, x1, y1) inclusive at floored zoom.
    pub fn visible_tiles(&self, viewport_w: f64, viewport_h: f64) -> (u32, u32, u32, u32) {
        let z = self.zoom.floor() as u8;
        let n = 2u32.pow(z as u32);
        let size = TILE_SIZE as f64 * self.scale();
        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
        let x0 = ((cx * self.scale() - viewport_w / 2.0) / size)
            .floor()
            .max(0.0) as u32;
        let y0 = ((cy * self.scale() - viewport_h / 2.0) / size)
            .floor()
            .max(0.0) as u32;
        let x1 = ((cx * self.scale() + viewport_w / 2.0) / size)
            .floor()
            .min((n - 1) as f64) as u32;
        let y1 = ((cy * self.scale() + viewport_h / 2.0) / size)
            .floor()
            .min((n - 1) as f64) as u32;
        (x0, y0, x1, y1)
    }
}

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

    #[test]
    fn center_projects_to_viewport_center() {
        let v = View::default();
        let (sx, sy) = v.project(v.center, 800.0, 600.0);
        assert!((sx - 400.0).abs() < 1e-6);
        assert!((sy - 300.0).abs() < 1e-6);
    }

    #[test]
    fn project_unproject_roundtrip() {
        let v = View::new(LonLat::new(105.85, 21.02), 13.5);
        let p = LonLat::new(105.90, 21.05);
        let (sx, sy) = v.project(p, 800.0, 600.0);
        let back = v.unproject(sx, sy, 800.0, 600.0);
        assert!((back.lon - p.lon).abs() < 1e-6, "lon");
        assert!((back.lat - p.lat).abs() < 1e-6, "lat");
    }

    #[test]
    fn pitch_clamped() {
        let mut v = View::default();
        v.set_pitch(90.0);
        assert_eq!(v.pitch, 60.0);
        v.set_pitch(-10.0);
        assert_eq!(v.pitch, 0.0);
    }

    #[test]
    fn bearing_normalized() {
        let mut v = View::default();
        v.set_bearing(370.0);
        assert!((v.bearing - 10.0).abs() < 1e-9);
        v.set_bearing(-30.0);
        assert!((v.bearing - 330.0).abs() < 1e-9);
    }
}