geojson-tile-renderer 0.1.0

Convert GeoJSON features to PNG tile images with Web Mercator projection
Documentation
use crate::error::Result;
use crate::projection::transform_polygon_coordinates_to_pixels;
use crate::svg::types::SvgContext;
use crate::svg::utils::get_property_str;
use geo_types::{MultiPolygon, Polygon};
use geojson::Feature;

/// Generates SVG path data string from polygon coordinates
///
/// # Arguments
/// * `polygons` - Slice of polygons to render
/// * `context` - SVG context for coordinate transformation
///
/// # Returns
/// SVG path data string (e.g., "M10,20 L30,40 Z")
fn generate_path_data(polygons: &[Polygon<f64>], context: &SvgContext) -> Result<String> {
	let mut path_data = String::new();

	for polygon in polygons {
		// Get exterior and interior rings
		let exterior = polygon.exterior().coords().copied().collect::<Vec<_>>();
		let interiors = polygon
			.interiors()
			.iter()
			.map(|ring| ring.coords().copied().collect::<Vec<_>>())
			.collect::<Vec<_>>();

		// Combine all rings
		let mut all_rings = vec![exterior];
		all_rings.extend(interiors);

		// Transform to pixel coordinates
		let transformed_rings = transform_polygon_coordinates_to_pixels(
			&all_rings,
			&context.mercator_bbox,
			context.size,
			context.x_scaling_factor,
			context.y_scaling_factor,
			0.0,
		)?;

		// Generate path data for each ring
		for ring in transformed_rings {
			for (i, coord) in ring.iter().enumerate() {
				if i == 0 {
					path_data.push_str(&format!("M{},{} ", coord.x, coord.y));
				} else {
					path_data.push_str(&format!("L{},{} ", coord.x, coord.y));
				}
			}
			path_data.push_str("Z ");
		}
	}

	Ok(path_data)
}

/// Generates SVG items for a Polygon feature
///
/// # Arguments
/// * `feature` - GeoJSON Polygon feature
/// * `polygon` - The polygon geometry
/// * `context` - SVG context for coordinate transformation
///
/// # Returns
/// Vector of SVG element strings
pub fn generate_polygon_svg_items(
	feature: &Feature,
	polygon: &Polygon<f64>,
	context: &SvgContext,
) -> Result<Vec<String>> {
	// TODO: Implement proper polygon clipping using geo-clipper or updated geo-booleanop
	// For now, we render the entire polygon (which will be clipped by SVG viewport)

	// Generate path data from polygon
	let path_data = generate_path_data(&[polygon.clone()], context)?;

	// Get styling properties
	let fill = get_property_str(feature, "fill", "black");
	let fill_opacity = get_property_str(feature, "fill-opacity", "1.0");

	Ok(vec![format!(
		r#"<path d="{}" fill="{}" fill-opacity="{}" fill-rule="evenodd" />"#,
		path_data, fill, fill_opacity
	)])
}

/// Generates SVG items for a MultiPolygon feature
///
/// # Arguments
/// * `feature` - GeoJSON MultiPolygon feature
/// * `multi_polygon` - The multi-polygon geometry
/// * `context` - SVG context for coordinate transformation
///
/// # Returns
/// Vector of SVG element strings
pub fn generate_multi_polygon_svg_items(
	feature: &Feature,
	multi_polygon: &MultiPolygon<f64>,
	context: &SvgContext,
) -> Result<Vec<String>> {
	let mut svg_items = Vec::new();

	// Get styling properties once
	let fill = get_property_str(feature, "fill", "black");
	let fill_opacity = get_property_str(feature, "fill-opacity", "1.0");

	// Process each polygon in the multi-polygon
	for polygon in multi_polygon.0.iter() {
		// TODO: Implement proper polygon clipping
		// For now, render the entire polygon

		// Generate path data
		let path_data = generate_path_data(&[polygon.clone()], context)?;

		svg_items.push(format!(
			r#"<path d="{}" fill="{}" fill-opacity="{}" fill-rule="evenodd" />"#,
			path_data, fill, fill_opacity
		));
	}

	Ok(svg_items)
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::projection::BoundingBox;
	use geo_types::{Coord, LineString};
	use serde_json::json;

	fn create_test_context() -> SvgContext {
		let bbox = BoundingBox::new(-1.0, -1.0, 1.0, 1.0);
		let image_polygon = Polygon::new(
			LineString::from(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 },
			]),
			vec![],
		);

		SvgContext::new(bbox, 256.0, 128.0, 128.0, image_polygon)
	}

	#[test]
	fn test_generate_path_data_single_polygon() {
		let context = create_test_context();
		let polygon = Polygon::new(
			LineString::from(vec![
				Coord { x: -0.5, y: -0.5 },
				Coord { x: 0.5, y: -0.5 },
				Coord { x: 0.5, y: 0.5 },
				Coord { x: -0.5, y: 0.5 },
				Coord { x: -0.5, y: -0.5 },
			]),
			vec![],
		);

		let path_data = generate_path_data(&[polygon], &context).unwrap();
		assert!(path_data.contains("M"));
		assert!(path_data.contains("L"));
		assert!(path_data.contains("Z"));
	}

	#[test]
	fn test_generate_polygon_svg_items_basic() {
		let context = create_test_context();
		let polygon = Polygon::new(
			LineString::from(vec![
				Coord { x: -0.5, y: -0.5 },
				Coord { x: 0.5, y: -0.5 },
				Coord { x: 0.5, y: 0.5 },
				Coord { x: -0.5, y: 0.5 },
				Coord { x: -0.5, y: -0.5 },
			]),
			vec![],
		);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Polygon", "coordinates": []},
			"properties": {}
		}))
		.unwrap();

		let items = generate_polygon_svg_items(&feature, &polygon, &context).unwrap();
		assert_eq!(items.len(), 1);
		assert!(items[0].contains("<path"));
		assert!(items[0].contains("fill=\"black\""));
		assert!(items[0].contains("fill-opacity=\"1.0\""));
	}

	#[test]
	fn test_generate_polygon_svg_items_with_style() {
		let context = create_test_context();
		let polygon = Polygon::new(
			LineString::from(vec![
				Coord { x: -0.5, y: -0.5 },
				Coord { x: 0.5, y: -0.5 },
				Coord { x: 0.5, y: 0.5 },
				Coord { x: -0.5, y: 0.5 },
				Coord { x: -0.5, y: -0.5 },
			]),
			vec![],
		);

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

		let items = generate_polygon_svg_items(&feature, &polygon, &context).unwrap();
		assert_eq!(items.len(), 1);
		assert!(items[0].contains("fill=\"red\""));
		assert!(items[0].contains("fill-opacity=\"0.5\""));
	}

	#[test]
	fn test_generate_polygon_svg_items_out_of_bounds() {
		let context = create_test_context();
		// Polygon completely outside tile bounds
		let polygon = Polygon::new(
			LineString::from(vec![
				Coord { x: 10.0, y: 10.0 },
				Coord { x: 11.0, y: 10.0 },
				Coord { x: 11.0, y: 11.0 },
				Coord { x: 10.0, y: 11.0 },
				Coord { x: 10.0, y: 10.0 },
			]),
			vec![],
		);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Polygon", "coordinates": []},
			"properties": {}
		}))
		.unwrap();

		let items = generate_polygon_svg_items(&feature, &polygon, &context).unwrap();
		// TODO: Once clipping is implemented, this should be 0
		// For now, out-of-bounds polygons are still rendered (will be clipped by SVG viewport)
		assert_eq!(items.len(), 1);
	}

	#[test]
	fn test_generate_multi_polygon_svg_items() {
		let context = create_test_context();
		let multi_polygon = MultiPolygon(vec![
			Polygon::new(
				LineString::from(vec![
					Coord { x: -0.8, y: -0.8 },
					Coord { x: -0.2, y: -0.8 },
					Coord { x: -0.2, y: -0.2 },
					Coord { x: -0.8, y: -0.2 },
					Coord { x: -0.8, y: -0.8 },
				]),
				vec![],
			),
			Polygon::new(
				LineString::from(vec![
					Coord { x: 0.2, y: 0.2 },
					Coord { x: 0.8, y: 0.2 },
					Coord { x: 0.8, y: 0.8 },
					Coord { x: 0.2, y: 0.8 },
					Coord { x: 0.2, y: 0.2 },
				]),
				vec![],
			),
		]);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "MultiPolygon", "coordinates": []},
			"properties": {}
		}))
		.unwrap();

		let items = generate_multi_polygon_svg_items(&feature, &multi_polygon, &context).unwrap();
		assert_eq!(items.len(), 2);
	}
}