Skip to main content

smriti/services/
map_math.rs

1//! Web Mercator projection + slippy tile math.
2//!
3//! Tiles follow the OSM/Google/Bing convention: tile (0,0) at zoom 0
4//! covers the whole world. At zoom `z`, the world is a 2^z x 2^z grid
5//! of 256x256 tiles.
6
7pub const TILE_SIZE: f64 = 256.0;
8pub const MIN_ZOOM: u8 = 2;
9pub const MAX_ZOOM: u8 = 18;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct TileId {
13    pub z: u8,
14    pub x: u32,
15    pub y: u32,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct LatLng {
20    pub lat: f64,
21    pub lng: f64,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub struct Viewport {
26    pub width: f32,
27    pub height: f32,
28    pub center: LatLng,
29    pub zoom: u8,
30}
31
32/// Longitude to fractional tile X at zoom z.
33pub fn lng_to_tile_x(lng: f64, z: u8) -> f64 {
34    let n = 2f64.powi(z as i32);
35    (lng + 180.0) / 360.0 * n
36}
37
38/// Latitude to fractional tile Y at zoom z (Web Mercator).
39/// Clamps `lat` to [-85.0511, 85.0511] — the Mercator cutoff.
40pub fn lat_to_tile_y(lat: f64, z: u8) -> f64 {
41    let lat = lat.clamp(-85.05112878, 85.05112878);
42    let rad = lat.to_radians();
43    let n = 2f64.powi(z as i32);
44    (1.0 - (rad.tan() + 1.0 / rad.cos()).ln() / std::f64::consts::PI) / 2.0 * n
45}
46
47pub fn tile_x_to_lng(x: f64, z: u8) -> f64 {
48    let n = 2f64.powi(z as i32);
49    x / n * 360.0 - 180.0
50}
51
52pub fn tile_y_to_lat(y: f64, z: u8) -> f64 {
53    let n = 2f64.powi(z as i32);
54    let rad = (std::f64::consts::PI * (1.0 - 2.0 * y / n)).sinh().atan();
55    rad.to_degrees()
56}
57
58/// Given a viewport, return the (tile_x, tile_y) at the viewport's
59/// center in fractional tile coordinates.
60pub fn viewport_center_tile(v: &Viewport) -> (f64, f64) {
61    (
62        lng_to_tile_x(v.center.lng, v.zoom),
63        lat_to_tile_y(v.center.lat, v.zoom),
64    )
65}
66
67/// Project a LatLng to a pixel (x, y) within the viewport.
68/// Origin (0,0) is the top-left of the viewport.
69pub fn latlng_to_viewport_pixel(v: &Viewport, p: LatLng) -> (f32, f32) {
70    let (cx, cy) = viewport_center_tile(v);
71    let px = lng_to_tile_x(p.lng, v.zoom);
72    let py = lat_to_tile_y(p.lat, v.zoom);
73    let dx = (px - cx) * TILE_SIZE;
74    let dy = (py - cy) * TILE_SIZE;
75    (
76        (v.width as f64 / 2.0 + dx) as f32,
77        (v.height as f64 / 2.0 + dy) as f32,
78    )
79}
80
81/// Inverse: a viewport pixel -> LatLng.
82pub fn viewport_pixel_to_latlng(v: &Viewport, px: f32, py: f32) -> LatLng {
83    let (cx, cy) = viewport_center_tile(v);
84    let dx = (px as f64 - v.width as f64 / 2.0) / TILE_SIZE;
85    let dy = (py as f64 - v.height as f64 / 2.0) / TILE_SIZE;
86    LatLng {
87        lng: tile_x_to_lng(cx + dx, v.zoom),
88        lat: tile_y_to_lat(cy + dy, v.zoom),
89    }
90}
91
92/// Return every tile that intersects the viewport.
93pub fn visible_tiles(v: &Viewport) -> Vec<TileId> {
94    let (cx, cy) = viewport_center_tile(v);
95    let half_w = v.width as f64 / 2.0 / TILE_SIZE;
96    let half_h = v.height as f64 / 2.0 / TILE_SIZE;
97    let min_x = (cx - half_w).floor() as i64;
98    let max_x = (cx + half_w).ceil() as i64;
99    let min_y = (cy - half_h).floor() as i64;
100    let max_y = (cy + half_h).ceil() as i64;
101    let n = 1i64 << v.zoom;
102    let mut out = Vec::with_capacity(((max_x - min_x + 1) * (max_y - min_y + 1)) as usize);
103
104    for ty in min_y..=max_y {
105        if ty < 0 || ty >= n {
106            continue;
107        }
108        for tx in min_x..=max_x {
109            if tx < 0 || tx >= n {
110                continue;
111            }
112            out.push(TileId {
113                z: v.zoom,
114                x: tx as u32,
115                y: ty as u32,
116            });
117        }
118    }
119
120    out
121}
122
123/// Compute the bounding box of a set of pins, then return a (center,
124/// zoom) that fits all pins with a small padding margin.
125/// Returns a default map center + MIN_ZOOM if pins is empty.
126pub fn fit_bounds(pins: &[LatLng], viewport_w: f32, viewport_h: f32) -> (LatLng, u8) {
127    if pins.is_empty() {
128        return (
129            LatLng {
130                lat: 20.0,
131                lng: 0.0,
132            },
133            MIN_ZOOM,
134        );
135    }
136
137    if pins.len() == 1 {
138        return (pins[0], 13);
139    }
140
141    let (mut min_lat, mut max_lat) = (f64::MAX, f64::MIN);
142    let (mut min_lng, mut max_lng) = (f64::MAX, f64::MIN);
143    for p in pins {
144        min_lat = min_lat.min(p.lat);
145        max_lat = max_lat.max(p.lat);
146        min_lng = min_lng.min(p.lng);
147        max_lng = max_lng.max(p.lng);
148    }
149
150    let center = LatLng {
151        lat: (min_lat + max_lat) / 2.0,
152        lng: (min_lng + max_lng) / 2.0,
153    };
154
155    for z in (MIN_ZOOM..=MAX_ZOOM).rev() {
156        let v = Viewport {
157            width: viewport_w,
158            height: viewport_h,
159            center,
160            zoom: z,
161        };
162        let (tl_px, tl_py) = latlng_to_viewport_pixel(
163            &v,
164            LatLng {
165                lat: max_lat,
166                lng: min_lng,
167            },
168        );
169        let (br_px, br_py) = latlng_to_viewport_pixel(
170            &v,
171            LatLng {
172                lat: min_lat,
173                lng: max_lng,
174            },
175        );
176        let bbox_w = (br_px - tl_px).abs();
177        let bbox_h = (br_py - tl_py).abs();
178        if bbox_w <= viewport_w * 0.8 && bbox_h <= viewport_h * 0.8 {
179            return (center, z);
180        }
181    }
182
183    (center, MIN_ZOOM)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn zoom0_is_one_tile() {
192        assert!((lng_to_tile_x(-180.0, 0) - 0.0).abs() < 1e-9);
193        assert!((lng_to_tile_x(180.0, 0) - 1.0).abs() < 1e-9);
194    }
195
196    #[test]
197    fn greenwich_is_half_world() {
198        assert!((lng_to_tile_x(0.0, 1) - 1.0).abs() < 1e-9);
199    }
200
201    #[test]
202    fn inverse_roundtrip() {
203        for z in [2u8, 5, 10, 18] {
204            for &lat in &[-60.0, -30.0, 0.0, 30.0, 60.0] {
205                for &lng in &[-170.0, -90.0, 0.0, 90.0, 170.0] {
206                    let x = lng_to_tile_x(lng, z);
207                    let y = lat_to_tile_y(lat, z);
208                    let lng2 = tile_x_to_lng(x, z);
209                    let lat2 = tile_y_to_lat(y, z);
210
211                    assert!((lng - lng2).abs() < 1e-6, "lng z={} {} -> {}", z, lng, lng2);
212                    assert!((lat - lat2).abs() < 1e-6, "lat z={} {} -> {}", z, lat, lat2);
213                }
214            }
215        }
216    }
217
218    #[test]
219    fn viewport_center_projects_to_middle() {
220        let v = Viewport {
221            width: 800.0,
222            height: 600.0,
223            center: LatLng {
224                lat: 12.9716,
225                lng: 77.5946,
226            },
227            zoom: 10,
228        };
229
230        let (px, py) = latlng_to_viewport_pixel(&v, v.center);
231        assert!((px - 400.0).abs() < 1.0);
232        assert!((py - 300.0).abs() < 1.0);
233    }
234
235    #[test]
236    fn pixel_to_latlng_inverse() {
237        let v = Viewport {
238            width: 1000.0,
239            height: 800.0,
240            center: LatLng {
241                lat: 48.8566,
242                lng: 2.3522,
243            },
244            zoom: 12,
245        };
246
247        let ll = viewport_pixel_to_latlng(&v, 500.0, 400.0);
248        assert!((ll.lat - v.center.lat).abs() < 1e-6);
249        assert!((ll.lng - v.center.lng).abs() < 1e-6);
250    }
251
252    #[test]
253    fn visible_tiles_zoom2_paris() {
254        let v = Viewport {
255            width: 800.0,
256            height: 600.0,
257            center: LatLng {
258                lat: 48.85,
259                lng: 2.35,
260            },
261            zoom: 2,
262        };
263
264        let tiles = visible_tiles(&v);
265        assert!(!tiles.is_empty());
266        assert!(tiles.iter().all(|t| t.z == 2));
267        assert!(tiles.iter().all(|t| t.x < 4 && t.y < 4));
268    }
269
270    #[test]
271    fn fit_bounds_single_point_zooms_in() {
272        let pins = vec![LatLng {
273            lat: 12.97,
274            lng: 77.59,
275        }];
276        let (c, z) = fit_bounds(&pins, 1000.0, 800.0);
277        assert_eq!(z, 13);
278        assert!((c.lat - 12.97).abs() < 1e-6);
279    }
280
281    #[test]
282    fn fit_bounds_world_wide_returns_min_zoomish() {
283        let pins = vec![
284            LatLng {
285                lat: -60.0,
286                lng: -170.0,
287            },
288            LatLng {
289                lat: 60.0,
290                lng: 170.0,
291            },
292        ];
293        let (_c, z) = fit_bounds(&pins, 800.0, 600.0);
294        assert!(z <= 3);
295    }
296}