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}