bland 0.2.0

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
use bland::{
    bode, gamma_from_z, multi_panel, qq_plot, Basemap, BasemapOpts, BodeOpts, ColorbarPosition,
    Figure, GraticuleOpts, Hatch, Marker, Normalize, PanelGridOpts, PaperSize, PolarGridOpts,
    Projection, QqOpts, Resolution, SmithGridOpts, Stroke, TextAnchor, TitleBlock,
};

#[test]
fn renders_minimum_figure() {
    let fig = Figure::new();
    let svg = fig.to_svg();
    assert!(svg.starts_with("<?xml"));
    assert!(svg.contains("<svg"));
    assert!(svg.ends_with("</svg>"));
}

#[test]
fn renders_line_with_legend() {
    let xs = [0.0, 1.0, 2.0, 3.0];
    let ys = [0.0, 1.0, 4.0, 9.0];
    let svg = Figure::new()
        .size(PaperSize::A5Landscape)
        .title("Quadratic")
        .xlabel("n")
        .ylabel("")
        .line(&xs, &ys, |s| s.label("").stroke(Stroke::Solid))
        .legend_top_right()
        .to_svg();
    assert!(svg.contains("Quadratic") || svg.contains("QUADRATIC"));
    assert!(svg.contains("polyline"));
    assert!(svg.contains(""));
}

#[test]
fn renders_all_series_types() {
    let xs = [0.0, 1.0, 2.0, 3.0];
    let ys = [0.0, 1.0, 4.0, 9.0];

    let cats = ["A", "B"];
    let bar_vals = [3.0, 7.0];

    let grid = vec![vec![0.0, 1.0, 2.0], vec![3.0, 4.0, 5.0]];

    let svg = Figure::new()
        .size(PaperSize::A4Landscape)
        .title("Everything")
        .line(&xs, &ys, |s| s.label("L"))
        .scatter(&xs, &ys, |s| s.label("S").marker(Marker::CircleOpen))
        .area(&xs, &ys, |s| s.label("A").hatch(Hatch::Diagonal))
        .bar(&cats, &bar_vals, |b| b.label("B"))
        .histogram(&[1.0, 1.0, 2.0, 3.0, 3.0, 3.0, 4.0], |h| h.bins(4).label("H"))
        .heatmap(grid, |h| h.label("M"))
        .hline(2.0, |s| s.stroke(Stroke::Dotted))
        .vline(1.5, |s| s.stroke(Stroke::DashDot))
        .legend_top_right()
        .to_svg();
    assert!(svg.contains("<svg"));
    // Hatch defs emitted
    assert!(svg.contains("bland-pattern-"));
    // Heatmap emits many rects
    assert!(svg.matches("<rect").count() > 10);
}

#[test]
fn renders_polygon_with_hatch() {
    let svg = Figure::new()
        .polygon(&[0.0, 1.0, 1.0, 0.0], &[0.0, 0.0, 1.0, 1.0], |p| {
            p.label("box").hatch(Hatch::Diagonal)
        })
        .legend_top_right()
        .to_svg();
    assert!(svg.contains("<polygon"));
    assert!(svg.contains("bland-pattern-diagonal"));
}

#[test]
fn renders_errorbar_with_yerr() {
    let xs = [1.0, 2.0, 3.0];
    let ys = [1.0, 2.5, 4.0];
    let svg = Figure::new()
        .errorbar(&xs, &ys, |e| {
            e.yerr(&[0.1, 0.2, 0.3]).label("σ")
        })
        .to_svg();
    assert!(svg.contains("<line"));
}

#[test]
fn renders_boxplot() {
    let groups = vec![
        ("a".to_string(), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]),
        ("b".to_string(), vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]),
    ];
    let svg = Figure::new()
        .boxplot(&groups, |b| b.label("dist"))
        .legend_top_right()
        .to_svg();
    assert!(svg.contains("<rect"));
}

#[test]
fn renders_stem() {
    let xs: Vec<f64> = (0..8).map(|i| i as f64).collect();
    let ys: Vec<f64> = xs.iter().map(|n| (n * 0.5).sin()).collect();
    let svg = Figure::new()
        .stem(&xs, &ys, |s| s.label("x[n]").marker(Marker::CircleFilled))
        .to_svg();
    assert!(svg.contains("<line"));
    assert!(svg.contains("<circle"));
}

#[test]
fn renders_quiver() {
    let svg = Figure::new()
        .quiver(
            &[0.0, 1.0],
            &[0.0, 1.0],
            &[1.0, -1.0],
            &[1.0, 1.0],
            |q| q.scale(0.5).head_size(4.0).label("F"),
        )
        .legend_top_right()
        .to_svg();
    assert!(svg.contains("<polygon"));
}

#[test]
fn renders_contour() {
    let grid = vec![
        vec![0.0, 1.0, 0.0],
        vec![1.0, 2.0, 1.0],
        vec![0.0, 1.0, 0.0],
    ];
    let svg = Figure::new()
        .contour(grid, |c| c.levels(vec![0.5, 1.5]).label("iso"))
        .to_svg();
    assert!(svg.contains("<line"));
}

#[test]
fn renders_histogram_cmf_as_line() {
    let svg = Figure::new()
        .histogram(&[1.0, 2.0, 2.0, 3.0, 3.0, 3.0], |h| {
            h.bins(5).normalize(Normalize::Cmf).label("F")
        })
        .to_svg();
    assert!(svg.contains("<polyline"));
}

#[test]
fn renders_polar() {
    let thetas: Vec<f64> = (0..=64).map(|i| i as f64 / 64.0 * std::f64::consts::TAU).collect();
    let rs: Vec<f64> = thetas.iter().map(|t| 1.0 + t.cos()).collect();
    let svg = Figure::polar(2.0)
        .polar_grid(PolarGridOpts::default())
        .line(&thetas, &rs, |s| s.label("cardioid"))
        .to_svg();
    assert!(svg.contains("<svg"));
}

#[test]
fn renders_smith_grid() {
    let (gr, gi) = gamma_from_z(1.0, 0.5);
    let svg = Figure::smith()
        .smith_grid(SmithGridOpts::default())
        .scatter(&[gr], &[gi], |s| s.label("Γ").marker(Marker::CircleFilled))
        .to_svg();
    assert!(svg.contains("<svg"));
    assert!(svg.contains("<polyline"));
}

#[test]
fn renders_mercator_with_graticule() {
    let svg = Figure::new()
        .size(PaperSize::A5Landscape)
        .projection(Projection::Mercator)
        .xlim(-180.0, 180.0)
        .ylim(-70.0, 70.0)
        .graticule(GraticuleOpts::default())
        .line(&[-30.0, 30.0], &[0.0, 0.0], |s| s.stroke(Stroke::Solid))
        .to_svg();
    assert!(svg.contains("<svg"));
}

#[test]
fn renders_bode() {
    let omegas: Vec<f64> = (0..50).map(|i| 10f64.powf(-1.0 + 4.0 * i as f64 / 49.0)).collect();
    let mag: Vec<f64> = omegas.iter().map(|w| -10.0 * (1.0 + w * w / 100.0).log10()).collect();
    let phase: Vec<f64> = omegas.iter().map(|w| -(w / 10.0).atan() * 180.0 / std::f64::consts::PI).collect();
    let svg = bode(&omegas, &mag, &phase, BodeOpts::default());
    assert!(svg.starts_with("<?xml"));
}

#[test]
fn renders_qq_plot() {
    let samples: Vec<f64> = (0..200)
        .map(|i| ((i as f64 - 100.0) / 30.0))
        .collect();
    let fig = qq_plot(&samples, QqOpts::default().label("residuals"));
    let svg = fig.to_svg();
    assert!(svg.contains("<svg"));
}

#[test]
fn renders_panels() {
    let a = Figure::new().line(&[0.0, 1.0], &[0.0, 1.0], |s| s);
    let b = Figure::new().line(&[0.0, 1.0], &[1.0, 0.0], |s| s);
    let svg = multi_panel(&[a, b], PanelGridOpts::default().columns(2).title("paired"));
    assert!(svg.contains("<svg"));
    assert!(svg.contains("paired"));
}

#[test]
fn renders_annotations() {
    let svg = Figure::new()
        .line(&[0.0, 1.0], &[0.0, 1.0], |s| s)
        .annotate_text(0.5, 0.5, "midpoint", TextAnchor::Middle)
        .annotate_arrow((0.2, 0.2), (0.5, 0.5))
        .to_svg();
    assert!(svg.contains("midpoint"));
    assert!(svg.contains("<polygon"));
}

#[test]
fn renders_colorbar_for_heatmap() {
    let grid = vec![vec![0.0, 1.0], vec![2.0, 3.0]];
    let svg = Figure::new()
        .heatmap(grid, |h| h.label("density"))
        .colorbar(ColorbarPosition::Right)
        .to_svg();
    assert!(svg.contains("density"));
    // Ticks on the right
    assert!(svg.matches("<line").count() > 5);
}

#[test]
fn schematic_earth_basemap_loads() {
    let coastlines = bland::basemaps::features(Basemap::EarthCoastlines, Resolution::Schematic);
    assert!(coastlines.iter().any(|f| f.name == "Africa"));
    let borders = bland::basemaps::features(Basemap::EarthBorders, Resolution::Schematic);
    assert!(borders.iter().any(|f| f.name == "USA (contiguous)"));
}

#[test]
fn natural_earth_110m_basemap_loads() {
    let coastlines = bland::basemaps::features(Basemap::EarthCoastlines, Resolution::Low);
    assert!(coastlines.len() > 50);
    let borders = bland::basemaps::features(Basemap::EarthBorders, Resolution::Low);
    assert!(borders.len() > 100);
}

#[test]
fn moon_maria_basemap_loads() {
    let maria = bland::basemaps::features(Basemap::MoonMaria, Resolution::Low);
    assert!(maria.iter().any(|f| f.name == "Mare Imbrium"));
    assert!(maria.iter().any(|f| f.name == "Oceanus Procellarum"));
}

#[test]
fn earth_tropics_includes_equator() {
    let tropics = bland::basemaps::features(Basemap::EarthTropics, Resolution::Low);
    assert!(tropics.iter().any(|f| f.name == "Equator"));
    assert!(tropics.iter().any(|f| f.name == "Tropic of Cancer"));
}

#[test]
fn renders_world_map_with_basemap() {
    let svg = Figure::new()
        .size(PaperSize::A5Landscape)
        .projection(Projection::Mercator)
        .xlim(-180.0, 180.0)
        .ylim(-65.0, 75.0)
        .basemap(Basemap::EarthCoastlines, |b| b)
        .basemap(Basemap::EarthBorders, |b| b.stroke(Stroke::Dashed))
        .basemap(Basemap::EarthTropics, |b| b.stroke(Stroke::Dotted))
        .to_svg();
    assert!(svg.starts_with("<?xml"));
    // 110m data: ~134 coastline polylines + ~288 border polygons.
    assert!(svg.matches("<polyline").count() > 100);
    assert!(svg.matches("<polygon").count() > 100);
}

#[test]
fn basemap_only_filter_keeps_subset() {
    // With only=[Africa, Eurasia], the rendered SVG should have far fewer
    // polygons than the unfiltered figure.
    let unfiltered = Figure::new()
        .basemap(Basemap::EarthCoastlines, |b| b.resolution(Resolution::Schematic))
        .to_svg();
    let filtered = Figure::new()
        .basemap(Basemap::EarthCoastlines, |b| {
            b.resolution(Resolution::Schematic).only(["Africa", "Eurasia"])
        })
        .to_svg();
    assert!(unfiltered.matches("<polygon").count() > filtered.matches("<polygon").count());
    let _ = BasemapOpts::default();
}

#[test]
fn renders_title_block() {
    let svg = Figure::new()
        .title("With block")
        .line(&[0.0, 1.0], &[0.0, 1.0], |s| s)
        .title_block(
            TitleBlock::new()
                .project("BLAND")
                .title("Test")
                .drawn_by("JM")
                .date("2026-04-28")
                .scale("1:1")
                .sheet("1 of 1")
                .rev("A"),
        )
        .to_svg();
    assert!(svg.contains("PROJECT"));
    assert!(svg.contains("DRAWN"));
    assert!(svg.contains("BLAND"));
}