#![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_renders_each_part_independently() {
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");
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![1, 2], "{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}");
}