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::projection::{lat_lon_to_web_mercator, tile_to_bounds, BoundingBox};
use crate::svg::{generate_svg_items, SvgContext};
use crate::types::{Settings, TileCoordinate};
use geojson::{Feature, FeatureCollection};
use rayon::prelude::*;

/// Main tile renderer
pub struct TileRenderer {
	settings: Settings,
}

/// Builder for TileRenderer
pub struct TileRendererBuilder {
	settings: Option<Settings>,
}

impl Default for TileRendererBuilder {
	fn default() -> Self {
		Self { settings: None }
	}
}

impl TileRendererBuilder {
	/// Set custom settings
	pub fn settings(mut self, settings: Settings) -> Self {
		self.settings = Some(settings);
		self
	}

	/// Build the TileRenderer
	pub fn build(self) -> Result<TileRenderer> {
		let settings = self.settings.unwrap_or_default();
		Ok(TileRenderer { settings })
	}
}

impl TileRenderer {
	/// Create a new TileRenderer with default settings
	pub fn new() -> Self {
		Self {
			settings: Settings::default(),
		}
	}

	/// Create a builder for TileRenderer
	pub fn builder() -> TileRendererBuilder {
		TileRendererBuilder::default()
	}

	/// Renders GeoJSON features to a tile image
	///
	/// # Arguments
	/// * `geojson` - GeoJSON feature collection to render
	/// * `tile` - Tile coordinates (z/x/y)
	///
	/// # Returns
	/// PNG image buffer of the rendered tile
	pub fn render(&self, geojson: &FeatureCollection, tile: TileCoordinate) -> Result<Vec<u8>> {
		self.render_internal(&geojson.features, tile)
	}

	/// Renders a single GeoJSON feature to a tile image
	///
	/// # Arguments
	/// * `feature` - GeoJSON feature to render
	/// * `tile` - Tile coordinates (z/x/y)
	///
	/// # Returns
	/// PNG image buffer of the rendered tile
	pub fn render_feature(&self, feature: &Feature, tile: TileCoordinate) -> Result<Vec<u8>> {
		self.render_internal(&[feature.clone()], tile)
	}

	/// Renders multiple tiles in parallel
	///
	/// # Arguments
	/// * `geojson` - GeoJSON feature collection to render
	/// * `tiles` - Slice of tile coordinates to render
	///
	/// # Returns
	/// Vector of PNG image buffers, one for each tile
	pub fn render_many(&self, geojson: &FeatureCollection, tiles: &[TileCoordinate]) -> Result<Vec<Vec<u8>>> {
		// Use rayon to parallelize tile rendering
		tiles
			.par_iter()
			.map(|&tile| self.render(geojson, tile))
			.collect()
	}

	/// Internal rendering implementation
	fn render_internal(&self, features: &[Feature], tile: TileCoordinate) -> Result<Vec<u8>> {
		let size = self.settings.size as f64;

		// Get the geographic bounds of this tile
		let image_polygon = tile_to_bounds(tile);

		// Calculate bounding box in Web Mercator projection
		let mercator_bbox = {
			let exterior = image_polygon.exterior();
			let coords: Vec<_> = exterior.coords().collect();

			// Get min/max from corners
			let min_coord = lat_lon_to_web_mercator(coords[0].x, coords[0].y)?;
			let max_coord = lat_lon_to_web_mercator(coords[2].x, coords[2].y)?;

			BoundingBox::new(min_coord.x, min_coord.y, max_coord.x, max_coord.y)
		};

		// Calculate scaling factors to convert from projected coordinates to pixels
		let x_scaling_factor = size / mercator_bbox.width();
		let y_scaling_factor = size / mercator_bbox.height();

		// Create SVG context for coordinate transformation
		let context = SvgContext::new(
			mercator_bbox,
			size,
			x_scaling_factor,
			y_scaling_factor,
			image_polygon,
		);

		// Generate SVG items for all features
		let svg_items = generate_svg_items(features, &context)?;

		// Build SVG containing all features
		let svg = format!(
			r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}">{}</svg>"#,
			self.settings.size,
			self.settings.size,
			svg_items.join("")
		);

		// Render SVG to PNG
		self.svg_to_png(&svg)
	}

	/// Convert SVG to PNG using resvg and tiny-skia
	fn svg_to_png(&self, svg: &str) -> Result<Vec<u8>> {
		// Parse SVG
		let opts = usvg::Options::default();
		let tree = usvg::Tree::from_str(svg, &opts)
			.map_err(|e| RenderError::SvgGeneration(format!("Failed to parse SVG: {}", e)))?;

		// Create pixmap with background color
		let size = self.settings.size;
		let mut pixmap = tiny_skia::Pixmap::new(size, size)
			.ok_or_else(|| RenderError::ImageRendering("Failed to create pixmap".to_string()))?;

		// Fill with background color
		let bg = &self.settings.background_color;
		let color = tiny_skia::Color::from_rgba8(bg.r, bg.g, bg.b, bg.a);
		pixmap.fill(color);

		// Render SVG onto pixmap
		resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());

		// Encode as PNG
		pixmap
			.encode_png()
			.map_err(|e| RenderError::ImageRendering(format!("Failed to encode PNG: {}", e)))
	}
}

impl Default for TileRenderer {
	fn default() -> Self {
		Self::new()
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::types::BackgroundColor;
	use serde_json::json;

	#[test]
	fn test_tile_renderer_new() {
		let renderer = TileRenderer::new();
		assert_eq!(renderer.settings.size, 256);
	}

	#[test]
	fn test_tile_renderer_builder() {
		let renderer = TileRenderer::builder()
			.settings(
				Settings::builder()
					.size(512)
					.background_color(BackgroundColor::white())
					.build()
					.unwrap(),
			)
			.build()
			.unwrap();

		assert_eq!(renderer.settings.size, 512);
		assert_eq!(renderer.settings.background_color, BackgroundColor::white());
	}

	#[test]
	fn test_render_empty_collection() {
		let renderer = TileRenderer::new();
		let collection = FeatureCollection {
			bbox: None,
			features: vec![],
			foreign_members: None,
		};

		let tile = TileCoordinate::new(10, 163, 395).unwrap();
		let result = renderer.render(&collection, tile);

		assert!(result.is_ok());
		let png_data = result.unwrap();
		assert!(!png_data.is_empty());

		// Verify PNG header
		assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
	}

	#[test]
	fn test_render_polygon() {
		let renderer = TileRenderer::new();

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {
				"type": "Polygon",
				"coordinates": [[
					[-122.5, 37.7],
					[-122.4, 37.7],
					[-122.4, 37.8],
					[-122.5, 37.8],
					[-122.5, 37.7]
				]]
			},
			"properties": {
				"fill": "red",
				"fill-opacity": "0.5"
			}
		}))
		.unwrap();

		let collection = FeatureCollection {
			bbox: None,
			features: vec![feature],
			foreign_members: None,
		};

		let tile = TileCoordinate::new(10, 163, 395).unwrap();
		let result = renderer.render(&collection, tile);

		assert!(result.is_ok());
		let png_data = result.unwrap();
		assert!(!png_data.is_empty());
		assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
	}

	#[test]
	fn test_render_feature() {
		let renderer = TileRenderer::new();

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {
				"type": "Point",
				"coordinates": [-122.45, 37.75]
			},
			"properties": {
				"text": "San Francisco"
			}
		}))
		.unwrap();

		let tile = TileCoordinate::new(10, 163, 395).unwrap();
		let result = renderer.render_feature(&feature, tile);

		assert!(result.is_ok());
		let png_data = result.unwrap();
		assert!(!png_data.is_empty());
	}

	#[test]
	fn test_render_many() {
		let renderer = TileRenderer::new();
		let collection = FeatureCollection {
			bbox: None,
			features: vec![],
			foreign_members: None,
		};

		let tiles = vec![
			TileCoordinate::new(10, 163, 395).unwrap(),
			TileCoordinate::new(10, 164, 395).unwrap(),
			TileCoordinate::new(10, 163, 396).unwrap(),
		];

		let results = renderer.render_many(&collection, &tiles);
		assert!(results.is_ok());

		let png_buffers = results.unwrap();
		assert_eq!(png_buffers.len(), 3);

		for png_data in png_buffers {
			assert!(!png_data.is_empty());
			assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
		}
	}

	#[test]
	fn test_render_with_background_color() {
		let renderer = TileRenderer::builder()
			.settings(
				Settings::builder()
					.background_color(BackgroundColor::rgb(255, 0, 0))
					.build()
					.unwrap(),
			)
			.build()
			.unwrap();

		let collection = FeatureCollection {
			bbox: None,
			features: vec![],
			foreign_members: None,
		};

		let tile = TileCoordinate::new(0, 0, 0).unwrap();
		let result = renderer.render(&collection, tile);

		assert!(result.is_ok());
		let png_data = result.unwrap();
		assert!(!png_data.is_empty());
	}

	#[test]
	fn test_render_different_tile_sizes() {
		for size in [128, 256, 512] {
			let renderer = TileRenderer::builder()
				.settings(Settings::builder().size(size).build().unwrap())
				.build()
				.unwrap();

			let collection = FeatureCollection {
				bbox: None,
				features: vec![],
				foreign_members: None,
			};

			let tile = TileCoordinate::new(5, 10, 12).unwrap();
			let result = renderer.render(&collection, tile);

			assert!(result.is_ok());
			let png_data = result.unwrap();
			assert!(!png_data.is_empty());
		}
	}
}