Skip to main content

slint_mapping/
projection.rs

1//! Web Mercator projection — the one every slippy-map tile source uses.
2//!
3//! At zoom level `z` the world is `2^z × 2^z` tiles, each `256 × 256`
4//! pixels by convention. `lon ∈ (-180, 180]` maps linearly to
5//! `x ∈ [0, 2^z)`; `lat ∈ (-85.0511, 85.0511)` maps non-linearly to
6//! `y ∈ [0, 2^z)` via the Mercator formula. We work in tile-space
7//! floating-point coordinates (e.g. `tile_x = 4.7`) and convert to /
8//! from integer tile indices + pixel offsets at the boundary.
9//!
10//! References: <https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames>
11
12/// Tile edge length in pixels. Every common slippy-map source uses 256.
13/// Vector / retina sources may emit 512; we keep that as a per-source
14/// option (`Tile::size` in the Slint model) rather than a global const.
15pub const TILE_SIZE_DEFAULT: f64 = 256.0;
16
17/// Maximum latitude representable in Web Mercator. Beyond this the
18/// projection blows up to infinity (the math is `atanh(sin(lat))`).
19pub const MAX_LATITUDE: f64 = 85.051_128_779_806_59;
20
21/// Convert a geographic coordinate + zoom to fractional tile-space.
22///
23/// At zoom 0 the whole world is one tile so the result is in `[0, 1)`;
24/// at zoom 12 it's in `[0, 4096)`; etc.
25#[inline]
26pub fn lonlat_to_tile(longitude: f64, latitude: f64, zoom: f64) -> (f64, f64) {
27    let n = 2f64.powf(zoom);
28    let x = (longitude + 180.0) / 360.0 * n;
29    let lat_rad = latitude.clamp(-MAX_LATITUDE, MAX_LATITUDE).to_radians();
30    let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0 * n;
31    (x, y)
32}
33
34/// Inverse of [`lonlat_to_tile`].
35#[inline]
36pub fn tile_to_lonlat(tile_x: f64, tile_y: f64, zoom: f64) -> (f64, f64) {
37    let n = 2f64.powf(zoom);
38    let longitude = tile_x / n * 360.0 - 180.0;
39    let lat_rad = (std::f64::consts::PI * (1.0 - 2.0 * tile_y / n))
40        .sinh()
41        .atan();
42    (longitude, lat_rad.to_degrees())
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn roundtrip_zero_meridian() {
51        for z in 0..=18 {
52            let (x, y) = lonlat_to_tile(0.0, 0.0, z as f64);
53            let (lon, lat) = tile_to_lonlat(x, y, z as f64);
54            assert!((lon - 0.0).abs() < 1e-9, "z={z} lon drift");
55            assert!((lat - 0.0).abs() < 1e-9, "z={z} lat drift");
56        }
57    }
58
59    #[test]
60    fn roundtrip_known_cities() {
61        let cases = [
62            ("NYC", -74.0060, 40.7128),
63            ("Sydney", 151.2093, -33.8688),
64            ("Reykjavik", -21.9426, 64.1466),
65        ];
66        for (name, lon, lat) in cases {
67            for z in [0.0, 5.0, 10.0, 17.0] {
68                let (x, y) = lonlat_to_tile(lon, lat, z);
69                let (lon2, lat2) = tile_to_lonlat(x, y, z);
70                assert!((lon - lon2).abs() < 1e-9, "{name}@z{z} lon drift");
71                assert!((lat - lat2).abs() < 1e-9, "{name}@z{z} lat drift");
72            }
73        }
74    }
75
76    #[test]
77    fn z0_covers_world_in_one_tile() {
78        // At zoom 0, every point on earth maps into the single tile
79        // (0, 0) at the [0, 1) coordinate range.
80        for lon in [-179.9, -90.0, 0.0, 90.0, 179.9] {
81            for lat in [-80.0, -45.0, 0.0, 45.0, 80.0] {
82                let (x, y) = lonlat_to_tile(lon, lat, 0.0);
83                assert!((0.0..1.0).contains(&x), "x out of range: {x}");
84                assert!((0.0..1.0).contains(&y), "y out of range: {y}");
85            }
86        }
87    }
88}