#![cfg(feature = "sf")]
use ggplot_rs::data::{DataFrame, Value};
use ggplot_rs::geom::sf::{GeomSf, StatSf};
use ggplot_rs::prelude::*;
use ggplot_rs::scale::scale_set::ScaleSet;
use ggplot_rs::spatial::SfProjection;
use ggplot_rs::stat::Stat;
fn strs(v: &[&str]) -> Vec<Value> {
v.iter().map(|s| Value::Str(s.to_string())).collect()
}
#[test]
fn stat_sf_adds_bounds_from_geometry() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&["POLYGON ((0 0, 4 0, 4 3, 0 3, 0 0))", "POINT (10 8)"]),
);
let out = StatSf::default().compute_group(&df, &ScaleSet::new());
assert!(out.has_column("geometry"));
for c in ["xmin", "xmax", "ymin", "ymax"] {
assert!(out.has_column(c), "missing {c}");
}
assert_eq!(out.column("xmin").unwrap()[0].as_f64(), Some(0.0));
assert_eq!(out.column("xmax").unwrap()[0].as_f64(), Some(4.0));
assert_eq!(out.column("xmax").unwrap()[1].as_f64(), Some(10.0));
assert_eq!(out.column("ymax").unwrap()[1].as_f64(), Some(8.0));
}
#[test]
fn polygon_choropleth_renders_with_fill() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))",
"POLYGON ((3 0, 6 0, 6 3, 3 3, 3 0))",
]),
);
df.add_column("pop".into(), vec![Value::Float(2.0), Value::Float(9.0)]);
let svg = GGPlot::new(df)
.aes(Aes::new().fill("pop"))
.geom_sf()
.scale_fill_viridis_c()
.render_svg_native()
.expect("sf render");
assert!(
svg.contains("<polygon") || svg.contains("<path"),
"polygons drawn"
);
}
#[test]
fn mixed_geometry_types_render() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
"LINESTRING (0 0, 5 5, 2 5)",
"MULTIPOINT ((1 1), (4 2), (2 4))",
]),
);
assert!(GGPlot::new(df)
.aes(Aes::new())
.geom_sf()
.render_svg_native()
.is_ok());
}
#[test]
fn scales_train_over_geometry_extent() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&["POLYGON ((10 5, 20 5, 20 12, 10 12, 10 5))"]),
);
let built = GGPlot::new(df).aes(Aes::new()).geom_sf().build();
let d = &built.layers[0].data;
assert_eq!(d.column("xmin").unwrap()[0].as_f64(), Some(10.0));
assert_eq!(d.column("ymax").unwrap()[0].as_f64(), Some(12.0));
}
#[test]
fn mercator_projection_reshapes_extent() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&["POLYGON ((0 60, 20 60, 20 80, 0 80, 0 60))"]),
);
let out = StatSf {
projection: SfProjection::Mercator,
}
.compute_group(&df, &ScaleSet::new());
let ymax = out.column("ymax").unwrap()[0].as_f64().unwrap();
let xmax = out.column("xmax").unwrap()[0].as_f64().unwrap();
assert!((ymax - 2.4362).abs() < 1e-3, "mercator ymax = {ymax}");
assert!((xmax - 20f64.to_radians()).abs() < 1e-9, "lon in radians");
}
#[test]
fn coord_sf_renders_projected_map() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"POLYGON ((0 0, 30 0, 30 40, 0 40, 0 0))",
"POLYGON ((30 0, 60 0, 60 50, 30 40, 30 0))",
]),
);
df.add_column("v".into(), vec![Value::Float(1.0), Value::Float(2.0)]);
let svg = GGPlot::new(df)
.aes(Aes::new().fill("v"))
.geom_sf_with(GeomSf::default().project(SfProjection::Mercator))
.coord_sf()
.scale_fill_viridis_c()
.render_svg_native()
.expect("mercator + coord_sf render");
assert!(svg.contains("<polygon") || svg.contains("<path"));
}
#[test]
fn hover_tooltip_emitted_as_title() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&["POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"]),
);
df.add_column("name".into(), strs(&["Region A"]));
df.add_column("pop".into(), vec![Value::Float(12.0)]);
let svg = GGPlot::new(df)
.aes(Aes::new().fill("pop").label("name"))
.geom_sf()
.scale_fill_viridis_c()
.render_svg_native()
.unwrap();
assert!(
svg.contains("<title>Region A: 12</title>"),
"tooltip in: {svg}"
);
}
#[test]
fn polygon_with_a_hole_renders_as_one_evenodd_path() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&["POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (3 3, 7 3, 7 7, 3 7, 3 3))"]),
);
let svg = GGPlot::new(df)
.aes(Aes::new())
.geom_sf()
.render_svg_native()
.expect("sf render");
assert!(svg.contains("fill-rule=\"evenodd\""), "{svg}");
let path_d = svg
.split("d=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("a path d attribute");
assert_eq!(path_d.matches('M').count(), 2, "exterior + hole: {path_d}");
assert_eq!(path_d.matches('z').count(), 2, "{path_d}");
}
#[test]
fn multipolygon_parts_render_as_evenodd_subpaths_of_one_path() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"MULTIPOLYGON (((0 0, 4 0, 4 4, 0 4, 0 0)), ((10 0, 14 0, 14 4, 10 4, 10 0), (11 1, 13 1, 13 3, 11 3, 11 1)))",
]),
);
let svg = GGPlot::new(df)
.aes(Aes::new())
.geom_sf()
.render_svg_native()
.expect("sf render");
assert_eq!(svg.matches("<path ").count(), 1, "{svg}");
let subpath_counts: Vec<usize> = svg
.split("d=\"")
.skip(1)
.map(|s| s.split('"').next().unwrap_or("").matches('M').count())
.collect();
assert_eq!(subpath_counts, vec![3], "{svg}");
}
#[test]
fn render_resolution_simplification_reduces_near_collinear_points() {
let mut pts: Vec<(f64, f64)> = Vec::new();
for i in 0..100 {
let t = i as f64 / 100.0;
pts.push((t * 100.0, if i % 2 == 0 { 0.0 } else { 0.05 }));
}
for i in 0..100 {
let t = i as f64 / 100.0;
pts.push((100.0, t * 100.0));
}
for i in 0..100 {
let t = i as f64 / 100.0;
pts.push((100.0 - t * 100.0, 100.0));
}
for i in 0..100 {
let t = i as f64 / 100.0;
pts.push((0.0, 100.0 - t * 100.0));
}
let wkt = {
let coords: Vec<String> = pts
.iter()
.chain(pts.first())
.map(|(x, y)| format!("{x} {y}"))
.collect();
format!("POLYGON (({}))", coords.join(", "))
};
let count_ls = |simplify_px: f64| {
let mut df = DataFrame::new();
df.add_column("geometry".into(), strs(&[&wkt]));
let svg = GGPlot::new(df)
.aes(Aes::new())
.geom_sf_with(GeomSf::default().simplify(simplify_px))
.render_svg_native_with_size(400, 400)
.expect("sf render");
let path_d = svg
.split("d=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("a path d attribute")
.to_string();
path_d.matches('L').count()
};
let simplified = count_ls(0.5);
let unsimplified = count_ls(0.0);
assert_eq!(unsimplified, 400, "every vertex kept at tolerance 0");
assert!(
simplified < unsimplified / 4,
"simplified {simplified} should be far fewer than unsimplified {unsimplified}"
);
}
#[test]
fn a_hole_renders_correctly_inside_a_facet_panel() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (3 3, 7 3, 7 7, 3 7, 3 3))",
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
]),
);
df.add_column("region".into(), strs(&["A", "B"]));
let svg = GGPlot::new(df)
.aes(Aes::new())
.geom_sf()
.facet_wrap("region", Some(2))
.render_svg_native()
.expect("faceted sf render");
assert!(svg.contains("fill-rule=\"evenodd\""), "{svg}");
let subpath_counts: Vec<usize> = svg
.split("d=\"")
.skip(1)
.map(|s| s.split('"').next().unwrap_or("").matches('M').count())
.collect();
assert!(subpath_counts.contains(&2), "{svg}");
}
#[test]
fn a_multipolygon_row_draws_as_one_mark_not_one_per_part() {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
strs(&[
"MULTIPOLYGON (\
((0 0, 2 0, 2 2, 0 2, 0 0)), \
((10 0, 20 0, 20 10, 10 10, 10 0), (13 3, 17 3, 17 7, 13 7, 13 3)), \
((30 0, 32 0, 32 2, 30 2, 30 0)))",
"POLYGON ((0 20, 10 20, 10 30, 0 30, 0 20))",
]),
);
df.add_column("rowid".into(), strs(&["multi", "single"]));
let svg = GGPlot::new(df)
.aes(Aes::new().data_attr("row", "rowid"))
.geom_sf()
.render_svg_native()
.expect("sf render");
assert_eq!(
svg.matches("data-row=\"multi\"").count(),
1,
"one mark for the whole MultiPolygon row: {svg}"
);
assert_eq!(
svg.matches("data-row=\"single\"").count(),
1,
"single Polygon row unaffected: {svg}"
);
fn path_for<'a>(svg: &'a str, needle: &str) -> &'a str {
svg.split("<path ")
.find(|el| el.contains(needle))
.expect("mark present")
}
fn d_of(el: &str) -> &str {
el.split("d=\"")
.nth(1)
.and_then(|after| after.split('"').next())
.unwrap_or("")
}
let multi_el = path_for(&svg, "data-row=\"multi\"");
let multi_d = d_of(multi_el);
assert_eq!(
multi_d.matches('M').count(),
4,
"3 exterior rings + 1 hole in one path: {multi_d}"
);
assert!(multi_el.contains("fill-rule=\"evenodd\""), "{multi_el}");
let single_d = d_of(path_for(&svg, "data-row=\"single\""));
assert_eq!(single_d.matches('M').count(), 1, "{single_d}");
}