cartography 0.10.0

Cartography is a map rendering library for Geographic features expressed using [georust](https://georust.org/) libraries.
Documentation
//! YAML style loader for cartography-rs
//!
//! Provides a way to load rendering styles from YAML files instead of hardcoding them in Rust.

use std::path::Path;

use serde::Deserialize;

use crate::{
  Result,
  styling::{self, LabelConfig, Style, StyleBuilder, SymbolColor},
};

mod color;
mod filter;

pub use color::parse_hex_color;

/// YAML style configuration
#[derive(Debug, Deserialize)]
pub struct YamlStyle
{
  /// Background color (CSS hex format: #rrggbb or #rrggbbaa)
  #[serde(default)]
  pub background: Option<String>,
  /// Rendering rules
  #[serde(default)]
  pub rules: Vec<YamlRule>,
}

/// YAML rule configuration
#[derive(Debug, Deserialize)]
pub struct YamlRule
{
  /// Filter criteria for this rule
  pub filter: YamlFilter,
  /// Symbol/rendering properties for matching features
  pub symbol: YamlSymbol,
}

/// Value of a tag
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum TagValue
{
  /// String
  String(String),
  /// Sequence
  Sequence(Vec<String>),
}

/// YAML geometry filter
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum YamlGeometry
{
  /// Point geometry
  Point,
  /// LineString geometry
  Linestring,
  /// Polygon geometry
  Polygon,
  /// MultiPolygon geometry
  Multipolygon,
}

/// YAML filter configuration
#[derive(Debug, Deserialize)]
pub struct YamlFilter
{
  /// Geometry type filter
  #[serde(default)]
  pub geometry: Option<YamlGeometry>,
  /// Tag filter: key -> value(s) with AND semantics across keys, OR semantics for values
  #[serde(default)]
  pub tag: Option<std::collections::HashMap<String, TagValue>>,
  /// Minimum zoom level for this rule
  #[serde(default)]
  pub min_zoom: Option<f64>,
  /// Maximum zoom level for this rule
  #[serde(default)]
  pub max_zoom: Option<f64>,
}

/// YAML symbol configuration
#[derive(Debug, Deserialize)]
pub struct YamlSymbol
{
  /// Fill color (CSS hex format)
  #[serde(default)]
  pub fill: Option<String>,
  /// Stroke color (CSS hex format)
  #[serde(default)]
  pub stroke: Option<String>,
  /// Stroke width in pixels
  #[serde(default)]
  pub stroke_width: Option<f64>,
  /// Point radius in pixels
  #[serde(default)]
  pub radius: Option<f64>,
  /// Optional text label
  #[serde(default)]
  pub label: Option<YamlLabel>,
}

/// YAML label configuration
#[derive(Debug, Deserialize)]
pub struct YamlLabel
{
  /// OSM tag field used as label text
  pub field: String,
  /// Label font size in pixels
  #[serde(default)]
  pub font_size: Option<f32>,
  /// Label text color (CSS hex format)
  #[serde(default)]
  pub color: Option<String>,
  /// Label halo color (CSS hex format)
  #[serde(default)]
  pub halo_color: Option<String>,
  /// Minimum zoom level at which to display labels
  #[serde(default)]
  pub min_zoom: Option<f64>,
}

/// Load a style from a YAML file
///
/// # Arguments
///
/// * `path` - Path to the YAML style file
///
/// # Returns
///
/// A `Style<OsmFeature>` that can be used for rendering
pub fn load_style(path: impl AsRef<Path>) -> Result<Style<crate::osm::OsmFeature>>
{
  let yaml_str = std::fs::read_to_string(path)?;
  let yaml_style: YamlStyle = serde_saphyr::from_str(&yaml_str)?;

  let mut builder = StyleBuilder::new();

  if let Some(bg_color) = &yaml_style.background
  {
    let rgba = parse_hex_color(bg_color)?;
    builder = builder.set_background_color(rgba);
  }

  for rule in yaml_style.rules
  {
    builder = add_rule_to_builder(builder, rule)?;
  }

  Ok(builder.into())
}

fn add_rule_to_builder(
  builder: StyleBuilder<crate::osm::OsmFeature>,
  rule: YamlRule,
) -> Result<StyleBuilder<crate::osm::OsmFeature>>
{
  let symbol = build_symbol(&rule.symbol)?;
  let checker = filter::compile_filter(&rule.filter);
  Ok(builder.add_rule(symbol).check(checker).finish_rule())
}

fn build_symbol(yaml_symbol: &YamlSymbol) -> Result<styling::Symbol<crate::osm::OsmFeature>>
{
  let fill_color = if let Some(fill_str) = &yaml_symbol.fill
  {
    let rgba = parse_hex_color(fill_str)?;
    SymbolColor::new(move |_, _| rgba)
  }
  else
  {
    styling::Rgba::TRANSPARENT.into()
  };

  let stroke_color = if let Some(stroke_str) = &yaml_symbol.stroke
  {
    let rgba = parse_hex_color(stroke_str)?;
    SymbolColor::new(move |_, _| rgba)
  }
  else
  {
    styling::Rgba::BLACK.into()
  };

  let stroke_width = yaml_symbol.stroke_width.unwrap_or(0.0);
  let radius = yaml_symbol.radius.unwrap_or(1.0);
  let label = yaml_symbol.label.as_ref().map(build_label).transpose()?;

  Ok(styling::Symbol {
    fill_color,
    stroke_color,
    stroke_width,
    radius,
    label,
  })
}

fn build_label(yaml_label: &YamlLabel) -> Result<LabelConfig<crate::osm::OsmFeature>>
{
  let field = yaml_label.field.clone();
  let font_size = yaml_label.font_size.unwrap_or(12.0);

  let color = if let Some(color_str) = &yaml_label.color
  {
    parse_hex_color(color_str)?
  }
  else
  {
    styling::Rgba::BLACK
  };

  let halo_color = if let Some(halo_str) = &yaml_label.halo_color
  {
    parse_hex_color(halo_str)?
  }
  else
  {
    styling::Rgba::WHITE
  };

  Ok(LabelConfig {
    text: std::sync::Arc::new(Box::new(move |feature: &crate::osm::OsmFeature| feature.tag(&field).map(str::to_owned))),
    font_size,
    color: color.into(),
    halo_color: halo_color.into(),
    min_zoom: yaml_label.min_zoom.unwrap_or(0.0),
  })
}

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

  #[test]
  fn test_parse_hex_color_6_digits()
  {
    let color = parse_hex_color("#ff0000").unwrap();
    assert!((color.red() - 1.0).abs() < 0.01);
    assert!((color.green() - 0.0).abs() < 0.01);
    assert!((color.blue() - 0.0).abs() < 0.01);
    assert!((color.alpha() - 1.0).abs() < 0.01);
  }

  #[test]
  fn test_parse_hex_color_8_digits()
  {
    let color = parse_hex_color("#ff000080").unwrap();
    assert!((color.red() - 1.0).abs() < 0.01);
    assert!((color.green() - 0.0).abs() < 0.01);
    assert!((color.blue() - 0.0).abs() < 0.01);
    assert!((color.alpha() - 0.5).abs() < 0.01);
  }

  #[test]
  fn test_parse_hex_color_invalid()
  {
    assert!(parse_hex_color("red").is_err());
    assert!(parse_hex_color("#ffff").is_err());
    assert!(parse_hex_color("#gggggg").is_err());
  }

  #[test]
  fn test_yaml_deserialize()
  {
    let yaml_str = "background: \"#aad3df\"\nrules:\n  - filter:\n      geometry: polygon\n      tag:\n        natural: water\n      min_zoom: 8\n    symbol:\n      fill: \"#4a90d9\"\n      stroke: \"#2c6fad\"\n      stroke_width: 0.5\n      label:\n        field: name\n        font_size: 11";

    let style: YamlStyle = serde_saphyr::from_str(yaml_str).unwrap();
    assert_eq!(style.background, Some("#aad3df".to_string()));
    assert_eq!(style.rules.len(), 1);
    assert_eq!(style.rules[0].filter.geometry, Some(YamlGeometry::Polygon));
    assert_eq!(style.rules[0].filter.min_zoom, Some(8.0));
    assert_eq!(
      style.rules[0]
        .symbol
        .label
        .as_ref()
        .map(|label| label.field.as_str()),
      Some("name")
    );
  }

  #[test]
  fn test_build_symbol_label_text()
  {
    let yaml_symbol: YamlSymbol =
      serde_saphyr::from_str("fill: \"#4a90d9\"\nlabel:\n  field: name\n  min_zoom: 5").unwrap();
    let symbol = build_symbol(&yaml_symbol).unwrap();
    let label = symbol.label.as_ref().unwrap();
    let feature = crate::osm::OsmFeature {
      id: 1,
      geometry: geo::Geometry::Point(geo::Point::new(0.0, 0.0)),
      tags: vec![("name".to_string(), "Paris".to_string())],
    };
    assert_eq!((label.text)(&feature), Some("Paris".to_string()));
    assert_eq!(label.min_zoom, 5.0);
  }
}