toolbox_rs/
vector_tile.rs

1//! Vector tile coordinate handling and conversions
2//!
3//! This module provides functionality for converting between different coordinate systems
4//! used in vector tile mapping:
5//! - WGS84 coordinates (latitude/longitude)
6//! - Pixel coordinates within tiles
7//! - Tile coordinates (x, y, zoom)
8//!
9//! # Vector Tiles
10//!
11//! Vector tiles are square vector images, typically 256×256 pixels, that together
12//! create a slippy map. The tile coordinate system:
13//! - Origin (0,0) is at the top-left corner
14//! - Tiles are addressed by x, y coordinates and zoom level
15//! - At zoom level z, the map consists of 2^z × 2^z tiles
16//!
17//! # Coordinate Systems
18//!
19//! ## Tile Coordinates
20//! - x: Column number from left (0 to 2^zoom - 1)
21//! - y: Row number from top (0 to 2^zoom - 1)
22//! - zoom: Detail level (typically 0-20)
23//!
24//! ## Pixel Coordinates
25//! - Within each tile: 0 to TILE_SIZE-1 (typically 256)
26//! - Global: 0 to 2^zoom * TILE_SIZE
27//!
28//! # Examples
29//!
30//! ```rust
31//! use toolbox_rs::vector_tile::{degree_to_pixel_lon, degree_to_pixel_lat};
32//! use toolbox_rs::wgs84::{FloatLatitude, FloatLongitude};
33//!
34//! // Convert Dresden coordinates to pixels at zoom level 12
35//! let lat = FloatLatitude(51.0504);
36//! let lon = FloatLongitude(13.7373);
37//! let zoom = 12;
38//!
39//! let px_x = degree_to_pixel_lon(lon, zoom);
40//! let px_y = degree_to_pixel_lat(lat, zoom);
41//! ```
42//!
43//! # Implementation Notes
44//!
45//! - Pixel coordinates increase from west to east (x) and north to south (y)
46//! - The equator is centered at y = 2^(zoom-1) * TILE_SIZE
47//! - Greenwich meridian is centered at x = 2^(zoom-1) * TILE_SIZE
48
49use std::f64::consts::PI;
50
51use crate::{
52    mercator::{lat_to_y, lat_to_y_approx, lon_to_x, y_to_lat},
53    wgs84::{FloatCoordinate, FloatLatitude, FloatLongitude},
54};
55
56/// Size of a map tile in pixels
57const TILE_SIZE: usize = 4096;
58
59/// Converts longitude in degrees to pixel x-coordinate
60///
61/// # Arguments
62/// * `lon` - Longitude in degrees
63/// * `zoom` - Zoom level
64pub fn degree_to_pixel_lon(lon: FloatLongitude, zoom: u32) -> f64 {
65    let shift = (1 << zoom) * TILE_SIZE;
66    let b = shift as f64 / 2.0;
67    b * (1.0 + lon.0 / 180.0)
68}
69
70/// Converts latitude in degrees to pixel y-coordinate
71///
72/// # Arguments
73/// * `lat` - Latitude in degrees
74/// * `zoom` - Zoom level
75pub fn degree_to_pixel_lat(lat: FloatLatitude, zoom: u32) -> f64 {
76    let shift = (1 << zoom) * TILE_SIZE;
77    let b = shift as f64 / 2.0;
78    b * (1.0 - lat_to_y(lat) / 180.0)
79}
80
81/// Converts pixel coordinates to degrees
82///
83/// # Arguments
84/// * `shift` - Pixel shift based on zoom level (2^zoom * TILE_SIZE)
85/// * `x` - x-coordinate in pixels (modified in place)
86/// * `y` - y-coordinate in pixels (modified in place)
87pub fn pixel_to_degree(shift: usize, x: &mut f64, y: &mut f64) {
88    let b = shift as f64 / 2.0;
89    *x = ((*x - b) / shift as f64) * 360.0;
90    let normalized_y = *y / shift as f64;
91    let lat_rad = std::f64::consts::PI * (1.0 - 2.0 * normalized_y);
92    *y = y_to_lat(lat_rad.to_degrees()).0;
93}
94
95/// Converts WGS84 coordinates to tile coordinates at a given zoom level
96///
97/// This function implements the standard Web Mercator projection used in web mapping.
98/// It converts geographical coordinates (latitude/longitude) to tile numbers that
99/// identify which tile contains the coordinate at the specified zoom level.
100///
101/// # Arguments
102/// * `coordinate` - WGS84 coordinate (latitude/longitude)
103/// * `zoom` - Zoom level (0-20)
104///
105/// # Returns
106/// A tuple (x, y) containing the tile coordinates:
107/// * x: Tile column (0 to 2^zoom - 1)
108/// * y: Tile row (0 to 2^zoom - 1)
109///
110/// # Examples
111/// ```rust
112/// use toolbox_rs::wgs84::{FloatCoordinate, FloatLatitude, FloatLongitude};
113/// use toolbox_rs::vector_tile::coordinate_to_tile_number;
114///
115/// let coordinate = FloatCoordinate {
116///     lat: FloatLatitude(50.20731),
117///     lon: FloatLongitude(8.57747),
118/// };
119///
120/// // Convert to tile coordinates at zoom level 14
121/// let (tile_x, tile_y) = coordinate_to_tile_number(coordinate, 14);
122///
123/// // Verify we get the correct tile
124/// assert_eq!(tile_x, 8582);
125/// assert_eq!(tile_y, 5541);
126/// ```
127///
128/// # Implementation Details
129/// The conversion uses the following formulas:
130/// * x_tile = floor((longitude + 180) / 360 * 2^zoom)
131/// * y_tile = floor((1 - ln(tan(lat) + 1/cos(lat)) / π) * 2^(zoom-1))
132pub fn coordinate_to_tile_number(coordinate: FloatCoordinate, zoom: u32) -> (u32, u32) {
133    let n = (1 << zoom) as f64;
134
135    let x_tile = (n * (coordinate.lon.0 + 180.0) / 360.0) as u32;
136
137    let lat_rad = coordinate.lat.0.to_radians();
138    let y_tile = (n * (1.0 - (lat_rad.tan() + (1.0 / lat_rad.cos())).ln() / PI) / 2.0) as u32;
139
140    (x_tile, y_tile)
141}
142
143#[derive(Debug)]
144pub struct TileBounds {
145    pub min_lon: FloatLongitude,
146    pub min_lat: FloatLatitude,
147    pub max_lon: FloatLongitude,
148    pub max_lat: FloatLatitude,
149}
150
151/// Calculates the WGS84 coordinate bounds of a map tile
152///
153/// Each tile is defined by its position (x, y) and zoom level.
154/// The returned coordinates describe a rectangle in WGS84 coordinates
155/// that fully encompasses the tile.
156///
157/// # Arguments
158/// * `zoom` - Zoom level (0-20)
159/// * `x` - X coordinate of the tile (0 to 2^zoom - 1)
160/// * `y` - Y coordinate of the tile (0 to 2^zoom - 1)
161///
162/// # Returns
163/// A `TileBounds` object containing the boundary coordinates:
164/// - `min_lon`: Western boundary
165/// - `min_lat`: Southern boundary
166/// - `max_lon`: Eastern boundary
167/// - `max_lat`: Northern boundary
168///
169/// # Examples
170/// ```rust
171/// use toolbox_rs::vector_tile::get_tile_bounds;
172///
173/// // Central Berlin at zoom 14
174/// let bounds = get_tile_bounds(14, 8802, 5373);
175/// assert!(bounds.min_lon.0 >= 13.4033203125);
176/// assert!(bounds.max_lon.0 <= 13.42529296875);
177/// ```
178///
179/// # Mathematical Details
180/// The calculation is based on the Web Mercator projection:
181/// - Longitude: linear from -180° to +180°
182/// - Latitude: non-linear due to Mercator projection
183/// - Y coordinates are calculated using arctan(sinh(π * (1-2y/2^zoom)))
184pub fn get_tile_bounds(zoom: u32, x: u32, y: u32) -> TileBounds {
185    let n = (1u32 << zoom) as f64;
186
187    let lon1 = x as f64 / n * 360.0 - 180.0;
188    let lon2 = (x + 1) as f64 / n * 360.0 - 180.0;
189    let lat1 = (PI * (1.0 - 2.0 * y as f64 / n)).sinh().atan().to_degrees();
190    let lat2 = (PI * (1.0 - 2.0 * (y + 1) as f64 / n))
191        .sinh()
192        .atan()
193        .to_degrees();
194
195    TileBounds {
196        min_lon: FloatLongitude(lon1),
197        min_lat: FloatLatitude(lat1),
198        max_lon: FloatLongitude(lon2),
199        max_lat: FloatLatitude(lat2),
200    }
201}
202
203/// Converts WGS84 coordinates to tile-local pixel coordinates
204///
205/// For a given sequence of WGS84 coordinates, this function computes the corresponding
206/// pixel coordinates within a specific tile. The resulting coordinates are in the range
207/// 0..TILE_SIZE-1 (typically 0..4095).
208///
209/// # Arguments
210/// * `points` - Slice of WGS84 coordinates to convert
211/// * `zoom` - Zoom level of the target tile
212/// * `tile_x` - X coordinate of the target tile
213/// * `tile_y` - Y coordinate of the target tile
214///
215/// # Returns
216/// A vector of (x,y) tuples containing pixel coordinates within the tile
217///
218/// # Examples
219/// ```rust
220/// use toolbox_rs::wgs84::{FloatCoordinate, FloatLatitude, FloatLongitude};
221/// use toolbox_rs::vector_tile::linestring_to_tile_coords;
222///
223/// // Create a line in central Berlin
224/// let points = vec![
225///     FloatCoordinate {
226///         lat: FloatLatitude(52.52),
227///         lon: FloatLongitude(13.405),
228///     },
229///     FloatCoordinate {
230///         lat: FloatLatitude(52.53),
231///         lon: FloatLongitude(13.410),
232///     },
233/// ];
234///
235/// // Convert to tile coordinates (tile 8802/5373 at zoom 14)
236/// let tile_coords = linestring_to_tile_coords(&points, 14, 8802, 5373);
237///
238/// // Verify coordinates are within tile bounds (0..4096)
239/// for (x, y) in tile_coords {
240///     assert!(x < 4096);
241///     assert!(y < 4096);
242/// }
243/// ```
244pub fn linestring_to_tile_coords(
245    points: &[FloatCoordinate],
246    zoom: u32,
247    tile_x: u32,
248    tile_y: u32,
249) -> Vec<(u32, u32)> {
250    let tile_bounds = get_tile_bounds(zoom, tile_x, tile_y);
251
252    // Tile bounds in Web Mercator
253    let min_x = lon_to_x(tile_bounds.min_lon);
254    let max_x = lon_to_x(tile_bounds.max_lon);
255    let min_y = lat_to_y_approx(tile_bounds.min_lat);
256    let max_y = lat_to_y_approx(tile_bounds.max_lat);
257
258    let x_span = max_x - min_x;
259    let y_span = max_y - min_y;
260
261    points
262        .iter()
263        .map(|coordinate| {
264            let x = lon_to_x(coordinate.lon);
265            let y = lat_to_y_approx(coordinate.lat);
266
267            // normalize to tile coordinates, i.e. 0..TILE_SIZE
268            let tile_x = ((x - min_x) * (TILE_SIZE as f64 - 1.0) / x_span) as u32;
269            let tile_y = ((y - min_y) * (TILE_SIZE as f64 - 1.0) / y_span) as u32;
270
271            (
272                tile_x.min(TILE_SIZE as u32 - 1),
273                tile_y.min(TILE_SIZE as u32 - 1),
274            )
275        })
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use crate::wgs84::FloatCoordinate;
282
283    use super::*;
284
285    const TEST_COORDINATES: [(f64, f64); 4] = [
286        (0.0, 0.0),     // equator
287        (51.0, 13.0),   // Dresden
288        (-33.9, 151.2), // Sydney
289        (85.0, 180.0),  // near pole
290    ];
291
292    #[test]
293    fn test_pixel_coordinates() {
294        // Test for zoom level 0
295        let center = FloatCoordinate {
296            lat: FloatLatitude(0.0),
297            lon: FloatLongitude(0.0),
298        };
299
300        let px_lat = degree_to_pixel_lat(center.lat, 0);
301        let px_lon = degree_to_pixel_lon(center.lon, 0);
302
303        assert!((px_lat - TILE_SIZE as f64 / 2.0).abs() < f64::EPSILON);
304        assert!((px_lon - TILE_SIZE as f64 / 2.0).abs() < f64::EPSILON);
305    }
306
307    #[test]
308    fn test_pixel_to_degree() {
309        let test_cases = [
310            // shift,    x_in,   y_in,    x_out,  y_out
311            (256, 128.0, 128.0, 0.0, 0.0),     // center at zoom 0
312            (512, 256.0, 256.0, 0.0, 0.0),     // center at zoom 1
313            (256, 0.0, 0.0, -180.0, 85.0),     // northwest corner
314            (256, 256.0, 256.0, 180.0, -85.0), // southeast corner
315        ];
316
317        for (shift, x_in, y_in, x_expected, y_expected) in test_cases {
318            let mut x = x_in;
319            let mut y = y_in;
320            pixel_to_degree(shift, &mut x, &mut y);
321
322            assert!(
323                (x - x_expected).abs() < 1e-10,
324                "x-coordinate wrong, shift={}: expected={}, result={}",
325                shift,
326                x_expected,
327                x
328            );
329
330            assert!(
331                (y - y_expected).abs() < 1.0,
332                "y-coordinate wrong, shift={}: expected={}, result={}",
333                shift,
334                y_expected,
335                y
336            );
337        }
338
339        // test roundtrip with degree_to_pixel_*
340        for &(lat, lon) in TEST_COORDINATES.iter() {
341            let zoom = 1u32;
342            let shift = (1 << zoom) * TILE_SIZE;
343
344            let orig_lat = FloatLatitude(lat);
345            let orig_lon = FloatLongitude(lon);
346
347            let px_x = degree_to_pixel_lon(orig_lon, zoom);
348            let px_y = degree_to_pixel_lat(orig_lat, zoom);
349
350            let mut x = px_x;
351            let mut y = px_y;
352            pixel_to_degree(shift, &mut x, &mut y);
353
354            assert!(
355                (x - lon).abs() < 1e-10,
356                "Longitude roundtrip failed: {} -> ({}, {}) -> {}",
357                lon,
358                px_x,
359                px_y,
360                x
361            );
362
363            assert!(
364                (y - lat).abs() < 1.0,
365                "Latitude roundtrip failed: {} -> ({}, {}) -> {}",
366                lat,
367                px_x,
368                px_y,
369                y
370            );
371        }
372    }
373
374    #[test]
375    fn test_degree_to_pixel_lat_zoom_levels() {
376        let test_coordinates = [
377            FloatLatitude(0.0),   // equator
378            FloatLatitude(51.0),  // Frankfurt
379            FloatLatitude(-33.9), // Sydney
380            FloatLatitude(85.0),  // near pole
381        ];
382
383        for zoom in 0..=18 {
384            let shift = (1 << zoom) * TILE_SIZE;
385            let center = shift as f64 / 2.0;
386
387            for &lat in &test_coordinates {
388                let px = degree_to_pixel_lat(lat, zoom);
389
390                // equator should be centered
391                if (lat.0 - 0.0).abs() < f64::EPSILON {
392                    assert!(
393                        (px - center).abs() < f64::EPSILON,
394                        "equator not centered at zoom {zoom}: expected={center}, result={px}"
395                    );
396                }
397
398                // Pixel coordinates must be within valid range
399                assert!(
400                    px >= 0.0 && px <= shift as f64,
401                    "Pixel coordinate outside valid range at zoom {}: lat={}, px={}",
402                    zoom,
403                    lat.0,
404                    px
405                );
406
407                // roundtrip test
408                let mut x = 0.0;
409                let mut y = px;
410                pixel_to_degree(shift, &mut x, &mut y);
411                assert!(
412                    (y - lat.0).abs() < 1.0,
413                    "Roundtrip failed at zoom {}: {} -> {} -> {}",
414                    zoom,
415                    lat.0,
416                    px,
417                    y
418                );
419            }
420        }
421    }
422
423    #[test]
424    fn test_coordinate_to_tile_conversion() {
425        let test_cases = [
426            (
427                // downtown Berlin
428                FloatCoordinate {
429                    lat: FloatLatitude(52.52),
430                    lon: FloatLongitude(13.405),
431                },
432                14,   // zoom
433                8802, // tile_x
434                5373, // tile_y
435            ),
436            (
437                FloatCoordinate {
438                    lat: FloatLatitude(50.20731),
439                    lon: FloatLongitude(8.57747),
440                },
441                14,   // zoom
442                8582, // tile_x
443                5541, // tile_y
444            ),
445            (
446                // coordinate on tile boundary
447                FloatCoordinate {
448                    lat: FloatLatitude(52.5224609375),
449                    lon: FloatLongitude(13.4033203125),
450                },
451                14,   // zoom
452                8802, // tile_x
453                5373, // tile_y
454            ),
455            (
456                FloatCoordinate {
457                    lat: FloatLatitude(52.5224609375),
458                    lon: FloatLongitude(13.4033203125),
459                },
460                14,
461                8802,
462                5373,
463            ),
464            (
465                // Hachiku statue in Tokyo
466                FloatCoordinate {
467                    lat: FloatLatitude(35.6590699),
468                    lon: FloatLongitude(139.7006793),
469                },
470                18,
471                232798,
472                103246,
473            ),
474        ];
475
476        for (coordinate, zoom, tile_x, tile_y) in test_cases {
477            let tile_coords = coordinate_to_tile_number(coordinate, zoom);
478
479            assert_eq!(
480                tile_coords.0, tile_x,
481                "x-coordinate mismatch: expected={}, result={}",
482                tile_x, tile_coords.0
483            );
484            assert_eq!(
485                tile_coords.1, tile_y,
486                "y-coordinate mismatch: expected={}, result={}",
487                tile_y, tile_coords.1
488            );
489        }
490    }
491
492    #[test]
493    fn test_linestring_to_tile_coords() {
494        let test_cases = [
495            // case 1: Berlin
496            (
497                vec![
498                    FloatCoordinate {
499                        lat: FloatLatitude(52.52),
500                        lon: FloatLongitude(13.405),
501                    },
502                    FloatCoordinate {
503                        lat: FloatLatitude(52.53),
504                        lon: FloatLongitude(13.410),
505                    },
506                ],
507                14,                          // zoom
508                8802,                        // tile_x
509                5373,                        // tile_y,
510                vec![(313, 890), (1244, 0)], // expected
511            ),
512            // case 2: tile boundary
513            (
514                vec![FloatCoordinate {
515                    lat: FloatLatitude(52.5224609375),
516                    lon: FloatLongitude(13.4033203125),
517                }],
518                14,             // zoom
519                8802,           // tile_x
520                5373,           // tile_y
521                vec![(0, 136)], // expected
522            ),
523        ];
524
525        for (points, zoom, tile_x, tile_y, expected) in test_cases {
526            let tile_coords = linestring_to_tile_coords(&points, zoom, tile_x, tile_y);
527
528            assert_eq!(tile_coords, expected, "Tile coordinates mismatch");
529
530            assert_eq!(
531                tile_coords.len(),
532                points.len(),
533                "number of points and tile coordinates differ"
534            );
535
536            // check tile coordinates are plausible
537            if points.len() >= 2 {
538                let (x1, y1) = tile_coords[0];
539                let (x2, y2) = tile_coords[1];
540
541                // compute Manhattan distance between points
542                let manhattan_dist =
543                    ((x2 as i32 - x1 as i32).abs() + (y2 as i32 - y1 as i32).abs()) as u32;
544
545                assert!(
546                    manhattan_dist < TILE_SIZE as u32,
547                    "implausible tile coordinates: {:?} -> {:?}, distance={}",
548                    tile_coords[0],
549                    tile_coords[1],
550                    manhattan_dist
551                );
552            }
553        }
554    }
555}