Repository navigation
v0.27.0
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 theregression
feature, including forwasm32-unknown-unknown(#174). Previously the GAM
smoother only existed behindregression, which pulls anofox-regression,
faer, nalgebra, statrs and rand (~100 crates) and does not build for wasm.
StatSmoothnow carries two GAM backends:compute_gam_regression
(unchanged, anofox-regression'sPSplineRegressor,regressionfeature)
and a new dependency-freecompute_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) andtests/gam_smooth.rs
now runs against whichever backend is active. Ported from
anofox-visualization's ownsrc/smooth.rs(DataZooDE/anofox-visualization#318).
Spatial
SfProjectiongainsEqualEarth { 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_Transformin DuckDB spatial) to
0.01 m absolute (≤1e-6 relative) over their usual range. Ported from
anofox-visualization'smap/project.rs(DataZooDE/anofox-visualization#318,
#177). TheggplotCLI's--projectionand the wasmrender_geoJSON
spec'sprojectionfield accept the new names (equal-earth,
albers-usa,albers-europe,utm:<zone>/utm:<zone>s).- Breaking:
SfProjectionno longer derivesEq(the new variants carry
f64fields);PartialEq/Debug/Clone/Copyare unaffected, so this
only matters to code that putSfProjectionin anEq-bound generic
(none in this crate). geom_sf_text()/geom_sf_label()(+_with): labels placed inside
geom_sfpolygons, ggplot2'sgeom_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 mappedsize
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 ownStatSftrains the X/Y scales on the labelled
geometry's extent even with nogeom_sflayer sharing the plot, like
ggplot2's..halo(color, r)draws a shadowtext-style halo (rpx out
in 8 directions, incolor, behind the label) for legibility over a
busy fill — a backend-agnostic approximation built from plain
draw_textcalls, so it needs noDrawBackend/TextStylechanges and
works identically on the SVG/pixel/plotters backends.geom_sf_label
additionally draws a background box, likegeom_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(..), featuresf): a
fill scale mapping two already-binned variables (a"{x}-{y}"composite
class, 1-indexed — thebiscaleR package's own convention; the binning
itself stays the caller's job, asScaleFillBinned's break maths does)
to annx × nypalette, and its square legend guide with two axis
captions. Each cell isbase − tx·(base − color_x) − ty·(base − color_y)per channel (clamped): exactlybaseat low-low, exactly
color_x/color_yat the pure-axis corners, compounding toward the
high-high corner —baseis 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. Verifiedgeom_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.mdM2d) can build on this
(DataZooDE/anofox-visualization#318, #182).
geom_sf'sPolygonnow 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/canvasand plotters backends all override it
with a real fill-rule/even-odd-scanline cut-out). AMultiPolygon's
parts already drew independently and keep doing so, each now correctly
honouring its own holes.GeomSf::simplify_px(default0.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'smap/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 newsimplify_px
field (..Default::default()already covers it).GGPlot::annotation_scale()/annotation_scale_with(..)(GeomAnnotationScale,
featuresf): 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 atmax_width_px(default 90) and omitted
entirely below 12px. Assumes the X/Y domain is in metres (anygeom_sf
projection other thanSfProjection::PlateCarree) — same constraint
ggspatial's ownannotation_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,
featuresf): a small, muted attribution text in a panel corner (e.g.
"© Natural Earth") — plain text only, no hyperlink (DrawBackendhas
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):RectStylegains acorner_radius: f64field (default0.0,
a plain rectangle).GeomRect/GeomTilegain aradiusfield and
.with_radius(px)builder method;GeomLabelgains the same for its
background box.svg_backendemits a nativerx/ry;
pixel_backend(featurecanvas) andplotters_backend(feature
plotters), which have no rounded-rect primitive, approximate it as a
polygon via the newrender::backend::rounded_rect_pointshelper.
GeomRectignoresradiusunder polar coords (drawn as an annulus
sector, not a rect). - Text wrapping (ggtext /
str_wrapbehaviour):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.overlapalready use, not a cruder estimate) and
.with_line_height(multiple_of_size)(default1.2) for the gap between
wrapped lines.GeomLabel's background box grows to fit however many
lines result, pluspadding, and inherits the box'sradius. The new
render::text::wrap_text/wrapped_block_widthdo 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-rolledexit()) that
returns where a straight line from a box's centre toward a point leaves
the box — feed its result as ageom_segment/geom_curverow'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 publicGGPlotbuilder API only,
checked against the SVG output's actualrx,<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 + 1increasing edges), one colour per bin —
R'sscale_fill_steps(breaks = ...), backing a::CLASSESchoropleth.
The legend shows each class's trained count ("10–50 (12)") and, when
any trained value isValue::Na, a "no data" key in a configurable
na_color(ggplot2'sna.value, default grey50) — no changes needed to
the existing discrete-legend drawing code, since both ride the same
Scale::breaks()/map_to_color()round tripScaleColorStepsalready
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) ignoredscale_size_area's area-
proportional mapping entirely and always used the plain linearrange
— so ascale_size_arealegend's circles didn't match the radii the
data itself draws at. It now uses the samemax_size · √(|v|/denom)
formulamap_to_sizedoes. -
PlotGrid::axis_collectand [AxisCollect] (#175): align a column of
stacked plots' plot areas and/or share one x domain across them —
patchwork'splot_layout(axes = ...)idea, applied to the x axis and
plot-area alignment specifically.AxisCollect::Aligngives 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'strack.rsuses via a render-measure-render loop,
done here as one layout pass instead).AxisCollect::CollectXadds 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 viaPlotGrid::heights
(anofox-visualization's::TRACKstrips, typically 16-30px) gets a
centredaspect_ratioinstead, so its panel comes out exactly as tall
as the cell rather than overflowing it —track.rs's own
Fit::correctaspecttrick, computed from the same layout pass.
DefaultAxisCollect::Keep: byte-identical output when not called.
Tracked from DataZooDE/anofox-visualization#318 (visualization logic
belongs in ggplot-rs, not the consumer);track.rskeeps its own
render-measure-render approach for now.