Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Encoding

The encoding object binds data fields to visual channels.

Channels

FamilyChannels
Positionx, y, x2, y2 (see Span channels), theta, radius
Offsetx_offset, y_offset — see Offset channels
Color & opacitycolor, fill, stroke, opacity
Size & shapesize, shape
Text & ordertext (see Text channel), tooltip, order — see Order channel
Groupingdetail — see Detail channel
Facetrow, column
Sankeysource, target, value

Channel shape

"x": {
  "field": "score",
  "type": "quantitative",
  "aggregate": "mean",
  "scale": {"type": "log"},
  "axis": {"title": "Average score", "format": ".2f"},
  "sort": "-y"
}
KeyPurpose
fieldColumn from the source (or transform output).
typeOne of nominal, ordinal, quantitative, temporal.
aggregateFriendly alias: mean, sum, count, null_count, median, q1, q3, min, max, range, stdev, variance, skewness, kurtosis, ci0, ci1, distinct, mode, frequency, plus wmean, ratio, lift, share. count, distinct, mode, frequency, and null_count work on any field type; numeric aggregates require a quantitative or temporal field. frequency is the scalar companion to mode — it returns the modal count (how many times the most frequent value occurs), whereas mode returns the value itself.
scaleScale spec (type, domain, range, scheme, padding, …).
axisAxis config (orient, title, format, grid, tick_count, label_angle, …) — see Axis placement — or null to hide the axis.
legendLegend config — placement (orient, padding, offset; see Legend placement), content (title, values, format, tick_count, label_limit; see Legend content) and layout (type, direction, symbol_type, symbol_size; see Symbol or gradient) — or null to hide the legend.
formatd3-format string for label formatting. Read on the text and tooltip channels and on a table column; on any other channel it is validated and then unread — put it on axis.format / legend.format instead, and see Spec › Warnings for the diagnostic that says so.
sort"ascending" / "descending" / "-y" / [explicit, order, ...].
stackPosition channels only. "zero" / "normalize" / "center" / true to stack, null / false to opt out — see Stacking.
keytrue to mark this channel as the animation join key — see Spec › Animation. At most one channel per encoding may set this; only valid on position channels (x, y, x2, y2, theta, radius) and mark channels (color, fill, stroke, opacity, size, shape, sankey source/target/value, geo longitude/latitude/feature).

Span channels

x2 and y2 turn a position into an interval. They take the same channel shape as x / y, with one rule that shapes everything else: a span channel never resolves a scale of its own. It is measured on the scale its base channel resolved, so both ends of a span land on one axis and in one set of units. Two consequences follow.

  • The base channel’s domain is widened with the span column’s values before the scale is built, so an interval reaching past the base column’s own range is never clipped.
  • x2.type must equal x.type (and y2.type must equal y.type). A mismatch is PRISM_SPEC_043, because declaring two types does not produce two scales — it produces one scale silently reading the second column under the first column’s rules.

A span channel also needs its base channel: x2 without x is an error, as is a span channel that names no field.

Per-mark semantics

Markx2y2
barSpans x→x2. The other axis keeps its band slot, so a ranged bar still needs a categorical axis on the side that is not ranged — bind both x2 and y2 only when you want a free-floating rect.Spans y→y2, replacing the baseline anchor.
rectSpans x→x2. An unranged axis uses its band width, or the historic 1-px cell when it is continuous.Spans y→y2, same rules.
ruleDraws an interval segment from (x, y) to (x2, y2) instead of spanning the plot. Both x and y must be bound; an unbound span channel holds its endpoint at the base pixel, so x/x2 + y is a horizontal whisker, y/y2 + x a vertical one, and all four a diagonal. Endpoints keep their authored order, so a descending interval stays descending.As x2.
areaNot supported — an area’s x sequence is the path it traces, not an extent.Replaces the implicit zero baseline with an explicit lower edge read per row. Grouping, x-sorting and curve interpolation are unchanged, so a color- or detail-split band keeps parallel boundaries.
every other markRejected (PRISM_SPEC_042).Rejected (PRISM_SPEC_042).

Rejecting rather than ignoring is a design choice: a span channel a mark cannot draw used to disappear silently, which made a ranged bar look like an ordinary baseline bar with no diagnostic.

A Gantt row — categorical y, ranged x:

{
  "mark": {"type": "bar"},
  "encoding": {
    "y":  {"field": "task",  "type": "nominal"},
    "x":  {"field": "start", "type": "quantitative"},
    "x2": {"field": "end",   "type": "quantitative"}
  }
}

A confidence band — area with an explicit lower edge:

{
  "mark": {"type": "area"},
  "encoding": {
    "x":  {"field": "day",   "type": "temporal"},
    "y":  {"field": "upper", "type": "quantitative"},
    "y2": {"field": "lower", "type": "quantitative"}
  }
}

Vega-Lite’s polar span channels theta2 and radius2 are not implemented. They are not in the schema, so a spec carrying either is rejected at decode.

Conditions

A channel can carry a condition clause that switches its visual value based on a declared selection or a structured predicate test. The channel’s own value / field supplies the fallback (“otherwise”) branch.

"color": {
  "condition": [
    {"selection": "brush", "value": "#22c55e"},
    {"test": {"op": "lt", "field": "score", "value": 0}, "value": "#ef4444"}
  ],
  "value": "#94a3b8"
}

Rules:

  • selection references a name declared in the spec’s selection block (validate rule PRISM_SPEC_025).
  • test is a structured predicate — the same grammar filter uses ({op, field, value} leaves and and / or / not combinators), not an expression string. It is evaluated row-by-row at encode time (PRISM_SPEC_026). See Spec › Filter transform for the full operator set.
  • Each entry needs exactly one of value or field. A selection-form entry without value inherits the channel’s own field binding (PRISM_SPEC_027).
  • Entries evaluate top-down; the first match wins.

Where the work happens:

  • test-driven entries are evaluated server-side at encode time and baked directly into the mark’s resolved style. SVG output reflects them with no client involvement.
  • selection-driven entries land in the scene-IR as a Mark.Conditions[] slice. The browser-side prism-selection module flips the matching SVG attribute when the named selection becomes active, and reverts to the resolved “otherwise” branch when it clears.

See the conditions gallery and the highlight-on-brush recipe.

Scales

Eight types: linear (default for quantitative), log, pow, sqrt, time (default for temporal), band (default for nominal bar x), point (default for nominal point x), ordinal (default for color over nominal).

See the scales gallery for one fixture per type.

Bounding the domain — domain, zero, nice

Three keys shape the resolved domain. They compose in a fixed precedence: domain wins outright, and when it is absent the data extent is widened by zero and then rounded by nice. How that resolved domain is then laid onto pixels is a separate set of keys — see Mapping the domain onto pixels.

KeyTypeDefaultEffect
domainarraydata extentPins the domain exactly. zero and nice are not applied on top of it.
zerobooleantrue on linear / pow / sqrt; ignored on log and timeWidens a positive-only extent down to 0 (or a negative-only extent up to 0).
nicebooleantrue on linear / pow / sqrt / time; false on logRounds the bounds outward so the axis starts and ends on a labelled tick.
"y": {
  "field": "score", "type": "quantitative",
  "scale": {"zero": false, "domain": [90, 130]}
}

domain shape by family. A continuous scale (linear, log, pow, sqrt) takes exactly two ascending numeric bounds. A time scale takes two bounds, each an ISO-8601 date string or an epoch-millisecond number. A discrete scale (band, point, ordinal) takes the ordered category list:

"x": {
  "field": "size", "type": "nominal",
  "scale": {"domain": ["small", "medium", "large"]}
}

A discrete domain pins the leading order; any data category the list omits is appended after it in first-seen order, so a partial domain reorders without dropping rows. This is Prism’s one intentional divergence from Vega-Lite, which treats a discrete domain as the complete category set.

Retired workaround. Explicit category order used to be reachable only by arranging the layers so the desired category appeared in the first layer’s data — order fell out of the domain-union order. Set scale.domain instead; layer ordering no longer affects category order.

A malformed domain — wrong arity, non-numeric bounds on a continuous scale, reversed or zero-width bounds, a non-string category — is rejected at validate time with PRISM_SPEC_041, and the encoder raises the same code for callers that skip validate.

nice is boolean-only. Vega-Lite’s numeric (tick-count) and time-interval forms of nice are rejected by the schema. Control tick density with axis.tick_count instead.

log has no zero-forcing, by design. A log domain cannot contain zero, so zero is ignored there — including an explicit zero: true.

Retired workaround. Because zero-forcing used to be unconditional on linear, scale.type: "log" was the only way to get an axis that did not start at zero. That is no longer necessary: use {"zero": false} on the linear scale, or pin domain outright, and reach for log only when the data genuinely wants a logarithmic mapping.

nice defaults on, so adding no scale block at all moves axis bounds compared with pre-E2-S1 Prism: an extent of 3..97 now resolves to 0..100 rather than 0..97. Set {"nice": false} to keep the raw extent.

Rows outside the domain — overflow and clip

Pinning domain: [90, 130] on a column that holds a 150 puts that row outside the plot rect. Prism’s answer is overflow with a clip: the row is encoded at its true position, and the plot region clips whatever reaches past its edge.

Three things it is not, by design:

  • Not dropped. The row still produces a mark, so a line stays continuous through it and an aggregate still counts it.
  • Not clamped. Pulling the value back to the domain bound is scale.clamp’s job. Making it the default would make that key meaningless and would silently misreport the value’s magnitude.
  • Not drawn over the chrome. Without a clip, a line reaching past the top of the plot would cross the title, and a bar reaching past the left would cross the y-axis labels.

The clip is expressed in the Scene IR, not invented by a renderer: the encoder registers the plot rect as a defs.clips entry and points scene.clip_ref at it, and every renderer applies it to the mark container alone. Axes, gridlines, legends and the title are siblings of that container and stay unclipped — including an axis raised above the marks with axis.zindex, which is emitted outside the container for exactly this reason. Gridlines need no clip of their own: they are generated from ticks inside the domain, so they can never reach past the plot edge.

When the clip is armed. By default, only when a position channel (x, y, x2, y2) pins an explicit scale.domain. That is the one way a mark can land outside the plot rect — with a data-derived domain the bounds come from the very rows being drawn, so every mark is inside by construction, and arming a clip there would only risk shaving a stroke or a glyph that legitimately overhangs the edge by a pixel.

Forcing it either way. mark_def.clip overrides the default:

"mark": {"type": "line", "clip": true}

true always clips, false never does. The clip bounds one plot rect rather than one mark, so in a layer a single clip: true arms it for every layer and a clip: false otherwise disarms it for all of them. concat / facet / repeat cells each own a plot rect and decide independently.

Choosing the colors — range, scheme, interpolate

scale.range is an inline list of colors: the alternative to naming a scheme. It is the top of the palette cascade, so it beats both scheme and whatever the active theme’s range slots supply:

"color": {
  "field": "origin", "type": "nominal",
  "scale": {"range": ["#4c78a8", "#f58518", "#54a24b"]}
}

Colors are #rrggbb or #rrggbbaa. An entry Prism cannot parse is dropped and the rest still win; a range whose every entry is unparseable falls through to the next tier rather than blanking the chart. On a discrete color channel the list is indexed positionally — the i-th category takes the (i mod n)-th color — which is what the symbol legend’s swatches show. On a quantitative color channel the list is the ramp’s control points, interpolated between.

range is honoured on color channels only. On a position channel (x, y, x2, y2, theta, radius) it is rejected at validate with PRISM_SPEC_045, not silently ignored. A position scale’s range is the plot rect Prism computes, and the axis, the gridlines and every mark all measure against that same rect — a spec-supplied range would move the marks without moving the chrome, producing a chart whose axes disagree with the data they label. Bound the axis with domain / zero / nice, and size the rect with width / height or the band paddings. Vega-Lite’s non-color ranges (size, opacity, a named range reference string) are not implemented; those forms read as “no explicit range” and fall through to the rest of the cascade.

scale.interpolate picks the colorspace a continuous ramp is traversed in:

ValueEffect
rgbDefault. Blends the sRGB components directly.
hslBlends hue along the shorter arc, keeping saturation up where sRGB would pass through grey.
labBlends in CIELAB, which is roughly perceptually uniform, so the ramp’s steps read as evenly spaced.

Vega-Lite’s hcl is not supported — the schema rejects it.

"color": {
  "field": "density", "type": "quantitative",
  "scale": {"scheme": "viridis", "interpolate": "lab"}
}

The interpolation happens once, at encode time: Prism resamples the ramp into evenly spaced sRGB stops that already trace the requested space’s curve. Everything downstream — the SVG renderer, the Scene IR’s gradient stops, the browser web component — only ever blends neighbouring stops linearly, so no renderer carries colorspace math of its own and every backend agrees. interpolate has no effect on a discrete palette, which is indexed rather than traversed.

Mapping the domain onto pixels — clamp, reverse, round

domain / zero / nice decide what the scale covers. Five further keys decide how that domain lands on pixels.

KeyTypeDefaultApplies toEffect
clampbooleanfalsecontinuousPins an out-of-domain value to the nearest domain edge instead of letting it map past the range.
reversebooleanfalseallRuns the scale the other way round, relative to the channel’s default direction.
roundbooleanfalseallQuantises layout to whole pixels.
padding_innernumber [0,1)0.1bandGap between adjacent bands, as a fraction of the step.
padding_outernumber [0,1)0.05 band, 0.5 pointband / pointGap before the first and after the last band.
alignnumber [0,1]0.5band / pointWhere the slack left over after layout sits.

An out-of-range padding or align is rejected at validate time with PRISM_SPEC_049; a caller that skips validate gets the value pinned into its legal range rather than an inside-out band.

clamp. By default a value outside the resolved domain keeps mapping past the range and is clipped by the plot rect — the useful behaviour when a pinned domain crops outliers on purpose. Turn clamp on to pile them against the edge instead:

"y": {
  "field": "score", "type": "quantitative",
  "scale": {"domain": [0, 100], "clamp": true}
}

clamp is a continuous-family knob. A band / point / ordinal scale has no out-of-domain image to pin — an unknown category is an error, not an overflow. On a log scale clamp pins positive out-of-domain values, but a zero or negative value still has no image at all and raises PRISM_SPEC_010.

reverse is axis-relative, not screen-relative. Prism’s y scales already run bottom-to-top: the domain minimum is handed the bottom pixel, because SVG’s origin is the top-left corner. reverse composes with that existing inversion rather than replacing it. Concretely:

  • on x, reverse: true puts the domain minimum on the right;
  • on y, reverse: true puts the domain minimum at the top, so the axis reads downward.

Axes, ticks and gridlines all place through the same scale, so they follow automatically — a reversed axis relabels itself rather than needing a separate axis override.

Continuous families implement reverse by flipping the pixel range. Discrete families instead hand the computed slots to the categories back to front — d3-scale’s own band behaviour — which keeps band widths and the signed step untouched, so a reversed band scale on y still draws horizontal bars correctly. The resolved domain order is not changed either way: scale.domain still lists categories in the order the axis reads them before reversal.

round is layout, not serialisation. On a band or point scale it floors the step and rounds the leading offset and the band width, which is what makes band edges land on device pixels and stops adjacent bars sharing a half-pixel seam. On a continuous scale it rounds the resolved pixel. It is independent of the renderer’s pinned 3-decimal coordinate quantisation (see Scene IR) — both can apply, and rounding the layout first is what actually produces whole numbers in the output.

Band geometry. A band scale divides its range into one step per category and draws the band inside that step:

step   = span / (n - padding_inner + 2 * padding_outer)
offset = (span - step * (n - padding_inner)) * align
width  = step * (1 - padding_inner)
left_i = range_start + offset + step * i

padding is the shorthand: on a band scale it sets padding_inner and padding_outer together, on a point scale it sets the (only) outer padding. An explicit padding_inner / padding_outer outranks it.

"x": {
  "field": "origin", "type": "nominal",
  "scale": {"padding_inner": 0.4, "padding_outer": 0.2}
}

The defaults — 0.1 inner, 0.05 outer, 0.5 align — are Vega-Lite’s, and they reproduce Prism’s pre-split band layout exactly (which spent half an inner gap at each end of the range), so adopting the split moved no existing chart. A point scale has no inner padding — every band collapses to a point — so padding_inner never reaches it; its outer padding defaults to 0.5, which is what centres the first and last point half a step inside the range.

align only moves where the leftover slack sits; it never changes the band width. 0 packs the bands against the range start, 1 against the end, 0.5 centres them.

Axes & legends

Both are auto-generated based on the encoded channels but can be overridden per channel. Bundled support: 4 orientations (bottom/left/top/right — see Axis placement), major + minor ticks, grid toggle, label rotation, overlap handling, gradient + symbol legends.

Axis placement

axis.orient on a position channel picks the side of the plot the axis occupies:

"encoding": {
  "x": {"field": "month", "type": "nominal", "axis": {"orient": "top"}},
  "y": {"field": "sales", "type": "quantitative", "axis": {"orient": "right"}}
}
ChannelorientResult
xbottomDefault. Axis below the plot.
xtopAxis above the plot.
yleftDefault. Axis to the left of the plot.
yrightAxis to the right of the plot.

An x axis runs horizontally and a y axis vertically, so only two of the four sides are meaningful per channel. The other two — {"x": {"axis": {"orient": "left"}}} — are rejected at validation with PRISM_SPEC_044 rather than silently ignored.

The padding follows the axis. The side an axis moves to reserves the room for its tick marks, labels and title; the side it left releases it and the plot rect expands into the freed space. A chart with x on top and y on the right therefore has the same plot size as the default one, mirrored. Grid lines, tick marks and the domain line all move with the axis.

Orient composes with the rest of the axis vocabulary. "axis": null wins over it: a hidden axis reserves nothing on any side, so the orient is moot (see Hiding an axis or legend).

Under layer, the layers share one pair of axes, so orient folds across them first-specified-wins like every other axis property — the first layer that sets it decides the side, and a later layer asking for a different one raises PRISM_WARN_AXIS_CONFIG_CONFLICT and is ignored (see Where warnings surface for where that reaches you). Under facet and repeat, every cell renders the same child spec, so the child’s orient places the shared axis for the whole grid: a top-oriented x axis anchors to the top row instead of the bottom, and a right-oriented y axis to the last column instead of the first.

Axis components and geometry

An axis is three drawable components plus a title, and the axis block on a position channel controls each one separately:

"x": {
  "field": "day", "type": "nominal",
  "axis": {
    "labels": true, "ticks": true, "domain": true,
    "tick_size": 5, "label_padding": 4, "title_padding": 8,
    "label_limit": 0, "zindex": 0
  }
}
KeyDefaultEffect
labelstrueDraws the tick labels. false suppresses only the labels — the tick marks, the domain line, the grid and the title all stay.
tickstrueDraws the tick marks. false suppresses only the marks; the labels sit at the same tick positions and are unaffected, as are the grid lines.
domaintrueDraws the axis line along the plot edge. false suppresses only that line.
tick_size5Major tick length in pixels. Minor ticks stay proportionally shorter (0.6×).
label_padding4The gap between the axis line and its tick labels. Added to a fixed per-side text allowance, so 0 puts the labels as close as the baseline permits.
title_padding8The gap between the tick labels and the axis title. Added to a fixed per-side text allowance, so 0 puts the title as close as the labels permit.
label_limit0 (no limit)Maximum label width in pixels. A wider label is truncated with an ellipsis (Engineering → Engi…). A limit too small to hold even the ellipsis drops the label.
zindex00 draws the axis and its grid lines behind the marks; any positive value draws them in front.

The three visibility switches compose independently — set any combination and each component obeys only its own key. They are also finer-grained than "axis": null, which suppresses the whole block: with labels, ticks and domain all false the axis is still emitted and still draws its grid lines and its title.

Suppression releases padding. The margin an axis reserves on its side of the plot is the sum of a tick-mark share and a label share, so "ticks": false or "labels": false hands that share back and the plot rect expands into it. The domain line rides on the plot edge and reserves nothing, so hiding it moves nothing. Reservations are fixed pixel metrics rather than measured text: a tick_size, label_padding or title_padding far larger than the default draws into the outer margin instead of growing the reservation.

zindex and clipping. An above-marks axis is emitted as a sibling of the mark container, not a child, so it is never subject to the plot-region clip that keeps out-of-domain marks inside the plot rect — its labels and title still draw in the margin. Under layer and facet a shared axis is emitted after every cell and therefore always draws above the marks, whatever its zindex. That shared-axis ordering is a known fidelity gap, not a clipping one: a shared axis lives outside every cell’s mark container, so the clip never reaches it either way. Honouring zindex there would move the shared axes ahead of the cells and restack every layered and faceted chart, so it is held for a change of its own, made on purpose.

Truncation is measured with a fixed heuristic. Prism runs no text measurement pass; label_limit (like overlap detection) estimates 6 px per character. Truncation happens once, at encode time, so the label text in the Scene IR is already shortened and every renderer agrees.

Spec beats theme. tick_size, label_padding and title_padding also exist as the theme tokens --prism-axis-tick-size, --prism-axis-label-padding and --prism-axis-title-padding — and, as axis_x / axis_y, per axis. The axis block wins outright wherever it states a value; the per-axis theme block comes next, then the shared theme block, and Prism’s built-in metric is the floor. See Themes.

All eight keys survive composition. Under layer and facet with the default (shared) resolve mode they are folded from the children with the same first-specified-wins rule as every other axis property — see Composition.

Ticks and gridlines

Gridline positions are tick positions. Prism emits one gridline per major tick, so whatever picks the ticks picks the gridlines with it. That coupling is intentional and matches Vega-Lite — there is no separate “grid values” knob, and there is not going to be one. Minor ticks get a tick mark but never a gridline.

Three keys on the axis block choose the tick set:

KeyEffect
tick_countHow many major ticks to aim for on a continuous axis. A hint: the generator rounds to a readable step (1/2/5 × 10ⁿ) and may land either side of the request. Default 5. 0 removes every tick — and therefore every gridline — while keeping the domain line and title.
valuesAn explicit tick set that replaces the generated one. Pins exactly what it names, and overrides both tick_count and tick_min_step.
tick_min_stepThe smallest gap, in domain units, allowed between adjacent generated ticks.
"y": {
  "field": "revenue", "type": "quantitative",
  "scale": {"domain": [0, 80]},
  "axis": {"values": [0, 25, 50, 75]}
}

That spec draws four gridlines, at 0, 25, 50 and 75, and nothing else.

tick_count applies to the continuous families — linear, log, pow, sqrt and time. On a log axis the decades drive the tick set, so the count acts as a ceiling: when there are more decades than the count allows, every k-th decade survives and the mantissa minor ticks are dropped. Discrete (band, point, ordinal) axes tick once per category and ignore tick_count.

values works on every family. Pin numbers on a quantitative axis, ISO-8601 date strings (or epoch milliseconds) on a temporal one, and exact category names on a discrete one:

"x": {"field": "month", "type": "temporal", "axis": {"values": ["2021-01-01", "2021-07-01"]}}
"x": {"field": "region", "type": "nominal",  "axis": {"values": ["north", "south"]}}

An entry the axis cannot place — outside the resolved scale domain, unreadable for the scale family, or a category the domain does not contain — is dropped, not drawn off-plot, and reported as PRISM_WARN_AXIS_VALUES_DROPPED listing every casualty. A dropped value takes its gridline with it. If the values are the ones you want, widen the domain with scale.domain. The warning reaches you through SceneDoc.Warnings — see Where warnings surface.

Pinning suppresses minor ticks. Minor ticks are midpoints of a generated nice sequence; an author-chosen set has no such sequence to halve, and inventing midpoints between arbitrary pinned values would add tick marks nobody asked for. So values yields exactly the ticks it names — all major, all with gridlines. tick_count and tick_min_step leave the generator in charge, so minor ticks are recomputed from whatever majors come out.

tick_min_step is enforced by asking the generator for fewer ticks until the gap opens, rather than by thinning the result, so the survivors stay round numbers (0/50/100, not 0/40/80). It shapes generated ticks only: values pins exactly what it names, spacing included.

All three keys survive composition. Under the default (shared) resolve mode a layer or facet folds its children’s axis blocks together property by property, first-specified-wins, so tick_count set on one layer reaches the shared axis — see Composition.

Crowded labels — label_overlap

When two tick labels’ estimated boxes collide, axis.label_overlap decides what gives. Prism has no text-measurement pass, so the estimate is 6px per character along a horizontal axis and a flat 12px line height along a vertical one — the same heuristic label_limit uses.

ValueEffect
true, "parity", "auto"Hide every other colliding label, so a crowded axis thins by half rather than collapsing to its first label. The default.
"greedy"Keep the last label shown and hide everything that collides with it, then carry on from it. Drops at least as much as parity, and leaves the survivors evenly legible.
false, "none"Draw every label. Overlap becomes the author’s problem.

Adjacency is direction-independent. Ticks reach this pass in domain order, not pixel order, and a band scale on a vertical axis runs bottom-to-top — the first category carries the largest y. The pass compares the absolute distance between two labels against their combined half-widths, so it behaves identically whichever way the pixels run. An earlier implementation compared signed leading and trailing edges, which read every adjacent pair on a left/right axis as overlapping no matter how much room there was; the visible result was a horizontal bar chart naming only every other bar, and y axes labelled 0 / 0.4 / 0.8 where all five ticks had room. Fixed in v0.15.1.

Overlap is measured in layout space, at encode time. The pass compares labels against the plot size the scene was encoded at, which is not necessarily the size it is displayed at. RenderOpts.Width / Height set the SVG’s width / height attributes and leave the viewBox alone, so they scale the finished drawing rather than re-running layout — render an 800x600 scene into a 200x150 box and labels the pass measured as clear at 13.5px are ~3px apart on screen, with nothing thinned. If the display size is known, lay out at it (encode.EncodeOpts{Width, Height} via CompileOptions.Encode) instead of scaling afterwards.

A label hidden this way is gone from the drawing with nothing marking its absence. On a continuous axis that costs precision a reader can interpolate back; on a band axis it costs identity they cannot recover. If your categorical axis is crowded, prefer giving it room, a shorter label_limit, or "none" plus a layout you control over letting either mode guess.

Legend placement

A legend is built from the color channel, and the legend block on that channel places it:

"color": {"field": "region", "type": "nominal", "legend": {"orient": "left"}}

orient accepts nine values, and they fall into two groups that behave differently:

orientGroupBehaviour
left, right, top, bottomSideReserves margin. The plot rect shrinks by the legend’s width (left/right) or height (top/bottom) plus the offset gap, and the legend parks in that reserved band — outside the axis chrome, so a left legend never lands on the y axis.
top-left, top-right, bottom-left, bottom-rightCornerOverlays the plot. The plot rect is untouched and the legend is drawn over its corner. This is the default (top-right) and the historical behaviour.
none—Suppresses the legend entirely. No legend is built and no margin is reserved.

Two knobs adjust the placement:

KeyEffect
paddingInterior padding. Grows the legend frame by that many pixels on every side and insets the swatches, labels and title by the same amount. A side placement widens its reserved band to match, so padding never eats into the plot. Default 0.
offsetThe gap the legend keeps from the plot. On a side placement it is the space between the legend and the plot’s chrome, and it widens the reserved band (defaults: 10 for left/right, 4 for top/bottom). On a corner placement it is the inward inset from the plot corner (default 0).

Reservations are measured, not guessed: a symbol legend is 104 px wide, and a top/bottom band is sized from the entry count. A channel with fewer than two categories builds no legend, so it reserves nothing either.

In a layer composite each layer resolves its own legend. Legends sharing an anchor stack rather than overlap, and a shared side’s reservation is the sum (top/bottom) or the widest (left/right) of what the stacked legends claim.

Legend content

The same legend block controls what the legend says:

KeyEffect
titleReplaces the title derived from the bound field. false suppresses the title entirely — the same string-or-false convention axis.title uses. Absent keeps the field name.
valuesSelects which entries the legend shows, in the order listed. Entries matching no category are dropped.
formatA d3-format specifier applied to the entry labels (the subset in encode/format, the same one axis.format and text.format use).
tick_countHow many labelled stops a gradient legend draws.
label_limitMaximum label width in pixels; longer labels are truncated with an ellipsis.
"color": {
  "field": "region", "type": "nominal",
  "legend": {
    "title": "Sales region",
    "values": ["EMEA", "APAC"],
    "label_limit": 60
  }
}

values never re-colours the legend. Each surviving entry keeps the palette slot of its original category position, so a legend narrowed to two of six regions still shows those two regions’ actual mark colours rather than the first two palette entries. The order is the author’s: listing ["APAC", "EMEA"] swaps the rows without swapping the swatches.

format goes through the real formatter. legend.format is parsed by the same encode/format d3 subset that PRISM_SPEC_011 validates it against, so ".1%" renders 12.3%. Since E7-S1 axis.format takes the identical path — it ran through fmt.Sprintf before, which rendered a documented specifier like ".0%" as %!(NOVERB) on an axis while the same string worked on a legend. A specifier the subset cannot parse falls back to the default %g label rather than emitting a broken one. It applies to gradient stop labels always, and to a symbol label whose category reads as a number; a non-numeric category is passed through untouched.

The frame follows the content. Filtering with values shortens the box, suppressing the title with "title": false drops the title band, and label_limit sets the width budget (the default is 14 characters, or 84 px). A side orient reserves its margin from those same numbers, so the plot never overlaps a resized legend.

tick_count applies to gradient legends — the continuous form used by a quantitative color channel. It defaults to 5 evenly spaced stops across the colour domain; 1 labels the minimum alone and 0 leaves a bare bar. Setting values instead pins the stops outright, dropping any value outside the domain.

Symbol or gradient — type

A legend takes one of two forms, and the channel’s own type chooses it:

Channel typeLegend formWhat it draws
quantitativeGradientOne continuous colour bar with tick_count labelled stops running down it, spanning the bound field’s numeric range.
nominal, ordinal, temporalSymbolOne swatch + label per category.

The inference is enough for almost every chart — a heatmap coloured by count gets a gradient bar, a bar chart coloured by region gets swatches — and neither form needs legend.type written at all.

legend.type overrides it explicitly:

"color": {"field": "count", "type": "quantitative", "legend": {"type": "gradient"}}

An override the channel cannot fill is rejected, not ignored: a gradient needs a numeric domain to run between and a symbol legend needs categories to name, so gradient on a discrete channel (or symbol on a quantitative one) raises PRISM_SPEC_051. To show a continuous field as discrete swatches, make it discrete first with a bin transform and declare the channel "type": "ordinal".

A gradient legend defaults to the right side, not a corner. The bar is 130 px tall with labels running alongside it, so a corner anchor would lie over the cells it describes. It anchors to the right side instead, which reserves a margin band and shrinks the plot clear of it. A symbol legend keeps its top-right corner default. An explicit orient wins over either.

Legend layout — direction, symbol_type, symbol_size

KeyEffect
directionvertical (the default) stacks entries down a column; horizontal lays them out across a single row. The frame follows, and so does the reserved band — a horizontal legend on a top / bottom orient is one row deep however many entries it carries, so it costs far less plot height. A gradient legend draws one bar and ignores it.
symbol_typeThe swatch shape: circle, square, triangle, cross or diamond. Absent, the swatch is the default filled square. An unrecognised name raises PRISM_SPEC_052.
symbol_sizeSwatch size in pixels — the side of a solid square, or the bounding-box diameter of a shaped symbol. Defaults are 12 px solid, 10 px symbol. A symbol wider than the 12-px swatch column widens the legend box so the label still clears it.
"color": {
  "field": "region", "type": "nominal",
  "legend": {"orient": "bottom", "direction": "horizontal", "symbol_type": "diamond", "symbol_size": 14}
}

symbol_type is the point mark’s own shape vocabulary, and a legend swatch and a point mark of the same shape are drawn by the same emitter — a diamond in the legend is geometrically the diamond in the plot, not a second approximation of one.

Hiding an axis or legend

Two legend-suppression syntaxes exist. "legend": null is the canonical form — it mirrors Vega-Lite and is symmetric with "axis": null. {"legend": {"orient": "none"}} is an accepted alias with identical behaviour. Prefer null; the alias exists because both landed in the same release, and it is kept for compatibility rather than because two spellings are desirable.

Details

"axis": null on a position channel suppresses that axis entirely — domain line, ticks, tick labels, title and grid lines all go. The padding the axis reserved on its side of the plot is released, so the plot rect expands into the freed space.

"legend": null on a mark channel suppresses that channel’s legend. Legends overlay the plot rather than reserving a side, so nothing moves; the legend simply is not emitted.

"encoding": {
  "x": {"field": "cat", "type": "nominal", "axis": null},
  "y": {"field": "val", "type": "quantitative"},
  "color": {"field": "cat", "type": "nominal", "legend": null}
}

Omitting the key is not the same as null. The two are distinct states and Prism decodes them apart:

Wire formMeaning
key absentDefault axis / legend, rendered with Prism’s defaults.
"axis": {...} / "legend": {...}Configured axis / legend — including an empty {}, which still renders.
"axis": null / "legend": nullSuppressed. Nothing is drawn, and an axis releases its padding.

The distinction survives a round-trip: re-serialising a spec re-emits the explicit null and never invents one for an absent key.

This is block-level suppression and is unrelated to axis.title: false, which drops only the axis title and leaves the ticks, labels, line and grid in place — or to axis.labels / axis.ticks / axis.domain, which each drop one component and leave the rest standing. See Axis components and geometry.

In a layer, the layers share one pair of axes, so the suppression has to be unanimous: an axis is hidden only when every layer that binds the channel sets "axis": null. In a facet or repeat, every cell renders the same child spec, so its suppression applies to the whole grid — the shared axis is dropped and each cell expands.

Text channel

The text channel supplies the label content for a text mark, for the node labels of the tree / dendrogram / network marks, and the line text for tooltips, which share the same slimmer channel shape: field, type, aggregate, format, title, value.

"text": {"field": "score", "type": "quantitative", "format": ".1f"}
KeyEffect on a text mark
fieldThe column whose value becomes the label.
valueA literal label, repeated on every row. Used when no field is set.
formatd3-format specifier applied to the label — .1f renders 80.04 as 80.0, .0% renders 0.42 as 42%. Invalid specifiers are rejected at validate time with PRISM_SPEC_011.
aggregateHonoured exactly like a position channel’s: it injects the same synthetic group-aggregate node, and a non-aggregated text field joins the group-by.

With no text channel bound, a text mark falls back to rendering the y field’s value verbatim (or x’s when y is unbound). See Marks › Text for the mark-side positioning rules.

On a graph mark

The tree, dendrogram and network marks read the same channel to label their nodes, and resolve field / value / format through exactly the same path. Two differences follow from those marks being node-oriented while the channel is row-oriented:

  • Labelling is opt-in. With no text channel a graph mark emits no label geometry at all, unlike a text mark, which always has a fallback. Binding the channel is the only way to turn node labels on.
  • A row’s label binds to that row’s target node, the channel those marks treat as the node identity. The first row wins when a target repeats. A node that never appears as a target — the root of an edge-list hierarchy, an uncited paper in a citation network — has no row of its own and falls back to its id.

See Marks › Tree / dendrogram / network for where the labels land relative to each node.

Table columns

The table mark replaces the position channels with encoding.columns[] — one object per column, each carrying the same channel shape as any other encoding plus an optional mark naming a sub-mark to draw in the cells.

"columns": [
  {"field": "name",    "type": "nominal",      "title": "Account"},
  {"field": "revenue", "type": "quantitative", "format": ",.0f"},
  {"field": "trend",   "type": "quantitative", "mark": "sparkline"}
]
KeyEffect on a table column
titleThe column header. Falls back to the field name.
formatd3-format specifier applied to the cell text — ,.0f renders 120000 as 120,000, .1% renders 0.4213 as 42.1%. Same subset every other format string uses; invalid specifiers are rejected at validate time with PRISM_SPEC_011.
markSub-mark drawn in each cell (e.g. "sparkline") instead of text.

Formatting happens at encode time, but the raw value is carried alongside the formatted text in the Scene IR, so a client-side header sort compares numbers rather than the formatted strings — 120,000 and 80,500 still order correctly. A format on a column that also binds a mark shapes nothing (that column draws geometry, not text) and is reported as PRISM_WARN_CHANNEL_INERT.

Note the subset has no currency prefix: write ,.0f, not $,.0f. The latter is rejected by PRISM_SPEC_011. Prefix a literal symbol in the title, or precompute the string upstream.

See Marks › Table for the mark-side rules.

Tooltip channel

"tooltip": [
  {"field": "brand_id"},
  {"field": "score", "format": ".2f"}
]

Materialized in the Scene IR as pre-formatted TooltipLine lists. SVG emits <title> per mark; the JS port renders rich HTML tooltips in P12+.

Detail channel

detail is a pure grouping channel. It splits a mark into one series per distinct value of the bound field — exactly as color does — but it consumes no palette slot, builds no legend, and leaves mark styling untouched. Use it when the data has more series than the chart should distinguish visually: ten sensors that all belong to one cohort should draw as ten separate lines in one colour, not ten colours plus a ten-entry legend.

"detail": {"field": "sensor", "type": "nominal"}

An array binds several fields; the grouping key is the tuple, so a new series starts at each distinct combination:

"detail": [
  {"field": "site", "type": "nominal"},
  {"field": "unit", "type": "nominal"}
]

Semantics:

AspectBehaviour
Marks affectedline and area — the path-forming marks, whose geometry spans multiple rows. Every other mark already emits one mark per row, so a partition changes nothing; detail is accepted there and is simply inert.
Composition with colorBoth bound produces one series per distinct (colour, detail…) pair. Every series sharing a colour keeps that colour — detail never advances the palette.
LegendNever. The legend is built from color alone, so a color + detail chart still shows one entry per colour.
Emission orderColour first-appearance order outer (so it continues to match legend order), full-tuple first-appearance order inner. Series sharing a colour are emitted contiguously.
Point orderingAs with color, points within a series are sorted by resolved x pixel ascending, so each path traces left-to-right regardless of upstream row order — unless order is bound, which hands the sequence to the author.
AggregatesA detail field joins the implicit group-by of the synthetic aggregate an aggregated channel injects, so detail + y: {"aggregate": …} aggregates per series.

Key coercion differs slightly between the two grouping channels: color categories are string-valued (a non-string cell has no palette entry), whereas a detail key is the value’s string form — a numeric series id groups per distinct number rather than collapsing into one bucket.

Offset channels (x_offset / y_offset)

x_offset and y_offset cut the band slot a category owns into sub-bands, so the rows sharing that category draw side by side instead of on top of one another. That is the grouped — or dodged — bar. Vega-Lite spells the channels xOffset / yOffset; Prism is snake_case throughout, so they are x_offset / y_offset.

x_offset subdivides x’s slot, y_offset subdivides y’s. Bind one or the other, never both: a mark dodges along a single axis, while the measure runs along the other.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"metric": "Awareness",     "series": "Acme",             "score": 62},
    {"metric": "Awareness",     "series": "category average", "score": 48},
    {"metric": "Consideration", "series": "Acme",             "score": 41},
    {"metric": "Consideration", "series": "category average", "score": 44},
    {"metric": "Preference",    "series": "Acme",             "score": 28},
    {"metric": "Preference",    "series": "category average", "score": 31}
  ]},
  "mark": {"type": "bar"},
  "encoding": {
    "x":        {"field": "metric", "type": "nominal"},
    "y":        {"field": "score",  "type": "quantitative"},
    "x_offset": {"field": "series", "type": "nominal"},
    "color":    {"field": "series", "type": "nominal"}
  }
}

Three metric slots, each split in two: one bar for the brand and one for its category average, adjacent and equally wide. Only bar draws this geometry — see Marks › Grouped bars for the mark-side rules and the horizontal form.

The four keys

An offset channel takes field, type, sort and scale, and nothing else.

KeyPurpose
fieldThe column whose distinct values become the sub-bands. It is what makes the channel do anything: an offset object carrying no field binds nothing and behaves exactly as an absent channel.
typenominal or ordinal. A slot is subdivided by a discrete field; quantitative / temporal describes no subdivision and is refused. Omitting the key is read as discrete.
sortThe sub-band order: a direction ("ascending" / "descending", with "asc" / "desc" as aliases) or an array naming the categories outright.
scaleThe sub-band scale’s own band geometry: domain, padding, padding_inner, padding_outer, align, reverse, round. Independent of the parent band’s own settings.

The omissions are the point. Reusing the x / y channel shape would have admitted axis, stack, aggregate, condition, format, bin, value, title and key — nine keys nothing on the offset path reads, which would decode, validate and then quietly do nothing. An unknown key inside an x_offset / y_offset object is rejected at decode instead. Keys on the scale block that describe something other than a band subdivision (type, zero, nice, scheme, range, …) are likewise unread there: write only the band-geometry keys listed above.

The sub-band domain is global, not per category

The sub-bands come from the distinct values of the offset field across the whole table, not from the values present in each category.

That is what makes bar widths comparable. A per-category domain would give a lone member of one group the full slot while a two-member group got half-slots each, and the eye would read the wider bar as the bigger number. With a global domain every group is cut the same way, so a series sits in the same place in every group and every bar is the same width — which is the one thing a grouped bar exists to make possible.

The consequence for ragged data: a category missing a series leaves that series’ sub-band empty. The gap is the honest reading — the series has no value here — and the neighbouring bars do not widen to absorb it.

Padding — sub-bands touch by default

The offset scale is a band scale in its own right, with its own padding, defaulting to padding_inner: 0 and padding_outer: 0. The sub-bands therefore touch each other and together fill the parent slot exactly, which is the classic grouped-bar look and matches Vega-Lite’s offset-scale defaults.

The parent band’s defaults (inner 0.1, outer 0.05) are untouched, so the spacing between groups and the spacing within a group are configured separately:

"x":        {"field": "metric", "type": "nominal", "scale": {"padding_inner": 0.3}},
"x_offset": {"field": "series", "type": "nominal", "scale": {"padding_inner": 0.1}}

Zero inner padding also buys a guarantee worth relying on: a field with a single distinct value reduces to one sub-band spanning the whole slot at zero displacement, so the chart is geometrically identical to the same spec with the offset channel removed. Any non-zero padding would break that.

It builds no legend

Like detail, an offset channel is a pure positional grouping. It consumes no palette slot, changes no colour and emits no legend entry — a dodged chart with nothing else bound draws every bar in the same fill.

To name the series, bind color to the same field:

"x_offset": {"field": "series", "type": "nominal"},
"color":    {"field": "series", "type": "nominal"}

The legend is then built from color in the ordinary way. The two channels are resolved independently, so nothing forces them to agree: binding color to a different field is legal and produces bars that dodge by one dimension and are coloured by another.

Ordering the sub-bands

Which series sits left and which sits right is decided by one precedence chain, highest first:

TierFormEffect
1"scale": {"domain": [...]}Pins the order outright. A partial domain leads, and any value it does not name is appended after it rather than dropped.
2"sort": ["b", "a"]Names the categories in order. Same append rule.
3"sort": "descending"A direction over the distinct values. All four spellings are honoured.
4nothingThe distinct values ascending.

An unrecognised sort spelling is an error, not a silent fall back to ascending.

Note the default in tier 4: ascending, not first-seen table order. This diverges from Prism’s x / y band scales on purpose. An offset’s order decides which sub-band a series occupies, and deriving it from row order would make the same data draw differently after an upstream sort — an unreadable chart change with no spec change behind it. Ascending is also Vega-Lite’s documented default for a discrete channel.

It does mean a chart’s category axis is in first-seen order while its sub-bands are alphabetical. That inconsistency is real and you will notice it: the metrics run along the x axis in the order the rows arrived, while the two series inside each slot are sorted by name. Pin whichever you care about.

Stable for a set of values, not across datasets

This is the caveat that matters for anything rendered more than once.

The ascending default is deterministic for a given set of values — permute the rows, add a transform, re-run the query, and the sub-bands stay put. It is not stable across datasets, and a spec written once as a template is run against many.

Consider two sub-bands, a brand name and the literal "category average". Ascending is byte order, and every ASCII capital sorts before every lowercase letter, so "Zenith" takes the left sub-band — but the same brand styled "zenith" takes the right one, and the same template, on the same chart kind, has swapped the pair. The order hinges on a label’s first byte.

So: any chart whose category set varies between renders should pin scale.domain on the offset channel.

"x_offset": {
  "field": "series",
  "type": "nominal",
  "scale": {"domain": ["category average", "Acme"]}
}

Prefer scale.domain over sort for this. It is the higher-precedence tier, it reads as a statement of intent rather than a rule, and a partial domain appends unlisted categories rather than dropping their rows — so a series that appears for the first time next month lands on the end of the group instead of breaking the chart.

Stacking steps aside

A bar that would stack implicitly stops doing so the moment an offset is bound. Stacking and dodging spend the same geometry on the same grouping, and the offset is the thing the author asked for explicitly, so the inferred stack yields to it. No stack key is needed to get grouped columns.

An explicit stack written beside an offset is a contradiction and is rejected with PRISM_SPEC_065 rather than half-honoured. Both opt-outs ("stack": null and "stack": false) agree with the offset and stay silent.

What it rejects

Every one of these is refused with a code rather than ignored.

CodeCondition
PRISM_SPEC_063The mark cannot dodge. Only bar implements the geometry; every other band-seated mark (rect, tick, heatmap, boxplot, violin, winloss, progress, the spark adornments) fills its slot with a single shape, so there is nothing to divide.
PRISM_SPEC_064The binding is incoherent: both offset channels bound, or the position channel the offset subdivides is unbound, quantitative / temporal, or declares a scale.type that resolves to no band. An offset needs a band slot to cut.
PRISM_SPEC_065An explicit stack on a position channel sits beside a bound offset — see above.
PRISM_SPEC_066A span channel claims the offset’s own axis.

PRISM_SPEC_066 is per axis, and only per axis. x2 states both ends of the mark along x, which leaves x’s slot with nothing to subdivide — so x2 + x_offset is refused. The other axis is untouched: y2 together with x_offset is a legal ranged, dodged bar, and so is x2 together with y_offset.

Two shapes are decided later than the rest, because the spec alone cannot answer them: an offset channel declaring "type": "quantitative", and a sort spelling nothing acts on, both surface at encode time as PRISM_ENCODE_001 naming the channel. Named, but later than PRISM_SPEC_06*.

Duplicate sub-bands warn

A sub-band is identified by the pair (category value, offset value). Two rows carrying the same pair land on exactly the same rect, and only the last one drawn stays visible — the marks overlap, which is the one thing binding an offset was supposed to stop.

Nothing geometric changes, but it is reported: PRISM_WARN_OFFSET_COLLISION, naming how many rows repeated and one key that did.

It is unreachable once the measure channel aggregates. The synthetic group-by keeps both the category field and the offset field — an offset is a pure grouping binding, exactly like detail — so each pair yields exactly one row:

"y":        {"aggregate": "mean", "field": "score", "type": "quantitative"},
"x_offset": {"field": "series", "type": "nominal"}

Raw, un-aggregated tables are the only place duplicates survive to encode.

Where warnings surface

PRISM_WARN_OFFSET_COLLISION and PRISM_WARN_OFFSET_CONFIG_CONFLICT — and every other PRISM_WARN_* this page mentions — land on SceneDoc.Warnings, surfaced as CompiledPlan.Diagnostics from the Go API.

  • prism plot and prism scene print them to stderr, so a CLI user sees them without asking.
  • prism scene also writes them into the Scene IR document itself, as its warnings array, so a consumer of that JSON gets them for free. (prism validate does not: it runs no rows, so an encode-time warning has not happened yet.)
  • A library embedder sees nothing unless it reads the field. A host that holds a CompiledPlan, hands plan.Scene to a renderer and never touches plan.Diagnostics drops every warning on the floor — including PRISM_WARN_NULL_DROPPED, which means rows have silently left the chart.

If you embed Prism, read the diagnostics and log them. A warning is only as good as the consumer reading it.

Under composition

An offset scale is shared by default across the children of a layer and the cells of a facet, exactly as x and y are, so a series sits in the same sub-band in every layer and every cell even when a child binds only some of the values. Opt out per channel:

"resolve": {"scale": {"x_offset": "independent"}}

concat / hconcat / vconcat / repeat cells are separate charts with their own position scales, so their offsets are independent too — there is nothing to opt out of. Children that describe a shared offset differently fold first-specified-wins per property and raise PRISM_WARN_OFFSET_CONFIG_CONFLICT; it is never settled in silence. See Composition › Offset (dodge) scales for the full rule.

Stacking

A bar or area whose measure channel is aggregated and whose marks are split by a grouping channel (color or detail) stacks by default, matching Vega-Lite. Without stacking, each segment would be drawn from the axis baseline and the taller ones would simply cover the shorter ones.

"encoding": {
  "x": {"field": "quarter", "type": "nominal"},
  "y": {"aggregate": "sum", "field": "revenue", "type": "quantitative"},
  "color": {"field": "segment", "type": "nominal"}
}

The stack key on the measure channel controls it explicitly:

ValueMeaning
absentThe default above: stack when the shape qualifies, otherwise don’t.
"zero" / trueStack from the baseline.
"normalize"Rescale each stack onto 0…1 — the 100% stacked chart.
"center"Float each stack’s baseline so the band is symmetric about zero — the streamgraph. See Centred stacks.
null / falseOpt out; every segment returns to the baseline.

Stacking and dodging are alternatives, not layers: binding an offset channel suppresses the implicit stack above, and an explicit stack written beside an offset is rejected with PRISM_SPEC_065 rather than half-applied. "stack": null / false agree with the offset and stay silent.

Semantics:

AspectBehaviour
Marks affectedbar and area only. Every other mark ignores stack.
Stack keyThe other position channel — the dimension axis. One stack per distinct value.
Segment orderFirst-appearance order of the (colour, detail…) tuple across the whole table — the same order the mark partitioner and the legend use, so a segment sits in the same slot in every stack. A centred stack reorders inside-out instead; see below.
Axis domainThe stacked totals reach scale resolution, so the measure axis spans 0…sum, not 0…max.
NegativesPositive and negative values accumulate independently from zero, so a mixed-sign stack grows in both directions.
Opt-outsAn explicit x2 / y2 span wins (the mark already knows both edges), as does a non-linear measure scale, as does a bound offset channel — a dodged mark already spends the band slot on its grouping.

Stacking is not a rendering trick: it compiles to a real stack transform node in the plan, whose <field>_start / <field>_end output columns are bound to the measure channel and its span companion before any scale resolves. prism execute shows them, and any consumer pinning the plan’s JSON shape sees them too.

Composition note: stacking resolves per leaf spec, so each layer / concat child stacks independently. A facet parent builds its upstream pipeline once with the child encoding stripped, so a faceted child does not stack — the same limitation that already applies to the synthetic encoding aggregate.

Centred stacks (the streamgraph)

"stack": "center" keeps every stack exactly as thick as "zero" makes it, then slides it so its own midpoint lands on zero. Each band is therefore symmetric about a single shared baseline no matter how much total it carries, which is the streamgraph shape:

"mark": {"type": "area", "interpolate": "monotone"},
"encoding": {
  "x": {"field": "week", "type": "quantitative"},
  "y": {"aggregate": "sum", "field": "contacts", "type": "quantitative", "stack": "center"},
  "color": {"field": "channel", "type": "nominal"}
}

Three things follow from that definition. The measure axis carries a signed domain. Because every stack spans [-h/2, +h/2] for its own total h, the union of all of them is [-H/2, +H/2] for the widest stack H — exactly symmetric, and it reaches scale resolution through the ordinary span path, so the axis draws it without any stack-specific code. A tick therefore reads as distance from the midline, not as a value: 200 means “200 units above the centre of this stack”, and the full thickness at any point is the distance between the band’s two edges. That is the honest reading of a floating baseline, and it is why Prism keeps the axis signed rather than hiding the shift inside a positive range. If the numbers are noise for your chart, suppress the axis outright with "axis": null — a streamgraph is usually read for shape, not for level.

This is a chosen divergence from Vega-Lite, which offsets every stack into a positive 0…max range instead. That keeps the axis non-negative but makes each tick an arbitrary distance from an invisible origin; centring on zero at least names the line the eye is already following. Segments reorder inside-out. Declaration order is wrong for a streamgraph: a series that only matters late should not sit against the centre line for the whole chart. So a centred stack ranks its series by where each one peaks, then deals them alternately onto whichever side of the stack is currently carrying less magnitude. The earliest-peaking series end up either side of the centre and the later ones fray outward. This is d3’s stackOrderInsideOut, the ordering Vega reaches for on the same chart. Peak position is measured against stack order, which is first-appearance order of the dimension values — shape it with a preceding sort transform if the rows do not already arrive in dimension order. An explicit order channel turns inside-out off: an author who states an order gets that order. So does a stack with no grouping channel bound, where there is one segment per row and nothing to arrange. center is an area-mark offset. A bar is baseline-anchored geometry — the whole point of the column is that it is measured from the axis — so centring it detaches every column from the axis and leaves the tick labels naming offsets from a synthetic midline. Prism rejects that combination at validate with PRISM_SPEC_053 rather than drawing something unreadable. zero and normalize are unaffected on bars.

Order channel

order is Vega-Lite’s overloaded ordering channel, and Prism honours all three of its senses. It binds one entry or an array of them: “order”: {“field”: “rank”, “type”: “quantitative”} “order”: [ {“field”: “tier”, “type”: “nominal”}, {“field”: “revenue”, “type”: “quantitative”, “sort”: “descending”} ] Array entries apply left to right, so the second key breaks ties in the first. Each entry takes field, type and an optional sort direction — "ascending" (the default) or "descending", with "asc" / "desc" accepted as aliases. Any other spelling is rejected (PRISM_SPEC_055) rather than read as ascending.

One mechanism, three senses

Binding order sorts the rows, upstream of everything that reads them. That single reordering produces all three behaviours the channel is named for:

SenseMarksWhat you see
Stack orderbar, areaWhich segment sits at the bottom of each stack. The stack ranks segments by first appearance, so the first-ordered series is the baseline one. Overrides the default (colour first-appearance) order.
Draw orderevery per-row mark — point, bar, rule, text, tick, rect, …Marks are emitted in row order, so a later-ordered mark paints on top of an earlier one. Bind order to put the series you care about above the overlap.
Point sequenceline, areaThe order points connect along the path. Without order, a grouped path is sorted left-to-right by resolved x pixel; with it, the path traces in row order — which is how you draw a connected scatterplot or a path that doubles back.
A mark whose geometry does not depend on row order (a single-mark type
such as arc, or a layout-computing mark such as sankey) simply
observes none of them. order is accepted on every mark so a spec
stays portable across a mark-type switch.

Unbound preserves table order exactly

This is the rule that makes the channel safe: Prism reorders only when order is explicitly bound. With it absent, no sort node is injected, nothing downstream changes, and the chart renders byte-identically to how it did before the channel existed. Vega-Lite behaves the same way.

Where the sort happens

order compiles to a real sort node in the plan, placed after the synthetic encoding aggregate and before the stack node — so an order key may name an aggregated output column, and the stack accumulates rows that are already in the author’s sequence. prism plan shows the node; prism execute shows the reordered rows. A non-aggregated order field joins the implicit group-by of the synthetic aggregate (the same treatment detail gets), so it survives to the sort below it. Naming a field that another channel already aggregates does not widen the group-by — the order key reads that aggregate’s output column instead.

aggregate is rejected

An aggregate on an order entry raises PRISM_SPEC_056. Ordering by a per-series total needs a second aggregation at a different granularity than the chart’s own, joined back onto the rows; Prism rejects the key rather than accepting and quietly ignoring it. Three supported ways to express the same intent:

  • precompute the total upstream with a window or join transform and order by that column;
  • drop the aggregate key and name the field another channel already aggregates, so the order key reads its output column;
  • shape row order with a sort transform and leave order unbound. Composition note: order resolves per leaf spec, so each layer / concat child orders independently. A facet parent builds its upstream pipeline once with the child encoding stripped, so a faceted child does not reorder — the same limitation that already applies to the synthetic encoding aggregate and to stacking.

Further reading