Skip to content

v0.27.0

Choose a tag to compare

@sipemu sipemu released this 24 Oct 08:02
2dc1b28

The DataZooDE/anofox-visualization#318 batch: a wasm-safe GAM smoother (#174),
PlotGrid axis alignment (#175), diagram primitives (#176) and the spatial set
(#177–#182: projections, polygon holes and simplification, geom_sf_text /
geom_sf_label, a binned fill scale with counts and an NA key,
annotation_scale, a bivariate fill scale with its 3×3 legend).

Breaking (pre-1.0 minor): new public fields on RectStyle, GeomRect,
GeomTile, GeomText and GeomLabel (build them with ..Default::default()
or the with_* builders); SfProjection no longer derives Eq; a polygon's
holes are now cut out (evenodd) instead of drawn as separate filled polygons;
DrawBackend gains draw_polygon_rings (with a default implementation). See
the Migration notes below.

Added

  • GeomSmooth::gam() (method = "gam") now works without the regression
    feature, including for wasm32-unknown-unknown (#174). Previously the GAM
    smoother only existed behind regression, which pulls anofox-regression,
    faer, nalgebra, statrs and rand (~100 crates) and does not build for wasm.
    StatSmooth now carries two GAM backends: compute_gam_regression
    (unchanged, anofox-regression's PSplineRegressor, regression feature)
    and a new dependency-free compute_gam_builtin — a cubic B-spline basis,
    a second-order difference penalty (Eilers & Marx), λ chosen by
    generalised cross-validation, and a handful of dense k×k solves by
    Gaussian elimination, plus a t-based confidence ribbon built from the
    same linear algebra (src/stat/gam.rs). A small dispatcher
    (StatSmooth::compute_gam) picks the regression-backed path when the
    feature is enabled and the builtin path otherwise; no other API changes.
    This is not a breaking change — gam() simply works in more
    configurations than before. The builtin fit is checked against the
    regression-backed one on the same data (src/stat/smooth.rs's
    gam_backend_parity_tests, regression-gated) and tests/gam_smooth.rs
    now runs against whichever backend is active. Ported from
    anofox-visualization's own src/smooth.rs (DataZooDE/anofox-visualization#318).

Spatial

  • SfProjection gains EqualEarth { lon0 } (Šavrič et al. 2018),
    Albers { lat1, lat2, lat0, lon0 } (spherical equal-area conic, with
    SfProjection::albers_usa()/albers_europe() presets) and
    Utm { zone, south } (ellipsoidal, Krüger series), alongside the existing
    PlateCarree/Mercator. SfProjection::utm_for(lon, lat) picks the zone
    and hemisphere for a point; SfProjection::utm_zone(lon) the zone alone.
    All three are tested against PROJ (ST_Transform in DuckDB spatial) to
    0.01 m absolute (≤1e-6 relative) over their usual range. Ported from
    anofox-visualization's map/project.rs (DataZooDE/anofox-visualization#318,
    #177). The ggplot CLI's --projection and the wasm render_geo JSON
    spec's projection field accept the new names (equal-earth,
    albers-usa, albers-europe, utm:<zone>/utm:<zone>s).
  • Breaking: SfProjection no longer derives Eq (the new variants carry
    f64 fields); PartialEq/Debug/Clone/Copy are unaffected, so this
    only matters to code that put SfProjection in an Eq-bound generic
    (none in this crate).
  • geom_sf_text()/geom_sf_label() (+ _with): labels placed inside
    geom_sf polygons, ggplot2's geom_sf_text/geom_sf_label. An
    anchor is either the ring's centroid or its pole of inaccessibility
    (SfLabelAnchor::Polylabel, Mapbox's algorithm — the inside point
    farthest from the outline, so a C-shaped region's label stays in the
    spine rather than the centroid, which can fall outside; default).
    .check_overlap(true) drops a lower-priority label (the mapped size
    aesthetic if present, else the region's screen area, larger first)
    that would collide with one already placed or fall outside the panel —
    geom_sf_text's own StatSf trains the X/Y scales on the labelled
    geometry's extent even with no geom_sf layer sharing the plot, like
    ggplot2's. .halo(color, r) draws a shadowtext-style halo (r px out
    in 8 directions, in color, behind the label) for legibility over a
    busy fill — a backend-agnostic approximation built from plain
    draw_text calls, so it needs no DrawBackend/TextStyle changes and
    works identically on the SVG/pixel/plotters backends. geom_sf_label
    additionally draws a background box, like geom_label.
    anofox-visualization's own label placement (map/labels.rs) can
    retire onto this (DataZooDE/anofox-visualization#318, #179).
  • ScaleFillBivariate (biscale-style; scale::bivariate::ScaleFillBivariate)
    • GeomBivariateLegend (GGPlot::bivariate_legend(..), feature sf): a
      fill scale mapping two already-binned variables (a "{x}-{y}" composite
      class, 1-indexed — the biscale R package's own convention; the binning
      itself stays the caller's job, as ScaleFillBinned's break maths does)
      to an nx × ny palette, and its square legend guide with two axis
      captions. Each cell is base − tx·(base − color_x) − ty·(base − color_y) per channel (clamped): exactly base at low-low, exactly
      color_x/color_y at the pure-axis corners, compounding toward the
      high-high corner — base is white for light, or a theme's card colour
      "lifted" for dark (plan-maps.md §15's D10); a CIE76 ΔE test checks the
      9 cells stay distinguishable for both. GeomBivariateLegend::from_scale
      builds the legend from the same scale used for the fill, and (like
      annotation_scale) draws as a furniture layer, not through the ordinary
      per-aesthetic legend — .legend_position(LegendPosition::None) hides
      that one. Verified geom_sf's ordinary Fill legend already follows an
      arbitrary custom theme (legend background/text/title colour, font
      family) with no new code needed — it rides the same generic
      discrete-legend path every other geom's Fill/Colour legend does, no
      sf-specific carve-out exists. anofox-visualization's own bivariate
      palette (planned, docs/plans/plan-maps.md M2d) can build on this
      (DataZooDE/anofox-visualization#318, #182).
  • geom_sf's Polygon now draws every ring (exterior + holes) as one
    shape under the even-odd fill rule, via a new
    DrawBackend::draw_polygon_rings (default impl: the old
    exterior-filled/holes-stroked-only approximation, for third-party
    backends; the SVG, pixel/canvas and plotters backends all override it
    with a real fill-rule/even-odd-scanline cut-out). A MultiPolygon's
    parts already drew independently and keep doing so, each now correctly
    honouring its own holes. GeomSf::simplify_px (default 0.5, builder
    .simplify(px)) simplifies every ring/line to the render resolution in
    screen space (Ramer-Douglas-Peucker) before drawing — a country
    boundary made of 10^5 points draws a fraction of that at a few hundred
    pixels across. Ported from anofox-visualization's map/mod.rs/
    map/write.rs (DataZooDE/anofox-visualization#318, #178).
    Migration: a polygon's holes were previously drawn as separately
    stroked (not filled-through) rings on top of the exterior fill — any
    snapshot test asserting on that exact (incorrect) look will need
    re-blessing; a hole now actually shows the panel background through
    it. GeomSf { .. } struct-literal callers gain the new simplify_px
    field (..Default::default() already covers it).
  • GGPlot::annotation_scale()/annotation_scale_with(..) (GeomAnnotationScale,
    feature sf): a metric scale bar in a panel corner — ggspatial's
    annotation_scale(). A "nice" 1/2/5 × 10ⁿ distance is picked from the
    trained X scale's domain at the panel's centre height (correct for a
    non-conformal projection like Equal Earth/Albers too, not just a
    uniform one), capped at max_width_px (default 90) and omitted
    entirely below 12px. Assumes the X/Y domain is in metres (any geom_sf
    projection other than SfProjection::PlateCarree) — same constraint
    ggspatial's own annotation_scale() has for an unprojected CRS; it
    draws a (wrong, undetectable-as-wrong-by-magnitude) bar from the raw
    degree domain rather than guessing.
  • GGPlot::annotation_attribution(text)/_with(..) (GeomAttribution,
    feature sf): a small, muted attribution text in a panel corner (e.g.
    "© Natural Earth") — plain text only, no hyperlink (DrawBackend has
    no link primitive; out of scope for this change).
    anofox-visualization's own scale bar and attribution (map/mod.rs) can
    retire onto these (DataZooDE/anofox-visualization#318, #181).

Diagram primitives (#176): a node-link diagram (boxes with wrapped labels,
rounded corners, arrows between box edges) can now be drawn with geoms
only — no hand-rolled SVG. Tracked from
DataZooDE/anofox-visualization#318,
which hand-rolled all three of these in its own diagram.rs for lack of
upstream support. No rendered output changes unless the new options are
used, but the new pub fields are an API break for exhaustive struct
literals (see Migration); cargo-semver-checks rates it major, i.e. the
next release needs a 0.x minor bump (0.27.0), not a patch.

Migration: code that builds RectStyle, GeomRect, GeomTile,
GeomText or GeomLabel with every field named (no
..Default::default()) must add the new fields — corner_radius: 0.0 /
radius: 0.0 / wrap_width: None / line_height: 1.2 — or switch to
..Default::default(). Code using ::default(), ..Default::default()
or the with_* builders is unaffected.

Added

  • Rounded rectangles (ggplot2 geom_label(label.r), ggchicklet
    radius): RectStyle gains a corner_radius: f64 field (default 0.0,
    a plain rectangle). GeomRect/GeomTile gain a radius field and
    .with_radius(px) builder method; GeomLabel gains the same for its
    background box. svg_backend emits a native rx/ry;
    pixel_backend (feature canvas) and plotters_backend (feature
    plotters), which have no rounded-rect primitive, approximate it as a
    polygon via the new render::backend::rounded_rect_points helper.
    GeomRect ignores radius under polar coords (drawn as an annulus
    sector, not a rect).
  • Text wrapping (ggtext / str_wrap behaviour): GeomText/GeomLabel
    gain .with_wrap(width_px) (word-wraps to a max width, measured with
    render::text::text_width's per-character metrics — the same table
    axis_label_fit/check.overlap already use, not a cruder estimate) and
    .with_line_height(multiple_of_size) (default 1.2) for the gap between
    wrapped lines. GeomLabel's background box grows to fit however many
    lines result, plus padding, and inherits the box's radius. The new
    render::text::wrap_text/wrapped_block_width do the wrapping and
    measure the widest resulting line. Unwrapped output (no .with_wrap()
    call) is byte-identical to before.
  • Arrow-to-box-edge (arrows on segments/curves via arrow(), since
    0.20): no change needed to the arrow API itself — a segment/curve already
    draws to whatever endpoint its data gives it, tip included, so "attach to
    a box edge" was always just a matter of computing that endpoint. Added
    geom::arrow::rect_edge_point(center, half_w, half_h, toward), a small
    public helper (ported from the consumer's hand-rolled exit()) that
    returns where a straight line from a box's centre toward a point leaves
    the box — feed its result as a geom_segment/geom_curve row's
    (x, y)/(xend, yend) instead of the box's raw centre. Documented with
    a worked example in its doc comment.
  • tests/diagram_primitives_176.rs: an integration test building a 4-box
    node-link diagram (rounded boxes, a wrapped label, arrows landing exactly
    on computed box edges) through the public GGPlot builder API only,
    checked against the SVG output's actual rx, <text> count, and
    polyline endpoints — not just rendered and eyeballed.

Scales / legend

  • ScaleFillBinned (scale_fill_binned(breaks, low, high) /
    scale_fill_binned_with(breaks, colors)): a binned fill scale with
    explicit class breaks (n + 1 increasing edges), one colour per bin —
    R's scale_fill_steps(breaks = ...), backing a ::CLASSES choropleth.
    The legend shows each class's trained count ("10–50 (12)") and, when
    any trained value is Value::Na, a "no data" key in a configurable
    na_color (ggplot2's na.value, default grey50) — no changes needed to
    the existing discrete-legend drawing code, since both ride the same
    Scale::breaks()/map_to_color() round trip ScaleColorSteps already
    uses. The break maths (quantile/Fisher-Jenks/equal-width/custom edges)
    stays the caller's job — this only bins against the edges it's given.
    anofox-visualization's own map legend (map/legend.rs) can retire its
    stepped-class rendering onto this (DataZooDE/anofox-visualization#318,
    #180).

  • Fix: ScaleSizeContinuous::map_fraction_to_size (used to size a
    graduated-symbol legend's keys) ignored scale_size_area's area-
    proportional mapping entirely and always used the plain linear range
    — so a scale_size_area legend's circles didn't match the radii the
    data itself draws at. It now uses the same max_size · √(|v|/denom)
    formula map_to_size does.

  • PlotGrid::axis_collect and [AxisCollect] (#175): align a column of
    stacked plots' plot areas and/or share one x domain across them —
    patchwork's plot_layout(axes = ...) idea, applied to the x axis and
    plot-area alignment specifically. AxisCollect::Align gives every cell
    in a column the same left/right plot-area inset, computed from the
    widest y-axis label requirement in that column (the same idea
    anofox-visualization's track.rs uses via a render-measure-render loop,
    done here as one layout pass instead). AxisCollect::CollectX adds a
    shared x domain — the union of every cell's own trained domain — with
    x-axis ticks/title/labels drawn only on the bottom cell of each column;
    a column whose x scale is discrete or untrained on any cell keeps
    Align's plot-area alignment but warns and skips the domain union.
    Every aligned cell also switches to
    axis_text_x_fit(AxisLabelFit::Shift), so an overflowing outer tick
    label can never perturb the alignment. A cell set thinner than the
    plotting engine's 50px panel-height floor via PlotGrid::heights
    (anofox-visualization's ::TRACK strips, typically 16-30px) gets a
    centred aspect_ratio instead, so its panel comes out exactly as tall
    as the cell rather than overflowing it — track.rs's own
    Fit::correct aspect trick, computed from the same layout pass.
    Default AxisCollect::Keep: byte-identical output when not called.
    Tracked from DataZooDE/anofox-visualization#318 (visualization logic
    belongs in ggplot-rs, not the consumer); track.rs keeps its own
    render-measure-render approach for now.