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

Composition

Prism supports five composition primitives, all v1:

OpWhatMulti-source?
layerStack marks on shared axesper-layer data allowed
concat / hconcat / vconcatSide-by-side panelsper-panel data allowed
facetGrid by data values (one cell per partition)usually single source
repeatGrid by field list (one cell per field)usually single source

What a parent passes down

A composition parent inherits exactly three things to its children:

KeyInherited?Rule
datasetsyesMerged; an entry the child redeclares wins.
datayesOnly when the child declares no data of its own.
$schemayesOnly when the child omits it.
encodingno—
mark, transform, title, theme, …no—

Every layer and every panel is a self-contained chart. That is a divergence from Vega-Lite, which inherits a parent encoding into layer children.

Because nothing reads it, an encoding block written beside a composition operator is rejected, not ignored:

{
  "$schema": "urn:prism:schema:v1:spec",
  "encoding": {"x": {"field": "month", "type": "ordinal"}},
  "layer": [ ... ]
}
PRISM_SPEC_054: An "encoding" block on the spec root sits beside
"layer", where nothing reads it.

The rule (validate/rules/composite_parent_encoding.go) covers every operator — layer, concat, hconcat, vconcat, facet, repeat — and walks the whole tree, so a layer nested inside a concat panel is caught at concat[1].layer. Two shapes stay legal, because their encoding really is read:

  • the spec child of a facet or repeat parent — that child is the chart being drawn, so its own encoding is where the chart lives;
  • a flat spec with mark + encoding and no composition operator, including the encoding.row / encoding.column facet shorthand.

To share channel config across layers, repeat the block on each child and leave resolve at its default (shared) — the encoder folds the children’s axis blocks and reports a disagreement rather than dropping one. See the shared-axis rule below.

Layer

{
  "layer": [
    {"$schema": "urn:prism:schema:v1:spec", "mark": "bar", "encoding": {...}},
    {"$schema": "urn:prism:schema:v1:spec", "mark": "rule", "encoding": {...}}
  ]
}

Layer order = render order = z-index (last is on top).

Concat / hconcat / vconcat

{
  "vconcat": [
    {"$schema": "...", "mark": "line", "encoding": {...}},
    {"$schema": "...", "mark": "histogram", "encoding": {...}}
  ]
}

hconcat lays out left-to-right. vconcat top-to-bottom. concat is a flat array; today it behaves like hconcat (the columns wrap parameter is post-v1).

Facet

{
  "facet": {"column": {"field": "region"}},
  "spec": {
    "$schema": "urn:prism:schema:v1:spec",
    "mark": "bar",
    "encoding": {...}
  }
}

Partitions data by region, renders one cell per partition. Inner spec is fully recursive — facet within facet within facet works.

Repeat

{
  "repeat": {"row": ["score", "share", "lift", "growth"]},
  "spec": {
    "$schema": "urn:prism:schema:v1:spec",
    "mark": "line",
    "encoding": {
      "x": {"field": "week"},
      "y": {"field": {"repeat": "row"}}
    }
  }
}

Each cell substitutes {repeat: "row"} with the field name for that cell. Pure substitution — no template expressions.

Per-cell theme overrides

facet and repeat both accept an optional cell_overrides array — a sparse theme override scoped to one cell of the resulting grid, addressed by its 0-based (row, column) grid position, not by the data value that landed in that cell. Each entry’s theme block is the same sparse override shape used for a whole-chart theme override (spec.ThemeOverride — see Themes); it merges over the chart’s resolved theme for that one cell only.

{
  "facet": {
    "column": {"field": "region"},
    "cell_overrides": [
      {"row": 0, "column": 1, "theme": {"marks": {"bar": {"fill": "#e15759"}}}}
    ]
  },
  "spec": {
    "$schema": "urn:prism:schema:v1:spec",
    "mark": "bar",
    "encoding": {...}
  }
}

Because addressing is positional, re-sorting or filtering the faceted/repeated field shifts which value occupies a given cell — the override always applies to whichever value currently lands in that grid slot, not to a named value. For repeat, row/column index into the repeat.row/repeat.column field lists (an axis left empty collapses to a single implicit slot at index 0, mirroring the encoder’s single-row/single-column scaffold); for facet, an axis with no row/column channel likewise collapses to a single implicit slot at index 0.

encode/encode_facet.go and encode/encode_repeat.go apply each cell’s matching CellThemeOverride.Theme on top of the chart’s resolved base theme via theme.ApplyOverride — the same merge machinery a whole-chart theme override uses — when materializing that cell’s child scene; cells with no matching entry render with the base theme unchanged. Note the override targets the same per-mark-type slot (marks.<type>) a built-in theme uses for that mark: a built-in theme (e.g. light) typically sets an explicit marks.bar.fill, which wins over the generic top-level mark.fill fallback, so a per-cell fill override on a bar chart should target marks.bar.fill (as above) rather than mark.fill. This is orthogonal to resolve.scale below — a per-cell theme override never changes whether scales/axes are shared or independent across cells.

Scale resolution

resolve.scale.{x,y,x_offset,y_offset,color,size} controls cross-cell scale sharing:

ValueBehavior
shared (default for x/y and x_offset/y_offset)Union of domains across cells/layers, single axis.
independent (default for color)Per-cell domains, per-cell axes.

Mixing incompatible types on a shared scale (quantitative + nominal) raises PRISM_PLAN_005.

Offset (dodge) scales

x_offset / y_offset subdivide a category’s band slot so rows sharing that category draw side by side. They resolve shared by default, exactly as x and y do, and for the same reason: two children that divide one slot differently produce marks that do not line up, which reads as a rendering fault rather than a configuration one. A child that binds only two of three series still reserves three sub-bands, so a bar is the same width everywhere and a series sits in the same place in every layer and every facet cell.

What is shared is the sub-band order, not a scale object — an offset scale’s range is the parent band width of the cell being drawn, so only the domain can be resolved once. The order comes from the usual precedence chain, applied to the union of every child’s values: scale.domain, then a sort naming categories, then a sort direction, then the distinct values ascending.

Children that describe the shared offset scale differently are folded first specified wins, per property — the same rule a shared axis uses — and a disagreement raises PRISM_WARN_OFFSET_CONFIG_CONFLICT naming the channel, the property, the winning child and the ignored value. It is never settled in silence.

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

opts out, and each child divides its band slot from its own rows. concat / hconcat / vconcat / repeat cells are separate charts with their own position scales, so their offsets are independent too; the fold applies to layer and facet.

Warnings land on SceneDoc.Warnings (CompiledPlan.Diagnostics from the Go API). prism plot and prism scene print them to stderr; a library embedder sees nothing unless it reads the field.

Axis config under shared vs independent scales

A channel’s axis block (grid, title, label_angle, label_overlap, format, …) is honoured under both resolve modes — which one is in force only changes whose block is read.

Resolve modeWhere the axis config comes from
independentEach child renders its own axis from its own axis block. Nothing is merged.
shared (the default for x/y)One axis is drawn for all children, so the children’s axis blocks are folded into one.

The shared-axis rule: first specified wins, per property

A shared axis is drawn once but may be described by N children that disagree. Prism resolves this property by property, in child declaration order:

  • A property is taken from the first child that specifies it. A child that omits the property does not participate — so if only layer 1 sets grid, layer 1’s value is used even though layer 0 came first.
  • A later child specifying the same property with the same value is a no-op.
  • A later child specifying the same property with a different value is ignored, and the encoder emits PRISM_WARN_AXIS_CONFIG_CONFLICT naming the channel, the property, the winning child, and the ignored value. A conflict is never silently resolved.

This matches Vega-Lite, which also resolves a shared axis from the first child that specifies it. Prism’s refinement is that the unit of resolution is the individual property rather than the whole axis block, so a child that sets only grid does not wipe out another child’s title.

{
  "layer": [
    {"encoding": {"x": {"field": "day", "type": "nominal", "axis": {"grid": false}}}},
    {"encoding": {"x": {"field": "day", "type": "nominal", "axis": {"grid": true, "title": "Day"}}}}
  ]
}

The shared x axis draws no grid (layer 0 specified grid first) with the title “Day” (only layer 1 specified title), and a PRISM_WARN_AXIS_CONFIG_CONFLICT reports that layer 1’s grid: true was ignored.

To silence a conflict, either set the property identically on every child that specifies it, set it on only one child, or opt the channel out of sharing with "resolve": {"scale": {"x": "independent"}} — noting that independent scales also stop the children’s positions from aligning.

Facets take the same path. A facet has a single child spec, so its axis block simply flows onto the shared axis and no conflict is possible.

Axis titles

When no child sets axis.title, a shared axis falls back to the field name bound on the first child that declares the channel. An explicit "title": false suppresses the title and is not overwritten by that fallback — including on a histogram’s synthetic bin-count axis, whose "count" label is only a fallback.

Worked examples