use core::fmt::Write as _;
use alloc::string::String;
use alloc::vec::Vec;
use geometry_cs::Cartesian;
use geometry_model::{Linestring, MultiLinestring, MultiPoint, MultiPolygon, Point, Polygon, Ring};
use geometry_trait::{
Linestring as LinestringTrait, MultiLinestring as MultiLinestringTrait,
MultiPoint as MultiPointTrait, MultiPolygon as MultiPolygonTrait, Point as PointTrait,
Polygon as PolygonTrait, Ring as RingTrait,
};
const MARGIN_FRACTION: f64 = 0.1;
const POINT_RADIUS: f64 = 5.0;
enum Shape {
Point(f64, f64),
Polyline(Vec<(f64, f64)>),
Polygon {
outer: Vec<(f64, f64)>,
holes: Vec<Vec<(f64, f64)>>,
},
}
pub struct SvgMapper {
width: u32,
height: u32,
elements: Vec<(Shape, String)>,
bbox: Option<(f64, f64, f64, f64)>,
}
impl SvgMapper {
#[must_use]
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
elements: Vec::new(),
bbox: None,
}
}
pub fn add<G: MapGeometry>(&mut self, g: &G, style: &str) {
g.collect_into(self, style);
}
#[must_use]
pub fn to_svg(&self) -> String {
let width = f64::from(self.width);
let height = f64::from(self.height);
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" standalone=\"no\"?>\n");
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
self.width, self.height
);
let transform = Transform::fit(self.bbox, width, height);
for (shape, style) in &self.elements {
render_shape(&mut out, shape, &transform, style);
}
out.push_str("</svg>");
out
}
fn expand(&mut self, x: f64, y: f64) {
self.bbox = Some(match self.bbox {
None => (x, y, x, y),
Some((min_x, min_y, max_x, max_y)) => {
(min_x.min(x), min_y.min(y), max_x.max(x), max_y.max(y))
}
});
}
fn push(&mut self, shape: Shape, style: &str) {
self.elements.push((shape, String::from(style)));
}
}
struct Transform {
min_x: f64,
min_y: f64,
scale: f64,
margin: f64,
height: f64,
}
impl Transform {
fn fit(bbox: Option<(f64, f64, f64, f64)>, width: f64, height: f64) -> Self {
let margin = MARGIN_FRACTION * width.min(height);
let (min_x, min_y, max_x, max_y) = bbox.unwrap_or((0.0, 0.0, 0.0, 0.0));
let span_x = max_x - min_x;
let span_y = max_y - min_y;
let scale = if span_x > 0.0 && span_y > 0.0 {
let scale_x = (width - 2.0 * margin) / span_x;
let scale_y = (height - 2.0 * margin) / span_y;
scale_x.min(scale_y)
} else {
1.0
};
Self {
min_x,
min_y,
scale,
margin,
height,
}
}
fn apply(&self, x: f64, y: f64) -> (f64, f64) {
let px = self.margin + (x - self.min_x) * self.scale;
let py = self.height - self.margin - (y - self.min_y) * self.scale;
(px, py)
}
}
fn render_shape(out: &mut String, shape: &Shape, transform: &Transform, style: &str) {
match shape {
Shape::Point(x, y) => {
let (px, py) = transform.apply(*x, *y);
let _ = writeln!(
out,
"<circle cx=\"{}\" cy=\"{}\" r=\"{POINT_RADIUS}\" style=\"{style}\" />",
round(px),
round(py),
);
}
Shape::Polyline(points) => {
out.push_str("<polyline points=\"");
push_point_list(out, points, transform);
let _ = writeln!(out, "\" style=\"{style}\" />");
}
Shape::Polygon { outer, holes } => {
out.push_str("<path d=\"");
push_ring_path(out, outer, transform);
for hole in holes {
out.push(' ');
push_ring_path(out, hole, transform);
}
let _ = writeln!(out, "\" style=\"{style}\" fill-rule=\"evenodd\" />");
}
}
}
fn push_point_list(out: &mut String, points: &[(f64, f64)], transform: &Transform) {
for (i, &(x, y)) in points.iter().enumerate() {
if i > 0 {
out.push(' ');
}
let (px, py) = transform.apply(x, y);
let _ = write!(out, "{},{}", round(px), round(py));
}
}
fn push_ring_path(out: &mut String, ring: &[(f64, f64)], transform: &Transform) {
for (i, &(x, y)) in ring.iter().enumerate() {
let (px, py) = transform.apply(x, y);
let cmd = if i == 0 { 'M' } else { 'L' };
if i > 0 {
out.push(' ');
}
let _ = write!(out, "{cmd} {} {}", round(px), round(py));
}
out.push_str(" Z");
}
fn round(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[doc(hidden)]
pub trait MapGeometry {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str);
}
fn ring_coords<R>(mapper: &mut SvgMapper, ring: &R) -> Vec<(f64, f64)>
where
R: RingTrait,
R::Point: PointTrait<Scalar = f64>,
{
let mut coords = Vec::new();
for p in ring.points() {
let (x, y) = (p.get::<0>(), p.get::<1>());
mapper.expand(x, y);
coords.push((x, y));
}
coords
}
impl MapGeometry for Point<f64, 2, Cartesian> {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
let (x, y) = (self.get::<0>(), self.get::<1>());
mapper.expand(x, y);
mapper.push(Shape::Point(x, y), style);
}
}
impl<P: PointTrait<Scalar = f64>> MapGeometry for Linestring<P> {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
let mut coords = Vec::new();
for p in self.points() {
let (x, y) = (p.get::<0>(), p.get::<1>());
mapper.expand(x, y);
coords.push((x, y));
}
mapper.push(Shape::Polyline(coords), style);
}
}
impl<P: PointTrait<Scalar = f64>> MapGeometry for Ring<P, true, true> {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
let outer = ring_coords(mapper, self);
mapper.push(
Shape::Polygon {
outer,
holes: Vec::new(),
},
style,
);
}
}
impl<P: PointTrait<Scalar = f64>> MapGeometry for Polygon<P, true, true> {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
let outer = ring_coords(mapper, self.exterior());
let holes = self.interiors().map(|r| ring_coords(mapper, r)).collect();
mapper.push(Shape::Polygon { outer, holes }, style);
}
}
impl<P: PointTrait<Scalar = f64>> MapGeometry for MultiPoint<P> {
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
for p in self.points() {
let (x, y) = (p.get::<0>(), p.get::<1>());
mapper.expand(x, y);
mapper.push(Shape::Point(x, y), style);
}
}
}
impl<L> MapGeometry for MultiLinestring<L>
where
L: LinestringTrait,
L::Point: PointTrait<Scalar = f64>,
{
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
for ls in self.linestrings() {
let mut coords = Vec::new();
for p in ls.points() {
let (x, y) = (p.get::<0>(), p.get::<1>());
mapper.expand(x, y);
coords.push((x, y));
}
mapper.push(Shape::Polyline(coords), style);
}
}
}
impl<Pg> MapGeometry for MultiPolygon<Pg>
where
Pg: PolygonTrait,
Pg::Point: PointTrait<Scalar = f64>,
{
fn collect_into(&self, mapper: &mut SvgMapper, style: &str) {
for pg in self.polygons() {
let outer = ring_coords(mapper, pg.exterior());
let holes = pg.interiors().map(|r| ring_coords(mapper, r)).collect();
mapper.push(Shape::Polygon { outer, holes }, style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
use geometry_cs::Cartesian;
use geometry_model::Point2D;
type Pt = Point2D<f64, Cartesian>;
fn assert_coords_in_canvas(svg: &str, width: f64, height: f64) {
for (attr, limit) in [("cx=\"", width), ("cy=\"", height)] {
for chunk in svg.split(attr).skip(1) {
let val: f64 = chunk
.split('"')
.next()
.unwrap()
.parse()
.expect("numeric attribute");
assert!(
(0.0..=limit).contains(&val),
"coordinate {val} out of canvas 0..={limit}"
);
}
}
}
fn square() -> Polygon<Pt> {
let outer = Ring::from_vec(vec![
Pt::new(0.0, 0.0),
Pt::new(0.0, 10.0),
Pt::new(10.0, 10.0),
Pt::new(10.0, 0.0),
Pt::new(0.0, 0.0),
]);
Polygon::with_inners(outer, vec![])
}
#[test]
fn polygon_renders_as_path() {
let mut mapper = SvgMapper::new(400, 400);
mapper.add(&square(), "fill:rgb(0,0,255);stroke:black");
let svg = mapper.to_svg();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("path"));
assert!(svg.contains("fill-rule=\"evenodd\""));
}
#[test]
fn polygon_coordinates_land_inside_canvas() {
let mut mapper = SvgMapper::new(400, 400);
mapper.add(&square(), "fill:blue");
let svg = mapper.to_svg();
let d = svg.split("d=\"").nth(1).unwrap().split('"').next().unwrap();
for token in d.split_whitespace() {
if let Ok(v) = token.parse::<f64>() {
assert!(
(0.0..=400.0).contains(&v),
"path coordinate {v} outside canvas"
);
}
}
}
#[test]
fn polygon_with_hole_emits_two_subpaths() {
let outer = Ring::from_vec(vec![
Pt::new(0.0, 0.0),
Pt::new(0.0, 10.0),
Pt::new(10.0, 10.0),
Pt::new(10.0, 0.0),
Pt::new(0.0, 0.0),
]);
let hole = Ring::from_vec(vec![
Pt::new(2.0, 2.0),
Pt::new(2.0, 4.0),
Pt::new(4.0, 4.0),
Pt::new(4.0, 2.0),
Pt::new(2.0, 2.0),
]);
let poly = Polygon::with_inners(outer, vec![hole]);
let mut mapper = SvgMapper::new(300, 300);
mapper.add(&poly, "fill:green");
let svg = mapper.to_svg();
assert_eq!(svg.matches("M ").count(), 2);
assert!(svg.contains("fill-rule=\"evenodd\""));
}
#[test]
fn single_point_renders_as_circle() {
let mut mapper = SvgMapper::new(200, 200);
mapper.add(&Pt::new(3.0, 4.0), "fill:red");
let svg = mapper.to_svg();
assert!(svg.contains("<circle"));
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert_coords_in_canvas(&svg, 200.0, 200.0);
}
#[test]
fn linestring_renders_as_polyline() {
let ls = Linestring(vec![
Pt::new(0.0, 0.0),
Pt::new(5.0, 8.0),
Pt::new(10.0, 2.0),
]);
let mut mapper = SvgMapper::new(250, 250);
mapper.add(&ls, "stroke:black;fill:none");
let svg = mapper.to_svg();
assert!(svg.contains("<polyline"));
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn empty_mapper_emits_valid_empty_svg() {
let svg = SvgMapper::new(120, 80).to_svg();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("width=\"120\""));
assert!(svg.contains("height=\"80\""));
assert!(!svg.contains("<circle"));
assert!(!svg.contains("<path"));
assert!(!svg.contains("<polyline"));
}
#[test]
fn multipoint_renders_a_circle_per_point() {
let mp = MultiPoint(vec![
Pt::new(1.0, 1.0),
Pt::new(9.0, 9.0),
Pt::new(5.0, 2.0),
]);
let mut mapper = SvgMapper::new(300, 300);
mapper.add(&mp, "fill:orange");
let svg = mapper.to_svg();
assert_eq!(svg.matches("<circle").count(), 3);
assert_coords_in_canvas(&svg, 300.0, 300.0);
}
#[test]
fn bare_ring_renders_as_single_ring_polygon() {
let ring: Ring<Pt> = Ring::from_vec(vec![
Pt::new(0.0, 0.0),
Pt::new(0.0, 10.0),
Pt::new(10.0, 10.0),
Pt::new(10.0, 0.0),
Pt::new(0.0, 0.0),
]);
let mut mapper = SvgMapper::new(300, 300);
mapper.add(&ring, "fill:purple");
let svg = mapper.to_svg();
assert!(svg.contains("<path"));
assert_eq!(svg.matches("M ").count(), 1); assert!(svg.contains("fill-rule=\"evenodd\""));
}
#[test]
fn multilinestring_renders_a_polyline_per_member() {
let mls: MultiLinestring<Linestring<Pt>> = MultiLinestring(vec![
Linestring(vec![Pt::new(0.0, 0.0), Pt::new(5.0, 5.0)]),
Linestring(vec![Pt::new(1.0, 9.0), Pt::new(9.0, 1.0)]),
]);
let mut mapper = SvgMapper::new(300, 300);
mapper.add(&mls, "stroke:black;fill:none");
let svg = mapper.to_svg();
assert_eq!(svg.matches("<polyline").count(), 2);
}
#[test]
fn multipolygon_renders_a_path_per_member() {
let mut member = square();
member.inners.push(Ring::from_vec(vec![
Pt::new(2.0, 2.0),
Pt::new(2.0, 4.0),
Pt::new(4.0, 4.0),
Pt::new(4.0, 2.0),
Pt::new(2.0, 2.0),
]));
let mpg: MultiPolygon<Polygon<Pt>> = MultiPolygon(vec![member.clone(), member]);
let mut mapper = SvgMapper::new(400, 400);
mapper.add(&mpg, "fill:teal");
let svg = mapper.to_svg();
assert_eq!(svg.matches("<path").count(), 2);
assert_eq!(svg.matches("M ").count(), 4); }
}