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.