geojson-tile-renderer 0.1.0

Convert GeoJSON features to PNG tile images with Web Mercator projection
Documentation
use crate::error::{RenderError, Result};
use crate::types::TileCoordinate;
use geo_types::{Coord, Polygon};
use std::f64::consts::PI;

/// Bounding box in Web Mercator projection [minX, minY, maxX, maxY]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoundingBox {
	pub min_x: f64,
	pub min_y: f64,
	pub max_x: f64,
	pub max_y: f64,
}

impl BoundingBox {
	/// Create a new bounding box
	pub const fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
		Self {
			min_x,
			min_y,
			max_x,
			max_y,
		}
	}

	/// Get the width of the bounding box
	pub fn width(&self) -> f64 {
		self.max_x - self.min_x
	}

	/// Get the height of the bounding box
	pub fn height(&self) -> f64 {
		self.max_y - self.min_y
	}
}

/// Converts WGS84 latitude/longitude coordinates to Web Mercator projection.
/// Web Mercator (EPSG:3857) is the standard projection used by most web mapping services.
///
/// # Arguments
/// * `lon` - Longitude in degrees (-180 to 180)
/// * `lat` - Latitude in degrees (-90 to 90)
///
/// # Returns
/// Coordinate in Web Mercator projection (x, y) in degrees
pub fn lat_lon_to_web_mercator(lon: f64, lat: f64) -> Result<Coord<f64>> {
	// Validate inputs
	if !lon.is_finite() || !lat.is_finite() {
		return Err(RenderError::InvalidCoordinate(format!(
			"Coordinate values must be finite: lon={}, lat={}",
			lon, lat
		)));
	}

	// X coordinate in Web Mercator is just the longitude
	let x = lon;

	// Clamp latitude to avoid infinity at poles (Web Mercator limit)
	// Web Mercator cannot represent latitudes beyond ~85.05 degrees
	let clamped_lat = lat.clamp(-85.05112878, 85.05112878);

	// Apply Web Mercator transformation formula for Y coordinate
	// This stretches areas near the poles to create the characteristic Mercator distortion
	let y = ((PI / 4.0 + (clamped_lat * PI / 180.0) / 2.0).tan().ln()) * 180.0 / PI;

	Ok(Coord { x, y })
}

/// Converts a tile's z/x/y coordinates to a polygon representing the tile's bounds.
/// Uses the standard Web Mercator tile scheme where tiles are indexed from 0.
///
/// # Arguments
/// * `tile` - The tile coordinate (z/x/y)
///
/// # Returns
/// A polygon with the tile's geographic bounds in WGS84 coordinates
pub fn tile_to_bounds(tile: TileCoordinate) -> Polygon<f64> {
	// Number of tiles at this zoom level (2^zoom tiles per side)
	let n = 2f64.powi(tile.z as i32);

	// Convert tile x coordinate to longitude
	let lon_deg = |x: f64| (x / n) * 360.0 - 180.0;

	// Convert tile y coordinate to latitude using inverse Web Mercator formula
	let lat_rad = |y: f64| (PI * (1.0 - 2.0 * y / n)).sinh().atan();
	let lat_deg = |y: f64| (lat_rad(y) * 180.0) / PI;

	// Calculate the bounds of this tile
	let min_lon = lon_deg(tile.x as f64);
	let max_lon = lon_deg(tile.x as f64 + 1.0);
	let min_lat = lat_deg(tile.y as f64 + 1.0); // y+1 because y increases downward
	let max_lat = lat_deg(tile.y as f64);

	// Create polygon with tile bounds
	// Order: bottom-left, bottom-right, top-right, top-left, close
	Polygon::new(
		vec![
			Coord { x: min_lon, y: min_lat },
			Coord { x: max_lon, y: min_lat },
			Coord { x: max_lon, y: max_lat },
			Coord { x: min_lon, y: max_lat },
			Coord { x: min_lon, y: min_lat }, // close the ring
		]
		.into(),
		vec![], // no holes
	)
}

/// Transforms geographic coordinates to pixel coordinates within a tile image.
/// Handles the conversion from Web Mercator projected coordinates to screen pixels.
///
/// # Arguments
/// * `coords` - Slice of coordinates in WGS84 (longitude, latitude)
/// * `bbox` - Bounding box of the tile in Web Mercator projection
/// * `size` - Size of the tile image in pixels
/// * `x_scaling_factor` - Pixels per degree longitude for this tile
/// * `y_scaling_factor` - Pixels per degree latitude for this tile
/// * `offset` - Offset to add to coordinates (for canvas buffer)
///
/// # Returns
/// Vector of pixel coordinates where (0,0) is top-left
pub fn transform_coordinates_to_pixels(
	coords: &[Coord<f64>],
	bbox: &BoundingBox,
	size: f64,
	x_scaling_factor: f64,
	y_scaling_factor: f64,
	offset: f64,
) -> Result<Vec<Coord<f64>>> {
	coords
		.iter()
		.map(|&coord| {
			// Validate coordinate
			if !coord.x.is_finite() || !coord.y.is_finite() {
				return Err(RenderError::InvalidCoordinate(format!(
					"Coordinate values must be finite: x={}, y={}",
					coord.x, coord.y
				)));
			}

			// Convert WGS84 coordinates to Web Mercator projection
			// This ensures proper scaling at different latitudes
			let merc = lat_lon_to_web_mercator(coord.x, coord.y)?;

			// Transform from projected coordinates to pixel coordinates
			// Subtract the tile's minimum bounds to get relative position
			let x = (merc.x - bbox.min_x) * x_scaling_factor + offset;

			// Invert Y axis because screen coordinates have Y=0 at top
			// while geographic coordinates have Y increasing northward
			let y = size - (merc.y - bbox.min_y) * y_scaling_factor + offset;

			Ok(Coord { x, y })
		})
		.collect()
}

/// Transforms polygon coordinates (including holes) to pixel coordinates.
///
/// # Arguments
/// * `rings` - Slice of polygon rings, where each ring is a slice of coordinates
/// * `bbox` - Bounding box of the tile in Web Mercator projection
/// * `size` - Size of the tile image in pixels
/// * `x_scaling_factor` - Pixels per degree longitude for this tile
/// * `y_scaling_factor` - Pixels per degree latitude for this tile
/// * `offset` - Offset to add to coordinates (for canvas buffer)
///
/// # Returns
/// Vector of pixel coordinate rings
pub fn transform_polygon_coordinates_to_pixels(
	rings: &[Vec<Coord<f64>>],
	bbox: &BoundingBox,
	size: f64,
	x_scaling_factor: f64,
	y_scaling_factor: f64,
	offset: f64,
) -> Result<Vec<Vec<Coord<f64>>>> {
	rings
		.iter()
		.map(|ring| {
			transform_coordinates_to_pixels(ring, bbox, size, x_scaling_factor, y_scaling_factor, offset)
		})
		.collect()
}

#[cfg(test)]
mod tests {
	use super::*;
	use approx::assert_abs_diff_eq;

	#[test]
	fn test_lat_lon_to_web_mercator_basic() {
		// Test equator
		let coord = lat_lon_to_web_mercator(0.0, 0.0).unwrap();
		assert_abs_diff_eq!(coord.x, 0.0, epsilon = 1e-10);
		assert_abs_diff_eq!(coord.y, 0.0, epsilon = 1e-10);

		// Test longitude = x
		let coord = lat_lon_to_web_mercator(45.0, 0.0).unwrap();
		assert_abs_diff_eq!(coord.x, 45.0, epsilon = 1e-10);
	}

	#[test]
	fn test_lat_lon_to_web_mercator_hemispheres() {
		// Northern hemisphere
		let north = lat_lon_to_web_mercator(0.0, 45.0).unwrap();
		assert!(north.y > 0.0);

		// Southern hemisphere
		let south = lat_lon_to_web_mercator(0.0, -45.0).unwrap();
		assert!(south.y < 0.0);

		// Symmetric
		assert_abs_diff_eq!(north.y, -south.y, epsilon = 1e-10);
	}

	#[test]
	fn test_lat_lon_to_web_mercator_clamping() {
		// Test that latitudes beyond Web Mercator limits are clamped
		let max_lat = lat_lon_to_web_mercator(0.0, 85.05112878).unwrap();
		let beyond_max = lat_lon_to_web_mercator(0.0, 90.0).unwrap();
		assert_abs_diff_eq!(max_lat.y, beyond_max.y, epsilon = 1e-6);

		let min_lat = lat_lon_to_web_mercator(0.0, -85.05112878).unwrap();
		let beyond_min = lat_lon_to_web_mercator(0.0, -90.0).unwrap();
		assert_abs_diff_eq!(min_lat.y, beyond_min.y, epsilon = 1e-6);
	}

	#[test]
	fn test_lat_lon_to_web_mercator_invalid() {
		// Test invalid coordinates
		assert!(lat_lon_to_web_mercator(f64::NAN, 0.0).is_err());
		assert!(lat_lon_to_web_mercator(0.0, f64::NAN).is_err());
		assert!(lat_lon_to_web_mercator(f64::INFINITY, 0.0).is_err());
	}

	#[test]
	fn test_tile_to_bounds_zoom_0() {
		// At zoom 0, there's only one tile covering the whole world
		let tile = TileCoordinate::new(0, 0, 0).unwrap();
		let polygon = tile_to_bounds(tile);

		let exterior = polygon.exterior();
		let coords: Vec<_> = exterior.coords().collect();

		// Check bounds are approximately -180 to 180 longitude
		assert_abs_diff_eq!(coords[0].x, -180.0, epsilon = 1e-6);
		assert_abs_diff_eq!(coords[1].x, 180.0, epsilon = 1e-6);

		// Check that polygon is closed
		assert_eq!(coords.first().unwrap(), coords.last().unwrap());
	}

	#[test]
	fn test_tile_to_bounds_polygon_closed() {
		let tile = TileCoordinate::new(5, 10, 12).unwrap();
		let polygon = tile_to_bounds(tile);

		let exterior = polygon.exterior();
		let coords: Vec<_> = exterior.coords().collect();

		// Polygon should be closed (first == last)
		assert_eq!(coords.first().unwrap(), coords.last().unwrap());

		// Should have 5 coordinates (4 corners + closing point)
		assert_eq!(coords.len(), 5);
	}

	#[test]
	fn test_transform_coordinates_to_pixels_basic() {
		let coords = vec![Coord { x: 0.0, y: 0.0 }];
		let bbox = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
		let size = 256.0;
		let x_scaling = size / bbox.width();
		let y_scaling = size / bbox.height();

		let pixels = transform_coordinates_to_pixels(&coords, &bbox, size, x_scaling, y_scaling, 0.0).unwrap();

		assert_eq!(pixels.len(), 1);
		// Point at (0, 0) should be in the center
		assert_abs_diff_eq!(pixels[0].x, 128.0, epsilon = 1e-6);
		assert_abs_diff_eq!(pixels[0].y, 128.0, epsilon = 1e-6);
	}

	#[test]
	fn test_transform_coordinates_to_pixels_offset() {
		let coords = vec![Coord { x: 0.0, y: 0.0 }];
		let bbox = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
		let size = 256.0;
		let x_scaling = size / bbox.width();
		let y_scaling = size / bbox.height();
		let offset = 10.0;

		let pixels = transform_coordinates_to_pixels(&coords, &bbox, size, x_scaling, y_scaling, offset).unwrap();

		assert_eq!(pixels.len(), 1);
		// With offset, coordinates should be shifted
		assert_abs_diff_eq!(pixels[0].x, 138.0, epsilon = 1e-6);
		assert_abs_diff_eq!(pixels[0].y, 138.0, epsilon = 1e-6);
	}

	#[test]
	fn test_transform_coordinates_to_pixels_invalid() {
		let coords = vec![Coord { x: f64::NAN, y: 0.0 }];
		let bbox = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
		let size = 256.0;

		assert!(transform_coordinates_to_pixels(&coords, &bbox, size, 1.0, 1.0, 0.0).is_err());
	}

	#[test]
	fn test_transform_polygon_coordinates_to_pixels() {
		// Simple square polygon
		let rings = vec![vec![
			Coord { x: -1.0, y: -1.0 },
			Coord { x: 1.0, y: -1.0 },
			Coord { x: 1.0, y: 1.0 },
			Coord { x: -1.0, y: 1.0 },
			Coord { x: -1.0, y: -1.0 },
		]];

		let bbox = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
		let size = 256.0;
		let x_scaling = size / bbox.width();
		let y_scaling = size / bbox.height();

		let pixel_rings = transform_polygon_coordinates_to_pixels(&rings, &bbox, size, x_scaling, y_scaling, 0.0).unwrap();

		assert_eq!(pixel_rings.len(), 1);
		assert_eq!(pixel_rings[0].len(), 5);
	}

	#[test]
	fn test_bounding_box() {
		let bbox = BoundingBox::new(0.0, 0.0, 100.0, 50.0);
		assert_eq!(bbox.width(), 100.0);
		assert_eq!(bbox.height(), 50.0);
	}
}