Skip to main content

maps_engine_rust/
view.rs

1//! Camera / view state — center, zoom, bearing, pitch. Port of `view.ts`.
2
3use crate::geo::LonLat;
4use crate::projection::{from_pixels, to_pixels, TILE_SIZE};
5
6/// Map camera state.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct View {
9    pub center: LonLat,
10    /// Zoom level, fractional allowed.
11    pub zoom: f64,
12    /// Bearing in degrees clockwise from north.
13    pub bearing: f64,
14    /// Pitch (tilt) in degrees, 0 = top-down.
15    pub pitch: f64,
16}
17
18impl Default for View {
19    fn default() -> Self {
20        View {
21            center: LonLat::new(105.85, 21.02), // Hà Nội
22            zoom: 12.0,
23            bearing: 0.0,
24            pitch: 0.0,
25        }
26    }
27}
28
29impl View {
30    pub fn new(center: LonLat, zoom: f64) -> Self {
31        View {
32            center,
33            zoom,
34            bearing: 0.0,
35            pitch: 0.0,
36        }
37    }
38
39    /// Set pitch in degrees, clamped to `[0, 60]` like the TS engine.
40    pub fn set_pitch(&mut self, pitch: f64) {
41        self.pitch = pitch.clamp(0.0, 60.0);
42    }
43
44    /// Set bearing, normalized to `[0, 360)`.
45    pub fn set_bearing(&mut self, bearing: f64) {
46        self.bearing = ((bearing % 360.0) + 360.0) % 360.0;
47    }
48
49    /// Pixel scale factor: world pixels per screen pixel at this zoom.
50    pub fn scale(&self) -> f64 {
51        let z_floor = self.zoom.floor();
52        let frac = self.zoom - z_floor;
53        2f64.powf(frac)
54    }
55
56    /// Convert a lon/lat to screen pixels relative to the view center,
57    /// given the viewport size. Bearing rotation is applied.
58    pub fn project(&self, p: LonLat, viewport_w: f64, viewport_h: f64) -> (f64, f64) {
59        let z = self.zoom.floor() as u8;
60        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
61        let (px, py) = to_pixels(p.lon, p.lat, z);
62        let s = self.scale();
63        let mut dx = (px - cx) * s;
64        let mut dy = (py - cy) * s;
65        if self.bearing != 0.0 {
66            let r = self.bearing.to_radians();
67            let (sin, cos) = r.sin_cos();
68            let rx = dx * cos - dy * sin;
69            let ry = dx * sin + dy * cos;
70            dx = rx;
71            dy = ry;
72        }
73        // Simple pitch foreshortening on the y axis.
74        if self.pitch != 0.0 {
75            dy *= self.pitch.to_radians().cos().max(0.3);
76        }
77        (viewport_w / 2.0 + dx, viewport_h / 2.0 + dy)
78    }
79
80    /// Inverse of [`View::project`] (ignores pitch foreshortening).
81    pub fn unproject(&self, sx: f64, sy: f64, viewport_w: f64, viewport_h: f64) -> LonLat {
82        let z = self.zoom.floor() as u8;
83        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
84        let s = self.scale();
85        let mut dx = (sx - viewport_w / 2.0) / s;
86        let mut dy = (sy - viewport_h / 2.0) / s;
87        if self.bearing != 0.0 {
88            let r = (-self.bearing).to_radians();
89            let (sin, cos) = r.sin_cos();
90            let rx = dx * cos - dy * sin;
91            let ry = dx * sin + dy * cos;
92            dx = rx;
93            dy = ry;
94        }
95        let (lon, lat) = from_pixels(cx + dx, cy + dy, z);
96        LonLat::new(lon, lat)
97    }
98
99    /// Visible tile range for a viewport: (x0, y0, x1, y1) inclusive at floored zoom.
100    pub fn visible_tiles(&self, viewport_w: f64, viewport_h: f64) -> (u32, u32, u32, u32) {
101        let z = self.zoom.floor() as u8;
102        let n = 2u32.pow(z as u32);
103        let size = TILE_SIZE as f64 * self.scale();
104        let (cx, cy) = to_pixels(self.center.lon, self.center.lat, z);
105        let x0 = ((cx * self.scale() - viewport_w / 2.0) / size)
106            .floor()
107            .max(0.0) as u32;
108        let y0 = ((cy * self.scale() - viewport_h / 2.0) / size)
109            .floor()
110            .max(0.0) as u32;
111        let x1 = ((cx * self.scale() + viewport_w / 2.0) / size)
112            .floor()
113            .min((n - 1) as f64) as u32;
114        let y1 = ((cy * self.scale() + viewport_h / 2.0) / size)
115            .floor()
116            .min((n - 1) as f64) as u32;
117        (x0, y0, x1, y1)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn center_projects_to_viewport_center() {
127        let v = View::default();
128        let (sx, sy) = v.project(v.center, 800.0, 600.0);
129        assert!((sx - 400.0).abs() < 1e-6);
130        assert!((sy - 300.0).abs() < 1e-6);
131    }
132
133    #[test]
134    fn project_unproject_roundtrip() {
135        let v = View::new(LonLat::new(105.85, 21.02), 13.5);
136        let p = LonLat::new(105.90, 21.05);
137        let (sx, sy) = v.project(p, 800.0, 600.0);
138        let back = v.unproject(sx, sy, 800.0, 600.0);
139        assert!((back.lon - p.lon).abs() < 1e-6, "lon");
140        assert!((back.lat - p.lat).abs() < 1e-6, "lat");
141    }
142
143    #[test]
144    fn pitch_clamped() {
145        let mut v = View::default();
146        v.set_pitch(90.0);
147        assert_eq!(v.pitch, 60.0);
148        v.set_pitch(-10.0);
149        assert_eq!(v.pitch, 0.0);
150    }
151
152    #[test]
153    fn bearing_normalized() {
154        let mut v = View::default();
155        v.set_bearing(370.0);
156        assert!((v.bearing - 10.0).abs() < 1e-9);
157        v.set_bearing(-30.0);
158        assert!((v.bearing - 330.0).abs() < 1e-9);
159    }
160}