Encoding
The encoding object binds data fields to visual channels.
Channels
| Family | Channels |
|---|---|
| Position | x, y, x2, y2 (see Span channels), theta, radius |
| Offset | x_offset, y_offset — see Offset channels |
| Color & opacity | color, fill, stroke, opacity |
| Size & shape | size, shape |
| Text & order | text (see Text channel), tooltip, order — see Order channel |
| Grouping | detail — see Detail channel |
| Facet | row, column |
| Sankey | source, target, value |
Channel shape
"x": {
"field": "score",
"type": "quantitative",
"aggregate": "mean",
"scale": {"type": "log"},
"axis": {"title": "Average score", "format": ".2f"},
"sort": "-y"
}
| Key | Purpose |
|---|---|
field | Column from the source (or transform output). |
type | One of nominal, ordinal, quantitative, temporal. |
aggregate | Friendly 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. |
scale | Scale spec (type, domain, range, scheme, padding, …). |
axis | Axis config (orient, title, format, grid, tick_count, label_angle, …) — see Axis placement — or null to hide the axis. |
legend | Legend 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. |
format | d3-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, ...]. |
stack | Position channels only. "zero" / "normalize" / "center" / true to stack, null / false to opt out — see Stacking. |
key | true 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.typemust equalx.type(andy2.typemust equaly.type). A mismatch isPRISM_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
| Mark | x2 | y2 |
|---|---|---|
bar | Spans 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. |
rect | Spans x→x2. An unranged axis uses its band width, or the historic 1-px cell when it is continuous. | Spans y→y2, same rules. |
rule | Draws 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. |
area | Not 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 mark | Rejected (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:
selectionreferences a name declared in the spec’sselectionblock (validate rulePRISM_SPEC_025).testis a structured predicate — the same grammarfilteruses ({op, field, value}leaves andand/or/notcombinators), 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
valueorfield. Aselection-form entry withoutvalueinherits 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 aMark.Conditions[]slice. The browser-sideprism-selectionmodule 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.
| Key | Type | Default | Effect |
|---|---|---|---|
domain | array | data extent | Pins the domain exactly. zero and nice are not applied on top of it. |
zero | boolean | true on linear / pow / sqrt; ignored on log and time | Widens a positive-only extent down to 0 (or a negative-only extent up to 0). |
nice | boolean | true on linear / pow / sqrt / time; false on log | Rounds 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.domaininstead; 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 pindomainoutright, and reach forlogonly 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:
| Value | Effect |
|---|---|
rgb | Default. Blends the sRGB components directly. |
hsl | Blends hue along the shorter arc, keeping saturation up where sRGB would pass through grey. |
lab | Blends 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.
| Key | Type | Default | Applies to | Effect |
|---|---|---|---|---|
clamp | boolean | false | continuous | Pins an out-of-domain value to the nearest domain edge instead of letting it map past the range. |
reverse | boolean | false | all | Runs the scale the other way round, relative to the channel’s default direction. |
round | boolean | false | all | Quantises layout to whole pixels. |
padding_inner | number [0,1) | 0.1 | band | Gap between adjacent bands, as a fraction of the step. |
padding_outer | number [0,1) | 0.05 band, 0.5 point | band / point | Gap before the first and after the last band. |
align | number [0,1] | 0.5 | band / point | Where 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: trueputs the domain minimum on the right; - on
y,reverse: trueputs 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"}}
}
| Channel | orient | Result |
|---|---|---|
x | bottom | Default. Axis below the plot. |
x | top | Axis above the plot. |
y | left | Default. Axis to the left of the plot. |
y | right | Axis 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
}
}
| Key | Default | Effect |
|---|---|---|
labels | true | Draws the tick labels. false suppresses only the labels — the tick marks, the domain line, the grid and the title all stay. |
ticks | true | Draws the tick marks. false suppresses only the marks; the labels sit at the same tick positions and are unaffected, as are the grid lines. |
domain | true | Draws the axis line along the plot edge. false suppresses only that line. |
tick_size | 5 | Major tick length in pixels. Minor ticks stay proportionally shorter (0.6×). |
label_padding | 4 | The 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_padding | 8 | The 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_limit | 0 (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. |
zindex | 0 | 0 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:
| Key | Effect |
|---|---|
tick_count | How 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. |
values | An explicit tick set that replaces the generated one. Pins exactly what it names, and overrides both tick_count and tick_min_step. |
tick_min_step | The 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.
| Value | Effect |
|---|---|
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:
orient | Group | Behaviour |
|---|---|---|
left, right, top, bottom | Side | Reserves 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-right | Corner | Overlays 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:
| Key | Effect |
|---|---|
padding | Interior 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. |
offset | The 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:
| Key | Effect |
|---|---|
title | Replaces 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. |
values | Selects which entries the legend shows, in the order listed. Entries matching no category are dropped. |
format | A d3-format specifier applied to the entry labels (the subset in encode/format, the same one axis.format and text.format use). |
tick_count | How many labelled stops a gradient legend draws. |
label_limit | Maximum 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 type | Legend form | What it draws |
|---|---|---|
quantitative | Gradient | One continuous colour bar with tick_count labelled stops running down it, spanning the bound field’s numeric range. |
nominal, ordinal, temporal | Symbol | One 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
| Key | Effect |
|---|---|
direction | vertical (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_type | The swatch shape: circle, square, triangle, cross or diamond. Absent, the swatch is the default filled square. An unrecognised name raises PRISM_SPEC_052. |
symbol_size | Swatch 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": nullis the canonical form — it mirrors Vega-Lite and is symmetric with"axis": null.{"legend": {"orient": "none"}}is an accepted alias with identical behaviour. Prefernull; 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 form | Meaning |
|---|---|
| key absent | Default axis / legend, rendered with Prism’s defaults. |
"axis": {...} / "legend": {...} | Configured axis / legend — including an empty {}, which still renders. |
"axis": null / "legend": null | Suppressed. 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"}
| Key | Effect on a text mark |
|---|---|
field | The column whose value becomes the label. |
value | A literal label, repeated on every row. Used when no field is set. |
format | d3-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. |
aggregate | Honoured 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
textchannel a graph mark emits no label geometry at all, unlike atextmark, 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
targetnode, the channel those marks treat as the node identity. The first row wins when a target repeats. A node that never appears as atarget— 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"}
]
| Key | Effect on a table column |
|---|---|
title | The column header. Falls back to the field name. |
format | d3-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. |
mark | Sub-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:
| Aspect | Behaviour |
|---|---|
| Marks affected | line 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 color | Both bound produces one series per distinct (colour, detail…) pair. Every series sharing a colour keeps that colour — detail never advances the palette. |
| Legend | Never. The legend is built from color alone, so a color + detail chart still shows one entry per colour. |
| Emission order | Colour 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 ordering | As 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. |
| Aggregates | A 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.
| Key | Purpose |
|---|---|
field | The 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. |
type | nominal 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. |
sort | The sub-band order: a direction ("ascending" / "descending", with "asc" / "desc" as aliases) or an array naming the categories outright. |
scale | The 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:
| Tier | Form | Effect |
|---|---|---|
| 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. |
| 4 | nothing | The 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.
| Code | Condition |
|---|---|
PRISM_SPEC_063 | The 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_064 | The 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_065 | An explicit stack on a position channel sits beside a bound offset — see above. |
PRISM_SPEC_066 | A 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 plotandprism sceneprint them to stderr, so a CLI user sees them without asking.prism scenealso writes them into the Scene IR document itself, as itswarningsarray, so a consumer of that JSON gets them for free. (prism validatedoes 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, handsplan.Sceneto a renderer and never touchesplan.Diagnosticsdrops every warning on the floor — includingPRISM_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:
| Value | Meaning |
|---|---|
| absent | The default above: stack when the shape qualifies, otherwise don’t. |
"zero" / true | Stack 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 / false | Opt 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:
| Aspect | Behaviour |
|---|---|
| Marks affected | bar and area only. Every other mark ignores stack. |
| Stack key | The other position channel — the dimension axis. One stack per distinct value. |
| Segment order | First-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 domain | The stacked totals reach scale resolution, so the measure axis spans 0…sum, not 0…max. |
| Negatives | Positive and negative values accumulate independently from zero, so a mixed-sign stack grows in both directions. |
| Opt-outs | An 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…maxrange 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’sstackOrderInsideOut, 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 precedingsorttransform if the rows do not already arrive in dimension order. An explicitorderchannel 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.centeris 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 withPRISM_SPEC_053rather than drawing something unreadable.zeroandnormalizeare 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:
| Sense | Marks | What you see |
|---|---|---|
| Stack order | bar, area | Which 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 order | every 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 sequence | line, area | The 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
windoworjointransform and order by that column; - drop the
aggregatekey and name the field another channel already aggregates, so the order key reads its output column; - shape row order with a
sorttransform and leaveorderunbound. Composition note:orderresolves per leaf spec, so eachlayer/concatchild orders independently. Afacetparent 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
- Spec field reference — every channel property exhaustively.
- Themes — how scale color schemes resolve.