Skip to content

spread ​

Partitions data by the by field and lays out one child per partition along an axis. The primary layout operator.

js
gf.chart(seafood, { axes: true })
  .flow(gf.spread({ by: "lake", dir: "x" }))
  .mark(gf.rect({ h: "count" }))
  .render(root, { w: 400, h: 250 });

Signature ​

ts
// Operator form (inside .flow):
spread({ by?, dir, spacing?, alignment?, glue?, ... })

// Combinator form (apply n marks to one datum):
spread({ dir, ... }, [m1, m2, ...])

Parameters ​

spread — Operator form ​

Arrange children along dir with spacing, aligning them on the cross axis.

OptionTypeDefaultDescription
bystring | FieldAccessorField to partition rows by; also accepts a field(...) accessor carrying domain ops (sort/reverse/bin).
dirstringAxis to spread along: x, y, or an axis name the enclosing coordinate space declares (polar theta/r, geo lon/lat).
spacingnumber8Gap between children, px.
alignmentstring"baseline"Cross-axis alignment ("start" | "middle" | "end" | "baseline").
sharedScalebooleanfalseShare one scale across all children.
anchor"edge" | "start" | "middle" | "end" | "baseline""edge"Whether spacing is measured between facing edges (edge), or as a fixed pitch between the named anchor point on each child.
reversebooleanfalseReverse the children's order along dir.
gluebooleanfalseStack semantics: children glued, sizes sum; spacing forced to 0.
axesAxesOptions
xnumber | string | FieldExprLeft edge of this operator's box, in the parent's space (pixels). Omitted, the parent places it.
ynumber | string | FieldExprTop/bottom edge (y-up: bottom) of this operator's box, in the parent's space (pixels). Omitted, the parent places it.
wnumber | string | FieldExprData-driven cross-axis extent (field/datum-sized children).
hnumber | string | FieldExprData-driven cross-axis extent (field/datum-sized children).
sizenumber | string | FieldExprPer-entry stack-axis extent (field/datum-sized children); a field(...).normalize() accessor makes it a space-filling spine.

spread — Combinator form ​

Low-level combinator form of spread. Same fields as the operator form (OPERATORS.spread) plus key and the full box-dims group.

OptionTypeDefaultDescription
xnumber | string | FieldExprLeft edge of this operator's box, in the parent's space (pixels). Omitted, the parent places it.
wnumber | string | FieldExprData-driven cross-axis extent (field/datum-sized children).
ynumber | string | FieldExprTop/bottom edge (y-up: bottom) of this operator's box, in the parent's space (pixels). Omitted, the parent places it.
hnumber | string | FieldExprData-driven cross-axis extent (field/datum-sized children).
bystring | FieldAccessorField to partition rows by; also accepts a field(...) accessor carrying domain ops (sort/reverse/bin).
dirstringAxis to spread along: x, y, or an axis name the enclosing coordinate space declares (polar theta/r, geo lon/lat).
spacingnumber8Gap between children, px.
alignmentstring"baseline"Cross-axis alignment ("start" | "middle" | "end" | "baseline").
sharedScalebooleanfalseShare one scale across all children.
anchor"edge" | "start" | "middle" | "end" | "baseline""edge"Whether spacing is measured between facing edges (edge), or as a fixed pitch between the named anchor point on each child.
reversebooleanfalseReverse the children's order along dir.
gluebooleanfalseStack semantics: children glued, sizes sum; spacing forced to 0.
axesAxesOptions
sizenumber | string | FieldExprPer-entry stack-axis extent (field/datum-sized children); a field(...).normalize() accessor makes it a space-filling spine.
keystringInternal per-node key override.

Box dimensions ​

OptionTypeDefaultDescription
cxnumber | string | FieldExprCenter x.
x2number | string | FieldExprRight edge position.
emXbooleanEmbed x in the parent's x space.
cynumber | string | FieldExprCenter y.
y2number | string | FieldExprOther y edge position.
emYbooleanEmbed y in the parent's y space.
dimsRecord<string, AxisDimsValue>Box dimensions by axis name: x/y, or a name the enclosing coordinate space declares (polar theta/r, geo lon/lat). Each value is a position (like x) or an interval {min, center, max, size, embedded}.

Examples ​

ts
// Horizontal bar chart: one bar per "letter"
.flow(spread({ by: "letter", dir: "x" }))

// Vertical layout with fixed width per group
.flow(spread({ by: "category", dir: "y", w: 40 }))

// Combinator form: apply different marks to the same datum
spread({ dir: "x" }, [rect({ h: "v" }), text({ text: "n" })])

Naming the axis with dir ​

dir: "x" and dir: "y" mean the first and second axis in any coordinate space. dir also takes the names the enclosing coordinate space declares, so under polar you can write dir: "theta" or dir: "r", and under geo dir: "lon" or dir: "lat":

ts
chart(data, { coord: polar() })
  .flow(spread({ by: "month", dir: "theta", spacing: 0 }))
  .mark(rect({ w: 0.5, h: "value" }));

dir: "theta" lays the months out exactly as dir: "x" does. A name the enclosing space does not declare throws an error that lists the names it does. stack takes the same dir.

Path-aware by ​

by accepts a field name, a lodash path string, a function, or a field(...) accessor:

ts
spread({ by: "species", dir: "x" }); // field name
spread({ by: "origin.country", dir: "x" }); // nested path
spread({ by: (r) => r.species, dir: "x" }); // function escape hatch
spread({ by: field("species").sort(), dir: "x" }); // field(...) accessor

The same bare field name works after a ref / selectAll selection. The stream items are then refs, not raw records, but a ref is read through its .datum rows automatically, so you still write by: "species". Do not add a datum. prefix: by: "datum.species" looks for a field named datum inside each row, finds nothing, and puts every ref in one group.

How by resolves on a ref (homogeneity collapse) ​

A ref's .datum is the raw bag of rows that flowed into the node (an array; a fully-split leaf is a 1-row array). A by: "field" path on a ref does not just _.get the field off the first row — it projects with homogeneity collapse:

field resolves to a scalar iff every row in the node's bag agrees on that field; otherwise it is undefined — the "this field is multi-valued here, grouping by it is ill-posed" signal.

This is exactly SQL's ONLY_FULL_GROUP_BY / functional-dependency rule: you may only group by a column that is constant within each row-bag.

Example. After selectAll("bars") where each ref is a lake aggregate of 5 species rows:

ts
group({ by: "lake" }); // resolves — all 5 rows share one lake
group({ by: "species" }); // undefined — 5 distinct species; ill-posed

To group by a field that is multi-valued in the current bag, disaggregate first (split the bag so each child is homogeneous in that field). A fully-split cell (1 row) trivially collapses, so by: "species" works once each node holds a single record. A function by gets the ref itself, not a row, so it must read the bag through r.datum (an array) on its own.

To read every value at a multi-valued path instead of collapsing to a scalar, use pluck — the un-collapsed counterpart of by.

So a ribbon chart reads:

ts
chart(selectAll("bars")) // stream of refs
  .flow(group({ by: "species" })) // bare field, read through each ref's rows
  .mark(ribbon({ opacity: 0.8 }));

spacing vs glue ​

spacing controls the visual gap between children. glue controls whether children's data-driven sizes get summed into a positional axis at this level:

  • glue: false (default): real spread. Each child keeps its data-driven size; the underlying-space kind on dir is SIZE (or ORDINAL when children are categorical).
  • glue: true: stack semantics. Children are pushed together (regardless of spacing), and their cumulative size becomes a continuous POSITION domain on dir. This is what stack does.

Use spread({ spacing: 0 }) if you want children touching but with each child still treated as its own thing (e.g. discrete-theta polar charts). Use stack(...) if you want a stacked-bar feel (continuous position axis running through the stack).

Field-expression pipeline ​

field(name) returns a chainable expression — a builder where each method appends one op to an ordered pipeline. It works in two disjoint places:

  • As by (a domain slot): .sort(by?, order?), .sort(values) (an explicit order list), .reverse(), .bin({ thresholds? }), and .dropNulls() decide which groups exist and in what order.
  • As a mark or size channel value (a value slot): .sum(), .mean(), .count(), and .distinct() fold a group's rows to one number, overriding the channel's own default aggregation (sum for size, mean for position).

Mixing the two — an aggregate op on by, or a domain op on a value channel — throws.

One method is neither: .between(lo, hi, { closed }) returns a row predicate for filter rather than appending an op, because a predicate never takes part in grouping, folding or scaling. For the same reason it throws if the expression already carries ops.

ts
// Keep the rows whose day falls in (100, 120]
.flow(filter(field("day").between(100, 120, { closed: "right" })))

closed is polars' is_between argument: "both" (the default), "left", "right" or "none", choosing which ends of the interval are inclusive. The comparison is by value, not by row count. lo and hi are plain numbers.

The same test on a bare value is exported as between(v, lo, hi, { closed }), which is how a window follows a timer(): the clock read goes in the predicate.

Sort a stack's groups by another field's total, instead of data order:

ts
// Bars ordered ascending by their own total `value`
.flow(spread({ by: field("category").sort("value"), dir: "x" }))
.mark(rect({ h: "value" }))

Omit the by argument to sort by the group key itself; pass order: "desc" for descending.

Sort groups by an explicit order, for a domain-specific sequence no aggregate expresses (severity, calendar order, a fixed ranking):

ts
// Weather categories in a fixed order, not alphabetical or by aggregate
.flow(stack({ by: field("weather").sort(["sun", "fog", "drizzle", "rain", "snow"]), dir: "y" }))

Groups whose key isn't in the list are appended after, in natural sort order.

Bin a numeric field into groups — a histogram, with no precomputed bins:

ts
// One bar per ~10 auto-computed bins of `age`, height = row count per bin
.flow(spread({ by: field("age").bin(), dir: "x" }))
.mark(rect({ h: field("age").count() }))

Empty bins are dropped, like an ordinary groupBy. Pass field("age").bin({ thresholds: 5 }) (a count) or explicit thresholds (an array) to control the binning.

Drop rows with a missing/null grouping field, before grouping:

ts
// Rows with a null/undefined "genre" are excluded entirely, instead of
// forming their own "null" group
.flow(treemap({ by: field("genre").dropNulls(), size: "worldwideGross" }))

Override a channel's default aggregation:

ts
// The bar height is each species' MEAN weight, not the sum
.flow(spread({ by: "species", dir: "x" }))
.mark(rect({ h: field("weight").mean() }))

.count() and .distinct() report measure "count" (they're counts, not the source field's own units) unless you annotate the accessor explicitly: field("id", "my-measure").distinct().

Space-filling spines (mosaic / marimekko) ​

size: field(<name>).normalize() turns a stack's entries into a space-filling spine: each entry's size becomes its SHARE of the window (the operator's own split entries, v_e / Σv_e), so the axis reads as a local 0–100% conditional distribution. It replaces the removed normalize: true layout flag — .normalize() is a data transform on the size channel (a windowed share, computed once up front), not a layout mode, so the same field can drive both a cross-axis marginal size and the normalized fill with no preprocessing.

That is exactly a mosaic. Nest two stacks on alternating axes: the outer sizes each column by its raw total (size: "count" — the marginal), the inner sizes by each entry's share (the conditional). Both come off one raw field — no per-cell aggregation or precomputed totals:

ts
chart(passengers, { axes: true })
  .flow(
    // columns by class — width ∝ each class's count (marginal)
    stack({ by: "pclass", dir: "x", size: "count" }),
    // survival share within each column (conditional), filling height
    stack({ by: "survived", dir: "y", size: field("count").normalize() })
  )
  .mark(rect({ fill: "survived", stroke: "white", strokeWidth: 1 }))
  .render(container, { w: 400, h: 300 });

.normalize() composes to any depth: a third alternating level gives a nested mosaic (class → sex → survived). Because each level's stacking axis is a local self-scaling region and the count is read raw everywhere, the marginal × conditional × … area factorization holds all the way down.

WARNING

Inner conditional axes are local scopes, so they don't yet render 0–1 tick labels — only the outermost marginal axis is labeled.