rmpca 0.1.1

Enterprise-grade route optimization engine — Chinese Postman Problem solver with Eulerian circuit detection, Lean 4 FFI boundary, and property-based testing
Documentation
//! Map renderer for route visualization.
//!
//! NOTE: Full rendering requires the `tiny-skia` crate to be vendored.
//! Until then, this module provides type definitions and a stub renderer
//! that returns an error for render operations.

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Configuration for map rendering
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapRendererConfig {
    /// Image width in pixels
    pub width: u32,
    /// Image height in pixels
    pub height: u32,
    /// Background color (RGBA)
    pub background_color: [u8; 4],
    /// Route line color (RGBA)
    pub route_color: [u8; 4],
    /// Route line width in pixels
    pub route_width: f32,
    /// Node/marker color (RGBA)
    pub node_color: [u8; 4],
    /// Node/marker radius in pixels
    pub node_radius: f32,
    /// Padding around the route in pixels
    pub padding: f32,
}

impl Default for MapRendererConfig {
    fn default() -> Self {
        Self {
            width: 1920,
            height: 1080,
            background_color: [255, 255, 255, 255], // White
            route_color: [66, 133, 244, 255],       // Blue
            route_width: 3.0,
            node_color: [234, 67, 53, 255],         // Red
            node_radius: 5.0,
            padding: 50.0,
        }
    }
}

/// Map renderer for route visualization.
///
/// Full rendering requires the `tiny-skia` crate. Until it is vendored,
/// render methods return an error suggesting the user vendor the dependency.
pub struct MapRenderer {
    #[allow(dead_code)]
    config: MapRendererConfig,
}

impl MapRenderer {
    /// Create a new map renderer with default configuration
    #[must_use]
    pub fn new() -> Self {
        Self {
            config: MapRendererConfig::default(),
        }
    }

    /// Create a new map renderer with custom configuration
    #[must_use]
    pub fn with_config(config: MapRendererConfig) -> Self {
        Self { config }
    }

    /// Render a route to a PNG file.
    ///
    /// Requires `tiny-skia` crate to be vendored. Returns an error until then.
    ///
    /// # Errors
    /// Returns an error if the route is empty or if the `tiny-skia` crate is not vendored.
    #[allow(clippy::unused_self)]
    pub fn render_route_to_png<P: AsRef<Path>>(
        &self,
        route: &[(f64, f64)],
        _output_path: P,
    ) -> Result<()> {
        if route.is_empty() {
            anyhow::bail!("Cannot render empty route");
        }
        anyhow::bail!(
            "Map rendering requires tiny-skia crate. \
             Vendor it with: cd vendor && cargo vendor-tiny-skia"
        );
    }

    /// Compute bounding box from route coordinates
    #[allow(dead_code)]
    #[must_use]
    pub fn compute_bbox(route: &[(f64, f64)]) -> (f64, f64, f64, f64) {
        let mut min_lon = f64::INFINITY;
        let mut min_lat = f64::INFINITY;
        let mut max_lon = f64::NEG_INFINITY;
        let mut max_lat = f64::NEG_INFINITY;

        for &(lon, lat) in route {
            min_lon = min_lon.min(lon);
            min_lat = min_lat.min(lat);
            max_lon = max_lon.max(lon);
            max_lat = max_lat.max(lat);
        }

        let lon_pad = (max_lon - min_lon).max(0.001) * 0.1;
        let lat_pad = (max_lat - min_lat).max(0.001) * 0.1;

        (
            min_lon - lon_pad,
            min_lat - lat_pad,
            max_lon + lon_pad,
            max_lat + lat_pad,
        )
    }

    /// Accessor for the renderer configuration
    #[allow(dead_code)]
    #[must_use]
    pub fn config(&self) -> &MapRendererConfig {
        &self.config
    }
}

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

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

    #[test]
    fn test_renderer_creation() {
        let renderer = MapRenderer::new();
        assert_eq!(renderer.config.width, 1920);
        assert_eq!(renderer.config.height, 1080);
    }

    #[test]
    fn test_renderer_custom_config() {
        let config = MapRendererConfig {
            width: 800,
            height: 600,
            ..Default::default()
        };
        let renderer = MapRenderer::with_config(config);
        assert_eq!(renderer.config.width, 800);
        assert_eq!(renderer.config.height, 600);
    }

    #[test]
    fn test_compute_bbox() {
        let renderer = MapRenderer::new();
        let route = vec![
            (-73.5, 45.5),
            (-73.6, 45.6),
            (-73.7, 45.7),
        ];

        let (min_lon, min_lat, max_lon, max_lat) = MapRenderer::compute_bbox(&route);

        assert!((min_lon + 73.72).abs() < 0.01);
        assert!((min_lat - 45.48).abs() < 0.01);
        assert!((max_lon + 73.48).abs() < 0.01);
        assert!((max_lat - 45.72).abs() < 0.01);
    }

    #[test]
    fn test_empty_route_fails() {
        let renderer = MapRenderer::new();
        let route: Vec<(f64, f64)> = vec![];

        assert!(renderer.render_route_to_png(&route, "/tmp/test.png").is_err());
    }
}