Statistics and positions

Stats derive marks from mapped rows. Positions control how derived marks share coordinate space.

The full list of statistical transforms lives in the stat reference: after_stat columns and which geoms accept each value. Open a specific stat, for example count or smooth.

Position adjustments are listed in the position reference: stack, fill, dodge, jitter, nudge, and identity, with positionParams for jitter and nudge.

Statistical summaries

<GeomPoint />
<GeomSmooth method="lm" />

Loess example: smoother and confidence ribbon on source points. Histogram, density, boxplot, and errorbar use the same derive-then-render path.

For discrete x, stat: "summary" collapses each group to one summary (default mean ± se). For continuous x, use stat: "summary_bin" instead.

Binned y summaries (summary_bin)

stat: "summary_bin" (ggplot2 stat_summary_bin) bins continuous x with the same break rules as stat_bin, then summarizes y in each non-empty (group × bin). Default fun is mean ± se. Available on point, line, and errorbar. Empty bins are omitted (unlike count bins).

<GeomPoint alpha={0.35} />
<GeomErrorbar stat="summary_bin" binwidth={1} boundary={0} width={0.4} />
<GeomLine stat="summary_bin" binwidth={1} boundary={0} />
gg(data, aes({ x: "x", y: "y" }))
  .geomPoint({ alpha: 0.35 })
  .geomErrorbar({ stat: "summary_bin", binwidth: 1, boundary: 0 })
  .geomLine({ stat: "summary_bin", binwidth: 1, boundary: 0 })
  .spec();

Bin knobs match histogram / freqpoly: bins, binwidth, boundary, center, closed. Summary knobs: fun, funMin, funMax.

Binned mean ± se: raw points with per-bin errorbars and a summary line.

Quantile regression lines

Linear quantile regression (ggplot2 geom_quantile / stat_quantile): fit y ~ x at each conditional quantile τ and draw one line per τ (default 0.25 / 0.5 / 0.75). v1 is linear only — no rqss, no weights.

<GeomPoint />
<GeomQuantile quantiles={[0.25, 0.5, 0.75]} />
gg(data, aes({ x: "x", y: "y" }))
  .geomPoint()
  .geomQuantile({ quantiles: [0.1, 0.5, 0.9] })
  .spec();

Quantile lines: scatter with three RQ lines.

Contour isolines

Contour isolines (ggplot2 geom_contour / stat_contour) draw open path polylines of constant z over a regular continuous x × y grid. Levels come from params.breaks, else binwidth, else bins evenly spaced from min(z)..max(z) inclusive (default 10). v1 is open polylines only — no contour_filled, no irregular triangulation, no default color-by-level.

<GeomContour bins={8} />
gg(grid, aes({ x: "x", y: "y", z: "z" }))
  .geomContour({ breaks: [0.25, 0.5, 0.75] })
  .spec();

Incomplete grid cells (missing/NaN corners) are skipped; groups without a usable grid or levels are dropped with a warning. after_stat level is carried for tooltips.

Contour isolines: nested levels of a radial peak.

2D density isolines

Bivariate KDE isolines (ggplot2 geom_density_2d / stat_density_2d) estimate a product Gaussian density over continuous x and y, then draw open path polylines of constant density. Bandwidth follows MASS bandwidth.nrd then kde2d's h/4 scaling (or params.h as one number or [hx, hy]). Grid params.n×n (default 100) spans a 5%-expanded data range. Levels use the same breaks / binwidth / bins rules as contour. Weights deferred.

<GeomPoint alpha={0.5} />
<GeomDensity2d bins={5} n={40} />
gg(scatter, aes({ x: "x", y: "y" }))
  .geomPoint({ alpha: 0.5 })
  .geomDensity2d({ bins: 5, n: 40 })
  .spec();

Groups with fewer than two finite points are dropped with a warning. after_stat level and density are carried for tooltips.

2D density isolines: scatter under nested KDE contours.

2D density filled bands

geom_density_2d_filled / stat_density_2d_filled reuses the same KDE grid and draws closed isoline rings as filled polygons (ggplot2 geom_density_2d_filled). Open rings are dropped with density-2d-filled-open-dropped. Fill defaults to after_stat level.

<GeomPoint alpha={0.45} />
<GeomDensity2dFilled bins={5} n={40} alpha={0.55} />
gg(scatter, aes({ x: "x", y: "y" }))
  .geomPoint({ alpha: 0.45 })
  .geomDensity2dFilled({ bins: 5, n: 40 })
  .spec();

True isobands between consecutive levels and weights are deferred.

2D density filled bands: scatter under closed KDE rings colored by level.

Dotplot (histodot)

Histodot stacked dots (ggplot2 geom_dotplot / stat_bindot): continuous x is binned with the same break rules as stat_bin, then one point per observation is stacked in each bin. y is after_stat stackpos only (not count). Diameter tracks binwidth in x pixels (dotsize multiplier; size for an absolute px override). stackdir: up | down | center | centerwhole; stackratio scales vertical spacing (default 1).

<GeomDotplot binwidth={0.5} boundary={0} stackdir="up" />
gg(data, aes({ x: "v" }))
  .geomDotplot({ binwidth: 0.5, boundary: 0 })
  .spec();

v1 is histodot only — no Wilkinson dotdensity, no binaxis = "y", no weights. Mapping aes.y fails loud (computed-y-mapped).

Dotplot histodot: stacked points in fixed bins.

Simple features (geom_sf)

geom_sf draws already-projected GeoJSON Geometry values stored as JSON strings in a data column (default geometry; override with params.geometry). Point/MultiPoint → points; LineString/MultiLineString → open paths; Polygon/MultiPolygon → closed fills. Multipart geometries expand to multiple marks. Interior rings are even-odd holes (SVG fill-rule="evenodd", canvas, and hit-testing). GeometryCollection is flattened to leaf Point/Line/Polygon families (recursive, nesting depth capped). Mixed families in one layer still error (split layers).

Default stat is public stat_sf (ggplot2 stat_sf): geometry expand runs on the normal non-identity frame path. Layer JSON stamps stat: "sf" (not identity). No CRS / coord_sf yet — coordinates are treated as already projected.

<GeomSf alpha={0.9} />
gg(regions, aes({ fill: "rate" })).geomSf().spec();
// layer.stat === "sf"; geometry column holds JSON.stringify({ type: "Polygon", ... })

SF polygons: three triangles filled by a rate field. GeometryCollection expand: one GC cell renders as two polygon parts.

SF text labels (geom_sf_text)

geom_sf_text (ggplot2 geom_sf_text) defaults to stat_sf_coordinates: one representative point per geometry part, then draws aes.label there. Point coordinates pass through; LineString uses the vertex mean; Polygon uses the exterior-ring shoelace centroid. MultiPoint / MultiLineString / MultiPolygon emit one label per part (feature aesthetics duplicated onto each part). GeometryCollection expands to leaves first, then the same per-part rule applies (one label per leaf part). Requires aes.label (no aes.x/aes.y).

Migration (multi-part labels): earlier releases labeled only the first Multi* component. Callers that relied on a single first-component label will now see one label per part — filter geometries or aggregate labels if you need the old single-label behavior.

<GeomSf alpha={0.55} />
<GeomSfText size={14} />
gg(regions, aes({ fill: "rate", label: "region" }))
  .geomSf({ alpha: 0.55 })
  .geomSfText({ size: 14 })
  .spec();

SF region labels: filled polygons with names at centroids.

SF boxed labels (geom_sf_label)

geom_sf_label is the boxed sibling of geom_sf_text: same stat_sf_coordinates placement, plus a measured rounded rect behind the text. color is ink + box stroke; fill is the box background (theme paper by default). Params include padding, radius, linewidth, and text size/anchor/dx/dy.

<GeomSf alpha={0.45} />
<GeomSfLabel padding={3} radius={2} size={13} />
gg(regions, aes({ fill: "rate", label: "region" }))
  .geomSf({ alpha: 0.45 })
  .geomSfLabel({ padding: 3, radius: 2, size: 13 })
  .spec();

SF boxed labels: names on paper-backed label boxes.

Ellipse confidence rings

Bivariate normal confidence ellipses (ggplot2 stat_ellipse, type norm only) on path layers: per group, estimate mean and sample covariance, scale by √χ²₂(level), and sample the perimeter (segments, default 51) plus a closing duplicate for a closed ring.

<GeomPoint />
<GeomPath stat="ellipse" level={0.95} segments={51} />
gg(data, aes({ x: "x", y: "y", color: "g" }))
  .geomPoint()
  .geomPath({ stat: "ellipse", level: 0.95 })
  .spec();

Path-only in v1 (not polygon). Groups with fewer than two finite points or zero variance are dropped with a warning. Rejected on other geoms.

Ellipse confidence rings: scatter under 95% rings per series.

Frequency polygon

Frequency polygon (ggplot2 geom_freqpoly) bins continuous x and draws a line through bin centers (y defaults to count). Canonical form is line + stat: "bin" + position identity — not a separate mark type:

<GeomFreqpoly bins={30} />
gg(data, aes({ x: "v", color: "g" })).geomFreqpoly({ bins: 30 }).spec();
// → { geom: "line", stat: "bin", position: "identity", y: { stat: "count" } }

Frequency polygon: Michelson light-speed runs as a line through bin centers (companion to the histogram specimen).

Unique (first-wins aesthetic dedupe)

stat: "unique" drops duplicate rows on the combination of mapped aesthetic fields before drawing — first occurrence wins, panel-local (ggplot2 stat_unique). Available on identity-capable geoms (point, line, path, text, col, area, rect, ribbon, rule, segment, errorbar).

<GeomPoint stat="unique" />

stat unique overplotting: stacked identical (x, y, series) triples collapse to one mark.

Blank (scale training without marks)

geom: "blank" (ggplot2 geom_blank) contributes mapped aesthetics to scale training and layout only — no paint, no hit targets. Use it to expand domains, force axes open for sparse marks, or reserve layout without drawing.

<GeomBlank />
gg(data, aes({ x: "x", y: "y" }))
  .geomPoint()
  .geomBlank({ aes: aes({ x: "x2", y: "y2" }) }) // expands domains only
  .spec();

No channels are required. Mapped style channels (color, size, …) train their scales without drawing marks. Surfaces: .geomBlank(), <GeomBlank />.

Blank domain expand: co-layer blank rows stretch axes past the plotted points. Blank axes only: axes and scales with no marks.

Convenience geoms (jitter, hline, vline)

Name aliases that normalize to existing marks — no new paint paths:

| Sugar | Normalizes to | |-------|----------------| | jitter | point + position: "jitter" | | hline | rule (horizontal) | | vline | rule (vertical) |

geomJitter / <GeomJitter> accept flat width / height / seed and assemble them into positionParams at the builder/component boundary.

hline / vline annotation intercepts (yintercept / xintercept) suppress plot-aes inheritance (ggplot2 inherit.aes = FALSE). Data-driven forms drop the orthogonal axis so the one-axis rule contract holds.

<GeomJitter width={0.2} height={0.2} />
<GeomHline yintercept={0} />
<GeomVline xintercept={10} />
gg(data, aes({ x: "x", y: "y" }))
  .geomJitter({ width: 0.2, height: 0.2 })
  .geomHline({ yintercept: 0 })
  .geomVline({ xintercept: 10 })
  .spec();

Jitter sugar: overplotted points with position jitter. Hline threshold and Vline cutoff: annotation intercepts as rule aliases.

Manual (portable named per-group transforms)

stat: "manual" (ggplot2 stat_manual, portable v1) applies a named per-group transform — no JS callbacks (PortableSpec only). Required params.fun:

| fun | Behavior | |-----|----------| | first / last | Keep one source row per aesthetic group | | mean / median / min / max / sum | One synthetic row; x and y aggregated independently |

Surfaces: point, line, path. Missing fun fails with manual-fun-required; unknown names are schema invalid-enum-value.

<GeomPoint stat="manual" fun="mean" />
gg(data, aes({ x: "x", y: "y", color: "g" }))
  .geomPoint({ stat: "manual", fun: "mean" })
  .spec();

stat manual mean centroids: identity scatter under large mean points per series.

Align (shared continuous-x grid for stack)

stat: "align" (ggplot2 stat_align) is for continuous-x area / line when series sample different x values. It unions finite x across groups, linearly interpolates each series onto that shared grid, and sets y to 0 outside a group's observed x range so position: "stack" / "fill" can compose without jagged seams.

gg(data, aes({ x: "t", y: "v", fill: "series" }))
  .geomArea({ stat: "align", position: "stack" })
  .spec();
<GeomArea stat="align" position="stack" />

Available on area and line only (not point or shared identity-only geoms). Outside a group's x span y is 0 (stack-friendly).

Connect (named path joins)

stat: "connect" (ggplot2 stat_connect) expands successive finite points into intermediate vertices so stepped joins are real path geometry — not only a stroke curve flag. params.connection: hv (default), vh, mid, linear. On path expansion is in data order; on line points are sorted by x first, and geometry skips a second x-sort so tied-x elbows stay intact.

<GeomPath stat="connect" connection="hv" />
gg(data, aes({ x: "x", y: "y" }))
  .geomPath({ stat: "connect", connection: "hv" })
  .spec();

Connect hv path: three data points expand to a horizontal-then-vertical polyline.

Curve connectors

Curved connectors (ggplot2 geom_curve): one quadratic Bezier per row from (x,y) to (xend,yend), tessellated in panel px so curvature is not aspect-skewed. Params: curvature (default 0.5), angle (degrees, default 90), ncp (control-point density). Same required channels as segment; lineend maps to SVG stroke-linecap (default butt).

<GeomCurve curvature={0.4} lineend="round" />
gg(data, aes({ x: "x", y: "y", xend: "xend", yend: "yend" }))
  .geomCurve({ curvature: 0.5, angle: 90, ncp: 5 })
  .spec();

Intentional subset: quadratic approximation, not full grid xspline. Curve connectors: Darwin maize pairs as arcs.

Spoke (origin + angle + radius)

geom: "spoke" (ggplot2 geom_spoke) draws one finite segment per row from (x, y) in direction angle (radians; 0 = +x, π/2 = +y) with length radius in data units. Endpoints are xend = x + radius·cos(angle), yend = y + radius·sin(angle), then the same position transform as x/y. Tips train domains; paint reuses segment strokes. angle and radius come from aes and/or constant params. Continuous x and y required.

<GeomSpoke />
gg(data, aes({ x: "x", y: "y", angle: "theta", radius: "r" }))
  .geomSpoke({ linewidth: 1.5, lineend: "round" })
  .spec();
// constants: .geomSpoke({ angle: 0, radius: 1 })

Spoke vector field: synthetic 5×5 field with mapped angle and radius.

Map (fortified choropleth)

geom: "map" (ggplot2 geom_map) joins a fortified map table to value rows. Map coordinates come from long+lat or x+y; the join key is aes.map_id on the value table matched to params.mapId on the map (default "region", then "id"). Optional map group splits multipoly rings. Missing regions drop with a map-region-missing warning.

<GeomMap map={{ values: fortified }} linewidth={1.2} />
gg(rates, aes({ map_id: "region", fill: "rate" }))
  .geomMap({ map: { values: fortified }, mapId: "region" })
  .spec();

Intentional subset: no network map fetches, no sf/CRS, no public geom_polygon (map ships the closed-path renderer only). Map choropleth: three toy regions filled by rate.

Positions

Stack sums, dodge side-by-side groups, fill normalizes each stack to one, jitter separates overlaps with a deterministic seed. Bar examples.